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

@@ -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 }