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 ` ${org.name} ${preheader ? `
${preheader}‌ ‌ ‌ ‌ 
` : ''}

${org.name}

${org.tagline}

${body}

${org.name} • ${org.email}${org.url}

This email was sent because you have an account or registration with ${org.name}.

`; } /** 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 `
${label}
`; } /** Renders a fallback link below a CTA button. */ function fallbackLink(url) { const color = getOrg().headerColor; return `

Or copy this link: ${url}

`; } /** Horizontal rule. */ function divider() { return `
`; } /** * 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 `
${content}
`; } /** Numbered payment option row. */ function paymentOption(num, title, detail) { const bg = getOrg().headerColor; return `
${num}

${title}

${detail}
`; } // ─── Builder functions ──────────────────────────────────────────────────────── function buildPasswordResetEmail({ name, resetUrl }) { const org = getOrg(); const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`; const body = `

Password reset request

We received a request to reset your password.

Hi ${name || 'there'},

Click the button below to choose a new password. This link will expire in 1 hour.

${ctaButton('Reset my password', resetUrl)} ${fallbackLink(resetUrl)} ${divider()}

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 ${org.email}.

`; 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 = `

Password changed

Security notification for your account

Hi ${name || 'there'},

Your ${org.name} account password was successfully changed ${when ? `on ${whenText}` : 'recently'}.

${callout(`This wasn't you?
If you did not make this change, your account may be compromised. Contact us immediately at ${contact} and reset your password right away.`, 'danger')}

If you made this change, no further action is needed. This is an automated security notification.

`; 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 = `

New login detected

A new session was opened on your account

Hi ${name || 'there'},

Detail Value
Time ${when}
Location ${location}
Device ${userAgent}
${callout(`Not you? If you don't recognise this login, change your password immediately and contact us at ${org.email}.`, 'danger')}

If this was you, no action is needed.

`; 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()}

Upcoming events

${events.map(e => ``).join('')}

${e.title}

${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}

${ctaButton('Browse all events', org.url + '/events')}` : `

Keep an eye on our website — new events are added regularly!

${ctaButton('View events', org.url + '/events')}`; const body = `

Welcome to ${org.name}!

Your account has been created

Hi ${name || 'there'},

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.

${eventsBlock} ${divider()}

We look forward to seeing you at our events!

`; 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 = `

Activate your account

One step to access your events and tickets

Hi ${name || 'there'},

Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more.

${ctaButton('Activate my account', activationUrl, { bg: '#059669' })} ${fallbackLink(activationUrl)} ${callout('This activation link expires in 24 hours.', 'warning')}

If you didn't expect this email, you can safely ignore it.

`; 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 = `

Account closed

Confirmation of account closure

Hi ${name || 'there'},

This email confirms that your ${org.name} account has been successfully closed.

${dataDeleted ? `

All personal data associated with your account has been permanently deleted as requested.

` : `

Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at ${org.email}.

` }

If you did not request this, please contact us immediately at ${org.email}.

`; 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, };