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,3 +1,7 @@
// model/emailConfig — the credential store. Same guarantees as before the Gmail
// removal (ciphertext at rest, never returned, blank means "leave it alone"),
// now over one transport-shaped blob instead of a single refresh-token column.
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
@@ -12,59 +16,122 @@ const db = require('../src/utils/db')
after(() => db.close())
const CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec-abc' }
// In-memory stand-in for the singleton row so the model never touches MariaDB.
let store
beforeEach(() => {
store = null
emailDb.get = async () => store
emailDb.upsert = async (fields) => {
store = { ...(store || { id: 1 }), ...fields }
store = { ...(store || { id: 1, transport: 'smtp' }), ...fields }
return store
}
})
test('save encrypts the refresh token (ciphertext at rest, decryptable)', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
assert.ok(store.refresh_token_enc)
assert.notEqual(store.refresh_token_enc, 'refresh-abc')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
const decrypted = () => JSON.parse(secretBox.decrypt(store.credential_enc))
test('save encrypts the credential (ciphertext at rest, decryptable)', async () => {
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
assert.ok(store.credential_enc)
assert.ok(!String(store.credential_enc).includes('sec-abc'))
assert.deepEqual(decrypted(), CRED)
const withSecret = await emailConfig.getWithSecret()
assert.equal(withSecret.refreshToken, 'refresh-abc')
assert.equal(withSecret.credentialSecret.password, 'sec-abc')
})
test('getSafe never leaks the refresh token', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
test('getSafe never leaks a secret field, but says which are set', async () => {
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
const safe = await emailConfig.getSafe()
assert.equal(safe.hasRefreshToken, true)
assert.equal(safe.senderEmail, 'me@gmail.com')
assert.equal('refreshToken' in safe, false)
assert.equal('refresh_token_enc' in safe, false)
assert.deepEqual(safe.credential, { host: 'relay.example.com', port: 587, secure: false, user: 'apikey' })
assert.equal('password' in safe.credential, false)
assert.deepEqual(safe.secretsSet, { password: true })
assert.equal(safe.hasCredential, true)
assert.equal(safe.senderEmail, 'mail@shard.example.com')
assert.equal('credentialSecret' in safe, false)
assert.equal('credential_enc' in safe, false)
})
test('blank refresh token on save leaves the existing one unchanged', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const cipherBefore = store.refresh_token_enc
test('a blank secret leaves the stored one unchanged; other fields still save', async () => {
await emailConfig.save({ credential: CRED })
const cipherBefore = store.credential_enc
await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken
assert.equal(store.refresh_token_enc, cipherBefore) // untouched
await emailConfig.save({ senderName: 'UOMysticmoon', credential: { ...CRED, password: '' } })
assert.equal(store.sender_name, 'UOMysticmoon')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
assert.equal(decrypted().password, 'sec-abc')
assert.notEqual(store.credential_enc, undefined)
assert.ok(cipherBefore)
})
test('disconnect clears the credential and disables sending', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
test('undeclared keys are dropped — a client cannot smuggle fields into the blob', async () => {
await emailConfig.save({ credential: { ...CRED, evil: 'x', proxy: 'http://attacker' } })
assert.deepEqual(Object.keys(decrypted()).sort(), ['host', 'password', 'port', 'secure', 'user'])
})
test('changing transport does not carry the old credential across', async () => {
await emailConfig.save({ credential: CRED })
// An unregistered target still clears rather than merging: leaving an SMTP
// password inside another transport's blob would be a stored secret nobody can
// see and nothing will ever use.
await emailConfig.save({ transport: 'mailgun', credential: { domain: 'x' } })
assert.equal(store.transport, 'mailgun')
assert.equal(store.credential_enc, null)
})
test('an incomplete credential is stored but is not "complete"', async () => {
// A username with no password authenticates as nobody.
await emailConfig.save({ credential: { host: 'relay.example.com', port: 587, user: 'apikey' } })
const safe = await emailConfig.getSafe()
assert.equal(safe.hasCredential, false)
assert.deepEqual(safe.secretsSet, { password: false })
})
test('an unreadable blob reads as absent, never as an error', async () => {
// The rotated-SECRET_ENC_KEY case. It must land the admin on a screen that says
// "unconfigured", not a 500 that takes the contact form down with it.
store = { id: 1, transport: 'smtp', credential_enc: 'not-ciphertext', enabled: 1 }
const safe = await emailConfig.getSafe()
assert.equal(safe.hasCredential, false)
assert.deepEqual(safe.credential, {})
})
test('disconnect clears the credential, the legacy token and the enabled flag', async () => {
store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 }
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
const safe = await emailConfig.disconnect(7)
assert.equal(store.credential_enc, null)
assert.equal(store.refresh_token_enc, null)
assert.equal(store.enabled, 0)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.hasCredential, false)
assert.equal(safe.hadLegacyConnection, false)
assert.equal(safe.status, 'unconfigured')
})
test('getSafe returns unconfigured defaults when no row exists', async () => {
test('hadLegacyConnection is the G22 warning condition, and nothing else', async () => {
// Present token + no replacement credential: this deployment's mail just
// stopped and it has to be told (ENGAGEMENT.md §1.2a consequence 3).
store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 }
let safe = await emailConfig.getSafe()
assert.equal(safe.hadLegacyConnection, true)
assert.equal(safe.hasCredential, false)
// Once SMTP is configured the pair stops matching, so the warning goes away
// without anything having to clear the deprecated column.
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED })
safe = await emailConfig.getSafe()
assert.equal(safe.hadLegacyConnection, true)
assert.equal(safe.hasCredential, true)
})
test('a fresh install is unconfigured, on the default transport, and warns nobody', async () => {
const safe = await emailConfig.getSafe()
assert.equal(safe.enabled, false)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.transport, 'smtp')
assert.equal(safe.hasCredential, false)
assert.equal(safe.hadLegacyConnection, false)
assert.equal(safe.status, 'unconfigured')
assert.equal(safe.senderEmail, null)
})