Files
website/server/src/utils/mailer.js
Claude f8652c2399 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
2026-07-07 22:29:27 -05:00

126 lines
4.7 KiB
JavaScript

// ── 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')
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')
// 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)
}
// 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
}
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 email is not configured/enabled, signals the caller
* to fall back to a mailto: link instead of throwing.
*/
async function sendContactMessage({ name, email, message }) {
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
}
}
/**
* 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 }