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:
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
@@ -55,6 +56,39 @@ async function announceIfNewlyPublished(post, transition) {
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
|
||||
// G22 — the Gmail removal degrades SILENTLY (ENGAGEMENT.md §1.2a consequence 3).
|
||||
// On upgrade, `transport` backfills to `smtp` with no credentials, so every sink
|
||||
// politely does nothing: the contact form falls back to `mailto`, invites surface
|
||||
// a copyable link, password resets still answer a generic 200. Nothing breaks
|
||||
// loudly, which is exactly the risk — email stops and nobody is told.
|
||||
//
|
||||
// So something has to say it. The condition is deliberately narrow: a deployment
|
||||
// that still holds the deprecated Gmail refresh token (it had working mail) and
|
||||
// has no replacement credential (it does not any more). A fresh install has never
|
||||
// had mail and is not warned — a nag about a capability nobody asked for is a
|
||||
// banner people learn to ignore, and this one has to be believed exactly once.
|
||||
//
|
||||
// Never fails the dashboard. A warning that can 500 the admin landing page is a
|
||||
// worse bug than the one it reports.
|
||||
async function emailWarning() {
|
||||
try {
|
||||
const c = await emailConfig.getSafe()
|
||||
if (!c.hadLegacyConnection || c.hasCredential) return null
|
||||
return {
|
||||
code: 'EMAIL_TRANSPORT_MIGRATION',
|
||||
message:
|
||||
'Outbound email is not configured. This deployment used the Gmail connect flow, which has been ' +
|
||||
'removed — mail is no longer being sent. Add SMTP credentials under Settings → Email. Gmail still ' +
|
||||
'works as an ordinary SMTP relay with an app password.',
|
||||
href: '/admin/settings',
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('dashboard email warning check failed', { message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function dashboard(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
@@ -67,6 +101,7 @@ async function dashboard(req, res) {
|
||||
posts: await posts.counts(),
|
||||
users: await users.count(),
|
||||
},
|
||||
warnings: [await emailWarning()].filter(Boolean),
|
||||
recent_activity: await activity.list({ limit: 10 }),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user