Files
website/server/test/mailer.test.js
Claude 47c8b37d45 feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:52:55 -05:00

182 lines
8.3 KiB
JavaScript

// utils/mailer.js — the transport resolution and, more importantly, the five
// failure contracts the six call sites depend on (ENGAGEMENT.md §1.2, Phase 1).
//
// The Gmail OAuth2 assertions are gone with the transport (§1.2a); what replaced
// them asserts the SAME things at the same seam — that a configured deployment
// builds the transport the operator selected from the credentials they supplied,
// and that an unconfigured one degrades exactly as before rather than throwing.
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 settings = require('../src/model/settings/settings.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
after(() => db.close())
// A complete SMTP config, as getWithSecret returns it.
const configured = (over = {}) => ({
transport: 'smtp',
enabled: true,
senderEmail: 'mail@shard.example.com',
senderName: 'UOMysticmoon',
replyTo: null,
hasCredential: true,
credentialSecret: { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec' },
...over,
})
let sent
let transportCfg
beforeEach(() => {
sent = null
transportCfg = null
emailConfig.recordStatus = async () => {}
settings.get = async () => 'contact@example.com'
nodemailer.createTransport = (cfg) => {
transportCfg = cfg
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
}
})
// ── the five failure contracts ──────────────────────────────────────────────
test('unconfigured → contact form falls back to mailto (never throws)', async () => {
emailConfig.getWithSecret = async () => null
emailConfig.getSafe = async () => ({ senderEmail: null, hasCredential: 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('unconfigured → invite returns NOT_CONFIGURED so the admin gets the link', async () => {
emailConfig.getWithSecret = async () => null
const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
test('unconfigured → password reset returns NOT_CONFIGURED (caller still answers 200)', async () => {
emailConfig.getWithSecret = async () => null
const r = await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
test('unconfigured → team notification returns NOT_CONFIGURED and never throws', async () => {
emailConfig.getWithSecret = async () => null
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
test('unconfigured → only sendTest throws, because only sendTest has an admin waiting', async () => {
emailConfig.getWithSecret = async () => null
await assert.rejects(() => mailer.sendTest('a@b.com'), (err) => err.code === 'NOT_CONFIGURED')
})
// ── transport resolution ────────────────────────────────────────────────────
test('configured → builds the selected transport from the stored credential and sends', async () => {
emailConfig.getWithSecret = async () => configured()
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' })
assert.equal(r.sent, true)
// Every connection detail comes from the operator's credential. Nothing in the
// code chooses a host, a port or a TLS mode (§3.2 rule 1).
assert.equal(transportCfg.host, 'relay.example.com')
assert.equal(transportCfg.port, 587)
assert.equal(transportCfg.secure, false)
assert.deepEqual(transportCfg.auth, { user: 'apikey', pass: 'sec' })
// From uses the display name; To is the contact_email setting; replyTo is the
// visitor, which still wins over a configured Reply-To.
assert.equal(sent.from, '"UOMysticmoon" <mail@shard.example.com>')
assert.equal(sent.to, 'contact@example.com')
assert.equal(sent.replyTo, 'ann@player.com')
})
test('an unauthenticated relay gets no auth block', async () => {
emailConfig.getWithSecret = async () => configured({
credentialSecret: { host: 'mta.example.com', port: 25, secure: false },
})
await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
assert.equal(transportCfg.auth, undefined)
})
test('the configured Reply-To is used when the caller has none', async () => {
emailConfig.getWithSecret = async () => configured({ replyTo: 'staff@shard.example.com' })
await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' })
assert.equal(sent.replyTo, 'staff@shard.example.com')
})
test('disabled is unconfigured — the toggle gates every sender, not just isConfigured', async () => {
emailConfig.getWithSecret = async () => configured({ enabled: false })
emailConfig.getSafe = async () => ({ senderEmail: 'mail@shard.example.com', hasCredential: true, enabled: false })
const r = await mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' })
assert.equal(r.sent, false)
assert.equal(r.fallback, 'mailto')
})
test('an incomplete credential is unconfigured, not a crash', async () => {
// A username with no password authenticates as nobody; smtp.isComplete says no.
emailConfig.getWithSecret = async () => configured({
credentialSecret: { host: 'relay.example.com', port: 587, user: 'apikey' },
})
const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
test('a stored transport id that is not registered degrades, it does not throw', async () => {
emailConfig.getWithSecret = async () => configured({ transport: 'mailgun' })
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
// ── failures ────────────────────────────────────────────────────────────────
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 () => configured()
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
assert.equal(recorded.status, 'error')
})
test('a rejected sender is diagnosed by name — the failure mode SMTP introduces', async () => {
// Under the removed consent flow the sender came back from the provider and was
// guaranteed to belong to the credential. Operator-typed, it can be refused,
// and "550 5.7.1" alone does not tell anyone why (§1.2a consequence 2).
let recorded = null
emailConfig.recordStatus = async (s) => { recorded = s }
nodemailer.createTransport = () => ({
sendMail: async () => {
const err = new Error('Sender address rejected')
err.responseCode = 550
throw err
},
})
emailConfig.getWithSecret = async () => configured()
await assert.rejects(() => mailer.sendTest('a@b.com'), /mail@shard\.example\.com/)
assert.match(recorded.statusDetail, /SPF\/DMARC/)
})
test('a team notification failure is swallowed, never thrown', async () => {
emailConfig.recordStatus = async () => {}
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('relay down') } })
emailConfig.getWithSecret = async () => configured()
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'SEND_FAILED' })
})