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 (
{config.status || 'unconfigured'}
{config.senderEmail && (

Sending as {config.senderEmail}

)} {config.statusDetail && (

{config.statusDetail}

)} {config.lastVerifiedAt && (

Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}

)}
) } // 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 ( ) } return ( ) } 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

{error}

if (!config) return null const catalog = config.transports || [] const selected = catalog.find((t) => t.id === transport) return (

Email delivery

Sends the contact form, invitations, password resets and team notifications. Contact-form mail is delivered to the Contact email above. Credentials are stored encrypted and never shown again.

{config.hadLegacyConnection && !config.hasCredential && (
This deployment was connected with the old Gmail sign-in, which has been removed. No mail is being sent. Enter SMTP credentials below to restore it — for Gmail, use smtp.gmail.com port 587 with an app password.
)} {catalog.length > 1 && ( )} {selected?.help && (

{selected.help}

)} {(selected?.credentialFields || []).map((f) => ( setCredential((prev) => ({ ...prev, [f.key]: v }))} /> ))}
{config.hasCredential && ( )}
{msg && {msg}} {actionError && {actionError}}
) }