-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Fix email template loading and conditional TLS configuration #3134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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`); | ||||||||||||||||||
| // 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`); | ||||||||||||||||||
|
Comment on lines
42
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 | Confidence: High The PR changes the default template search path from using
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| this.logger.debug({ | ||||||||||||||||||
| message: `Loading template: ${templateName}`, | ||||||||||||||||||
| service: SERVICE_NAME, | ||||||||||||||||||
| method: "loadTemplate", | ||||||||||||||||||
| templatePath: templatePath, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| const templateContent = this.fs.readFileSync(templatePath, "utf8"); | ||||||||||||||||||
| return this.compile(templateContent); | ||||||||||||||||||
| 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: error.message, | ||||||||||||||||||
| 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; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
41
to
74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid unhandled init failures after
🔧 Proposed fix- init = async () => {
+ init = () => {
/**
* Loads an email template from the filesystem.
*
* `@param` {string} templateName - The name of the template to load.
* `@returns` {Function} A compiled template function that can be used to generate HTML email content.
*/🤖 Prompt for AI Agents |
||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 | Confidence: High The PR gates TLS configuration (including Code Suggestion: // Build TLS configuration that respects both secure and STARTTLS scenarios
const tlsConfig = {};
// These are top-level Nodemailer options for STARTTLS behavior, relevant even when secure=false
if (systemEmailIgnoreTLS !== undefined) {
tlsConfig.ignoreTLS = systemEmailIgnoreTLS;
}
if (systemEmailRequireTLS !== undefined) {
tlsConfig.requireTLS = systemEmailRequireTLS;
}
// Node.js TLS socket options (inside 'tls' object) should be applied when using TLS (secure OR STARTTLS)
// Determine if TLS will be used: either secure connection OR STARTTLS is not ignored
const willUseTLS = systemEmailSecure || (systemEmailIgnoreTLS === false);
if (willUseTLS) {
const tlsSettings = {};
if (systemEmailRejectUnauthorized !== undefined) {
tlsSettings.rejectUnauthorized = systemEmailRejectUnauthorized;
}
if (systemEmailTLSServername) {
tlsSettings.servername = systemEmailTLSServername;
}
if (Object.keys(tlsSettings).length > 0) {
tlsConfig.tls = tlsSettings;
}
}
return tlsConfig;Evidence: method:buildTLSConfig, path:client/src/Utils/NetworkService.js |
||||||||||||||||||
| 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; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+214
to
+256
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Short summary — how Nodemailer handles TLS / STARTTLS and the relevant options:
Practical notes:
Sources:
🌐 Web query:
💡 Result: Summary of how these Nodemailer options interact:
Practical combinations:
References: Nodemailer SMTP docs and TLS/connection docs. (nodemailer.com) Citations:
TLS options are incorrectly gated by In Nodemailer, Remove the outer 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| 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 { | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 | Confidence: Medium Adding Code Suggestion: // In init() or a dedicated connect() method
async initializeTransporter(config) {
this.transporter = this.nodemailer.createTransport(emailConfig);
try {
await this.transporter.verify();
this.transporterVerified = true;
} catch (error) {
this.logger.warn({ message: 'Transporter verification failed', error: error.message });
this.transporterVerified = false;
// Optionally still keep transporter but log warning
}
}
// In sendEmail, skip verify() if already verified, or add a retry/refresh mechanism.Evidence: method:sendEmail |
||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||
| } | ||||||||||||||||||
| }; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 | Confidence: Medium
The refactored
buildEmailConfigPreviewfunction replicates the same incorrect TLS conditional logic as the server-sidebuildTLSConfig. This creates a validation mismatch: the client preview will show a configuration that differs from what the server will actually use whensystemEmailSecure=falsebut TLS options are set. This defeats the purpose of the preview, as users will see an incomplete config and not realize their TLS settings are being ignored. The client preview should accurately reflect the server's actual configuration logic.Code Suggestion: