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

@@ -455,10 +455,11 @@ export const api = {
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- Email delivery / Gmail OAuth2 (admin only) -----
// ----- Email delivery (admin only) -----
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
// config response now carries the transport catalog the form renders from.
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},

View File

@@ -66,6 +66,34 @@ export default function Dashboard() {
return (
<section>
{/* Operator warnings: things that are quietly not working and would
otherwise be discovered by someone not receiving an email. The list is
normally empty, which is why it sits above the fold rather than in a
panel — see ENGAGEMENT.md §1.2a (G22). */}
{(dash.warnings || []).map((w) => (
<div
key={w.code}
className="sans"
style={{
fontSize: '0.86rem',
lineHeight: 1.5,
borderRadius: 10,
padding: '12px 16px',
marginBottom: 18,
border: '1px solid #7a6440',
background: 'rgba(224,176,112,0.08)',
color: '#e0b070',
}}
>
{w.message}
{w.href && (
<>
{' '}
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
</>
)}
</div>
))}
<div
style={{
display: 'flex',

View File

@@ -2,11 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
// captures a refresh token server-side — the token is write-only over the API
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
// requires the Google provider to be configured on the Authentication page first.
// Email delivery panel, rendered as a section on the Settings page. Sending goes
// through a registered mail transport (SMTP today) whose credentials the operator
// types here; they are stored encrypted server-side and are write-only over the
// API — a secret field comes back as "set", never as its value.
//
// **The form is not written here.** The server ships each transport's declared
// `credentialFields` with the config, and this renders them. That is the whole
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
// editing this file. So there is no `host`, `port` or `password` anywhere below —
// only field kinds.
//
// The "Connect Gmail" button, its redirect banner and its six error strings went
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
// relay with an app password — which the operator types in like any other host.
const STATUS_COLOR = {
connected: '#7fd0a4',
@@ -14,17 +23,6 @@ const STATUS_COLOR = {
unconfigured: 'var(--muted)',
}
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
const ERROR_TEXT = {
denied: 'Google sign-in was cancelled or denied.',
bad_state: 'The connect session expired. Please try again.',
no_client: 'The Google OAuth client is not configured.',
no_refresh_token:
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
no_email: 'Could not read the Gmail address from Google.',
error: 'Could not connect the Gmail account. Please try again.',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
@@ -52,60 +50,100 @@ function StatusPanel({ config }) {
)
}
// One declared credential field. A `secret` already held renders empty with a
// "leave blank to keep" hint, matching the server's patch semantics: an empty
// secret is omitted from the save, not written as a blank.
function CredentialField({ field, value, isSet, onChange }) {
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
.filter(Boolean)
.join(' ')
if (field.kind === 'boolean') {
return (
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
<span>
{field.label}
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
</span>
</label>
)
}
return (
<label style={{ display: 'block' }}>
<span className="field-label">
{field.label}
{field.required ? '' : ' (optional)'}
</span>
<input
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className="input"
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
placeholder={field.placeholder || ''}
/>
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
</label>
)
}
export default function EmailDelivery() {
const { siteTitle } = useSite()
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [transport, setTransport] = useState('smtp')
const [senderEmail, setSenderEmail] = useState('')
const [senderName, setSenderName] = useState('')
const [replyTo, setReplyTo] = useState('')
const [credential, setCredential] = useState({})
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
// Seed the credential inputs from the non-secret values the server returned,
// falling back to each field's declared default. Secrets are never seeded —
// the server does not send them and an empty box means "keep what you have".
const seedCredential = useCallback((c, transportId) => {
const def = (c.transports || []).find((t) => t.id === transportId)
const next = {}
for (const f of def?.credentialFields || []) {
if (f.kind === 'secret') continue
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
}
return next
}, [])
const load = useCallback(async (seedForm = false) => {
try {
const c = await api.admin.getEmailConfig()
setConfig(c)
if (seedForm) {
setTransport(c.transport || 'smtp')
setSenderEmail(c.senderEmail || '')
setSenderName(c.senderName || '')
setReplyTo(c.replyTo || '')
setEnabled(c.enabled)
setCredential(seedCredential(c, c.transport || 'smtp'))
}
return c
} catch {
setError('Could not load email settings.')
return null
}
}, [])
}, [seedCredential])
// On mount, surface the outcome of a just-completed connect redirect, strip the
// query params so a refresh doesn't replay the banner, then load config.
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('email_connected')) {
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
} else if (params.has('email_error')) {
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
}
if (params.has('email_connected') || params.has('email_error')) {
params.delete('email_connected')
params.delete('email_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
load(true)
}, [load])
async function connect() {
setBusy('connect')
setActionError('')
try {
const { url } = await api.admin.emailConnectUrl()
window.location.href = url
} catch (err) {
setActionError(err.message || 'Could not start the connect flow.')
setBusy('')
}
// Switching transport starts from the new one's declared defaults, because the
// server does the same: a credential blob is never carried across transports.
function changeTransport(id) {
setTransport(id)
setCredential(seedCredential(config, id))
}
async function save() {
@@ -113,10 +151,19 @@ export default function EmailDelivery() {
setMsg('')
setActionError('')
try {
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
setConfig(saved)
setEnabled(saved.enabled)
setCredential(seedCredential(saved, saved.transport))
setMsg('Saved.')
} catch (err) {
// A refused enable comes back with the reverted config attached, so the
// screen shows what is actually stored rather than the state that was
// rejected.
if (err.body?.config) {
setConfig(err.body.config)
setEnabled(err.body.config.enabled)
}
setActionError(err.message || 'Could not save.')
} finally {
setBusy('')
@@ -133,12 +180,13 @@ export default function EmailDelivery() {
await load()
} catch (err) {
setActionError(err.message || 'Could not send the test email.')
await load()
} finally {
setBusy('')
}
}
async function disconnect() {
async function clearCredentials() {
setBusy('disconnect')
setMsg('')
setActionError('')
@@ -146,9 +194,11 @@ export default function EmailDelivery() {
const c = await api.admin.disconnectEmail()
setConfig(c)
setEnabled(false)
setMsg('Disconnected.')
setSenderEmail('')
setCredential(seedCredential(c, c.transport))
setMsg('Credentials cleared.')
} catch (err) {
setActionError(err.message || 'Could not disconnect.')
setActionError(err.message || 'Could not clear the credentials.')
} finally {
setBusy('')
}
@@ -157,84 +207,118 @@ export default function EmailDelivery() {
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
if (!config) return null
const connected = config.hasRefreshToken
const catalog = config.transports || []
const selected = catalog.find((t) => t.id === transport)
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
<div>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
Sends the contact form through Gmail over OAuth2, delivered to the
<strong> Contact email</strong> above. Reuses the Google authentication
client configure that on the Authentication page first.
Sends the contact form, invitations, password resets and team
notifications. Contact-form mail is delivered to the
<strong> Contact email</strong> above. Credentials are stored encrypted
and never shown again.
</p>
</div>
{banner && (
{config.hadLegacyConnection && !config.hasCredential && (
<div
className="sans"
style={{
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
}}
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
>
{banner.text}
This deployment was connected with the old Gmail sign-in, which has been
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
below to restore it for Gmail, use <code>smtp.gmail.com</code> port 587
with an app password.
</div>
)}
<StatusPanel config={config} />
{!config.googleConfigured && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
The Google authentication provider needs a client ID and secret before
you can connect a Gmail account.
</p>
{catalog.length > 1 && (
<label style={{ display: 'block' }}>
<span className="field-label">Transport</span>
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
{catalog.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</label>
)}
{!connected ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
{selected?.help && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
)}
{(selected?.credentialFields || []).map((f) => (
<CredentialField
key={f.key}
field={f}
value={credential[f.key]}
isSet={Boolean(config.secretsSet?.[f.key])}
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
/>
))}
<label style={{ display: 'block' }}>
<span className="field-label">Send from</span>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="input"
autoComplete="off"
placeholder="noreply@example.com"
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Must be an address this account is allowed to send as, or the relay will
reject it. Use <strong>Send test</strong> to confirm.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Reply-To (optional)</span>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="input"
autoComplete="off"
placeholder="Leave blank to reply to the sending address"
/>
</label>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
{config.hasCredential && (
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
Clear credentials
</button>
</div>
) : (
<>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
<button onClick={connect} disabled={busy === 'connect'} className="pill">
Reconnect
</button>
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
Disconnect
</button>
</div>
</>
)}
)}
</div>
<div style={{ minHeight: 18 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}