// ── 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 }