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:
11
.env.example
11
.env.example
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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') -----
|
||||
|
||||
243
client/src/routes/admin/views/EmailDelivery.jsx
Normal file
243
client/src/routes/admin/views/EmailDelivery.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
28
server/src/model/emailConfig/emailConfig.db.js
Normal file
28
server/src/model/emailConfig/emailConfig.db.js
Normal 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 }
|
||||
92
server/src/model/emailConfig/emailConfig.model.js
Normal file
92
server/src/model/emailConfig/emailConfig.model.js
Normal 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 }
|
||||
@@ -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',
|
||||
|
||||
211
server/src/router/v1/admin/emailConfig.controller.js
Normal file
211
server/src/router/v1/admin/emailConfig.controller.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
70
server/test/emailConfig.model.test.js
Normal file
70
server/test/emailConfig.model.test.js
Normal 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)
|
||||
})
|
||||
72
server/test/mailer.test.js
Normal file
72
server/test/mailer.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user