// ── The mail transport registry ──────────────────────────────────────────── // // ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email, // push, in-app); a **transport** is how one channel actually delivers. This file // is the second half only; the channel half is `../channels.js`, which Phase 3 // added when `notification_channel_prefs` needed a single place for `defaultMode` // to live. Its render/deliver functions are still deferred to the phases that can // exercise them, for the reason this comment used to give about the whole file: // registering a function nothing calls freezes a signature before anything has // tried to use it. // // What this replaces: `mailer.buildTransport()` had Gmail's host, port and // OAuth2 auth type as literals, so "which provider" was a code edit. Now the // stored `email_config.transport` names a registration, and the registration // declares its own credential fields — which drives the admin form, the encrypted // blob's shape and the validation, from one place. // // **`credentialFields` is the contract.** It is read by three consumers that // would otherwise drift: the admin form renders it, `sanitizeCredential()` below // filters a submitted body through it, and `describe()` tells the client which // values are secret so they are never sent back. Adding a field to a transport is // therefore one edit, not four. // // **No transport may carry a default host, endpoint or sender** (§3.2 rule 1). A // transport with no operator configuration is `unconfigured` and its channel is // off — it never falls back to somewhere we chose. `scripts/checkNoExternalHosts.js` // is the CI backstop for that rule; this file is where it would be broken first. // // Nothing here touches the database or the network at require time. const log = require('../../utils/logger')('mailer') // id → transport definition const transports = new Map() // Field kinds the admin form knows how to render. `secret` is the only one that // changes behaviour server-side: it is write-only, so an unchanged value arrives // as '' and must be read from the stored credential rather than overwritten. const FIELD_KINDS = new Set(['text', 'number', 'secret', 'boolean']) /** * Register a mail transport. Shape-checked at the call and collision-checked * here, the same validate-then-commit discipline `modules/registries.js` uses. * * @param {object} def * @param {string} def.id stable id stored in email_config.transport * @param {string} def.label human name for the admin form * @param {Array} def.credentialFields [{ key, label, kind, required, help, default }] * @param {Function} def.build (credential, config) → a nodemailer-shaped transport * @param {Function} def.isComplete (credential) → boolean; are the required fields present */ function registerMailTransport(def) { if (!def || typeof def !== 'object') throw new Error('registerMailTransport: definition required') const { id, label, credentialFields, build, isComplete } = def if (typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id)) { throw new Error(`registerMailTransport: invalid id ${JSON.stringify(id)}`) } if (transports.has(id)) throw new Error(`registerMailTransport: ${id} is already registered`) if (typeof label !== 'string' || !label) throw new Error(`registerMailTransport(${id}): label required`) if (!Array.isArray(credentialFields) || credentialFields.length === 0) { throw new Error(`registerMailTransport(${id}): credentialFields required`) } for (const f of credentialFields) { if (!f || typeof f.key !== 'string' || !f.key) { throw new Error(`registerMailTransport(${id}): every credential field needs a key`) } if (!FIELD_KINDS.has(f.kind)) { throw new Error(`registerMailTransport(${id}): field ${f.key} has unknown kind ${f.kind}`) } } if (typeof build !== 'function') throw new Error(`registerMailTransport(${id}): build() required`) if (typeof isComplete !== 'function') throw new Error(`registerMailTransport(${id}): isComplete() required`) transports.set(id, { ...def, credentialFields: credentialFields.map((f) => ({ ...f })) }) return id } /** The registered transport, or null. Callers must handle null — a stored id can * name a transport that no longer exists (a downgrade, a removed provider), and * that must degrade to "unconfigured", never throw at send time. */ function get(id) { return transports.get(id) || null } function has(id) { return transports.has(id) } /** Every transport, as the admin form needs it: no functions, secrets flagged. */ function describe() { return [...transports.values()].map((t) => ({ id: t.id, label: t.label, help: t.help || null, credentialFields: t.credentialFields.map((f) => ({ key: f.key, label: f.label || f.key, kind: f.kind, required: Boolean(f.required), help: f.help || null, default: f.default === undefined ? null : f.default, placeholder: f.placeholder || null, })), })) } /** * Filter a submitted credential body down to the transport's declared fields, * coercing each to its declared kind. Anything not declared is dropped — the * blob that reaches `secretBox.encrypt` only ever holds fields a transport asked * for, so a client cannot smuggle extra keys into stored ciphertext. * * `secret` fields submitted empty are OMITTED rather than blanked, which is the * "leave the existing one alone" convention `botConfig.save`/`emailConfig.save` * already use; `mergeCredential()` is what puts the stored value back. */ function sanitizeCredential(id, body) { const t = get(id) if (!t) return {} const out = {} for (const f of t.credentialFields) { if (!(f.key in (body || {}))) continue const raw = body[f.key] if (f.kind === 'secret') { if (raw === undefined || raw === null || raw === '') continue out[f.key] = String(raw) } else if (f.kind === 'number') { const n = Number(raw) if (Number.isFinite(n)) out[f.key] = n } else if (f.kind === 'boolean') { out[f.key] = Boolean(raw) } else { out[f.key] = raw === null || raw === undefined ? '' : String(raw) } } return out } /** Stored credential + the submitted patch. Omitted secrets keep their stored value. */ function mergeCredential(id, stored, patch) { return { ...(stored || {}), ...(patch || {}) } } /** Non-secret fields only — safe to return over the admin API. */ function publicCredential(id, credential) { const t = get(id) if (!t || !credential) return {} const out = {} for (const f of t.credentialFields) { if (f.kind === 'secret') continue if (credential[f.key] !== undefined) out[f.key] = credential[f.key] } return out } /** Which declared secrets are actually held, so the form can say "set" without * ever returning the value. */ function secretsPresent(id, credential) { const t = get(id) if (!t) return {} const out = {} for (const f of t.credentialFields) { if (f.kind !== 'secret') continue out[f.key] = Boolean(credential && credential[f.key]) } return out } /** Does this credential have everything its transport needs to send? */ function isComplete(id, credential) { const t = get(id) if (!t) return false try { return Boolean(t.isComplete(credential || {})) } catch (err) { log.warn('transport isComplete threw', { transport: id, message: err.message }) return false } } // Test-only: the registry is module-level state and a suite that registers a // fake transport must be able to undo it. function _reset() { transports.clear() } module.exports = { registerMailTransport, get, has, describe, sanitizeCredential, mergeCredential, publicCredential, secretsPresent, isComplete, _reset, }