feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
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>
This commit is contained in:
147
server/src/engagement/emailChannel.js
Normal file
147
server/src/engagement/emailChannel.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// ── 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 crypto = require('crypto')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
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()
|
||||
|
||||
// Lower-cased first: a bounce reported for "Darrow@example.com" has to match the
|
||||
// row written for "darrow@example.com", and a hash of two spellings is two
|
||||
// different rows. The local part is technically case-sensitive per RFC 5321 and
|
||||
// no relay anybody deploys treats it that way.
|
||||
const hashAddress = (address) =>
|
||||
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
|
||||
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
|
||||
// The send log stores a sha256 of the address and never the address itself
|
||||
// (schema.sql): enough to correlate a bounce in Phase 9, 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, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
|
||||
Reference in New Issue
Block a user