Compare commits
15 Commits
6c19a0608a
...
docs/engag
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd5d065b7 | |||
| d9abe3d941 | |||
| 73c3a467e7 | |||
| c93151b352 | |||
| 2161119c8c | |||
| 556124562b | |||
| c03dfc14ba | |||
| aa5c1d63b0 | |||
| 315a7c7fca | |||
| 73e1669a5c | |||
| 97b652fed3 | |||
| 5924276fe7 | |||
| deaf491dc5 | |||
| 713e6fa6c8 | |||
| 7b7a26f0ac |
@@ -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
158
website/TEAMS.md
158
website/TEAMS.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user