Files
website/server/src/engagement/emailChannel.js
wtclaude c208543044
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
feat(engagement): deliverability — suppression, bounces and the verification gate
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.

`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.

The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.

`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.

Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.

Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.

`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.

- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
  → Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated

Docs: RunicGateway/docs#191.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:47:34 -05:00

242 lines
12 KiB
JavaScript

// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
//
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
// behaviourless ("declaring a function nothing calls freezes a signature before
// anything has tried to use it"); this is the phase that has something to try it
// with, and the signature survived unchanged.
//
// **What it does is four lookups and one send**, and the order matters because
// each step is a way the mail should not go out:
//
// 1. the address — re-checked for `status = 'active'`, because a delayed
// row can outlive the account it was queued for
// 2. the rule — for its per-channel template key; the outbox row
// carries `rule_id` and FK CASCADE guarantees it exists
// 3. the values — the payload snapshot, plus §4.6.1's structural
// projection, plus this recipient's unsubscribe link
// 4. the template — `renderByKey`, which falls back to the shipped seed
// rather than failing, and refuses a draft
// 5. the send — `mailer.sendNotification`, which classifies rather
// than throwing
//
// **It never throws**, and that is a stronger statement than the worker's
// `try/catch` around it: a throw would be read as a transient failure and retried
// five times, so an unrenderable template would become five identical failures in
// the send log instead of one honest terminal row.
//
// **The unsubscribe link is per recipient and is built from `scope_key`, never
// from `subject_key`.** They differ for every Team event: the subject is
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
// A Team renamed between the mail and the click must not orphan the link in it.
const rulesDb = require('../model/engagement/engagementRules.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const suppressions = require('./suppressions')
const settings = require('../model/settings/settings.model')
const unsubscribeToken = require('../utils/unsubscribeToken')
const log = require('../utils/logger')('engagement')
// **Required lazily, and it is a real cycle rather than a style preference.**
// `engagement/index.js` requires `coreChannels`, which requires this file; and
// `utils/mailer` requires `engagement/index` for the transport registry. A
// top-level `require('../utils/mailer')` here therefore resolves while
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
// `transports` and every send would fail on `transports.get is not a function` —
// at send time, on a deployment, with the boot log clean. Resolved at call time
// instead, by which point both modules are fully evaluated.
const mailer = () => require('../utils/mailer')
// The template a rule renders through when it names none. §4.6.1 property 1: a
// new trigger must be mailable with no authoring at all, and this plus
// `projection.project` is that property's implementation.
const DEFAULT_TEMPLATE = 'notify.event'
const baseUrl = () => templates.baseUrl()
// Re-exported rather than defined here since Phase 9: the send log's hash and
// the suppression list's key have to be the same function or a bounce never finds
// the row it belongs to. `suppressions.js` owns it, next to the masking.
const hashAddress = suppressions.hashAddress
/**
* The two unsubscribe URLs for one recipient of one scope, or nulls.
*
* TWO urls from one token, and they are not interchangeable. `unsubscribeUrl` is
* the human one that goes in the mail body: the site's own page, which explains
* what is about to happen and POSTs once a person has read it. `unsubscribeApiUrl`
* is the machine one for the `List-Unsubscribe` header, where RFC 8058 says a
* client may POST without showing anybody anything — so it has to be an endpoint,
* not a page. The API route answers GET on the same path with a redirect to the
* page, which covers clients that render the header as an ordinary link.
*
* A scope the token format cannot carry yields nulls rather than an exception:
* the mail is worth sending without a one-click unsubscribe, and the recipient
* still has the preferences screen. It is logged because it is a programming
* error in whatever chose the scope key.
*/
function unsubscribeUrls(userId, scopeKey) {
try {
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
const base = baseUrl()
return {
unsubscribeUrl: `${base}/unsubscribe/${token}`,
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
}
} catch (err) {
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
}
}
/** Where this channel would send to, or null. */
const addressFor = (userId) => recipients.addressFor(userId)
/**
* Narrow an audience to the users this channel may write an outbox row for
* (Phase 9, decision 4).
*
* **One gate, and it is the Phase 1b verification setting.** With
* `email_verification_required` on, a user whose address is unverified is
* excluded here rather than refused at delivery, and the org lead settled it that
* way for two reasons. It is a STANDING property — unlike a suppression, which
* can appear inside a `delay_seconds` window and therefore has to be re-checked
* at send time — so the outbox row would be written only to be thrown away. And a
* deployment that upgraded before verifying anybody has an audience that is
* almost entirely unverified: excluding at delivery would write a `suppressed`
* row per person per rule firing, which is a send log nobody can read.
*
* The count comes back so the admin reach preview can say "1,204 excluded:
* unverified" instead of quietly promising a number the engine will not deliver.
*
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
* it would abandon the whole rule for every channel it names. A database having
* a bad minute would become a rule that silently sent nothing, with a clean send
* log and nothing in the outbox to retry. Same direction as the suppression
* check, for the same reason: the recoverable mistake is mail going out, not mail
* silently stopping.
*/
async function eligible(userIds) {
const list = userIds || []
if (!list.length) return { userIds: [], excluded: {} }
try {
if (!(await settings.isEmailVerificationRequired())) {
return { userIds: list.slice(), excluded: {} }
}
const unverified = await recipients.unverifiedAmong(list)
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
return {
userIds: list.filter((id) => !unverified.has(Number(id))),
excluded: { unverified: unverified.size },
}
} catch (err) {
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
return { userIds: list.slice(), excluded: {} }
}
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
const to = await addressFor(row.user_id)
if (!to) {
// Terminal. Retrying does not give somebody an address, and a banned
// account is not going to be un-banned by a five-minute backoff.
return { ok: false, detail: 'no deliverable address for this user' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
// Once, not once per use: the body's link and the header's must be the same
// token, or a client that offers both offers two different unsubscribes.
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
const rendered = await templates.renderByKey(key, values)
if (!rendered) {
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
// the operator deleted a template a rule points at, which the admin surface
// refuses with a 409 — so reaching here means it happened out of band.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
if (rendered.missing.length) {
// Not a refusal: an optional variable a trigger chose not to supply renders
// as nothing by design. Logged with NAMES ONLY, never values — the same
// rule the emit and dispatch log lines follow.
log.debug('template variables had no value', { key, missing: rendered.missing })
}
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
// row can sit through a `delay_seconds` grace window, and an address can hard
// bounce inside it — so the only check that can be correct is the one taken
// immediately before the transport call. It is also the check the acceptance
// criterion describes: a `suppressed` row in the send log, and no transport
// call at all.
const blocked = await suppressions.isSuppressed(to.address)
if (blocked) {
return {
ok: false,
suppressed: true,
detail: `address is suppressed (${blocked.reason})`,
addressHash: hashAddress(to.address),
}
}
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
// A failed send is where a hard bounce enters the system on SMTP, and the
// reason it is worth catching rather than waiting for an API provider: a
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
// the most valuable deliverability signal there is and it was already being
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
// note goes into the log on BOTH outcomes, so "this failed and was not
// suppressed" says why.
if (result && !result.ok && result.smtp) {
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
// A bounce is terminal by definition. Overriding `retry` matters because
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
// without this a genuine dead mailbox could still be retried four more
// times — each one another refusal on our record with the relay.
if (verdict.suppressed) {
return {
ok: false,
retry: false,
// `engagement_sends.status` has carried 'bounced' since §4.5 and
// nothing wrote it until here, so the Send Log's "Bounced" filter
// matched nothing — the live rig is what showed that. It is a distinct
// status rather than a flavour of 'failed' because the two need
// different actions: a failure means look at the relay, and a bounce
// means that person's address is gone.
bounced: true,
transport: result.transport,
detail: `${result.detail}${verdict.note}`,
addressHash: hashAddress(to.address),
}
}
return { ...result, detail: `${result.detail}${verdict.note}`, addressHash: hashAddress(to.address) }
}
// The send log stores a sha256 of the address and never the address itself
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
// Attached on every outcome, because a failure is exactly the row a bounce
// would need to be matched against.
return { ...result, addressHash: hashAddress(to.address) }
} catch (err) {
// See the header: a throw here would be retried as if it were the relay's
// fault. Classified as terminal instead, with the reason in the send log.
log.error('email delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }