// ── Outbound mail ────────────────────────────────────────────────────────── // // Email is configured in Admin → Settings → Email, not via env vars. The // `email_config` singleton holds the enabled flag, the sender identity and the // AES-GCM-encrypted credential for whichever transport is selected; the transport // itself is a registration in `src/engagement/transports` (ENGAGEMENT.md §3.1), // so which provider is used is DATA, not a code path in this file. // // Gmail OAuth2 was removed in engagement Phase 1 (§1.2a). Gmail is still reachable // as an ordinary SMTP relay (`smtp.gmail.com:587` with an app password) — the // operator types those in like any other host; nothing in here knows about it. // // Engagement Phase 5a moved every subject and body out of this file. What each // sender still owns is its RECIPIENT, its headers and its failure contract; what // it says is an `engagement_templates` row rendered by `engagement/templates.js` // (§4.6.1), which an operator can edit and which falls back to the shipped seed. // // **The failure contracts are the point of this file.** Six call sites, five // senders, and each one degrades a specific way when mail is unconfigured. Those // contracts are unchanged by the transport rewrite and are asserted in // test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite // and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their // callers can surface a link / answer a generic 200, sendTeamNotification never // throws at all, and only sendTest throws — because only sendTest has an admin // waiting to be told why. const emailConfig = require('../model/emailConfig/emailConfig.model') const settings = require('../model/settings/settings.model') const { transports } = require('../engagement') const templates = require('../engagement/templates') const log = require('./logger')('mailer') /** * The body for one message, from its template (engagement Phase 5a). * * Every subject and body in this file used to be a template literal; they are now * `engagement_templates` rows an operator can edit, and this is the single seam * where that happens. Three properties the senders below depend on: * * - **It cannot fail.** `renderByKey` falls back to the shipped seed whenever the * row is missing or unusable, so no failure mode of the templates table can * stop a password-reset mail. It returns null only for a key that names neither * a row nor a seed, which is a programmer error and throws here rather than * sending a blank message. * - **The text part is byte-identical to what this file used to build**, which is * §5a's acceptance criterion and is pinned by `test/emailTemplates.test.js`. * - **The HTML part is new.** Nothing here had one before; mail is now * multipart/alternative, so a client that prefers HTML shows the branded body * and one that does not shows exactly the text it always showed. */ async function body(key, values) { const rendered = await templates.renderByKey(key, values) if (!rendered) throw new Error(`mailer: no template and no shipped seed for "${key}"`) if (rendered.missing.length) { // Not an error: an optional variable a caller chose not to supply renders as // nothing by design. Logged because the other cause is a renamed variable in // an operator's edited template, and that one reads as words gone missing. log.debug('template variables had no value', { key, missing: rendered.missing }) } return rendered } // Ready to send only when enabled, holding a complete credential for a // registered transport, and knowing which address to send as. async function isConfigured() { const c = await emailConfig.getSafe() return Boolean(c.enabled && c.hasCredential && c.senderEmail) } // Recipient for the contact form: the admin-editable contact_email setting, or // the configured sending address as a last resort. async function contactRecipient(senderEmail) { const to = await settings.get('contact_email') return to || senderEmail || null } /** * Build a transport from the stored config. Returns { transport, config } or * null when unconfigured — every sender handles null itself. * * Null, never a throw, for all five ways this can fail: no row, disabled, no * sender address, an incomplete credential, or a stored transport id that is not * registered (a downgrade, or a provider removed from the build). The last one is * the reason `transports.get()` is checked rather than assumed: a send-time * exception from an unknown id would break the contact form for a reason the * admin screen already shows. * * **`enabled` is checked here now, and it was not before.** Under the connect * flow this function tested only "is there a refresh token and a sender", so the * contact form kept sending after an admin unticked "Enable email sending" — * `isConfigured()` honoured the toggle but the direct senders bypassed it. With * the toggle no longer set as a side effect of a consent redirect it has to mean * what it says, so the gate lives on the one path every sender shares. */ async function buildTransport() { const config = await emailConfig.getWithSecret() if (!config || !config.enabled || !config.senderEmail) return null const def = transports.get(config.transport) if (!def) { log.warn('email send skipped: no such transport', { transport: config.transport }) return null } if (!transports.isComplete(config.transport, config.credentialSecret)) { log.warn('email send skipped: transport credentials are incomplete', { transport: config.transport }) return null } try { return { transport: def.build(config.credentialSecret, config), config } } catch (err) { log.error('could not build the mail transport', err) return null } } function fromHeader(config) { return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail } // Reply-To is the operator's optional override; the contact form's per-message // replyTo (the visitor's address) wins over it, which is the whole reason the // contact form has one. function replyToFor(config, override) { return override || config.replyTo || undefined } /** * Send a contact message. If email is not configured/enabled, signals the caller * to fall back to a mailto: link instead of throwing. */ async function sendContactMessage({ name, email, message }) { const built = await buildTransport() if (!built) { const c = await emailConfig.getSafe() return { sent: false, fallback: 'mailto', email: await contactRecipient(c.senderEmail) } } const { transport, config } = built const to = await contactRecipient(config.senderEmail) // `fromLabel` and `fromName` are the same missing name with the two different // fallbacks this message has always used — 'a visitor' in the subject, 'unknown' // in the body. Kept exactly, and now visible to an operator who wants one word. const { subject, html, text } = await body('admin.contact-message', { fromLabel: name || 'a visitor', fromName: name || 'unknown', fromEmail: email || 'no email', message, }) try { await transport.sendMail({ from: fromHeader(config), to, replyTo: replyToFor(config, email), subject, text, html, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() }) return { sent: true } } catch (err) { log.error('contact send failed', err) await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err) }) throw err } } /** * Turn a transport error into something an operator can act on. * * This matters more than it used to. Under Gmail OAuth2 the sending address came * back from Google's userinfo and was guaranteed to be a mailbox the credential * owned. Under SMTP `sender_email` is operator-typed, so a relay rejecting the * envelope From is now a live failure mode (§1.2a consequence 2) — and it arrives * as a bare "550 5.7.1" that means nothing without the sender in front of it. */ function describeSendError(err, config) { const code = err && (err.responseCode || err.code) const base = (err && (err.response || err.message)) || 'Send failed' const sender = config && config.senderEmail if (sender && (code === 550 || code === 553 || code === 554 || code === 'EENVELOPE')) { return `${base} — the server refused "${sender}" as the sender. It must be an address this account is allowed to send as (SPF/DMARC).` } if (code === 'EAUTH') return `${base} — the username or password was rejected.` if (code === 'ESOCKET' || code === 'ECONNECTION') { return `${base} — could not connect. Check the host, the port, and whether "Implicit TLS" matches it (on for 465, off for 587).` } if (code === 'ETIMEDOUT') { return `${base} — the connection timed out. A common cause is "Implicit TLS" left on for port 587.` } return String(base) } /** * Send a test email to `to`, used by the admin "Send test" button. Throws on * failure; records the outcome either way. Returns { sent: true } on success. * * **This is now the real verification of the whole configuration** — host, port, * TLS mode, credentials AND whether the relay will accept the operator-typed * sender. There is no consent flow left to prove any of it beforehand. */ async function sendTest(to) { const built = await buildTransport() if (!built) { const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.') err.code = 'NOT_CONFIGURED' throw err } const { transport, config } = built const recipient = to || (await contactRecipient(config.senderEmail)) if (!recipient) { const err = new Error('No recipient available for the test email.') err.code = 'NO_RECIPIENT' throw err } const { subject, html, text } = await body('admin.test', { transport: config.transport, sentAt: new Date().toISOString(), }) try { await transport.sendMail({ from: fromHeader(config), to: recipient, replyTo: replyToFor(config), subject, text, html, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() }) return { sent: true, to: recipient } } catch (err) { const detail = describeSendError(err, config) log.error('test send failed', err) await emailConfig.recordStatus({ status: 'error', statusDetail: detail }) const wrapped = new Error(detail) wrapped.code = err.code || 'SEND_FAILED' throw wrapped } } /** * Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened * accept link, `role` their assigned access level, `invitedByName` optional. If * email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so * the caller can surface the accept link for the admin to share manually rather * than throwing. Throws only on an actual send failure. */ async function sendInvite({ to, acceptUrl, role, invitedByName }) { const built = await buildTransport() if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } const { transport, config } = built // The two conditional fragments stay HERE, where a ternary belongs, and reach // the template as values. §4.6.2's grammar has no conditional by design. const roleLabel = role && role !== 'player' ? ` as ${role}` : '' const by = invitedByName ? ` by ${invitedByName}` : '' const { subject, html, text } = await body('auth.invite', { acceptUrl, roleLabel, invitedBy: by, }) try { await transport.sendMail({ from: fromHeader(config), to, replyTo: replyToFor(config), subject, text, html, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() }) return { sent: true } } catch (err) { log.error('invite send failed', err) await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) throw err } } /** * Send a password-reset link. `to` is the account's email, `resetUrl` the tokened * reset link, `username` names which account it's for. If email is not configured, returns * { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic * success to avoid leaking whether the address exists. Throws only on a send failure. */ async function sendPasswordReset({ to, resetUrl, username }) { const built = await buildTransport() if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } const { transport, config } = built const forWhom = username ? ` for the account “${username}”` : '' const { subject, html, text } = await body('auth.password-reset', { resetUrl, username, forWhom }) try { await transport.sendMail({ from: fromHeader(config), to, replyTo: replyToFor(config), subject, text, html, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() }) return { sent: true } } catch (err) { log.error('password reset send failed', err) await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) throw err } } /** * Send an email-verification link (engagement Phase 1b). `to` is the address * being PROVED — which is by definition not yet the account's address, and may * belong to someone who has never heard of this site. So the copy names the * account and says plainly what to do if it was not you, and the link installs * an address rather than granting any access. * * Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured; * the caller surfaces that honestly, because unlike a password reset there is no * enumeration reason to pretend a mail went out to an address the CALLER typed. */ async function sendEmailVerification({ to, verifyUrl, username }) { const built = await buildTransport() if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } const { transport, config } = built const forWhom = username ? ` “${username}”` : '' const { subject, html, text } = await body('auth.email-verify', { verifyUrl, username, forWhom }) try { await transport.sendMail({ from: fromHeader(config), to, replyTo: replyToFor(config), subject, text, html, }) await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() }) return { sent: true } } catch (err) { log.error('email verification send failed', err) await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }) throw err } } /** * Send a Team notification — one event (`immediate` mode) or a day's worth * (`digest` mode). TEAMS.md §6.4. * * **This one carries CONTENT, and the push tickle beside it deliberately does * not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable * topic, so it carries `{ stream, ref }` and the app pulls the real thing over an * access-checked API. A mailbox is a destination the recipient chose. Same * reasoning as the Discord bridge (§7.2), and it is why this function takes * excerpts rather than ids. * * **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius * of a mis-addressed or forwarded mail is a sentence rather than a thread. The * caller does the truncation, because it is the caller that knows the body was * already stripped of markup. * * The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button * appear, and both halves are needed: the `mailto:`-free URL form for clients that * open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs * without ever showing the user a page. Both reach the same tokened endpoint that * writes the same per-Team mute the site shows. * * Never throws. A notification failing must not fail the forum write that caused * it, and there is nobody up the stack to catch it — the digest worker runs on a * timer and the immediate send is fired from a request that has already replied. */ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) { const built = await buildTransport() if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } const { transport, config } = built const lines = [intro, ''] for (const item of items || []) { lines.push(`${item.heading}`) if (item.excerpt) lines.push(` ${item.excerpt}`) if (item.url) lines.push(` ${item.url}`) lines.push('') } if (teamUrl) lines.push(teamUrl, '') if (unsubscribeUrl) { lines.push('To stop these emails for this team, use this link:', unsubscribeUrl) } try { await transport.sendMail({ from: fromHeader(config), to, replyTo: replyToFor(config), subject, text: lines.join('\n'), // The header carries the API url, not the one in the body: a one-click // client POSTs to whatever is here without rendering anything, so it has to // be an endpoint. Falls back to the body's url when no API one was passed. headers: (unsubscribeApiUrl || unsubscribeUrl) ? { 'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', } : undefined, }) return { sent: true } } catch (err) { // Logged and swallowed, unlike every other sender in this file. Those are // called by a request that can report the failure to whoever caused it; this // one is not, and recordStatus already puts the error where an admin reads it. log.warn('team notification send failed', { message: err.message }) await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }).catch(() => {}) return { sent: false, reason: 'SEND_FAILED' } } } module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset, sendEmailVerification, sendTeamNotification, }