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
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.
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.