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>
402 lines
21 KiB
JavaScript
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}‌ ‌ ‌ ‌ </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} •
|
|
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> •
|
|
<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,
|
|
}; |