feat(email): engagement Phase 1 — remove Gmail OAuth2, SMTP behind a transport registry #165
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/engagement-phase-1-smtp"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Engagement Phase 1 (
ENGAGEMENT.md§1.2a, §3.1, §3.2 — scope decision 4). A subtraction and a replacement in one PR, because leaving the OAuth2 flow half-wired across a release is worse than either end state.Deleted — §1.2a's inventory in full
GET /admin/email/connect/start,GET /admin/email/connect/callbackconnectStart/connectCallback, theemail_oauth_txsigned cookie, the PKCE verifier and CSRF nonce plumbing,EMAIL_SCOPE,googleClient()— about half the filesmtp.gmail.com/465literalsrefreshTokendecrypt?email_connected/?email_errorbanner, six Gmail-specific error stringsproviderandrefresh_token_encstay as columns under the additive-only discipline, unread as configuration.SSO is unaffected, and one real coupling goes with this: email borrowed the
googleprovider's client id and secret, so rotating the SSO secret silently broke outbound mail with nothing on either screen relating the two.Added
A mail transport registry (
server/src/engagement/transports/) withsmtpas the sole registration.credentialFieldsis the single declaration that the admin form renders, the request sanitizer filters against, and the "is this secret" answer comes from — so adding a relay is a registration, not four edits across a form, a validator, a column set and a model.Only that half of §3.1 is built.
registerDeliveryChannelis not here: Phase 1 has no consumer foraddressFor/render/deliver(that is the Phase 4 engine), and a registered channel nothing calls is a shape frozen before anything tried to use it.email_configgainstransport,credential_encandreply_to. The credential is one encrypted JSON blob, not a column per field, because the field list belongs to the transport — a column per union member would make each new transport a schema change. A blob that will not decrypt reads as absent rather than raising, so a rotatedSECRET_ENC_KEYlands an admin on an "unconfigured" screen instead of a 500 that takes the contact form with it.Send test becomes the real verification. Under the consent flow the sender came back from Google's userinfo and was guaranteed to be a mailbox the credential owned. Operator-typed, it can be refused — a silent SPF/DMARC failure, not an error — so failures now name the sender and the likely cause instead of passing a bare
550through.One deliberate behaviour change
enablednow gates every sender.buildTransport()used to test only "is there a refresh token and a sender", so the contact form kept sending after an admin unticked Enable email sending —isConfigured()honoured the toggle but the five direct senders bypassed it. The connect flow used to setenabledas a side effect of a consent redirect; with a credential form it has to mean what it says, so the gate moved onto the one path every sender shares. Flagged because it is a change, not a refactor.G22 — the silent degradation, and the warning it earns
On upgrade
transportbackfills tosmtpwith no credentials, and every sink politely does nothing: contact →mailto, invites → copyable link, resets → generic 200, Team notifications logged and dropped. Nothing breaks loudly, which is exactly the risk.So the admin dashboard now returns
warnings[], with one entry today. The condition is narrow on purpose: the deprecated Gmail token present and no replacement credential — the one deployment this happens to. A fresh install has never had mail and is not nagged; a banner people learn to ignore is worth nothing, and this one has to be believed exactly once. It clears itself when a credential is saved.Also: a one-time, self-limiting
UPDATEcorrects thestatus = 'connected'that an upgraded row would otherwise still be claiming.Guardrails
npm run check:hostsis new — §3.2 rule 4. No transport may ship a default host, endpoint or sender. It reads code, not prose (documentation namingsmtp.gmail.comis fine; a literal in a transport is not) and has its own self-test that feeds it code it must reject, the disciplinecheckModuleIdentifiers.test.jsestablished. Wired intopr-checksbefore the install, alongsidecheck:modules.warnings[]documented);routes.manifest.jsonandroutes.guards.jsonregenerated, delta exactly −2 routes.How it was tested
npm test --prefix server— 1203 passing, 0 failing. 31 new tests acrossmailTransports.test.js(new),mailer.test.jsandemailConfig.model.test.js(rewritten around the transport seam, keeping every failure-contract assertion), andcheckNoExternalHosts.test.js(new).mailTransports.test.jsas a bare file-level'test failed'with no failing subtest, while a Docker container, an Astro build and a Vite build were running alongside it. Not reproducible — green in isolation, green in a four-file group, and green on a clean re-run of the whole suite. Reporting it rather than quietly re-running.npm test --prefix client— 288 passing.npm run build --prefix client— clean.npm run check:hosts,npm run check:modules,npm run routes:manifest -- --check— all clean.Not yet exercised against a live SMTP server. The transport build is asserted against a stubbed nodemailer; a real send is a rig walk and is worth doing before this reaches the deployment.
Checklist
AI-assisted contributions (required)
Claude Code. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License
`npm run swagger` cannot run on this tree. It dies with swagger-autogen's "FATAL ERROR: invalid array length - Allocation failed", generating nothing, and it reproduces on a pristine checkout under both Node 20 and Node 24 — so the committed spec cannot be regenerated by anyone, and any PR that adds or changes a route is unable to meet the standing obligation to update it. Bisected to one statement in `teams.router.js`: param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)) Hoisting that arrow to a named const fixes it outright. Nothing else changes and the regenerated spec is byte-identical to the committed one, so this is a generator fix, not a spec change. The diagnosis worth keeping, because the file's own comment recorded a different one. Phase 8 shipped a bare regex LITERAL before `.test(` and phase 9 hoisted the regex, blaming a per-file route limit measured at twenty statements; the file has sat at nineteen ever since on the theory that it was one under the edge. That theory is wrong. Probing every router file individually, `teams.router.js` at nineteen statements dies while a THREE-route file carrying only this one route also dies — so the trigger is the inline arrow reaching `.test(`, not the count. Hoisting the regex was half the fix; the predicate around it needed hoisting too. The comments in `teams.router.js`, `teamsVoice.router.js` and `admin/index.js` are corrected to say so, since all three currently tell the next person to keep counting statements. Co-Authored-By: Claude <noreply@anthropic.com>