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:
@@ -327,19 +327,33 @@ CREATE TABLE IF NOT EXISTS bot_config (
|
||||
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
|
||||
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
|
||||
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
|
||||
-- they are read live from the `google` auth_providers row. The refresh token is
|
||||
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
|
||||
-- admin API (never returned; responses expose only hasRefreshToken).
|
||||
-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the
|
||||
-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it
|
||||
-- is write-only over the admin API (never returned; responses expose only
|
||||
-- hasCredential and the non-secret fields the transport declares).
|
||||
--
|
||||
-- `transport` names a registered mail transport (server/src/engagement/transports).
|
||||
-- `credential_enc` is that transport's whole credential set as one encrypted JSON
|
||||
-- blob rather than a column per field, because the field list is the transport's to
|
||||
-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain
|
||||
-- and a key, and a column per union member would make adding a transport a schema
|
||||
-- change. ENGAGEMENT.md §3.1.
|
||||
--
|
||||
-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held
|
||||
-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather
|
||||
-- than dropped under the additive-only discipline, and `refresh_token_enc` earns
|
||||
-- its keep in the meantime as the marker for "this deployment had working mail
|
||||
-- before the upgrade" — which is what the admin dashboard warning reads.
|
||||
CREATE TABLE IF NOT EXISTS email_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread
|
||||
transport VARCHAR(32) NOT NULL DEFAULT 'smtp',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
|
||||
sender_email VARCHAR(255) NULL, -- envelope From, operator-typed
|
||||
sender_name VARCHAR(120) NULL, -- optional From display name
|
||||
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail
|
||||
credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed
|
||||
refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
last_verified_at DATETIME NULL,
|
||||
@@ -1464,3 +1478,26 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ──────────
|
||||
-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills
|
||||
-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an
|
||||
-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies
|
||||
-- SMTP settings. That is the whole point of the G22 warning below: nothing about
|
||||
-- this fails loudly, so something has to say it out loud.
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
|
||||
|
||||
-- The status a Gmail-connected deployment carries is 'connected', and after the
|
||||
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
|
||||
-- makes this idempotent and self-limiting — it matches only a row that still holds
|
||||
-- a Gmail refresh token AND has no replacement credential, so re-running it after
|
||||
-- the operator configures SMTP touches nothing, and it can never overwrite a real
|
||||
-- status recorded by a later send.
|
||||
UPDATE email_config
|
||||
SET status = 'unconfigured',
|
||||
status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.'
|
||||
WHERE refresh_token_enc IS NOT NULL
|
||||
AND credential_enc IS NULL
|
||||
AND status <> 'unconfigured';
|
||||
|
||||
Reference in New Issue
Block a user