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>
330 lines
12 KiB
JavaScript
330 lines
12 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { api } from '../../../api/client.js'
|
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
|
|
|
// 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',
|
|
error: '#d98b84',
|
|
unconfigured: 'var(--muted)',
|
|
}
|
|
|
|
function StatusPanel({ config }) {
|
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
|
return (
|
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
|
{config.status || 'unconfigured'}
|
|
</span>
|
|
</div>
|
|
{config.senderEmail && (
|
|
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: 'var(--ink)' }}>
|
|
Sending as <strong>{config.senderEmail}</strong>
|
|
</p>
|
|
)}
|
|
{config.statusDetail && (
|
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
|
)}
|
|
{config.lastVerifiedAt && (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
|
Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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('')
|
|
|
|
// 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])
|
|
|
|
useEffect(() => {
|
|
load(true)
|
|
}, [load])
|
|
|
|
// 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() {
|
|
setBusy('save')
|
|
setMsg('')
|
|
setActionError('')
|
|
try {
|
|
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('')
|
|
}
|
|
}
|
|
|
|
async function sendTest() {
|
|
setBusy('test')
|
|
setMsg('')
|
|
setActionError('')
|
|
try {
|
|
const r = await api.admin.testEmail()
|
|
setMsg(`Test email sent to ${r.to}.`)
|
|
await load()
|
|
} catch (err) {
|
|
setActionError(err.message || 'Could not send the test email.')
|
|
await load()
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function clearCredentials() {
|
|
setBusy('disconnect')
|
|
setMsg('')
|
|
setActionError('')
|
|
try {
|
|
const c = await api.admin.disconnectEmail()
|
|
setConfig(c)
|
|
setEnabled(false)
|
|
setSenderEmail('')
|
|
setCredential(seedCredential(c, c.transport))
|
|
setMsg('Credentials cleared.')
|
|
} catch (err) {
|
|
setActionError(err.message || 'Could not clear the credentials.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
|
if (!config) return null
|
|
|
|
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, 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>
|
|
|
|
{config.hadLegacyConnection && !config.hasCredential && (
|
|
<div
|
|
className="sans"
|
|
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
|
|
>
|
|
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} />
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
|
|
<div style={{ minHeight: 18 }}>
|
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
|
{actionError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{actionError}</span>}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|