// 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 // An already-rendered body, which is what `sendNotification` takes: the email // channel renders the template and this file's job is only the transport and the // headers (ENGAGEMENT.md Phase 6). const RENDERED = { subject: 'A subject', html: '

body

', text: 'body' } 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' }) }) // Unconfigured is RETRYABLE for this one sender, and it is the only one where // that is the right answer: an operator halfway through typing SMTP credentials // should find the outbox drains once they finish, not a backlog of rows the // worker gave up on five minutes in. test('unconfigured → an engagement send is a retryable failure, never a throw', async () => { emailConfig.getWithSecret = async () => null const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED }) assert.equal(r.ok, false) assert.equal(r.retry, true) }) 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" ') 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.sendNotification({ to: 'a@b.com', rendered: RENDERED }) assert.equal(r.ok, false) }) // ── 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('an engagement send failure is swallowed and classified, never thrown', async () => { emailConfig.recordStatus = async () => {} nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('relay down') } }) emailConfig.getWithSecret = async () => configured() const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED }) assert.equal(r.ok, false) // Transient: a relay that is down now may not be in five minutes. The worker's // flat backoff is what this classification feeds. assert.equal(r.retry, true) }) // The other half of the classification, and the one that costs something to get // wrong in the safe direction: a rejected recipient is not going to be accepted // on the fifth attempt, and retrying it is four more chances to be seen as a // sender who ignores bounces. test('a permanent SMTP refusal is classified terminal, not retried', async () => { emailConfig.recordStatus = async () => {} nodemailer.createTransport = () => ({ sendMail: async () => { const err = new Error('550 No such user') err.responseCode = 550 throw err }, }) emailConfig.getWithSecret = async () => configured() const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED }) assert.equal(r.ok, false) assert.equal(r.retry, false) }) // RFC 8058 one-click is only one-click when BOTH headers are present, and the // header url has to be the API endpoint rather than the page: a client POSTs to // it without rendering anything. test('both List-Unsubscribe headers ride along, and the header carries the API url', async () => { emailConfig.getWithSecret = async () => configured() const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED, unsubscribeUrl: 'https://x.test/unsubscribe/tok', unsubscribeApiUrl: 'https://x.test/api/v1/public/engagement/unsubscribe/tok', }) assert.equal(r.ok, true) assert.equal(sent.headers['List-Unsubscribe'], '') assert.equal(sent.headers['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click') }) test('an engagement send is multipart — the html and the text both go out', async () => { emailConfig.getWithSecret = async () => configured() await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED }) assert.equal(sent.subject, 'A subject') assert.equal(sent.html, '

body

') assert.equal(sent.text, 'body') })