Modernize email: Gmail OAuth2 sending, configured under Settings

Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.

- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
  row), contact recipient = contact_email setting, mailto: fallback preserved,
  plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
  + PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
  now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
This commit is contained in:
2026-07-07 22:29:27 -05:00
parent 17d42cebfe
commit f8652c2399
16 changed files with 966 additions and 56 deletions

View File

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

View File

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