// ── 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 }