Skip to content
Merged
Show file tree
Hide file tree
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
73 changes: 7 additions & 66 deletions backend/src/notifications/channels/email.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
'use strict';

const nodemailer = require('nodemailer');

/**
* Email notification channel.
* Uses nodemailer in production; stubs in development/test.
Expand Down Expand Up @@ -107,56 +103,11 @@ export async function sendEmail(to, { subject, body, html }) {
logger.error('email.transport.failed', { error: err.message });
return { success: false, error: err.message };
}
let transporter = null;

function getTransporter() {
if (transporter) {
return transporter;
}

const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS } = process.env;

if (!SMTP_HOST) {
return null;
}

transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT ? Number(SMTP_PORT) : 587,
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER
? {
user: SMTP_USER,
pass: SMTP_PASS,
}
: undefined,
});

return transporter;
}

/**
* Send an email message.
*
* @param {Object} message
* @param {string} message.to Recipient address.
* @param {string} message.subject Subject line.
* @param {string} message.text Plain-text body.
* @param {string} [message.html] Optional HTML body.
* @returns {Promise<{sent: boolean, reason?: string}>}
*/
async function sendEmail({ to, subject, text, html }) {
if (!to || !subject) {
throw new Error('sendEmail requires "to" and "subject"');
}

const transport = getTransporter();

if (!transport) {
// No SMTP configured: log and report as not sent so callers can decide.
// eslint-disable-next-line no-console
console.warn(`[email] SMTP not configured, skipping email to ${to}: ${subject}`);
return { sent: false, reason: 'smtp_not_configured' };
if (!t) {
// No SMTP configured — stub in dev/test environments
logger.warn('email.stub', { to, subject });
return { success: true, stub: true };
}

try {
Expand All @@ -172,15 +123,7 @@ async function sendEmail({ to, subject, text, html }) {
} catch (err) {
logger.error('email.send.failed', { to, subject, error: err.message });
return { success: false, error: err.message };
await transport.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER || 'no-reply@localhost',
to,
subject,
text,
html,
});

return { sent: true };
}
}

/**
Expand Down Expand Up @@ -213,7 +156,5 @@ async function sendVerificationEmail({ to, token, ttlMinutes = 15 }) {
return sendEmail({ to, subject, text, html });
}

module.exports = {
sendEmail,
sendVerificationEmail,
};
export { sendVerificationEmail };
export default { sendEmail, sendVerificationEmail };
3 changes: 1 addition & 2 deletions backend/src/notifications/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ export async function cleanupStaleNotifications({ now = new Date() } = {}) {
* @param {string} [params.locale] - Override locale; if omitted, read from user preferences
* @returns {Promise<object>} Results per channel
*/
export async function sendNotification({ userId, type, data = {}, email, phone, phoneRegion, publicKey, actionUrl, actionRetryParams, channels = CHANNELS }) {
export async function sendNotification({ userId, type, data = {}, email, phone, publicKey, actionUrl, actionRetryParams, channels = CHANNELS, locale }) {
export async function sendNotification({ userId, type, data = {}, email, phone, phoneRegion, publicKey, actionUrl, actionRetryParams, channels = CHANNELS, locale }) {
const results = {};

// Resolve locale: caller may supply it explicitly (e.g. from a webhook
Expand Down
13 changes: 2 additions & 11 deletions backend/src/notifications/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,24 +101,15 @@ export function renderHtmlTemplate(template, data = {}) {
}

/**
* Get a rendered template for a given type and channel.
* Get a rendered template for a given type, channel, and locale.
* Email templates are rendered as both plain text (`body`) and HTML (`html`).
* The subject is a plain-text header and is never HTML-encoded.
* @param {string} type - Template key (e.g. 'transaction_received')
* @param {string} channel - 'email' | 'push' | 'sms' | 'inApp'
* @param {Record<string, string>} data
* @returns {{ subject?: string, title?: string, body: string, html?: string } | null}
*/
export function getRenderedTemplate(type, channel, data = {}) {
if (!Object.prototype.hasOwnProperty.call(TEMPLATES, type)) return null;
const tmpl = Object.prototype.hasOwnProperty.call(TEMPLATES[type], channel) ? TEMPLATES[type][channel] : null;
* Get a rendered template for a given type, channel, and locale.
*
* @param {string} type - Template key (e.g. 'transaction_received')
* @param {string} channel - 'email' | 'push' | 'sms' | 'inApp'
* @param {Record<string, string>} data - Interpolation variables
* @param {string} [locale='en'] - Recipient's preferred locale
* @returns {{ subject?: string, title?: string, body: string } | null}
* @returns {{ subject?: string, title?: string, body: string, html?: string } | null}
*/
export function getRenderedTemplate(type, channel, data = {}, locale = 'en') {
const tmpl = getRawTemplate(type, channel, locale);
Expand Down
Loading