Compare commits

..
Author SHA1 Message Date
gorkem-bwl 8c27d60a85 Fix email template loading and conditional TLS configuration
## Changes

### Server-side (emailService.js)
- Fixed template path resolution: Changed default from `templates/` to `src/templates/`
  to correctly locate email templates in the project structure
- Added EMAIL_TEMPLATE_PATH environment variable support for Docker deployments
- Added input validation (validateEmailParams, validateFromAddress) to catch
  missing fields before attempting to send
- Implemented conditional TLS configuration - TLS settings only applied when
  systemEmailSecure is enabled, allowing services like Resend to work properly
- Added stronger content validation to prevent empty emails from being sent
- Added transporter.verify() before sending to catch configuration issues early
- Improved error logging with more context for debugging

### Client-side (SettingsEmail.jsx)
- Refactored settings preview to use buildEmailConfigPreview() builder function
- TLS fields now only shown in JSON preview when secure mode is enabled

Fixes #3054
2026-01-15 21:01:20 -05:00
10 changed files with 250 additions and 224 deletions

No files matched your search

+1 -7
View File
@@ -5,7 +5,6 @@ import axios from "axios";
const initialState = {
isLoading: false,
authToken: "",
refreshToken: "",
user: "",
success: null,
msg: null,
@@ -139,7 +138,6 @@ const handleAuthFulfilled = (state, action) => {
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.refreshToken = action.payload.data.refreshToken || "";
state.user = action.payload.data.user;
};
const handleAuthRejected = (state, action) => {
@@ -190,15 +188,11 @@ const authSlice = createSlice({
reducers: {
clearAuthState: (state) => {
state.authToken = "";
state.refreshToken = "";
state.user = "";
state.isLoading = false;
state.success = true;
state.msg = "Logged out successfully";
},
setAuthToken: (state, action) => {
state.authToken = action.payload;
},
},
extraReducers: (builder) => {
// Register thunk
@@ -252,4 +246,4 @@ const authSlice = createSlice({
});
export default authSlice.reducer;
export const { clearAuthState, setAuthToken } = authSlice.actions;
export const { clearAuthState } = authSlice.actions;
+2 -1
View File
@@ -178,7 +178,8 @@ const PageSpeedSetup = () => {
const handleRemove = async (event) => {
event.preventDefault();
await deleteMonitor({ monitor: { id: monitorId }, redirect: "/pagespeed" });
const TEMP_MONITOR = { id: monitor._id };
await deleteMonitor({ monitor: TEMP_MONITOR, redirect: "/pagespeed" });
};
const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing;
+42 -23
View File
@@ -101,6 +101,47 @@ const SettingsEmail = ({
return null;
}
/**
* Build email configuration object for code preview
* Constructs the config incrementally instead of using nested conditional spreads
*/
const buildEmailConfigPreview = () => {
const config = {
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
};
if (systemEmailSecure) {
if (systemEmailIgnoreTLS) {
config.ignoreTLS = systemEmailIgnoreTLS;
}
if (systemEmailRequireTLS) {
config.requireTLS = systemEmailRequireTLS;
}
const tlsSettings = {};
if (systemEmailRejectUnauthorized !== undefined) {
tlsSettings.rejectUnauthorized = systemEmailRejectUnauthorized;
}
if (systemEmailTLSServername && systemEmailTLSServername !== "") {
tlsSettings.servername = systemEmailTLSServername;
}
if (Object.keys(tlsSettings).length > 0) {
config.tls = tlsSettings;
}
}
return config;
};
return (
<ConfigBox>
<Box>
@@ -271,29 +312,7 @@ const SettingsEmail = ({
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
},
null,
2
)}
</code>
<code>{JSON.stringify(buildEmailConfigPreview(), null, 2)}</code>
</Box>
</Box>
+4 -83
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import i18next from "i18next";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
import { clearAuthState, setAuthToken } from "../Features/Auth/authSlice";
import { clearAuthState } from "../Features/Auth/authSlice";
class NetworkService {
constructor(store, dispatch, navigate) {
this.store = store;
@@ -26,7 +26,7 @@ class NetworkService {
(config) => {
const currentLanguage = i18next.language || "en";
const { authToken, refreshToken } = store.getState().auth;
const { authToken } = store.getState().auth;
config.headers = {
Authorization: `Bearer ${authToken}`,
@@ -34,36 +34,15 @@ class NetworkService {
...config.headers,
};
// Only send refresh token to the refresh endpoint
if (refreshToken && config.url?.includes("/auth/refresh")) {
config.headers["x-refresh-token"] = refreshToken;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Track if we're currently refreshing to prevent multiple refresh calls
this.isRefreshing = false;
this.failedQueue = [];
const processQueue = (error, token = null) => {
this.failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
this.failedQueue = [];
};
this.axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
(error) => {
// Handle network errors (server unreachable)
if (error.code === "ERR_NETWORK") {
// Navigate to server unreachable page
@@ -72,66 +51,8 @@ class NetworkService {
return Promise.reject(error);
}
const originalRequest = error.config;
// Handle authentication errors
// Handle 403 - server is telling us to request a new access token
// Skip refresh logic for the refresh endpoint itself to prevent circular dependency
const isRefreshEndpoint = originalRequest.url?.includes("/auth/refresh");
if (
error.response &&
error.response.status === 403 &&
!originalRequest._retry &&
!isRefreshEndpoint
) {
// Check if we have a refresh token before attempting refresh
const { refreshToken } = store.getState().auth;
if (!refreshToken) {
dispatch(clearAuthState());
navigate("/login");
return Promise.reject(error);
}
if (this.isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers["Authorization"] = `Bearer ${token}`;
return this.axiosInstance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
// Call refresh endpoint
const response = await this.axiosInstance.post("/auth/refresh");
const newToken = response.data.data.token;
// Update the auth token in store
dispatch(setAuthToken(newToken));
// Process queued requests
processQueue(null, newToken);
// Retry original request with new token
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
return this.axiosInstance(originalRequest);
} catch (refreshError) {
// Refresh failed, clear auth and redirect to login
processQueue(refreshError, null);
dispatch(clearAuthState());
navigate("/login");
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
// Handle 401 - authentication failed (invalid/expired refresh token or no token)
if (error.response && error.response.status === 401) {
dispatch(clearAuthState());
navigate("/login");
+5 -30
View File
@@ -36,11 +36,11 @@ class AuthController {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
const { user, token, refreshToken } = await this.userService.registerUser(req.body, req.file);
const { user, token } = await this.userService.registerUser(req.body, req.file);
res.status(200).json({
success: true,
msg: "User registered successfully",
data: { user, token, refreshToken },
data: { user, token },
});
} catch (error) {
next(error);
@@ -53,7 +53,7 @@ class AuthController {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
const { user, token, refreshToken } = await this.userService.loginUser(req.body.email, req.body.password);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
success: true,
@@ -61,7 +61,6 @@ class AuthController {
data: {
user,
token,
refreshToken,
},
});
} catch (error) {
@@ -129,35 +128,11 @@ class AuthController {
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
await newPasswordValidation.validateAsync(req.body);
const { user, token, refreshToken } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
msg: "Password has been reset successfully",
data: { user, token, refreshToken },
});
} catch (error) {
next(error);
}
};
refreshAuthToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const authToken = req.headers.authorization?.split(" ")[1];
const refreshToken = req.headers["x-refresh-token"] as string;
if (!authToken || !refreshToken) {
return res.status(400).json({
success: false,
msg: "Auth token and refresh token are required",
});
}
const { user, token, refreshToken: newRefreshToken } = await this.userService.refreshAuthToken(authToken, refreshToken);
return res.status(200).json({
success: true,
msg: "Token refreshed successfully",
data: { user, token, refreshToken: newRefreshToken },
data: { user, token },
});
} catch (error) {
next(error);
+1 -3
View File
@@ -41,9 +41,7 @@ const verifyJWT = (req, res, next) => {
if (err) {
const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken;
err.details = { msg: errorMessage };
// Return 403 for expired tokens to trigger client refresh flow
// Return 401 for invalid tokens to trigger logout
err.status = err.name === "TokenExpiredError" ? 403 : 401;
err.status = 401;
err.service = SERVICE_NAME;
err.method = "verifyJWT";
next(err);
-1
View File
@@ -15,7 +15,6 @@ class AuthRoutes {
initRoutes() {
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
this.router.post("/login", this.authController.loginUser);
this.router.post("/refresh", this.authController.refreshAuthToken);
this.router.post("/recovery/request", this.authController.requestRecovery);
this.router.post("/recovery/validate", this.authController.validateRecovery);
+3 -48
View File
@@ -62,17 +62,6 @@ class UserService {
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
issueRefreshToken = (payload: any, appSettings: any) => {
const refreshTokenTTL = appSettings?.refreshTokenTTL ?? "7d";
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
const payloadData = { ...payload };
// Remove sensitive data from refresh token payload
delete payloadData.password;
delete payloadData.profileImage;
delete payloadData.avatarImage;
return this.jwt.sign(payloadData, refreshTokenSecret, { expiresIn: refreshTokenTTL });
};
registerUser = async (user: any, file: any) => {
// Create a new user
// If superAdmin exists, a token should be attached to all further register requests
@@ -103,7 +92,6 @@ class UserService {
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(userForToken, appSettings);
const refreshToken = this.issueRefreshToken(userForToken, appSettings);
try {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
@@ -126,7 +114,7 @@ class UserService {
});
}
return { user: newUser, token, refreshToken };
return { user: newUser, token };
};
loginUser = async (email: string, password: string) => {
@@ -146,10 +134,9 @@ class UserService {
// Happy path, return token
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(userWithoutPassword, appSettings);
const refreshToken = this.issueRefreshToken(userWithoutPassword, appSettings);
// reset avatar image
userWithoutPassword.avatarImage = user.avatarImage;
return { user: userWithoutPassword, token, refreshToken };
return { user: userWithoutPassword, token };
};
editUser = async (updates: any, file: any, currentUser: any) => {
@@ -204,39 +191,7 @@ class UserService {
const user = await this.db.recoveryModule.resetPassword(password, recoveryToken);
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(user._doc, appSettings);
const refreshToken = this.issueRefreshToken(user._doc, appSettings);
return { user, token, refreshToken };
};
refreshAuthToken = async (oldAuthToken: string, refreshTokenFromHeader: string) => {
const appSettings = await this.settingsService.getSettings();
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
const jwtSecret = appSettings?.jwtSecret;
// Verify refresh token
try {
this.jwt.verify(refreshTokenFromHeader, refreshTokenSecret);
} catch (err: any) {
const errorMessage = err.name === "TokenExpiredError" ? "Refresh token has expired" : "Invalid refresh token";
throw this.errorService.createAuthenticationError(errorMessage);
}
// Refresh token is valid, get payload from old auth token (ignoring expiration)
let payloadData: any;
try {
payloadData = this.jwt.verify(oldAuthToken, jwtSecret, { ignoreExpiration: true });
} catch (err: any) {
throw this.errorService.createAuthenticationError("Invalid auth token");
}
// Remove old token metadata
delete payloadData.iat;
delete payloadData.exp;
// Issue new access token
const newAuthToken = this.issueToken(payloadData, appSettings);
return { user: payloadData, token: newAuthToken, refreshToken: refreshTokenFromHeader };
return { user, token };
};
deleteUser = async (user: any) => {
+192 -26
View File
@@ -1,9 +1,3 @@
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SERVICE_NAME = "EmailService";
/**
@@ -46,16 +40,37 @@ class EmailService {
*/
this.loadTemplate = (templateName) => {
try {
const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
return this.compile(templateContent);
} catch (error) {
this.logger.error({
message: error.message,
// Use EMAIL_TEMPLATE_PATH environment variable or default to src/templates/ in cwd
const templateBase = process.env.EMAIL_TEMPLATE_PATH || this.path.join(process.cwd(), "src", "templates");
const templatePath = this.path.join(templateBase, `${templateName}.mjml`);
this.logger.debug({
message: `Loading template: ${templateName}`,
service: SERVICE_NAME,
method: "loadTemplate",
templatePath: templatePath,
});
const templateContent = this.fs.readFileSync(templatePath, "utf8");
const compiled = this.compile(templateContent);
this.logger.debug({
message: `Template loaded successfully: ${templateName}`,
service: SERVICE_NAME,
method: "loadTemplate",
});
return compiled;
} catch (error) {
this.logger.error({
message: `Failed to load template '${templateName}': ${error.message}`,
service: SERVICE_NAME,
method: "loadTemplate",
templateName: templateName,
error: error.message,
stack: error.stack,
});
// Fail fast - throw error instead of returning empty function
throw error;
}
};
@@ -83,20 +98,169 @@ class EmailService {
buildEmail = async (template, context) => {
try {
if (!this.templateLookup[template]) {
this.logger.error({
message: `Template '${template}' not found in templateLookup`,
service: SERVICE_NAME,
method: "buildEmail",
availableTemplates: Object.keys(this.templateLookup),
});
throw new Error(`Template '${template}' not found`);
}
if (typeof this.templateLookup[template] !== "function") {
this.logger.error({
message: `Template '${template}' is not a function. Type: ${typeof this.templateLookup[template]}`,
service: SERVICE_NAME,
method: "buildEmail",
templateValue: this.templateLookup[template],
});
throw new Error(`Template '${template}' is not a function`);
}
const mjml = this.templateLookup[template](context);
// Check if MJML is empty (template failed to load)
if (!mjml || mjml.trim() === "") {
const msg = `Template '${template}' returned empty MJML content. Template may have failed to load.`;
this.logger.error({
message: msg,
service: SERVICE_NAME,
method: "buildEmail",
template: template,
});
throw new Error(msg);
}
const html = await this.mjml2html(mjml);
// Check if HTML is empty
if (!html || !html.html) {
const msg = `MJML conversion failed for template '${template}'. No HTML output.`;
this.logger.error({
message: msg,
service: SERVICE_NAME,
method: "buildEmail",
template: template,
mjmlLength: mjml.length,
});
throw new Error(msg);
}
return html.html;
} catch (error) {
this.logger.error({
message: error.message,
message: `Failed to build email for template '${template}': ${error.message}`,
service: SERVICE_NAME,
method: "buildEmail",
template: template,
error: error.message,
stack: error.stack,
});
throw error;
}
};
/**
* Validates email parameters.
* @param {string} to - The recipient email address.
* @param {string} subject - The email subject.
* @param {string} html - The email HTML content.
* @returns {boolean} True if valid, false otherwise.
*/
validateEmailParams(to, subject, html) {
if (!to || !subject) {
this.logger.error({
message: "Invalid email parameters: missing 'to' or 'subject'",
service: SERVICE_NAME,
method: "validateEmailParams",
});
return false;
}
if (!html || html.trim() === "") {
this.logger.error({
message: "Cannot send email: HTML content is empty",
service: SERVICE_NAME,
method: "validateEmailParams",
});
return false;
}
return true;
}
/**
* Validates the from email address.
* @param {string} systemEmailAddress - The system email address.
* @returns {boolean} True if valid, false otherwise.
*/
validateFromAddress(systemEmailAddress) {
const fromAddress = systemEmailAddress;
if (!fromAddress || !fromAddress.includes("@")) {
this.logger.error({
message: "Missing or invalid systemEmailAddress - a valid email address is required",
service: SERVICE_NAME,
method: "validateFromAddress",
});
return false;
}
return true;
}
/**
* Builds the TLS configuration for the email transporter.
* @param {Object} config - The email configuration object.
* @returns {Object} The TLS configuration object.
*/
buildTLSConfig(config) {
const { systemEmailSecure, systemEmailIgnoreTLS, systemEmailRequireTLS, systemEmailRejectUnauthorized, systemEmailTLSServername } = config;
const tlsConfig = {};
// Only apply TLS settings if secure is enabled
if (systemEmailSecure) {
// Top-level Nodemailer options (control STARTTLS behavior):
// - ignoreTLS: If true, the connection will not attempt to use STARTTLS
// - requireTLS: If true, connection will fail if STARTTLS is not available
if (systemEmailIgnoreTLS !== undefined) {
tlsConfig.ignoreTLS = systemEmailIgnoreTLS;
}
if (systemEmailRequireTLS !== undefined) {
tlsConfig.requireTLS = systemEmailRequireTLS;
}
// Node.js TLS options (go inside the 'tls' object):
// - rejectUnauthorized: If false, accepts self-signed certificates
// - servername: Overrides the hostname for SNI (Server Name Indication)
const tlsSettings = {};
if (systemEmailRejectUnauthorized !== undefined) {
tlsSettings.rejectUnauthorized = systemEmailRejectUnauthorized;
}
if (systemEmailTLSServername !== undefined && systemEmailTLSServername !== null && systemEmailTLSServername !== "") {
tlsSettings.servername = systemEmailTLSServername;
}
// Only add tls property if we have TLS settings
if (Object.keys(tlsSettings).length > 0) {
tlsConfig.tls = tlsSettings;
this.logger.debug({
message: `TLS settings applied to email config`,
service: SERVICE_NAME,
method: "buildTLSConfig",
tlsSettings: Object.keys(tlsSettings),
});
}
}
return tlsConfig;
}
sendEmail = async (to, subject, html, transportConfig) => {
// Validate email parameters
if (!this.validateEmailParams(to, subject, html)) {
return false;
}
let config;
if (typeof transportConfig !== "undefined") {
config = transportConfig;
@@ -107,35 +271,35 @@ class EmailService {
systemEmailHost,
systemEmailPort,
systemEmailSecure,
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
systemEmailIgnoreTLS,
systemEmailRequireTLS,
systemEmailRejectUnauthorized,
} = config;
// Validate from address
if (!this.validateFromAddress(systemEmailAddress)) {
return false;
}
// Build base email config
const emailConfig = {
host: systemEmailHost,
port: Number(systemEmailPort),
secure: systemEmailSecure,
pool: config.systemEmailPool ?? false,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: systemEmailPassword,
},
name: systemEmailConnectionHost || "localhost",
connectionTimeout: 5000,
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
};
// Apply TLS configuration
const tlsConfig = this.buildTLSConfig(config);
Object.assign(emailConfig, tlsConfig);
this.transporter = this.nodemailer.createTransport(emailConfig);
try {
@@ -144,7 +308,8 @@ class EmailService {
this.logger.warn({
message: "Email transporter verification failed",
service: SERVICE_NAME,
method: "verifyTransporter",
method: "sendEmail",
error: error.message,
});
return false;
}
@@ -164,6 +329,7 @@ class EmailService {
method: "sendEmail",
stack: error.stack,
});
return false;
}
};
}
@@ -3,8 +3,6 @@ const SERVICE_NAME = "SettingsService";
const envConfig = {
jwtSecret: process.env.JWT_SECRET,
jwtTTL: process.env.TOKEN_TTL,
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || process.env.JWT_SECRET,
refreshTokenTTL: process.env.REFRESH_TOKEN_TTL || "7d",
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
nodeEnv: process.env.NODE_ENV,
logLevel: process.env.LOG_LEVEL,