Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesStaff NewsletterStaff Newsletter — Behavior

Staff Newsletter — Behavior

Two paths, one catalog

┌─ cadence = immediately ─→ StaffNewsletterListener ─→ 1 mail / event domain event ────────┤ └─ cadence = daily|weekly ─→ (nothing; not buffered) cron tick ──→ for each org with staff ──→ digest queries the window ──→ 1 mail / recipient

Nothing is buffered. An event configured daily is dropped by the listener and re-derived by the digest from the domain’s own tables.

Resolution order

cadenceFor(storedConfig, event):

  1. The org’s stored value for that event, if any — including an explicit off.
  2. Otherwise DEFAULT_STAFF_NEWSLETTER_CONFIG[event].
  3. Otherwise off.

The immediate path

Cheapest check first, so an org that wants an event daily costs one config read and no queries:

  1. config.cadenceOf(orgId, event) — not immediately → return.
  2. Resolve the org locale, build the item (the only DB reads), send to recipientsFor(orgId, event) — the roster minus anyone who muted event.

Every handler is wrapped: a throw is logged and swallowed. Producers emit synchronously from inside or just after their own writes — several on a money path — so an escaping exception would surface on a member’s cancellation or a charge record.

The digest path

Per cadence, under its own advisory lock:

  1. orgIdsWithStaff() — orgs with at least one active owner/admin.
  2. Per org, isolated in a try/catch so one failure does not cost later orgs:
    1. Build sections for every event at this cadence.
    2. Every section empty → skip, without claiming the window.
    3. Already claimed for this (cadence, window) → skip.
    4. Write the audit claim.
    5. Send one mail per recipient, each carrying only the sections that recipient has not muted. A recipient left with no populated section gets no mail. The claim is per org, so a fully-muted roster still claims the window — nothing is owed to anyone for it.

Personal opt-out

Who receives event E = active owners/admins of the org minus those whose staff_alert_prefs.muted_events contains E. Applied in one place, StaffNewsletterConfigService.recipientsFor, which both send paths resolve their audience through.

  • GET /organizations/:orgId/staff-newsletter/me → { data: { events: { [event]: boolean /* I receive it */ }, orgCadence: { [event]: cadence } } }. orgCadence travels with it so a client can tell “I muted it” from “the org sends it to nobody” without a second call.
  • PATCH …/me with { events: { [event]: boolean } } — only the named events change; returns the same shape.
  • The settings screen saves each switch the moment it flips (optimistic, with a toast; rolled back on error), apart from the org’s save bar. A switch is disabled with “Off for the whole team” while the org’s saved cadence is off, still showing the person’s own choice.

Every staff mail ends with “You get this because you’re an owner or admin of {org}.” plus two links:

LinkTarget
Manage your alerts{FRONTEND_URL}/{locale}/dashboard/settings/notifications
Stop “{event}” emails (immediate) / Stop all alert emails from {org} (digest){FRONTEND_URL}/{locale}/staff-alerts/unsubscribe?token=…

The token is base64url("sa1.{userId}.{orgId}.{event|*}.{expiresAt}") . HMAC, signed with STAFF_ALERT_UNSUBSCRIBE_SECRET (falls back to CLERK_SECRET_KEY). The sa1 prefix means a lead-consent token can never verify as one even under the same secret. Tokens expire after 90 days: the token mutes a staff account, so a forwarded mail should not keep that power forever, and a staff member can always mute from settings.

The web page mutes only on an explicit click, which POSTs { token } to the public POST /staff-newsletter/unsubscribe. The mail’s List-Unsubscribe header points straight at that endpoint (token in the query) with List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) when API_BASE_URL is set; without it the header falls back to the web page. There is no mutating GET, so a link scanner mutes nobody.

The endpoint always answers 200 — { status: 'unsubscribed' | 'invalid', scope: event | 'all' | null, organizationName } — because one-click clients expect success; a forged, expired or orphaned token is invalid and changes nothing. '*' mutes every event in today’s catalog.

Invariants

InvariantEnforcement
An org that never saved a config still gets the daily retention sectionsDEFAULT_STAFF_NEWSLETTER_CONFIG, applied by cadenceFor
One digest per (org, cadence, window)Audit claim written before the send; wasSent() reads it back
An overlapping run sends nothingwithAdvisoryLock per cadence, returns null when held
An empty digest is never sentSections filtered in the cron and again in the sender
A consumer can never fail its producertry/catch at both the emit site and the handler
retentionCaseAttention can never be immediatelyDropped by cadencesFor, rejected by StaffNewsletterConfigSchema, absent from the select
Staff joining an org is not a new memberrole === 'member' in both paths
A growing balance is not a new debtAll three producers emit only on debt_since NULL → timestamp
One recipient’s bad address does not cost the rosterPer-recipient try/catch in safeSend
Nobody is mailed an event they mutedrecipientsFor(orgId, event) for immediate; per-recipient section filter for digests
A personal switch cannot add mailMutes only subtract from the org’s roster; nothing reads them to send

Edge cases

