feat(email): remove Gmail OAuth2, put SMTP behind a transport registry

Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-28 20:41:43 -05:00
parent e25e7ade80
commit 47c8b37d45
26 changed files with 1535 additions and 461 deletions

View File

@@ -0,0 +1,22 @@
// ── The engagement subsystem — one door ────────────────────────────────────
//
// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's
// own transports; the trigger registry, the rules engine and the delivery
// channels arrive in later phases and hang here too.
//
// **Core's transports register through the same door a module's would**, and
// they register HERE rather than at the bottom of the registry file. That keeps
// the registry free of any knowledge of its registrants — the same reason
// `registerCore()` is called from app.js rather than from inside
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
// registry never has the side effect of populating it.
//
// Requiring this module is what makes `smtp` available. Everything that resolves
// a transport goes through here, so there is exactly one place a transport can
// come into existence.
require('./transports/smtp')
const transports = require('./transports')
module.exports = { transports }

View File

@@ -0,0 +1,195 @@
// ── 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 registry arrives with the engine that
// consumes it — registering a channel nothing calls would be a shape frozen
// before anything had 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,
}

View File

@@ -0,0 +1,96 @@
// ── SMTP — the baseline mail transport ─────────────────────────────────────
//
// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This
// is the only registered transport, and it is deliberately plain SMTP rather than
// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA
// and Gmail-with-an-app-password are all reachable through these five fields, so
// one transport covers all three postures §7.1 Q5 asks to document.
//
// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with
// no operator configuration is unconfigured, never pointed at somewhere we chose.
// `secure` gets a default because it is a protocol choice, not a destination — and
// even that is only a form default, not a fallback applied to a stored blank.
//
// **`secure` is the field operators get wrong**, so its help text says which port
// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is
// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The
// combination that silently fails is 587 with secure on — the handshake hangs
// rather than erroring cleanly — which is exactly why "Send test" is the real
// verification path now (§1.2a consequence 2).
const nodemailer = require('nodemailer')
const registry = require('./index')
const CREDENTIAL_FIELDS = [
{
key: 'host',
label: 'SMTP host',
kind: 'text',
required: true,
placeholder: 'smtp.example.com',
help: 'Your relay or mail server. No default — nothing is sent until you set this.',
},
{
key: 'port',
label: 'Port',
kind: 'number',
required: true,
default: 587,
help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.',
},
{
key: 'secure',
label: 'Implicit TLS',
kind: 'boolean',
required: false,
default: false,
help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.',
},
{
key: 'user',
label: 'Username',
kind: 'text',
required: false,
help: 'Leave blank for an unauthenticated local relay.',
},
{
key: 'password',
label: 'Password / API key',
kind: 'secret',
required: false,
help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.',
},
]
// Authentication is optional (a local MTA on port 25 needs none), so the only
// hard requirement is a destination. A username without a password is not
// "complete" — that combination authenticates as nobody and fails at the server.
function isComplete(credential) {
const c = credential || {}
if (!c.host || !Number(c.port)) return false
if (c.user && !c.password) return false
return true
}
function build(credential) {
const c = credential || {}
const options = {
host: String(c.host),
port: Number(c.port),
secure: Boolean(c.secure),
}
if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') }
return nodemailer.createTransport(options)
}
registry.registerMailTransport({
id: 'smtp',
label: 'SMTP',
help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.',
credentialFields: CREDENTIAL_FIELDS,
isComplete,
build,
})
module.exports = { CREDENTIAL_FIELDS, isComplete, build }