Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.
- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
row), contact recipient = contact_email setting, mailto: fallback preserved,
plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
+ PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
244 lines
8.7 KiB
JavaScript
244 lines
8.7 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
// 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.
|
|
|
|
const STATUS_COLOR = {
|
|
connected: '#7fd0a4',
|
|
error: '#d98b84',
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
export default function EmailDelivery() {
|
|
const [config, setConfig] = useState(null)
|
|
const [error, setError] = useState('')
|
|
const [senderName, setSenderName] = useState('')
|
|
const [enabled, setEnabled] = useState(false)
|
|
const [busy, setBusy] = useState('')
|
|
const [msg, setMsg] = useState('')
|
|
const [actionError, setActionError] = useState('')
|
|
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text }
|
|
|
|
const load = useCallback(async (seedForm = false) => {
|
|
try {
|
|
const c = await api.admin.getEmailConfig()
|
|
setConfig(c)
|
|
if (seedForm) {
|
|
setSenderName(c.senderName || '')
|
|
setEnabled(c.enabled)
|
|
}
|
|
return c
|
|
} catch {
|
|
setError('Could not load email settings.')
|
|
return null
|
|
}
|
|
}, [])
|
|
|
|
// 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('')
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
setBusy('save')
|
|
setMsg('')
|
|
setActionError('')
|
|
try {
|
|
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
|
setConfig(saved)
|
|
setMsg('Saved.')
|
|
} catch (err) {
|
|
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.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function disconnect() {
|
|
setBusy('disconnect')
|
|
setMsg('')
|
|
setActionError('')
|
|
try {
|
|
const c = await api.admin.disconnectEmail()
|
|
setConfig(c)
|
|
setEnabled(false)
|
|
setMsg('Disconnected.')
|
|
} catch (err) {
|
|
setActionError(err.message || 'Could not disconnect.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
|
if (!config) return null
|
|
|
|
const connected = config.hasRefreshToken
|
|
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
{banner && (
|
|
<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',
|
|
}}
|
|
>
|
|
{banner.text}
|
|
</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>
|
|
)}
|
|
|
|
{!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'}
|
|
</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="UOMysticmoon"
|
|
/>
|
|
</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 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>
|
|
)
|
|
}
|