Files
hope-events/backend/src/utils/email.js
T
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00

402 lines
21 KiB
JavaScript

const nodemailer = require('nodemailer');
// ─── Transport ────────────────────────────────────────────────────────────────
// The SMTP transporter is built lazily and rebuilt whenever the settings cache
// reports a different configuration (e.g. after an admin updates SMTP settings).
const { getSettingSync } = require('./settingsCache');
/** Read current SMTP config from settings cache, falling back to env vars. */
function _smtpConfig() {
return {
host: getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || ''),
port: getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587'),
secure: getSettingSync('smtp_secure', process.env.SMTP_SECURE || process.env.EMAIL_SECURE || 'false'),
user: getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || ''),
pass: getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || ''),
from: getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || ''),
};
}
function _configHash(c) {
return [c.host, c.port, c.secure, c.user, c.pass].join('|');
}
let _cachedTransporter = null;
let _cachedConfigHash = null;
function _getTransporter() {
const cfg = _smtpConfig();
const hash = _configHash(cfg);
if (!_cachedTransporter || hash !== _cachedConfigHash) {
_cachedConfigHash = hash;
if (cfg.host) {
_cachedTransporter = nodemailer.createTransport({
host: cfg.host,
port: parseInt(cfg.port, 10) || 587,
secure: String(cfg.secure).toLowerCase() === 'true' || String(cfg.port) === '465',
auth: cfg.user && cfg.pass ? { user: cfg.user, pass: cfg.pass } : undefined,
});
} else {
_cachedTransporter = nodemailer.createTransport({ jsonTransport: true });
if (process.env.NODE_ENV !== 'production') {
console.info('[email] SMTP not configured — using jsonTransport (dev). Configure SMTP via Admin → Site Settings or set SMTP_HOST env var.');
}
}
}
return { transporter: _cachedTransporter, cfg };
}
async function sendMail({ to, subject, html, text, attachments }) {
// Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) {
console.info('[email] Skipping send to deleted account:', to);
return;
}
const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@crosscode.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
if (transporter.options && transporter.options.jsonTransport) {
try {
const payload = typeof info.message === 'string' ? JSON.parse(info.message) : info.message;
console.info('[email][dev] simulated:', { to, subject, envelope: info.envelope });
if (payload && payload.html) console.info('[email][dev] html (first 300 chars):', String(payload.html).slice(0, 300));
} catch {
console.info('[email][dev] simulated (raw):', info && info.message);
}
}
}
// ─── Shared template helpers ──────────────────────────────────────────────────
function getOrg() {
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
// headerColor prefers the new primary_color setting; falls back to the
// legacy accent_color value (which used to double as "the one brand color"
// before the 3-color Primary/Secondary/Accent system existed) so emails
// stay branded correctly until the admin re-saves the Branding tab.
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
};
}
/**
* Wraps HTML content in a professional, responsive email shell.
* @param {string} body - Inner HTML content
* @param {{ preheader?: string }} options
*/
function emailWrapper(body, { preheader = '' } = {}) {
const org = getOrg();
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>${org.name}</title>
</head>
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all">${preheader}&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;</div>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f1f5f9">
<tr><td align="center" style="padding:40px 16px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,${org.headerColor} 0%,#2d5287 100%);border-radius:12px 12px 0 0;padding:40px 48px;text-align:center">
<h1 style="color:#ffffff;font-size:28px;font-weight:800;margin:0;letter-spacing:-0.5px;font-family:${ff};text-shadow:0 1px 3px rgba(0,0,0,0.3)">${org.name}</h1>
<p style="color:#ffffff;font-size:14px;font-weight:500;margin:8px 0 0 0;font-family:${ff};letter-spacing:0.3px;opacity:0.9">${org.tagline}</p>
</td></tr>
<!-- Body -->
<tr><td style="background:#ffffff;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;padding:48px 48px 40px 48px">
<div style="font-family:${ff};color:#1e293b;font-size:15px;line-height:1.75">
${body}
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:24px 48px;text-align:center">
<p style="color:#94a3b8;font-size:12px;margin:0 0 4px 0;font-family:${ff}">
${org.name} &bull;
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> &bull;
<a href="${org.url}" style="color:#94a3b8;text-decoration:underline">${org.url}</a>
</p>
<p style="color:#cbd5e1;font-size:11px;margin:6px 0 0 0;font-family:${ff}">
This email was sent because you have an account or registration with ${org.name}.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
/** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
bg = bg || getOrg().headerColor;
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
<a href="${url}" target="_blank"
style="display:inline-block;padding:14px 36px;font-size:15px;font-weight:700;color:${fg};text-decoration:none;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;letter-spacing:0.2px;mso-hide:none"
>${label}</a>
</td></tr>
</table>`;
}
/** Renders a fallback link below a CTA button. */
function fallbackLink(url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
</p>`;
}
/** Horizontal rule. */
function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
}
/**
* Coloured callout box. type: info | success | warning | danger | neutral
* These 4 semantic colors are deliberately NOT brand-driven — a "this wasn't
* you" security warning must always read as urgent/red regardless of the
* org's brand color, so don't wire these to getOrg().headerColor.
*/
function callout(content, type = 'info') {
const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
success: { bg: '#f0fdf4', border: '#22c55e', color: '#166534' },
warning: { bg: '#fffbeb', border: '#f59e0b', color: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', color: '#991b1b' },
neutral: { bg: '#f8fafc', border: '#e2e8f0', color: '#475569' },
};
const s = map[type] || map.info;
return `<div style="background:${s.bg};border-left:4px solid ${s.border};border-radius:0 6px 6px 0;padding:16px 20px;margin:24px 0;color:${s.color};font-size:14px;line-height:1.6">
${content}
</div>`;
}
/** Numbered payment option row. */
function paymentOption(num, title, detail) {
const bg = getOrg().headerColor;
return `<tr>
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
<div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
</td>
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
<div style="font-size:13px;color:#64748b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;line-height:1.5">${detail}</div>
</td>
</tr>`;
}
// ─── Builder functions ────────────────────────────────────────────────────────
function buildPasswordResetEmail({ name, resetUrl }) {
const org = getOrg();
const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password reset request</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">We received a request to reset your password.</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">Click the button below to choose a new password. This link will expire in <strong>1 hour</strong>.</p>
${ctaButton('Reset my password', resetUrl)}
${fallbackLink(resetUrl)}
${divider()}
<p style="margin:0;font-size:13px;color:#94a3b8">
If you did not request a password reset, you can safely ignore this email — your password will not be changed.
If you're concerned, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const text = `Hi ${name || 'there'},\n\nWe received a request to reset your ${org.name} password.\n\nReset link (expires in 1 hour):\n${resetUrl}\n\nIf you did not request this, ignore this email.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildPasswordChangedEmail({ name, when, supportEmail }) {
const org = getOrg();
const contact = supportEmail || org.email;
const whenText = when ? new Date(when).toLocaleString() : 'recently';
const preheader = `Your ${org.name} password was changed. If this wasn't you, act immediately.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password changed</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Security notification for your account</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">
Your <strong>${org.name}</strong> account password was successfully changed
${when ? `on <strong>${whenText}</strong>` : 'recently'}.
</p>
${callout(`<strong>This wasn't you?</strong><br/>
If you did not make this change, your account may be compromised. Contact us immediately at
<a href="mailto:${contact}" style="color:#991b1b;font-weight:600">${contact}</a>
and reset your password right away.`, 'danger')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
If you made this change, no further action is needed. This is an automated security notification.
</p>`;
const text = `Hi ${name || 'there'},\n\nYour ${org.name} password was changed ${when ? 'on ' + whenText : 'recently'}.\n\nIf you did NOT make this change, contact us immediately at ${contact}.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildLoginNotificationEmail({ name, when, location, userAgent }) {
const org = getOrg();
const preheader = `New login to your ${org.name} account detected.`;
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">New login detected</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">A new session was opened on your account</p>
<p style="margin:0 0 24px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;width:36%;font-family:${ff}">Detail</td>
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Value</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Time</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${when}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Location</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${location}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Device</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;word-break:break-all;font-family:${ff}">${userAgent}</td>
</tr>
</table>
${callout(`<strong>Not you?</strong> If you don't recognise this login, change your password immediately and contact us at <a href="mailto:${org.email}" style="color:#991b1b">${org.email}</a>.`, 'danger')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">If this was you, no action is needed.</p>`;
const text = `Hi ${name || 'there'},\n\nA new login to your ${org.name} account was detected.\n\nTime: ${when}\nLocation: ${location}\nDevice: ${userAgent}\n\nIf this was NOT you, change your password immediately and contact ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildWelcomeEmail({ name, events }) {
const org = getOrg();
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const hasEvents = Array.isArray(events) && events.length > 0;
const preheader = `Welcome to ${org.name}! Your account is ready.`;
const eventsBlock = hasEvents
? `${divider()}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0">Upcoming events</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
${events.map(e => `<tr>
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9">
<p style="margin:0 0 2px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:${ff}">${e.title}</p>
<p style="margin:0;font-size:13px;color:#64748b;font-family:${ff}">${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
</td>
</tr>`).join('')}
</table>
${ctaButton('Browse all events', org.url + '/events')}`
: `<p style="color:#64748b;font-size:14px;margin:0">Keep an eye on our website — new events are added regularly!</p>
${ctaButton('View events', org.url + '/events')}`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Welcome to ${org.name}!</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Your account has been created</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 0 0;color:#374151">
Your ${org.name} account is set up and ready to go. Use your account to register for events, manage your bookings, and view your tickets — all in one place.
</p>
${eventsBlock}
${divider()}
<p style="margin:0;font-size:13px;color:#94a3b8">We look forward to seeing you at our events!</p>`;
const eventsText = hasEvents
? `Upcoming events:\n${events.map(e => `- ${e.title} (${new Date(e.startDate).toLocaleDateString()})`).join('\n')}`
: 'Keep an eye on our website for upcoming events.';
const text = `Welcome to ${org.name}, ${name || 'there'}!\n\nYour account has been created successfully.\n\n${eventsText}\n\n${org.url}\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildAccountActivationEmail({ name, activationUrl }) {
const org = getOrg();
const preheader = `Activate your ${org.name} account to get started.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Activate your account</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">One step to access your events and tickets</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">
Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more.
</p>
${ctaButton('Activate my account', activationUrl, { bg: '#059669' })}
${fallbackLink(activationUrl)}
${callout('This activation link expires in <strong>24 hours</strong>.', 'warning')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
If you didn't expect this email, you can safely ignore it.
</p>`;
const text = `Hi ${name || 'there'},\n\nActivate your ${org.name} account by visiting the link below:\n\n${activationUrl}\n\nThis link expires in 24 hours.\n\nIf you didn't expect this, ignore this email.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildAccountClosedEmail({ name, dataDeleted }) {
const org = getOrg();
const preheader = `Your ${org.name} account has been closed.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Account closed</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Confirmation of account closure</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 16px 0;color:#374151">
This email confirms that your <strong>${org.name}</strong> account has been successfully closed.
</p>
${dataDeleted
? `<p style="margin:0 0 16px 0;color:#374151">All personal data associated with your account has been permanently deleted as requested.</p>`
: `<p style="margin:0 0 16px 0;color:#374151">Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`
}
<p style="margin:0 0 0 0;color:#374151">
If you did not request this, please contact us immediately at
<a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const dataNote = dataDeleted
? 'All personal data has been permanently deleted.'
: 'Your account has been deactivated. Contact us if you also want your data deleted.';
const text = `Hi ${name || 'there'},\n\nYour ${org.name} account has been closed.\n\n${dataNote}\n\nIf you did not request this, contact us immediately at ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader }) };
}
module.exports = {
sendMail,
emailWrapper,
ctaButton,
fallbackLink,
divider,
callout,
paymentOption,
buildPasswordResetEmail,
buildPasswordChangedEmail,
buildLoginNotificationEmail,
buildWelcomeEmail,
buildAccountActivationEmail,
buildAccountClosedEmail,
};