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
212 lines
8.8 KiB
JavaScript
212 lines
8.8 KiB
JavaScript
// ── 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 }
|