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
126 lines
4.7 KiB
JavaScript
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 }
|