ScenarioBehavior
Org has no active owner/adminNot in orgIdsWithStaff(); immediate path resolves zero recipients and sends nothing
FRONTEND_URL unsetRows render without a link; nothing throws
The row an immediate event names is goneItem is null, nothing sent
The person behind a row cannot be resolvedpersonName is null and the template substitutes localized copy — never a hardcoded “Unknown”
Cron misses a tickNext tick re-derives the same window; the claim stops a double-send only if the first actually sent
Process dies between claim and sendThe window is claimed and that digest is lost — deliberate: a duplicate is worse than a miss for a digest
Two sends the same day, window filled in betweenSecond is skipped (claimed). An owner who needs it sooner changes the cadence
Owner flips weekly → immediately mid-windowEvents from before the flip are still in the weekly digest; the flip only affects events that land after it
Org spans timezonesWindows are UTC. Known gap — see the README
Recipient locales differCopy, dates and money all render per recipient; the item is built once per org
Admin mutes every event in today’s digestNo digest for them; the rest of the roster gets theirs
Unsubscribe link older than 90 daysPage shows “invalid or expired” with a link to settings; nothing changes
Staff member demoted to coach, then clicks an old linkThe mute is stored and inert — they are not a recipient anyway

Side effects

ActionWritesSends
Immediate event—1 email per active owner/admin who has not muted the event
Digest tick (per org)1 audit_events row1 email per active owner/admin with at least one unmuted populated section
PATCH configUpserts staff_newsletter_configs—
PATCH …/me, unsubscribe linkUpserts staff_alert_prefs—

Permissions

GET and PATCH on the org config both require organization:manage (owner/admin — the same gate as Settings → General and Settings → Booking). A coach gets 403.

GET/PATCH …/me require an active owner or admin membership — the people who receive alerts — regardless of the permission matrix. A coach gets 403. POST /staff-newsletter/unsubscribe is @Public(); the signed token is the credential, throttled at 30/min like the lead unsubscribe.

A join-link signup activates its membership before any payment page is shown, and an unfinished checkout is released unpaid 45 minutes later. So “new member” is decided by the member activation gate (apps/api/src/member-activation-gate/), not by the raw membership.activated event:

  • MemberActivationGateService listens to membership.activated and emits membership.activation_confirmed. Both the staff newMember mail and the new_member automation listen to the confirmed event. Every other consumer (audit, analytics, onboarding forms, CRM conversion) still reads the raw event.
  • It confirms at once for a membership that did not come from a join link, for staff roles, for a join link without a plan, and when the member already owns a settled subscription (scheduled, active, past_due, paused, debt). If any lookup fails before the hold is written, it also confirms at once.
  • Whether the joiner chose a plan comes from joinPlanId on the event when acceptJoinLink activated the membership. When the Clerk invitation activated it (acceptPendingInvitations, which carries no plan), the gate reads the landing intent the join form recorded: shop_plan means a plan, shop_browse means none. No intent means it cannot tell, and it confirms at once.
  • Otherwise it holds the activation: one row in member_activation_holds (Postgres, the source of truth, carrying the event) plus one delayed job (member-activation-gate, the timer). The delay is derived from the release timing in payments/services/join-link-release.ts: 45-minute window + 10-minute sweep interval + 15 minutes of slack = 70 minutes. At most one hold is open per membership; a duplicate activation folds into it.
  • A hold ends exactly once. Every path claims the row with UPDATE … WHERE resolved_at IS NULL, and only the path that closes it emits membership.activation_confirmed (outcome + resolution record how it ended):
    • Payment lands (subscription.started, or subscription.presale_sold when a presale checkout stores the card without charging): confirmed at once (paid), and the queued job is removed. This is the normal path for a member who pays.
    • The delayed decision (MemberActivationGateProcessor → decideHold), from the subscriptions created since the hold (both timestamps are database time): a settled one confirms (settled), none confirms (no_purchase), only cancelled ones drop it (checkout_cancelled), a membership that is no longer an active member drops it (not_member), and a still-pending one is looked at again an hour later until 24 hours, then dropped (expired). If the decision keeps failing, the last retry confirms (undecided).
    • A later activation confirmed at once (a late payer reviving a released membership): that activation is confirmed by the gate, and the old hold is closed without a second emit (superseded).
    • The reconcile sweep (MemberActivationGateService.reconcile, every 15 minutes): a hold still open 30 minutes past its due time with no live decision job (Redis lost it, or it could not be queued) is decided right there; a still-pending one is handed back to the queue. Any such hold is logged as an error and reported to Sentry as member activation decisions missing from Redis.
  • A decision lives under one of two job ids per membership (new-member-<id>, new-member-<id>-recheck); a re-check takes whichever id the running job does not hold, which is what lets the gate and the sweep find it.

A join that is released unpaid becomes a lead (stage “Awaiting payment” or “Incomplete signup”). That lead, and a lead reopened this way, carries recoveredSignup: true on its lead.created / lead.status_changed event. new_lead automations skip it. The staff newLead mail is unchanged.