Modernize email: Gmail OAuth2 sending, configured under Settings

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
This commit is contained in:
2026-07-07 22:29:27 -05:00
parent 17d42cebfe
commit f8652c2399
16 changed files with 966 additions and 56 deletions

View File

@@ -53,13 +53,10 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
# client to fall back to a mailto: link instead.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. Until it's connected, the contact form falls back to
# a mailto: link (recipient = the `contact_email` site setting).
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173

View File

@@ -234,10 +234,13 @@ who"; `activity_log` provides the history feed.
## 7. Email
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
instead. Errors never leak credentials.
---
@@ -287,11 +290,7 @@ COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
```

View File

@@ -39,7 +39,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, Pangolin reverse proxy |
@@ -276,8 +276,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
@@ -343,8 +342,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. SMTP is optional — the contact
form falls back to a `mailto:` link when unconfigured.
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
---

View File

@@ -189,6 +189,13 @@ export const api = {
// ----- Discord bot control (admin only) -----
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- Email delivery / Gmail OAuth2 (admin only) -----
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' }),
},
// ----- player self-service (role: 'player') -----

View File

@@ -0,0 +1,243 @@
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>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
// Editable settings shown on this screen (key -> label + control type).
const FIELDS = [
@@ -9,7 +10,11 @@ const FIELDS = [
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true },
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
{ key: 'status_message', label: 'Status message' },
{ key: 'contact_email', label: 'Contact email' },
{
key: 'contact_email',
label: 'Contact email',
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
},
{
key: 'player_registration',
label: 'Player registration',
@@ -111,6 +116,8 @@ export default function SettingsAdmin() {
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
<EmailDelivery />
</section>
)
}

View File

@@ -70,11 +70,10 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
# It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. The contact recipient is the `contact_email` site
# setting; while email is unconfigured the contact form falls back to a mailto: link.
CLIENT_ORIGIN=http://localhost:5173

View File

@@ -236,6 +236,29 @@ CREATE TABLE IF NOT EXISTS bot_config (
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
-- they are read live from the `google` auth_providers row. The refresh token is
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
-- admin API (never returned; responses expose only hasRefreshToken).
CREATE TABLE IF NOT EXISTS email_config (
id INT PRIMARY KEY DEFAULT 1,
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
enabled TINYINT(1) NOT NULL DEFAULT 0,
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
sender_name VARCHAR(120) NULL, -- optional From display name
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
status_detail VARCHAR(500) NULL,
last_verified_at DATETIME NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else

View File

@@ -0,0 +1,28 @@
const { query } = require('../../utils/db')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin connects Gmail for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM email_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (refresh token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO email_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }

View File

@@ -0,0 +1,92 @@
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
// never includes it — callers see only `hasRefreshToken`.
const db = require('./emailConfig.db')
const secretBox = require('../../utils/secretBox')
function toSafe(row) {
if (!row) {
return {
provider: 'gmail_oauth2',
enabled: false,
senderEmail: null,
senderName: null,
hasRefreshToken: false,
status: 'unconfigured',
statusDetail: null,
lastVerifiedAt: null,
}
}
return {
provider: row.provider || 'gmail_oauth2',
enabled: Boolean(row.enabled),
senderEmail: row.sender_email || null,
senderName: row.sender_name || null,
hasRefreshToken: Boolean(row.refresh_token_enc),
status: row.status || 'unconfigured',
statusDetail: row.status_detail || null,
lastVerifiedAt: row.last_verified_at || null,
}
}
async function getSafe() {
return toSafe(await db.get())
}
// Decrypted refresh token included — server-side only (building the mailer's
// OAuth2 transport). Returns null when no row exists yet.
async function getWithSecret() {
const row = await db.get()
if (!row) return null
return {
...toSafe(row),
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
}
}
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
// "leave the existing token unchanged" (same convention as botConfig.save).
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
const fields = {}
if (senderEmail !== undefined) fields.sender_email = senderEmail
if (senderName !== undefined) fields.sender_name = senderName
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
}
// Clear the stored credential and disable sending (admin "Disconnect").
async function disconnect(updatedBy) {
const row = await db.upsert({
refresh_token_enc: null,
sender_email: null,
enabled: 0,
status: 'unconfigured',
status_detail: null,
last_verified_at: null,
updated_by: updatedBy ?? null,
})
return toSafe(row)
}
// Record the outcome of the last send / verification so the admin panel has
// something to show. `lastVerifiedAt` may arrive as a Date or ISO string.
async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
const fields = {}
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail ? String(statusDetail).slice(0, 500) : null
if (lastVerifiedAt !== undefined) {
fields.last_verified_at = lastVerifiedAt ? new Date(lastVerifiedAt) : null
}
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
}
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }

View File

@@ -10,6 +10,7 @@ const account = require('./account.controller')
const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const moderation = require('./moderation.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
@@ -575,6 +576,84 @@ adminRouter.put(
discordBot.saveConfig,
)
// ── Email delivery (Gmail OAuth2, admin only) ─────────────────────────
// Modern replacement for env SMTP: the refresh token is captured by the connect
// flow and is write-only over this API (stored encrypted, never returned).
adminRouter.get(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Get email delivery config + status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.getConfig,
)
adminRouter.put(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Update email delivery config (admin only)'
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
body('enabled').optional().isBoolean(),
validate,
emailConfig.saveConfig,
)
adminRouter.get(
'/email/connect/start',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.connectStart,
)
adminRouter.get(
'/email/connect/callback',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
adminOnly,
emailConfig.connectCallback,
)
adminRouter.post(
'/email/test',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Send a test email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate,
emailConfig.testSend,
)
adminRouter.post(
'/email/disconnect',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.disconnect,
)
// ── Authentication providers / SSO (admin only) ───────────────────────
adminRouter.get(
'/auth/providers',

View File

@@ -0,0 +1,211 @@
// ── Admin: outbound email configuration (Gmail OAuth2) ─────────────────────
//
// Modern replacement for env-var SMTP. Sending goes through Gmail over OAuth2;
// the admin connects the mailbox with an in-app consent flow that captures a
// refresh token. We reuse the existing `google` SSO OAuth client (its id/secret)
// rather than a second app — so the only per-mailbox secret is the refresh token,
// stored AES-GCM-encrypted and write-only over this API (never returned).
//
// The connect flow mirrors sso.controller.js: a signed httpOnly tx cookie carries
// the CSRF nonce + PKCE verifier across the redirect to Google and back. It differs
// only in scope (https://mail.google.com/ for SMTP XOAUTH2) and access_type=offline
// + prompt=consent, which guarantee a refresh token even on reconnect.
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const authProviders = require('../../../model/authProviders/authProviders.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const GoogleProvider = require('../../../auth/providers/google.provider')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('admin')
// Gmail scope grants SMTP (XOAUTH2) access; openid+email let us read back which
// address was connected. The narrower gmail.send scope only works via the Gmail
// API, not SMTP, so we need the full-access scope here.
const EMAIL_SCOPE = 'https://mail.google.com/ openid email'
const TX_COOKIE = 'email_oauth_tx'
// Public base URL for the OAuth redirect_uri — same fallback pattern as
// sso.controller.js. Must be identical between start and callback.
function appBaseUrl(req) {
const configured = process.env.APP_BASE_URL
if (configured) return configured.replace(/\/+$/, '')
const derived = `${req.protocol}://${req.get('host')}`
log.warn('APP_BASE_URL not set — deriving email redirect_uri from the request', { derived })
return derived
}
function redirectUri(req) {
return `${appBaseUrl(req)}/api/v1/admin/email/connect/callback`
}
function txCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
}
// Front-end redirect targets after the callback resolves.
const CONNECTED_URL = '/admin/settings?email_connected=1'
const errorUrl = (code) => `/admin/settings?email_error=${code}`
// Load the Google OAuth client (id + decrypted secret) reused for email. Returns
// null when the google provider hasn't been configured with credentials yet.
async function googleClient() {
const row = await authProviders.getWithSecret('google')
if (!row || !row.client_id || !row.client_secret) return null
return { clientId: row.client_id, clientSecret: row.client_secret }
}
// GET /admin/email/config
async function getConfig(req, res) {
try {
const config = await emailConfig.getSafe()
// Surface whether the Google client email can borrow is configured, so the
// UI can explain why Connect is unavailable.
config.googleConfigured = Boolean(await googleClient())
return res.json(config)
} catch (err) {
log.error('emailConfig.getConfig', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/email/config — sender name + enabled toggle. Enabling requires a
// connected mailbox (a stored refresh token).
async function saveConfig(req, res) {
const { senderName, enabled } = req.body
try {
const current = await emailConfig.getSafe()
if (enabled && !current.hasRefreshToken) {
return res.status(400).json({ message: 'Connect a Gmail account before enabling email.' })
}
const saved = await emailConfig.save({
senderName: senderName !== undefined ? senderName || null : undefined,
enabled,
updatedBy: req.user.id,
})
saved.googleConfigured = Boolean(await googleClient())
await activity.log({ req, action: 'email.config.update', detail: { enabled: saved.enabled } })
log.info('email config updated', { by: req.user.username, enabled: saved.enabled })
return res.json(saved)
} catch (err) {
log.error('emailConfig.saveConfig', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/email/connect/start — returns { url } for the browser to navigate to.
async function connectStart(req, res) {
try {
const client = await googleClient()
if (!client) {
return res.status(400).json({
message: 'Configure the Google authentication provider (client id + secret) before connecting email.',
})
}
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tx = ssoState.createTx({ flow: 'email' })
res.cookie(TX_COOKIE, tx.txToken, txCookieOptions(req))
const params = new URLSearchParams({
client_id: client.clientId,
redirect_uri: redirectUri(req),
response_type: 'code',
scope: EMAIL_SCOPE,
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state: tx.nonce,
code_challenge: tx.codeChallenge,
code_challenge_method: 'S256',
})
const url = `${provider.authEndpoint()}?${params.toString()}`
return res.json({ url })
} catch (err) {
log.error('emailConfig.connectStart', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/email/connect/callback — exchange the code, capture the refresh
// token + connected address, store encrypted, and redirect back to Settings.
async function connectCallback(req, res) {
const txToken = req.cookies && req.cookies[TX_COOKIE]
const { code, state, error: oauthError } = req.query
res.clearCookie(TX_COOKIE, token.cookieOptions(req)) // single-use
if (oauthError) {
log.warn('email connect: provider returned error', { error: String(oauthError).slice(0, 60) })
return res.redirect(errorUrl('denied'))
}
const tx = ssoState.verifyTx(txToken, state)
if (!tx || tx.flow !== 'email' || !code) {
log.warn('email connect: bad state')
return res.redirect(errorUrl('bad_state'))
}
try {
const client = await googleClient()
if (!client) return res.redirect(errorUrl('no_client'))
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tokenSet = await provider.exchangeCode({
code,
redirectUri: redirectUri(req),
codeVerifier: tx.verifier,
})
if (!tokenSet.refresh_token) {
// Google only returns a refresh token when it hasn't already granted one
// for this client+scope. prompt=consent should force it; if it's still
// missing the admin can revoke the app's access and retry.
log.warn('email connect: no refresh_token returned')
return res.redirect(errorUrl('no_refresh_token'))
}
const profile = await provider.getUserProfile(tokenSet.access_token)
const senderEmail = profile.email || null
if (!senderEmail) return res.redirect(errorUrl('no_email'))
await emailConfig.save({
senderEmail,
refreshToken: tokenSet.refresh_token,
enabled: true,
status: 'connected',
statusDetail: 'Connected',
updatedBy: req.user.id,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Connected', lastVerifiedAt: new Date() })
await activity.log({ req, action: 'email.connect', detail: { senderEmail } })
log.info('email connected', { senderEmail, by: req.user.username })
return res.redirect(CONNECTED_URL)
} catch (err) {
log.error('emailConfig.connectCallback', err)
return res.redirect(errorUrl('error'))
}
}
// POST /admin/email/test — send a test message (to the given address, or the
// contact recipient by default).
async function testSend(req, res) {
try {
const result = await mailer.sendTest(req.body.to)
await activity.log({ req, action: 'email.test', detail: { to: result.to } })
return res.json(result)
} catch (err) {
log.warn('email test send failed', { message: err.message })
return res.status(502).json({ message: err.message || 'Could not send the test email.' })
}
}
// POST /admin/email/disconnect — clear the stored credential and disable sending.
async function disconnect(req, res) {
try {
const config = await emailConfig.disconnect(req.user.id)
config.googleConfigured = Boolean(await googleClient())
await activity.log({ req, action: 'email.disconnect' })
log.info('email disconnected', { by: req.user.username })
return res.json(config)
} catch (err) {
log.error('emailConfig.disconnect', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getConfig, saveConfig, connectStart, connectCallback, testSend, disconnect }

View File

@@ -8,7 +8,6 @@ const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mailer = require('./utils/mailer')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const pkg = require('../package.json')
@@ -30,7 +29,7 @@ async function start() {
logFile: createLogger.logFilePath || 'disabled (console only)',
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
cookieSecure: process.env.COOKIE_SECURE || 'auto',
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
email: 'gmail-oauth2 (configured in admin → settings)',
})
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in

View File

@@ -1,41 +1,125 @@
// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ───────────────────────
//
// Email is configured in Admin → Settings → Email, not via env vars. The
// connection (enabled flag, connected Gmail address, encrypted refresh token)
// lives in the email_config singleton; the OAuth client id/secret are reused
// from the `google` auth_providers row. nodemailer takes the refresh token and
// auto-mints short-lived access tokens for each send.
//
// When email is not configured, sendContactMessage does NOT throw — it signals
// the caller to fall back to a mailto: link (the contact form relies on this).
const nodemailer = require('nodemailer')
require('dotenv').config()
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO } = process.env
const emailConfig = require('../model/emailConfig/emailConfig.model')
const authProviders = require('../model/authProviders/authProviders.model')
const settings = require('../model/settings/settings.model')
const log = require('./logger')('mailer')
function isConfigured() {
return Boolean(SMTP_HOST && CONTACT_TO)
// Ready to send only when enabled, connected (has a refresh token), and we know
// which address to send as.
async function isConfigured() {
const c = await emailConfig.getSafe()
return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail)
}
let transporter = null
function getTransporter() {
if (!transporter) {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT) || 587,
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
})
// Recipient for the contact form: the admin-editable contact_email setting, or
// the connected sending address as a last resort.
async function contactRecipient(senderEmail) {
const to = await settings.get('contact_email')
return to || senderEmail || null
}
// Build a nodemailer OAuth2 transport from the stored config + reused Google
// client credentials. Returns { transport, config } or null when unconfigured.
async function buildTransport() {
const config = await emailConfig.getWithSecret()
if (!config || !config.refreshToken || !config.senderEmail) return null
const google = await authProviders.getWithSecret('google')
if (!google || !google.client_id || !google.client_secret) {
log.warn('email send skipped: Google OAuth client is not configured')
return null
}
return transporter
const transport = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
type: 'OAuth2',
user: config.senderEmail,
clientId: google.client_id,
clientSecret: google.client_secret,
refreshToken: config.refreshToken,
},
})
return { transport, config }
}
function fromHeader(config) {
return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail
}
/**
* Send a contact message. If SMTP is not configured, signals the caller to fall
* back to a mailto: link instead of throwing. Credentials come from env only.
* Send a contact message. If email is not configured/enabled, signals the caller
* to fall back to a mailto: link instead of throwing.
*/
async function sendContactMessage({ name, email, message }) {
if (!isConfigured()) {
return { sent: false, fallback: 'mailto', email: CONTACT_TO || null }
const built = await buildTransport()
if (!built) {
const c = await emailConfig.getSafe()
return { sent: false, fallback: 'mailto', email: await contactRecipient(c.senderEmail) }
}
await getTransporter().sendMail({
from: SMTP_USER || CONTACT_TO,
to: CONTACT_TO,
const { transport, config } = built
const to = await contactRecipient(config.senderEmail)
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: email,
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('contact send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
}
module.exports = { isConfigured, sendContactMessage }
/**
* Send a test email to `to`, used by the admin "Send test" button. Throws on
* failure; records the outcome either way. Returns { sent: true } on success.
*/
async function sendTest(to) {
const built = await buildTransport()
if (!built) {
const err = new Error('Email is not configured. Connect Gmail first.')
err.code = 'NOT_CONFIGURED'
throw err
}
const { transport, config } = built
const recipient = to || (await contactRecipient(config.senderEmail))
if (!recipient) {
const err = new Error('No recipient available for the test email.')
err.code = 'NO_RECIPIENT'
throw err
}
try {
await transport.sendMail({
from: fromHeader(config),
to: recipient,
subject: 'UOMysticmoon email test',
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
return { sent: true, to: recipient }
} catch (err) {
log.error('test send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
}
module.exports = { isConfigured, sendContactMessage, sendTest }

View File

@@ -0,0 +1,70 @@
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
const emailDb = require('../src/model/emailConfig/emailConfig.db')
const secretBox = require('../src/utils/secretBox')
const db = require('../src/utils/db')
after(() => db.close())
// In-memory stand-in for the singleton row so the model never touches MariaDB.
let store
beforeEach(() => {
store = null
emailDb.get = async () => store
emailDb.upsert = async (fields) => {
store = { ...(store || { id: 1 }), ...fields }
return store
}
})
test('save encrypts the refresh token (ciphertext at rest, decryptable)', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
assert.ok(store.refresh_token_enc)
assert.notEqual(store.refresh_token_enc, 'refresh-abc')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
const withSecret = await emailConfig.getWithSecret()
assert.equal(withSecret.refreshToken, 'refresh-abc')
})
test('getSafe never leaks the refresh token', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const safe = await emailConfig.getSafe()
assert.equal(safe.hasRefreshToken, true)
assert.equal(safe.senderEmail, 'me@gmail.com')
assert.equal('refreshToken' in safe, false)
assert.equal('refresh_token_enc' in safe, false)
})
test('blank refresh token on save leaves the existing one unchanged', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const cipherBefore = store.refresh_token_enc
await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken
assert.equal(store.refresh_token_enc, cipherBefore) // untouched
assert.equal(store.sender_name, 'UOMysticmoon')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
})
test('disconnect clears the credential and disables sending', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const safe = await emailConfig.disconnect(7)
assert.equal(store.refresh_token_enc, null)
assert.equal(store.enabled, 0)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.status, 'unconfigured')
})
test('getSafe returns unconfigured defaults when no row exists', async () => {
const safe = await emailConfig.getSafe()
assert.equal(safe.enabled, false)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.status, 'unconfigured')
assert.equal(safe.senderEmail, null)
})

View File

@@ -0,0 +1,72 @@
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const nodemailer = require('nodemailer')
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const settings = require('../src/model/settings/settings.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
after(() => db.close())
// Restore a clean slate of stubs before each test.
beforeEach(() => {
emailConfig.recordStatus = async () => {}
settings.get = async () => 'contact@example.com'
})
test('unconfigured → mailto fallback (never throws)', async () => {
emailConfig.getWithSecret = async () => null
emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: false, enabled: false })
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi' })
assert.deepEqual(r, { sent: false, fallback: 'mailto', email: 'contact@example.com' })
})
test('configured → builds a Gmail OAuth2 transport and sends', async () => {
let transportCfg = null
let sent = null
nodemailer.createTransport = (cfg) => {
transportCfg = cfg
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
}
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt-123', senderEmail: 'shard@gmail.com', senderName: 'UOMysticmoon' })
authProviders.getWithSecret = async (id) => {
assert.equal(id, 'google')
return { client_id: 'cid', client_secret: 'csec' }
}
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' })
assert.equal(r.sent, true)
// Transport is Gmail SMTP over XOAUTH2 with the reused Google client + stored refresh token.
assert.equal(transportCfg.host, 'smtp.gmail.com')
assert.equal(transportCfg.port, 465)
assert.equal(transportCfg.secure, true)
assert.equal(transportCfg.auth.type, 'OAuth2')
assert.equal(transportCfg.auth.user, 'shard@gmail.com')
assert.equal(transportCfg.auth.clientId, 'cid')
assert.equal(transportCfg.auth.clientSecret, 'csec')
assert.equal(transportCfg.auth.refreshToken, 'rt-123')
// From uses the display name; To is the contact_email setting; replyTo is the sender.
assert.equal(sent.from, '"UOMysticmoon" <shard@gmail.com>')
assert.equal(sent.to, 'contact@example.com')
assert.equal(sent.replyTo, 'ann@player.com')
})
test('send failure propagates and is recorded', async () => {
let recorded = null
emailConfig.recordStatus = async (s) => { recorded = s }
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('smtp boom') } })
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt', senderEmail: 'shard@gmail.com', senderName: null })
authProviders.getWithSecret = async () => ({ client_id: 'cid', client_secret: 'csec' })
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
assert.equal(recorded.status, 'error')
})