Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
248 lines
11 KiB
JavaScript
248 lines
11 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
|
|
|
|
// 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: '<p>body</p>', 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" <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.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'], '<https://x.test/api/v1/public/engagement/unsubscribe/tok>')
|
|
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, '<p>body</p>')
|
|
assert.equal(sent.text, 'body')
|
|
})
|