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:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user