Files
website/server/test/mailer.test.js
wtclaude 12ff201ed5
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:39 -05:00

191 lines
8.9 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 templatesDb = require('../src/model/engagement/engagementTemplates.db')
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'
// Engagement Phase 5a: every body now comes from `engagement_templates`, so a
// send reaches three more model functions than it used to. Stubbed here for the
// reason everything else in this file is — the suite must never touch a database
// — and `getByKey` answering null exercises the shipped-seed fallback, which is
// exactly the state a fresh deployment is in before its first seed runs.
settings.getInstanceName = async () => 'UOMysticmoon'
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
templatesDb.getByKey = async () => null
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' })
})