diff --git a/.env.example b/.env.example index 416a131..3a711a0 100644 --- a/.env.example +++ b/.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 diff --git a/BACKEND_DESIGN.md b/BACKEND_DESIGN.md index 4bf9c9a..2d947be 100644 --- a/BACKEND_DESIGN.md +++ b/BACKEND_DESIGN.md @@ -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 ``` diff --git a/README.md b/README.md index 1e586af..5887e73 100644 --- a/README.md +++ b/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` / `/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. --- diff --git a/client/src/api/client.js b/client/src/api/client.js index 5c0fc18..2ea408d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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') ----- diff --git a/client/src/routes/admin/views/EmailDelivery.jsx b/client/src/routes/admin/views/EmailDelivery.jsx new file mode 100644 index 0000000..27c27c0 --- /dev/null +++ b/client/src/routes/admin/views/EmailDelivery.jsx @@ -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= 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 ( +
+
+ + + {config.status || 'unconfigured'} + +
+ {config.senderEmail && ( +

+ Sending as {config.senderEmail} +

+ )} + {config.statusDetail && ( +

{config.statusDetail}

+ )} + {config.lastVerifiedAt && ( +

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

+ )} +
+ ) +} + +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

{error}

+ if (!config) return null + + const connected = config.hasRefreshToken + + return ( +
+
+

Email delivery

+

+ Sends the contact form through Gmail over OAuth2, delivered to the + Contact email above. Reuses the Google authentication + client — configure that on the Authentication page first. +

+
+ + {banner && ( +
+ {banner.text} +
+ )} + + + + {!config.googleConfigured && ( +

+ The Google authentication provider needs a client ID and secret before + you can connect a Gmail account. +

+ )} + + {!connected ? ( +
+ +
+ ) : ( + <> + + + + +
+ + + + +
+ + )} + +
+ {msg && {msg}} + {actionError && {actionError}} +
+
+ ) +} diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx index ce95ef0..0cbd339 100644 --- a/client/src/routes/admin/views/SettingsAdmin.jsx +++ b/client/src/routes/admin/views/SettingsAdmin.jsx @@ -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 && {error}} + + ) } diff --git a/server/.env.example b/server/.env.example index 44c4ffb..a6656f4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/db/schema.sql b/server/db/schema.sql index 6c536bb..2d1407f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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 diff --git a/server/src/model/emailConfig/emailConfig.db.js b/server/src/model/emailConfig/emailConfig.db.js new file mode 100644 index 0000000..648be48 --- /dev/null +++ b/server/src/model/emailConfig/emailConfig.db.js @@ -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 } diff --git a/server/src/model/emailConfig/emailConfig.model.js b/server/src/model/emailConfig/emailConfig.model.js new file mode 100644 index 0000000..7570857 --- /dev/null +++ b/server/src/model/emailConfig/emailConfig.model.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 68c4458..1d9dd53 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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', diff --git a/server/src/router/v1/admin/emailConfig.controller.js b/server/src/router/v1/admin/emailConfig.controller.js new file mode 100644 index 0000000..e6dad60 --- /dev/null +++ b/server/src/router/v1/admin/emailConfig.controller.js @@ -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 } diff --git a/server/src/server.js b/server/src/server.js index 4a91e1f..8ad8e81 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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 diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index 6219d13..31e1dfe 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -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) } + } + 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 } - await getTransporter().sendMail({ - from: SMTP_USER || CONTACT_TO, - to: CONTACT_TO, - replyTo: email, - subject: `UOMysticmoon contact from ${name || 'a visitor'}`, - text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`, - }) - return { sent: true } } -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 } diff --git a/server/test/emailConfig.model.test.js b/server/test/emailConfig.model.test.js new file mode 100644 index 0000000..3d62677 --- /dev/null +++ b/server/test/emailConfig.model.test.js @@ -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) +}) diff --git a/server/test/mailer.test.js b/server/test/mailer.test.js new file mode 100644 index 0000000..0807f3c --- /dev/null +++ b/server/test/mailer.test.js @@ -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" ') + 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') +})