Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions server/src/service/v1/infrastructure/emailService.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class EmailService {
return EmailService.SERVICE_NAME;
}

init = async () => {
// Initialize template loader and pre-compile frequently used templates.
// NOTE: This is intentionally synchronous so that templates are available
// immediately after the constructor returns (prevents race conditions
// where callers invoke buildEmail before templateLookup is populated).
init = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Medium

Synchronous file reads (readFileSync) during initialization could block the event loop, especially with many templates. While the change aims to prevent race conditions, it may impact application startup time and scalability.

Code Suggestion:

this.initialized = false;
init = async () => {
	// ... async file operations
	this.initialized = true;
};
// In buildEmail, check initialization status

/**
* Loads an email template from the filesystem.
*
Expand All @@ -48,6 +52,7 @@ class EmailService {
try {
const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
// compile returns a function that accepts a context and returns the rendered mjml string
return this.compile(templateContent);
} catch (error) {
this.logger.error({
Expand All @@ -56,6 +61,8 @@ class EmailService {
method: "loadTemplate",
stack: error.stack,
});
// Return null when template can't be loaded so callers can handle it explicitly
return null;
}
};

Expand Down Expand Up @@ -83,16 +90,40 @@ class EmailService {

buildEmail = async (template, context) => {
try {
const mjml = this.templateLookup[template](context);
const html = await this.mjml2html(mjml);
return html.html;
// Ensure the template exists and is a function. Templates are stored in the lookup
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The change from silently returning undefined to throwing errors in buildEmail creates a breaking change for all callers. The related context shows settingsController.js calls buildEmail without try-catch, meaning template errors will now bubble up and crash the email test endpoint. This impacts method:buildEmail because existing callers expect graceful failure but will now receive unhandled exceptions.

Code Suggestion:

// In settingsController.js, wrap the buildEmail call:
try {
	const html = await this.emailService.buildEmail("testEmailTemplate", context);
} catch (error) {
	// Handle template error appropriately
	throw this.errorService.createServerError("Failed to generate email template.");
}

Evidence: path:server/src/controllers/v1/settingsController.js, method:buildEmail

// with keys like "hardwareIncidentTemplate" mapping to compiled functions.
let tplFn = this.templateLookup[template];
if (typeof tplFn !== "function") {
// Attempt to lazy-load the template. Templates are named by dropping a trailing
// "Template" suffix (eg. hardwareIncidentTemplate -> hardwareIncident.mjml).
const baseName = String(template).replace(/Template$/i, "");
tplFn = this.loadTemplate(baseName);
Comment on lines +97 to +100
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Medium

The template naming convention (removing "Template" suffix) creates implicit coupling between template lookup keys and filesystem names. This could lead to confusion if new templates don't follow this pattern. The logic is also case-insensitive (/Template$/i) which might mask naming inconsistencies.

Code Suggestion:

const templateMappings = {
	hardwareIncidentTemplate: 'hardwareIncident',
	// ... other mappings
};
const baseName = templateMappings[template] || template.replace(/Template$/i, "");

if (typeof tplFn === "function") {
// cache the compiled template for future calls
this.templateLookup[template] = tplFn;
} else {
this.logger.error({
message: `Template not found or failed to compile: ${template}`,
service: SERVICE_NAME,
method: "buildEmail",
});
throw new Error(`Template not found or failed to compile: ${template}`);
}
Comment on lines +104 to +111
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Low

The error message doesn't include the resolved template path, making debugging more difficult when templates are missing. Developers would need to manually trace the template resolution logic.

Suggested change
} else {
this.logger.error({
message: `Template not found or failed to compile: ${template}`,
service: SERVICE_NAME,
method: "buildEmail",
});
throw new Error(`Template not found or failed to compile: ${template}`);
}
throw new Error(`Template not found or failed to compile: ${template} (resolved to: ${baseName}.mjml)`);

}

const mjml = tplFn(context);
const result = this.mjml2html(mjml);
return (result && result.html) || "";
Comment on lines +114 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The mjml2html function is called without await, but the method is marked async and the previous implementation used await. If mjml2html returns a Promise (asynchronous MJML compilation), this will return a Promise instead of the HTML string, breaking the email rendering pipeline and causing downstream type errors.

Suggested change
const mjml = tplFn(context);
const result = this.mjml2html(mjml);
return (result && result.html) || "";
const mjml = tplFn(context);
const result = await this.mjml2html(mjml);
return (result && result.html) || "";

} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "buildEmail",
stack: error.stack,
});
// Re-throw to allow callers to detect templating failures instead of silently
// sending empty emails. Callers can catch and handle/log as needed.
throw error;
}
};

Expand Down