Files
website/server/test/mailer.test.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

73 lines
3.2 KiB
JavaScript

process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const nodemailer = require('nodemailer')
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const settings = require('../src/model/settings/settings.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
after(() => db.close())
// Restore a clean slate of stubs before each test.
beforeEach(() => {
emailConfig.recordStatus = async () => {}
settings.get = async () => 'contact@example.com'
})
test('unconfigured → mailto fallback (never throws)', async () => {
emailConfig.getWithSecret = async () => null
emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: false, enabled: false })
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi' })
assert.deepEqual(r, { sent: false, fallback: 'mailto', email: 'contact@example.com' })
})
test('configured → builds a Gmail OAuth2 transport and sends', async () => {
let transportCfg = null
let sent = null
nodemailer.createTransport = (cfg) => {
transportCfg = cfg
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
}
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt-123', senderEmail: 'shard@gmail.com', senderName: 'UOMysticmoon' })
authProviders.getWithSecret = async (id) => {
assert.equal(id, 'google')
return { client_id: 'cid', client_secret: 'csec' }
}
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' })
assert.equal(r.sent, true)
// Transport is Gmail SMTP over XOAUTH2 with the reused Google client + stored refresh token.
assert.equal(transportCfg.host, 'smtp.gmail.com')
assert.equal(transportCfg.port, 465)
assert.equal(transportCfg.secure, true)
assert.equal(transportCfg.auth.type, 'OAuth2')
assert.equal(transportCfg.auth.user, 'shard@gmail.com')
assert.equal(transportCfg.auth.clientId, 'cid')
assert.equal(transportCfg.auth.clientSecret, 'csec')
assert.equal(transportCfg.auth.refreshToken, 'rt-123')
// From uses the display name; To is the contact_email setting; replyTo is the sender.
assert.equal(sent.from, '"UOMysticmoon" <shard@gmail.com>')
assert.equal(sent.to, 'contact@example.com')
assert.equal(sent.replyTo, 'ann@player.com')
})
test('send failure propagates and is recorded', async () => {
let recorded = null
emailConfig.recordStatus = async (s) => { recorded = s }
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('smtp boom') } })
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt', senderEmail: 'shard@gmail.com', senderName: null })
authProviders.getWithSecret = async () => ({ client_id: 'cid', client_secret: 'csec' })
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
assert.equal(recorded.status, 'error')
})