Compare commits

..

15 Commits

Author SHA1 Message Date
2fd5d065b7 docs(website): §7.1 Q9 — core's own news.post emitter
`news.post` is a declared trigger with no caller: `coreTriggers.js` says so in
as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming
it can never fire, so on a real deployment the only in-app or email items the
engine can produce today come from Teams. Phase 7 flagged it in passing; this
writes it down properly as an open question.

The substance is not the call — it is the three other things a news publish
already fires, and which of them the engine has any business replacing:

- the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a
  one-shot delivery to a channel of the deployment, with retry. NOT the
  engine's.
- a module's post hook (`registerPostHook`) — idempotent state mirroring that
  also runs on delete and refreshes on a silent edit. NOT the engine's.
- the raw `pushDispatch.publish('news.post', …)` — a per-person notification.
  THIS is the one that becomes an emit.

So modules keep both doors onto a news publish and neither changes. What a
module does not get is the ability to fire `news.post` itself — the id's owner
is core, `emit` binds the owner at the call, and §7.2's one namespace gives an
id exactly one owner across both facets. A module wanting its own person-facing
news notification declares its own trigger.

Three things to settle first, recorded rather than decided: continuity (the
emit replacing the tickle stops push silently until a rule is enabled — G22's
shape, and Phase 6 decision 3's), reusing the job-id transition signal rather
than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose
title understates it — a pointer and an extra acceptance line land there too.

Code: RunicGateway/website#175

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:27:48 -05:00
d9abe3d941 docs(website): the in-app channel as built (engagement Phase 7)
ENGAGEMENT.md gains the Phase 7 as-built: the four decisions the org lead
settled before any code (in-app defaults to `instant`; the phase takes push's
`deliver` and the web preferences screen; the inbox takes `/notifications` and
the settings move under it; `ctx.inbox.push` respects a preference where one
exists), the block-role mapping that turns a template into a row, and five
things the tree contradicted or the build found — including the one only the
live rig could see, that staff had no reachable inbox at all.

Two earlier passages amended where the phase made them false: Phase 2's
"`ctx.inbox.push` throws until Phase 7" and Phase 3's "`inapp` is declared
`off`". Both kept as history with the correction beside them.

BACKEND_DESIGN.md gains the four inbox routes in the `/auth/me` table and a
`user_notifications` entry in the table inventory — the user-scoped dedupe
index, why `body` is text rather than the email HTML, and the retention policy.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:11:46 -05:00
73c3a467e7 Merge pull request 'docs(website): the email channel and the Teams migration as built (engagement Phase 6)' (#187) from docs/engagement-email-channel into edge
Reviewed-on: #187
2026-08-31 06:07:57 +00:00
c93151b352 docs(website): the email channel and the Teams migration as built (engagement Phase 6)
ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front,
the audience problem that shaped the phase, why scope_key is not subject_key, the
one place decision 4 as phrased could not ship, the projection's rule and what it
refuses to guess, the digest correcting Phase 4a rather than only implementing
§4.2b, and the three defects the build found. §4.2b and §6.0b updated with it.

TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own
thing. It now says which of the three sinks moved and which did not, where each
of the four properties it always claimed lives now, that Team email is OFF until
an operator turns a rule on, and what the generalized unsubscribe token does.
§6.3's `last_digest_at` is marked as no longer read.

BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the
unsubscribe routes — the canonical /public/engagement pair plus the /public/teams
path kept permanently because mail is not editable once sent.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:12:15 -05:00
2161119c8c Merge pull request 'docs(website): the template editor as built (engagement Phase 5b)' (#186) from docs/engagement-template-editor into edge
Reviewed-on: #186
2026-08-29 23:37:22 +00:00
556124562b docs(website): the template editor as built (engagement Phase 5b)
ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.

The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.

Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.

Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:14:12 -05:00
c03dfc14ba Merge pull request 'docs(website): the seeded template set as built (engagement Phase 5a)' (#185) from docs/engagement-templates into edge 2026-08-29 18:13:47 +00:00
aa5c1d63b0 docs(website): the seeded template set as built (engagement Phase 5a)
Companion to website#TBD. §6.0b assigns this phase two documents; both land here.

ENGAGEMENT.md gains an "As built — 5a" section: the three decisions the survey
forced, the argument for two registries rather than one, why the ternaries stayed
at the call site, and the two behaviour changes an operator will notice.

BACKEND_DESIGN.md gains the `engagement_templates` table, the two-block-registry
split, the token grammar, and the §7 note that mail is now multipart.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:50 -05:00
315a7c7fca Merge pull request 'docs(website): the engagement admin surface as built (Phase 4b)' (#184) from docs/engagement-rules-admin into edge
Reviewed-on: #184
2026-08-29 17:29:06 +00:00
73e1669a5c docs(website): the browser pass on the engagement screens (Phase 4b)
Six defects that only driving the two screens in a browser could find, plus two
environment facts worth not re-deriving:

  - rebuilding client/dist while the server is running blanks the whole SPA. The
    HTML shell resolves core's hashed bundle filename at boot, so after a
    rebuild it points at a file that no longer exists; core's bundle 404s,
    window.__rg is never published, and modules/uo/entry.js throws MODULE_API.md
    §3.1's "core did not publish its shared dependencies" into a blank page. The
    error names core, and core is not at fault. Restart after every build.
  - a window.confirm blocks CDP entirely, so the two destructive actions cannot
    be driven from a script and a session that opens one is stuck until a human
    dismisses it. Exercise those paths over the API.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:26:41 -05:00
97b652fed3 docs(website): the engagement admin surface as built (Phase 4b)
Companion to website#171.

ENGAGEMENT.md gains an "As built - 4b" section: the four decisions the org lead
settled, the correctness argument behind the standalone enable route, the three
honesty fields on the reach preview, and the two defects the live walk found in
Phase 4a's own code (a rule pointing at a dormant segment reading as healthy,
and the delete refusal's "1 rule still use"). It also records the throwaway
module rig that made the whole segment half walkable at all - core declares no
audiences, so on a stock local stack none of the §5.1a arithmetic can be
exercised without one.

§5.1a gains the composition UI it was owed: its own nav entry rather than a tab,
"exclude" offered only under "all of", the stored ceiling displayed and never
chosen, and a tree nested deeper than the composer renders shown read-only
rather than flattened.

BACKEND_DESIGN.md's route table gains the twelve routes, including why the
enable switch is a PATCH of one column and why deleting a segment in use is a
409 rather than a cascade.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:18 -05:00
5924276fe7 Merge pull request 'docs(website): the engagement engine as built (Phase 4a)' (#183) from docs/engagement-engine into edge
Reviewed-on: #183
2026-08-29 13:24:26 +00:00
deaf491dc5 docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.

ENGAGEMENT.md
  - Phase 4 is split 4a / 4b, with what each owes.
  - Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
    (android CI) is open.
  - The as-built: the gate order and why two of its placements are load-bearing,
    the segment rule the design never stated (not is legal only inside an and,
    and contributes no ceiling), dormancy three ways and why audience_segment_id
    has no foreign key, the conditions grammar's two fail-closed properties, and
    why emit does not await the engine.
  - Section 4.1's cooldown statement and section 4.2a's dedupe index are
    corrected in place, so the design sections stop teaching the two defects.

BACKEND_DESIGN.md
  - Five new tables in the schema inventory, each with the reasoning a reader
    would otherwise have to reconstruct: why subject_key is in the primary key,
    why the dedupe index is scoped, why the send log survives an account
    deletion and is not a second address book, and why a rule points at a
    segment without a foreign key doing it.

No route table changes - Phase 4a adds no routes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:42 -05:00
713e6fa6c8 Merge pull request 'docs(website): per-channel notification preferences (engagement Phase 3)' (#182) from docs/engagement-channel-prefs into edge
Reviewed-on: #182
2026-08-29 12:11:04 +00:00
7b7a26f0ac docs(website): per-channel notification preferences (engagement Phase 3)
The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.

`ENGAGEMENT.md`:
- Phase 3's as-built: the three decisions settled before any code, why the
  channel registry could not wait for Phase 6, what the sparse PUT buys, the
  projection stated as an invariant, and the staff-ceiling filter.
- **The phase's own acceptance line was wrong and is struck through.** "A fresh
  user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN,
  push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no
  row genuinely does mean notified. Push STREAM subscriptions have never worked
  that way — `notification_subscriptions` holds a row only on opt-in — so
  `instant` would have projected the entire catalog into the legacy GET for
  every existing user. §3.1's comment is corrected in the same pass.

`BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the
`notification_channel_prefs` table entry (including that absence means the
channel's default rather than `off`, and that all three agreeing on `off` today
is a fact about the declarations and not about the table), and a note on
`notification_subscriptions` that it is now the push projection.

`android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working
and the `{"streams":[]}` gotcha still applies to that endpoint. The new section
documents the superset endpoint for whenever the app adopts it: render toggles
from each item's `channels` rather than a hardcoded three, `modes` is the
effective mode and the client must not re-implement the defaulting, the PUT is
sparse so the empty-array gotcha does NOT apply here, and one id may be missing
that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff
caller).

Companion to website#169.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:09:16 -05:00
4 changed files with 3033 additions and 1465 deletions

View File

@@ -1250,6 +1250,67 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29)
Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added
`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`.
**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their
exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep
working — `notification_subscriptions` is now the **push projection** of the new table, and every
write to either fans out to the other. That was the acceptance criterion the phase was built against,
with the empty-array case tested explicitly.
**What the new endpoint adds, for whenever the app adopts it:**
```jsonc
// GET /auth/me/notifications/channels
{
"channels": [ // the delivery-channel registry
{ "id": "push", "label": "Push", "carriesContent": false,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] },
{ "id": "email", "label": "Email", "carriesContent": true,
"defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] },
{ "id": "inapp", "label": "On the site", "carriesContent": true,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }
],
"items": [ // every subscribable id, streams AND triggers
{ "id": "news.post", "label": "News posts", "description": "…",
"personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated",
"channels": ["push", "email", "inapp"],
"modes": { "push": "instant", "email": "off", "inapp": "off" } }
]
}
```
Four properties the UI should be built on rather than around:
- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an
event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries
no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it —
so **render the toggles from `channels`, never from a hardcoded three**.
- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the
server substitutes that channel's `defaultMode`. The client never has to know which it is looking
at, and must not re-implement the defaulting.
- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send
only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`.
Everything not named is left alone, so the notifications screen can save one toggle without holding
the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not
apply here at all**: there is no "clearing the last one" case, because turning something off is a
row like any other. `prefs` is still required, so a DTO field with no default is still the right
shape.
- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does
not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored
state, so re-render from it rather than assuming the request took.
**One id may be missing from `items` that the app expects.** A trigger whose declared audience
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified.
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
places:
a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
> record of what a row meant before the migration. **The two preference columns are unchanged and are
> still the authority for Team notifications** — the engine reads them rather than replacing them.
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
@@ -1608,7 +1613,7 @@ places:
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused
### 6.4 Email — the third sink
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
running neither currently gets no notification that someone replied to their own thread** — which is
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
same event**, not a fourth pipeline.
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
> line still describes what a recipient receives; what changed is who decides to send it. The design of
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
> every other doc that touches a contract it does not own.
#### What moved, and what did not
`teamNotify.js` had three sinks. **One moved:**
| Sink | Where it lives now |
| --- | --- |
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
digest, `notify.event` for the two roster events and for announcements).
#### The four properties this section always claimed, and where each one lives now
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
off.** All three still hold, and the first is now explicit rather than incidental:
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
the event says who it is about.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
topic. It carries the thread title, an excerpt and a link; never the full post.
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
silences every channel, and `email_mode` decides email and says nothing about the others. The
alternative — intersecting it with the newer per-stream preference — would have silenced every
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
and as of Phase 6 there is a second gate above it, below.
#### **Team email is OFF until an operator turns it on**
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
off; the release note says it too. Push and the Discord bridge are unaffected.
#### The digest
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
pending-items table — and, in Phase 6, over the engine's own outbox:
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
2. a post a moderator hid after it was written is simply not in the query;
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
they are not emailed content they can no longer read.
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
notifications every time the mail provider had a bad minute.
**What Phase 6 changed is the state, not the design.** The stamp moved from
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
properties above. The worker is also gated on an enabled email rule, so switching Team email off
switches off both halves of it rather than the instant half only.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
> has.
- **The digest computes at send time and keeps no queue** (as built). The only state is
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
eat a day of somebody's notifications every time the mail provider had a bad minute.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
writes the preference the site shows.
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
> once — and it exists before it is needed rather than after.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
> session behind it.
> would start mailing every member of every Team the moment an operator connects a mail transport.
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
> already has.
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
argument survives as the default rather than as a sink the code declines to call.
#### One-click unsubscribe
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
site shows.
> **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
> absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
> must mean what clicking it once meant. The capability is deliberately the narrowest that does the
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
> needed rather than after.
>
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
> and read as the email channel for that Team, which is a reading of what they always meant.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
> not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
> nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
> session behind it.
>
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there.