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:
@@ -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 }
|
||||
Reference in New Issue
Block a user