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

@@ -1,7 +1,8 @@
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
'id, provider, transport, enabled, sender_email, sender_name, reply_to, credential_enc, refresh_token_enc, ' +
'status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('email_config', COLS)

View File

@@ -1,30 +1,69 @@
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
// never includes it — callers see only `hasRefreshToken`.
// Outbound email config store. Mirrors the botConfig model split: the DB layer
// only ever sees ciphertext, and only getWithSecret() (used by the mailer at send
// time) decrypts the credential. The admin-facing getSafe() never includes it —
// callers see the non-secret fields plus which secrets are set.
//
// The credential is ONE encrypted JSON blob, not a column per field, because the
// field list belongs to the transport (ENGAGEMENT.md §3.1). `credential_enc`
// holds `{ host, port, secure, user, password }` for `smtp`; a future relay's
// blob would hold different keys against the same column.
//
// `provider` and `refresh_token_enc` are the removed Gmail OAuth2 connection
// (§1.2a). They are no longer read as configuration — `hadLegacyConnection`
// exposes the token column's presence for one purpose only: telling an upgraded
// deployment that its mail just stopped.
const db = require('./emailConfig.db')
const secretBox = require('../../utils/secretBox')
const { transports } = require('../../engagement')
function toSafe(row) {
const DEFAULT_TRANSPORT = 'smtp'
// A stored blob that will not parse is treated as ABSENT, never as an error —
// the same fail-safe rule utils/settingsJson.js applies. A deployment whose
// SECRET_ENC_KEY was rotated must degrade to "unconfigured" and say so on the
// admin screen, not 500 the settings page and the contact form with it.
function readCredential(row) {
if (!row || !row.credential_enc) return null
try {
const parsed = JSON.parse(secretBox.decrypt(row.credential_enc))
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
function toSafe(row, credential) {
const transport = (row && row.transport) || DEFAULT_TRANSPORT
if (!row) {
return {
provider: 'gmail_oauth2',
transport: DEFAULT_TRANSPORT,
enabled: false,
senderEmail: null,
senderName: null,
hasRefreshToken: false,
replyTo: null,
credential: {},
secretsSet: transports.secretsPresent(DEFAULT_TRANSPORT, null),
hasCredential: false,
hadLegacyConnection: false,
status: 'unconfigured',
statusDetail: null,
lastVerifiedAt: null,
}
}
return {
provider: row.provider || 'gmail_oauth2',
transport,
enabled: Boolean(row.enabled),
senderEmail: row.sender_email || null,
senderName: row.sender_name || null,
hasRefreshToken: Boolean(row.refresh_token_enc),
replyTo: row.reply_to || null,
credential: transports.publicCredential(transport, credential),
secretsSet: transports.secretsPresent(transport, credential),
hasCredential: transports.isComplete(transport, credential),
// Deliberately the raw column, not "is Gmail configured": nothing reads the
// token any more. It answers "did this deployment have working mail before
// the upgrade?", which is the G22 warning's whole condition.
hadLegacyConnection: Boolean(row.refresh_token_enc),
status: row.status || 'unconfigured',
statusDetail: row.status_detail || null,
lastVerifiedAt: row.last_verified_at || null,
@@ -32,38 +71,53 @@ function toSafe(row) {
}
async function getSafe() {
return toSafe(await db.get())
const row = await db.get()
return toSafe(row, readCredential(row))
}
// Decrypted refresh token included — server-side only (building the mailer's
// OAuth2 transport). Returns null when no row exists yet.
// Decrypted credential included — server-side only (building the transport at
// send time). Returns null when no row exists yet.
async function getWithSecret() {
const row = await db.get()
if (!row) return null
return {
...toSafe(row),
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
}
const credential = readCredential(row)
return { ...toSafe(row, credential), credentialSecret: credential || {} }
}
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
// "leave the existing token unchanged" (same convention as botConfig.save).
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
// Save admin-supplied config. `credential` is a PATCH, merged over the stored
// blob: a secret field submitted empty is omitted by the registry's sanitizer and
// therefore keeps its stored value (same convention as botConfig.save).
async function save({ transport, senderEmail, senderName, replyTo, credential, enabled, status, statusDetail, updatedBy }) {
const row = await db.get()
const fields = {}
const nextTransport = transport !== undefined ? transport : (row && row.transport) || DEFAULT_TRANSPORT
if (transport !== undefined) fields.transport = transport
if (senderEmail !== undefined) fields.sender_email = senderEmail
if (senderName !== undefined) fields.sender_name = senderName
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
if (replyTo !== undefined) fields.reply_to = replyTo
if (credential !== undefined) {
// Changing transport starts from an empty credential rather than merging one
// transport's fields into another's — an SMTP password left inside a relay's
// blob is a stored secret nobody can see and nothing will ever use.
const base = row && row.transport === nextTransport ? readCredential(row) : null
const merged = transports.mergeCredential(nextTransport, base, transports.sanitizeCredential(nextTransport, credential))
fields.credential_enc = Object.keys(merged).length ? secretBox.encrypt(JSON.stringify(merged)) : null
}
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
const saved = await db.upsert(fields)
return toSafe(saved, readCredential(saved))
}
// Clear the stored credential and disable sending (admin "Disconnect").
// Clear the stored credential and disable sending (admin "Clear credentials").
// The legacy Gmail token goes too: this is the operator saying "there is no
// mailbox here", and leaving the deprecated column set would keep the G22 warning
// on screen for a deployment that has deliberately turned mail off.
async function disconnect(updatedBy) {
const row = await db.upsert({
credential_enc: null,
refresh_token_enc: null,
sender_email: null,
enabled: 0,
@@ -72,7 +126,7 @@ async function disconnect(updatedBy) {
last_verified_at: null,
updated_by: updatedBy ?? null,
})
return toSafe(row)
return toSafe(row, readCredential(row))
}
// Record the outcome of the last send / verification so the admin panel has
@@ -86,7 +140,7 @@ async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
}
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
return toSafe(row, readCredential(row))
}
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus, DEFAULT_TRANSPORT }

View File

@@ -5,8 +5,8 @@
// Push is opt-out: a user in one Team must never have to configure anything to be
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
// turning on Gmail in the admin panel must not start sending daily mail to every
// member of every Team on the deployment.
// configuring a mail transport in the admin panel must not start sending daily
// mail to every member of every Team on the deployment.
//
// Both are read the same way — COALESCE to the column default, never treat a
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and