docs: add notifications spec doc

[skip ci]
scarlett
NGPixel 2 days ago
parent 9712cbfefc
commit ba12798381
No known key found for this signature in database

@ -0,0 +1,888 @@
# Notifications
**Status:** a proposal. Nothing here is implemented yet. What already exists and is built on (page
watching, the inbox shell, the disabled Profile entry) is listed in [§2](#2-what-exists-today). Table,
column, route and file names are proposals until the first phase lands.
**Covers:** what a user is notified about, how they choose which notifications they get and how,
how an event becomes an inbox entry and an email without slowing the request that caused it, and
how new kinds of notification are added later.
A wiki already tells you a great deal if you go looking for it: the watch list, the review queue,
the Talk tab. What it does not do is *come and find you*. This document is about that half: being
told that a page you watch has changed, that someone answered you, that a suggestion is waiting for
your review.
The constraints this is written against:
- **A notification never tells anyone more than they could have found out for themselves.** Every
recipient is checked against the permission that would let them see the thing they are told
about, *at the time they are told*.
- **The request that caused an event does one write, and nothing else.** Working out who to tell,
checking each person's access, and talking to an SMTP server all happen in worker threads,
however many people end up being notified.
- **A new kind of notification is one file, one emit call and its strings.** The admin-facing ones
that are coming (registrations, failed logins, a failed git sync) must not need a second system.
---
## 1. Goals and non-goals
**Goals**
- **Seven categories at launch** ([§4.2](#42-the-launch-categories)), five on by default and two
opt-in.
- **Per-user, per-category choice of channel**: Email, In-App, both or neither, from **Profile →
Notifications**. One set of preferences per user, not one per site.
- **Scales with the instance**: the cost of an event is roughly proportional to the number of
people who actually receive it, not to the number of accounts on the wiki.
- **Coalescing**: a page saved forty times in an afternoon does not produce forty inbox entries.
- **RFC 8058 one-click unsubscribe** on every notification email. This is a hard requirement.
- **A per-site switch** that turns notifications off for a site.
- **Polling now, push later**, without the push work having to change anything polling relies on.
**Non-goals**
- **Live push in the first version.** [§9.3](#93-adding-push-later) says how it attaches.
- **Hourly and daily digest emails in the first version.** They are expected to follow, and
[§10.5](#105-leaving-room-for-hourly-and-daily-digests) is what the first version does to make
them additive.
- **Other channels** (browser Web Push, Slack DMs, Matrix). The preference model leaves room for
them ([§5](#5-preferences)), but none is planned.
- **Third-party comment providers.** Disqus, Giscus and the rest keep their discussions in somebody
else's service. Only the built-in provider produces comment events.
- **Notifying guests.** A notification needs an account to belong to.
- **Notifying anyone of their own actions.** The actor of an event is never one of its recipients.
---
## 2. What exists today
| Piece | Where | State |
| ----- | ----- | ----- |
| Page watching | `db/schema.ts` → `pageWatching`, `models/pageWatching.ts`, `api/watching.ts` | Done. One row per (page, user), carrying the site. The comments in that code already expect "whatever delivers the news later" to read it. **Rows cascade away when the page is deleted**, which matters for [§6.3](#63-deletion-snapshot-the-watchers-first) |
| Watch button and list | the bell in the page header, `pages/InboxWatching.vue` | Done |
| Inbox shell | `layouts/InboxLayout.vue`, `/_inbox/messages` → `pages/InboxMessages.vue` | Placeholder, reading "Nothing here yet" |
| Review deep link | `/_inbox/review/:submissionId?` | Done. Its route comment anticipates a notification linking to it |
| Profile entry | `layouts/ProfileLayout.vue`, key `notifications` | Present but `disabled: true`, and has no route |
| Header button | `components/HeaderNav.vue`, `mdi:inbox-full` → `/_inbox` | Present, with no badge |
| Strings | `locales/en.json` → `profile.notifications`, `inbox.*` | `inbox.title` already reads "Inbox & Notifications" |
What it is built from:
| Piece | Where | Used for |
| ----- | ----- | -------- |
| Webhook emit | `models/hooks.ts` → `emit()` | The contract to copy: called from the models, never throws, and does no delivery inline. Every `hooks.emit` call site is a candidate notification emit site |
| Scheduler and workers | `core/scheduler.ts`, `worker.ts`, `tasks/workers/` | Fan-out and mail both run as worker tasks. `dispatch-webhook.ts` is the model for a worker task that imports its own models |
| Page rules | `helpers/pageRules.ts` → `resolvePageRule`, `models/groups.ts` → `checkAccess` | Pure and in memory. The answer depends only on a user's *set of groups*, which is what makes bulk access checks cheap ([§7.2](#72-access-is-checked-per-group-set-not-per-user)) |
| Approval rules | `models/approvals.ts` → `matchesPage`, `reviewerGroups` | Who reviews a page |
| Mentions | `models/comments.ts` → `resolveMentions` | Turns `@handle` text into users with one query |
| Mail | `models/mail.ts`, `locales.translator()` | Templates, two bodies (HTML and text), locale fallback |
| Import scope | `storage.importingFrom()` (AsyncLocalStorage) | Tells an import or a git pull apart from a person's edit ([§6.2](#62-origin)) |
| Form bodies | `@fastify/formbody`, registered in `index.ts` | The one-click unsubscribe POST is `application/x-www-form-urlencoded` |
---
## 3. Architecture
```
request path worker: fan-out worker: mail drain
──────────── ─────────────── ──────────────────
notifications.emit() ──► notificationEvents ──► notifications ──► SMTP
one INSERT, never (outbox; claimed one row per recipient one mail per user
throws; a debounced with SKIP LOCKED) per coalescing key, with per drain, single
job kick in-app and email state or digest
│
└──► GET /notifications/summary ◄── polling
(push attaches here later, §9.3)
```
Three tables and two worker tasks. This is deliberately **not** one scheduler job per recipient,
or even one per event. Every job costs a `jobs` row and a `jobHistory` row. On a busy wiki one job
per page save is thousands of rows a day before anyone has been notified, and one job per recipient
is that multiplied by the audience. Instead, the outbox is drained in batches, and the job is only
the thing that wakes the drain up.
### 3.1 Where the code lives
| Path | What |
| ---- | ---- |
| `backend/notifications/index.ts` | The category registry: `NOTIFICATION_CATEGORIES`, the `NotificationCategory` type, lookup by event |
| `backend/notifications/categories/<key>.ts` | One file per category ([§4.1](#41-a-category-is-a-file)) |
| `backend/models/notifications.ts` | `emit()`, preferences, inbox queries, the unsubscribe token, settings |
| `backend/api/notifications.ts` | Inbox, summary, preferences, unsubscribe, admin settings |
| `backend/tasks/workers/dispatch-notifications.ts` | Fan-out ([§7](#7-fan-out)) |
| `backend/tasks/workers/send-notification-mail.ts` | Mail drain ([§10](#10-email)) |
| `backend/tasks/simple/purge-notifications.ts` | Retention ([§12.3](#123-purging)) |
`notifications/` is a new top-level backend directory rather than a directory under `modules/`:
`refreshFromDisk` expects every directory there to hold a `definition.yml`. A category is code, not
an installable module, and there is nothing to discover from disk.
---
## 4. Categories
### 4.1 A category is a file
```ts
export interface NotificationCategory<E extends NotificationEventKind = NotificationEventKind> {
key: string // 'watchedPage', also the preference key and the i18n key
events: readonly E[] // which emitted events it reacts to
scope: 'site' | 'instance' // see §11; every launch category is 'site'
origins: readonly EventOrigin[] // which origins it fires for, see §6.2
defaults: { inApp: boolean; email: boolean }
priority: number // higher wins when one event reaches a user twice, §7.4
visibleTo(actor: AccessActor): boolean // shown in Profile → Notifications?
recipients(event, ctx): AsyncIterable<string[]> // candidate user IDs, in batches
accessCheck: string | null // page permission each recipient must hold NOW
groupKey(event): string // the coalescing key, §8.3
variant(event): string // which message: 'edited', 'moved', 'deleted', ...
snapshot(event): Record<string, unknown> // what the entry displays, §8.2
}
```
`NOTIFICATION_CATEGORIES` is a closed `as const` list, the same pattern as `AUDIT_ACTIONS`, so
`npm run typecheck` refuses a key that does not exist. Each key is also its translation prefix
(`notifications.categories.<key>.*`), so adding a category means adding its strings too.
`recipients` returns **candidates**. Removing the actor, checking access, applying preferences and
deduplicating are done once by the fan-out for every category ([§7](#7-fan-out)), so a category
cannot get any of them wrong.
### 4.2 The launch categories
| Key | Reacts to | Candidates | Must hold now | Default | Coalesces on | Origins |
| --- | --------- | ---------- | ------------- | ------- | ------------ | ------- |
| `watchedPage` | `page:edit`, `page:rename`, `page:delete` | the page's watchers | `read:pages` | in-app ✓ email ✓ | `watchedPage:<pageId>` | all |
| `reviewRequested` | `submission:new`, `submission:update` | members of the reviewer groups of every enabled rule matching the page | none ([§4.3](#43-reviewers)) | ✓ ✓ | `review:<submissionId>` | user |
| `watchedPageComment` | `comment:new` | the page's watchers | `read:comments` | ✓ ✓ | `watchedComment:<pageId>` | user |
| `commentReply` | `comment:new` with a parent | the author of the parent comment, if it has an account | `read:comments` | ✓ ✓ | `reply:<parentId>` | user |
| `mention` | `comment:new`, `comment:edit` | users whose handle is written in the comment (on an edit, only handles that were not there before) | `read:comments` | ✓ ✓ | `mention:<commentId>` | user |
| `pageCreated` | `page:create` | users who opted in | `read:pages` | ✗ ✗ | `pageCreated:<pageId>` | user |
| `pageDeleted` | `page:delete` | users who opted in | `read:pages`, against the page as it was | ✗ ✗ | `pageDeleted:<pageId>` | user |
**`watchedPage` variants.** `edited`, `moved` (path or locale changed, which is what `page:rename`
is), `published`, `unpublished`, `scheduled` (the publish window was set or changed), and `deleted`.
A publish-state change is a variant of an edit, worked out by comparing the old and new
`publishState`, `publishStartDate` and `publishEndDate` in `updatePage`. A coalesced entry
([§8.3](#83-coalescing-and-idempotency-are-the-same-index)) records every variant it has absorbed, so
"edited 4 times and moved" can be said.
**Email defaults apply only where email can be sent.** On an instance where `mail.isConfigured`
is false, every email preference reads as off and the Profile screen says why
([§5.2](#52-the-profile-screen)).
### 4.3 Reviewers
Only **members of the groups named in `reviewerGroups`** of the enabled rules that match the page
are notified. Holding `review:pages` at the page, or `manage:system`, does not make anyone a
recipient. On a large wiki, every administrator would otherwise receive every suggestion. An
administrator who wants these notifications is placed in a reviewer group like anyone else. (They
can still see and answer the whole queue, as `getReviewableSubmissions` allows today. Only the
notification is narrower.)
No page permission is checked on top of that: the rule naming the group *is* the grant, and the
notification says no more than the reviewer's own queue already shows. The submitter is excluded,
like any actor. A guest's suggestion notifies just as a signed-in user's does.
`submission:update` is the author revising a suggestion that is still open (there is one per author
per page, by the unique index). It bumps the existing unread entry rather than adding a second one.
`approvals.matchesPage` has to be callable from a worker thread, so if it reads anything off `this`
beyond its arguments, it moves to a pure helper beside `helpers/pageRules.ts`.
---
## 5. Preferences
### 5.1 Storage
```
userNotificationPrefs
userId uuid → users.id, on delete cascade
category varchar(64)
channel varchar(16) -- 'inApp' | 'email'
enabled boolean
updatedAt timestamp
PRIMARY KEY (userId, category, channel)
INDEX (category, channel) WHERE enabled
```
**A row is stored only when it differs from the category's default.** No rows means the defaults,
and setting a choice back to its default deletes the row. That keeps the table small, and keeps the
defaults in the registry rather than copied into every account.
**A table rather than a key in `users.prefs`**, because of the opt-in categories. To fan out
`pageCreated`, the question is "who has turned this on?" across every account on the instance. That
is an index lookup on the partial index above, rather than a scan of a JSONB column. The default-on
categories ask the other way round, "of these watchers, who has turned it off?", which joins the
already-small candidate list against the primary key.
**One row per channel, not one column per channel**, so a third channel would be a new value
rather than a migration.
Preferences are **global**: one set per user, covering every site they use.
### 5.2 The Profile screen
`pages/ProfileNotifications.vue`, at `/_profile/notifications` (enabling the entry
`ProfileLayout.vue` already has). One row per category the caller can see (`visibleTo`), each with
its title, a one-line description, and two toggles: **In-App** and **Email**. Grouped under
headings: *Pages you watch*, *Discussions*, *Reviews*, *Everything on the wiki*. The last group
holds the two opt-in categories, with a note that they can be busy.
- With mail not configured, the Email column is disabled, with a hint that the administrator has not
set up outgoing mail.
- On a site where notifications are switched off ([§11](#11-the-site-switch)), a banner says so.
The preferences still apply on the user's other sites.
- A **Stop all email** action at the foot of the screen turns off email for every category in one
go. It is the same thing the unsubscribe page offers.
`GET /_api/users/me/notifications` answers with every visible category, its effective
`{ inApp, email }`, its defaults, and `emailAvailable`. `PUT` takes the full map back, stores only
the rows that differ from the defaults, and audits `updateNotificationPrefs` (kind `profile`) with
the categories that changed.
---
## 6. Emitting
### 6.1 The call
```ts
WIKI.models.notifications.emit('page:edit', {
siteId, actorId, pageId,
snapshot: { title, path, locale, tags, publishState, ... },
variant: 'edited'
})
```
It writes one `notificationEvents` row and asks for a fan-out run. Like `hooks.emit`, it **never
throws**: a notification problem must not fail the action that caused it. It returns at once, and
it is called only after the action has succeeded.
It returns without writing anything when:
- the site has notifications switched off ([§11](#11-the-site-switch)), checked against
`WIKI.sites`, which the request path has and a worker does not, or
- no category listens for this event from this origin. A git pull's `page:create` is dropped here
rather than written and then thrown away.
**The kick is debounced per instance.** The first emit arms a timer of about one second, and when it
fires, a single `dispatchNotifications` job is added. A burst of 500 saves becomes a handful of jobs.
A `* * * * *` `SYSTEM_SCHEDULE` entry runs the same task as a safety net, so an instance that dies
with a timer armed loses at most a minute.
Emit sites, beside the existing `hooks.emit` calls:
| Event | Where |
| ----- | ----- |
| `page:create` | `pages.createPage`, and the restore path that re-creates a page |
| `page:edit` | `pages.updatePage`, including the save `approvals` makes when a suggestion is approved (the actor is the reviewer, and the snapshot carries who suggested it) |
| `page:rename` | `pages.movePage` |
| `page:delete` | `pages.deletePage`, `deletePagesByTag`, `tree.deleteFolder`, and git's removals in `applyIncoming`. Every path that removes a page row has to go through [§6.3](#63-deletion-snapshot-the-watchers-first) |
| `submission:new` / `submission:update` | where `approvals` writes a `pageEditSubmissions` row |
| `comment:new` / `comment:edit` | `api/comments.ts`, beside the webhook emits (comments are only ever created from a route) |
### 6.2 Origin
Every event carries an `origin`: `user`, `import` or `bulk`.
- **`import`**: anything running inside `storage.importingFrom()`, which covers Import Everything
and a git pull's `applyIncoming`. `emit` reads the scope itself, so callers do not pass it.
- **`bulk`**: `deletePagesByTag` and `tree.deleteFolder`. These run their per-page work inside a
`notifications.bulk(work)` AsyncLocalStorage scope of the same shape.
- **`user`**: everything else.
A category's `origins` decides whether it fires for an origin. **`watchedPage` fires for all
three**, so a watcher hears about a page a git pull rewrote, with the entry saying "via storage
sync" where there is no actor. **`pageCreated` and `pageDeleted` fire for `user` only**, so an
import of 5,000 pages, or a tag deletion, does not send 5,000 notifications to everyone who opted in.
### 6.3 Deletion: snapshot the watchers first
`pageWatching` rows are deleted with the page, so by the time a fan-out runs, a deleted page has no
watchers left to find. The `page:delete` emit therefore happens **before** the page row is deleted,
and captures the watchers in the same statement:
```sql
INSERT INTO "notificationEvents" (…, "recipients")
VALUES (…, ARRAY(SELECT "userId" FROM "pageWatching" WHERE "pageId" = $1))
```
The IDs never pass through Node, and the row is written whether or not the delete then succeeds. A
delete that fails after this point (rare, since the page row is the last thing to go) leaves an
event saying a page was deleted that was not. To avoid that, the fan-out checks that the page row
is really gone before it sends any `deleted` variant, and drops the event if the page is still there.
Access for a deleted page is checked against the snapshot's path, locale and tags, the page as it
was.
Restoring a deleted page does not bring its watchers back: the cascade has already removed them.
That is a property of the existing schema, noted here rather than changed.
---
## 7. Fan-out
`tasks/workers/dispatch-notifications.ts`, in a worker thread.
### 7.1 Claiming
It claims up to 50 unprocessed events, oldest first, with `FOR UPDATE SKIP LOCKED`, writing
`claimedAt` and `claimedBy`. Several instances can drain the outbox at once without two of them
taking the same event. A claim older than `scheduler.taskTimeout` plus a margin counts as abandoned
and can be claimed again, the same reasoning as `reapStaleJobs`. When the batch is done, the task
runs again straight away if more events are waiting, and stops otherwise.
### 7.2 Access is checked per group set, not per user
`checkAccess` depends only on the actor's groups (plus `manage:system`). Candidates are read
together with their memberships:
```sql
SELECT u.id, array_agg(ug."groupId" ORDER BY ug."groupId") AS groups, u.prefs->>'locale' …
FROM users u JOIN "userGroups" ug ON ug."userId" = u.id
WHERE u.id = ANY($candidates) AND u."isActive" AND NOT u."isSystem"
GROUP BY u.id
```
The check is memoized on the joined group IDs. Ten thousand candidates spread over six group
combinations cost six rule evaluations. The worker has no `WIKI.models.groups`, so it reads
`groups.rules` and `groups.permissions` directly once per run and calls `resolvePageRule` itself.
What it answers is what `checkAccess` would answer, from the same rows.
Access is checked **when the fan-out runs**, not when the event happened: someone whose access was
removed in between is not told.
### 7.3 Candidates, in batches
`recipients()` yields IDs in batches of 1,000, using keyset pagination on `userId`:
- **Watchers**: `pageWatching` by `pageId`, or `notificationEvents.recipients` for a deletion.
- **Opted in**: `userNotificationPrefs` where `category = $1 AND enabled`, on its partial index. The
users table is never scanned.
- **Reviewers**: `userGroups` where `groupId = ANY($reviewerGroups)`.
- **Mentions and replies**: a handful of IDs, resolved from the comment.
### 7.4 One entry per person per event
A single `comment:new` can reach the same person three ways: they watch the page, they wrote the
parent comment, and they are mentioned in it. They get **one** entry, from the category with the
highest `priority` among those that would notify them on some channel:
`mention` (40) > `commentReply` (30) > `watchedPageComment` (10). Likewise `watchedPage` (20) beats
`pageDeleted` (5) for a watcher who also opted into deletions everywhere.
The dedup runs after preferences have been applied: someone who turned mentions off but watches the
page still gets the watched-page entry.
### 7.5 Writing
Rows are written 500 at a time, as one multi-row `INSERT … ON CONFLICT … DO UPDATE`
([§8.3](#83-coalescing-and-idempotency-are-the-same-index)). Each row carries `inApp` from the
preferences, and `emailState = 'pending'` with `emailAfter` set ([§10.2](#102-cadence)) where email is
on, the account is verified and mail is configured. When anything has been written with email
pending, the mail drain is kicked.
### 7.6 Long fan-outs
The task checks `signal` between batches. When it is about to time out, it writes the keyset cursor
it has reached onto the event row (`cursor`) and leaves the event claimed but unprocessed. The next
run picks it up from the cursor. This is the same idea as `renderPages` handing the rest of its
queue to a fresh job, so a `pageCreated` going out to a very large opted-in audience is spread over
several runs instead of running into the timeout.
---
## 8. Storage
### 8.1 `notificationEvents` (the outbox)
```
id uuid PK -- the event ID, also the idempotency key
kind varchar(64) -- 'page:edit', 'comment:new', …
origin varchar(16) -- 'user' | 'import' | 'bulk'
siteId uuid null -- null for an instance-scoped event, §11
actorId uuid null -- no FK: an event outliving its actor is fine
data jsonb -- snapshot, variant, IDs of the page / comment / submission
recipients uuid[] null -- pre-resolved candidates (deletions)
cursor jsonb null -- progress of an interrupted fan-out
claimedAt timestamp null
claimedBy varchar null
processedAt timestamp null
createdAt timestamp
INDEX (createdAt) WHERE processedAt IS NULL
```
Processed events are kept for a day, which is enough to debug a notification that did not arrive.
After that they are purged ([§12.3](#123-purging)).
### 8.2 `notifications` (the inbox and the email queue)
```
id uuid PK
userId uuid → users.id, on delete cascade
siteId uuid null → sites.id, on delete cascade -- null: instance-scoped, §11
category varchar(64)
variant varchar(32) -- the latest variant
groupKey varchar(255)
pageId uuid null → pages.id, on delete set null
commentId uuid null → comments.id, on delete set null
actorId uuid null → users.id, on delete set null
data jsonb -- snapshot: title, path, locale, actor name, excerpt, variants seen, …
count integer default 1
lastEventId uuid
inApp boolean
emailState varchar(16) default 'none' -- none | pending | sent | failed | skipped
emailAfter timestamp null
emailedAt timestamp null
readAt timestamp null
createdAt timestamp
updatedAt timestamp -- bumped on coalescing, the inbox's sort key
INDEX (userId, updatedAt DESC) -- the inbox
INDEX (userId) WHERE readAt IS NULL AND inApp -- the badge
INDEX (emailAfter) WHERE emailState = 'pending' -- the mail drain
UNIQUE (userId, groupKey) WHERE readAt IS NULL -- §8.3
```
**The entry carries a snapshot**, because what it is about may be renamed, moved or deleted before
it is read. The link is built from `pageId` where the page still exists, so it follows a move, and
from the snapshot's path otherwise.
**An excerpt is shown only while its comment exists.** `commentId` is `set null` when the comment
is deleted, and the API leaves the excerpt out of any entry whose `commentId` is null. A moderator
deleting spam therefore removes its text from every inbox it reached. An email that has already
been sent cannot be recalled.
**One row covers both channels.** An email-only recipient still gets a row, with `inApp = false`,
and the inbox and the badge never show it. That keeps one source of truth, and gives coalescing
([§8.3](#83-coalescing-and-idempotency-are-the-same-index)) and the email cadence
([§10.2](#102-cadence)) one model to work on. An email-only row is closed (`readAt = emailedAt`) once
its email is sent, since there is no inbox in which it could be read.
### 8.3 Coalescing and idempotency are the same index
Every category gives each entry a `groupKey`, and a user has at most one **unread** entry per key.
The insert is:
```sql
INSERT INTO notifications (…) VALUES (…)
ON CONFLICT ("userId", "groupKey") WHERE "readAt" IS NULL
DO UPDATE SET
count = notifications.count + 1,
variant = EXCLUDED.variant,
data = notifications.data || EXCLUDED.data, -- variants seen are unioned in code
actorId = EXCLUDED."actorId",
lastEventId = EXCLUDED."lastEventId",
updatedAt = now(),
emailState = <per §10.2>,
emailAfter = <per §10.2>
WHERE notifications."lastEventId" IS DISTINCT FROM EXCLUDED."lastEventId"
```
- **Coalescing**: forty saves to a page you have not looked at yet are one entry, "edited 40
times, last by Bob", which moves to the top of the inbox each time.
- **Idempotency**: a fan-out retried after a crash replays the same event ID. The `WHERE` makes the
replay change nothing, so nobody's count is inflated and nobody gets a second row.
- **Reading resets it**: once the entry is read it no longer holds the unique slot, and the next
event starts a new one.
The keys in [§4.2](#42-the-launch-categories) are chosen so that only things worth merging merge:
two comments on a watched page merge, but two mentions in two comments do not (`mention:<commentId>`).
---
## 9. In-app delivery
### 9.1 API
All routes are the caller's own rows: any logged-in session, no permission, and 401 for a guest.
No route declares `config.permissions`. Each comments `No route-level permissions:` and filters by
`userId` from the session.
| Route | What |
| ----- | ---- |
| `GET /_api/sites/:siteId/notifications?cursor=&unread=` | Keyset-paginated on `(updatedAt, id)`, 30 per page. Includes the site's rows plus instance-scoped ones (`siteId IS NULL`) |
| `GET /_api/sites/:siteId/notifications/summary` | `{ unread, latestAt }`. `unread` is counted from `SELECT 1 … LIMIT 100` on the badge index and shown as "99+" above 99. Answers with an `ETag` built from the two values, so a poll that finds nothing new is a 304 |
| `PUT /_api/sites/:siteId/notifications/:id/read` | Marks one entry read |
| `PUT /_api/sites/:siteId/notifications/read` | Marks everything read, or `{ pageId }` for one page's entries (mark-read-on-view, [§10.2](#102-cadence)) |
| `DELETE /_api/sites/:siteId/notifications/:id` | Dismisses an entry |
Marking read and dismissing are **not audited**. They are bookkeeping on one's own inbox, and a
click per notification would bury the audit log, for the same reason page views are not recorded.
Preference changes and the admin settings are audited.
### 9.2 Polling
A Pinia store, `stores/notifications.js`, holds `unread`, `latestAt` and the loaded page of entries,
and has one method that matters: `refresh()`, which calls `summary` and loads the list only if
`latestAt` moved. What calls `refresh()` is kept separate from the store itself:
- app boot, once the session is known to be signed in;
- a route change, throttled to once per 15 s;
- `visibilitychange` to visible;
- an interval of 60 s, **only while the tab is visible**, so a hundred background tabs do not poll.
The badge in `HeaderNav.vue` reads `unread`. `InboxMessages.vue` becomes the list: grouped by day,
unread entries marked, each entry's text produced **in the browser** from
`notifications.messages.<category>.<variant>` plus its snapshot, so an entry is read in the
language the interface is drawn in. Each entry links to its target:
- a page, or the page's Talk tab with the comment anchored;
- `/_inbox/review/:submissionId` for a review;
- for a deleted page, the snapshot's path, which leads to the page-not-found screen and its restore
offer where the reader may restore.
### 9.3 Adding push later
Push is designed in now but not built. When it is added, it only ever says **"something changed,
call `refresh()`"**. The summary endpoint stays the single source of truth, so a pushed update and a
polled one leave the store in exactly the same state. Nothing about polling or the endpoint changes.
When it is built:
- every write that changes what a user's summary would answer (the fan-out's inserts, the read and
dismiss routes, the purge) sends `NOTIFY notifications` with the affected user IDs, through
`createNotifier` and chunked under Postgres's 8 kB payload limit;
- every instance `LISTEN`s and forwards to its own sockets on a `/_notifications` websocket, beside
the collab and terminal ones;
- the 60 s interval drops to a 5 minute safety net while the socket is open.
No `NOTIFY` is sent in the polling version: with nothing listening it would be dead code.
---
## 10. Email
### 10.1 Templates
`MailTemplateData` gains `notification` (one entry) and `notificationDigest` (several), with strings
under `mail.notification.*`. Both render through the existing `MailContent` → `htmlShell` /
`textBody` pair. The language is the recipient's `prefs.locale`, falling back to the site's primary
locale, which is what `localeFor` already does.
What a notification email contains: the title, who did it, what it is about (page title and path),
for a comment the excerpt, one action button linking to the target, and a footer with **Manage
notifications** (linking to `/_profile/notifications`) and **Unsubscribe**. No page content is ever
included.
**The mail model has to run in a worker.** `mail.ts` currently reads `WIKI.sites[siteId]` (hostname,
title, primary locale) and goes through `WIKI.models.locales`, and a worker has neither. `send()` is
changed to take a `MailSiteContext` (`{ baseUrl, title, primaryLocale }`) from its caller. The
existing callers build it from `WIKI.sites`; the drain builds it from the `sites` rows it loads
once per run. The translator is checked to work with the worker's lazily opened database, and
imported directly the way `dispatch-webhook.ts` imports `hooks`.
### 10.2 Cadence
**A window, then quiet until read.** An entry is emailed once, a short delay after it first
appears, and then not again until its recipient has read it:
| | `emailState` / `emailAfter` |
| - | --------------------------- |
| **A new row** | `pending`, `emailAfter = now() + emailDelay` |
| **A coalesce while `pending`** | unchanged. The event merges into the email that is already due |
| **A coalesce after `sent`** | **stays `sent`.** The inbox entry keeps counting, but no further email is sent |
| **Read** | the entry gives up its unique slot ([§8.3](#83-coalescing-and-idempotency-are-the-same-index)), so the next event starts a new entry and a new email |
`emailAfter` is the only thing the drain looks at. It groups everything due for one user into a
single mail: the `notification` template for one entry, `notificationDigest` for several.
In practice: Alice watches *Runbook*, Bob saves it twelve times between 10:00 and 10:40, and at
10:05 Carol mentions Alice on another page. Alice receives two emails, "Runbook was edited" at about
10:03 and the mention at about 10:08, and nothing more about Runbook until she opens it. Her inbox
entry reads "edited 12 times". Sending an email per event would have meant thirteen of them, and a
window alone about ten, since Bob's pace keeps opening new windows.
**This only works if entries get read in the ordinary course of things**, or it becomes "one email,
ever". So **opening a page marks that page's `watchedPage` entries read**, and opening its Talk tab
marks its `watchedPageComment` entries read. The page payload already answers `isWatching`, so it
also answers `hasUnreadNotifications`, and the browser calls `PUT …/notifications/read { pageId }`
only when that is true. A page view therefore writes nothing unless there was something to clear.
**An email-only row closes when it is emailed** ([§8.2](#82-notifications-the-inbox-and-the-email-queue)),
because it has no inbox to be read in. Its recipient therefore gets one email per window rather than
one ever.
**`emailAfter` is computed in exactly one place**, `emailAfterFor(user, now)` in the notifications
model, and both the fan-out insert and the coalesce go through it. Today it answers
`now + emailDelay`. It is the seam where hourly and daily digests attach later
([§10.5](#105-leaving-room-for-hourly-and-daily-digests)).
### 10.3 The drain
`tasks/workers/send-notification-mail.ts`:
1. Claims up to `notifications.mailBatchSize` (default 100) **(user, site) pairs** with rows due,
using `SELECT DISTINCT "userId", "siteId" … WHERE "emailState" = 'pending' AND "emailAfter" <=
now() … FOR UPDATE SKIP LOCKED`.
2. For each pair, loads the due rows (capped at 50; above that, the digest says "and N more" and
links to the inbox), drops them as `skipped` if the site has notifications switched off, renders
one mail and sends it. **One mail per site, not one per user**, because a mail names the wiki it
comes from and its links use that site's hostname. A user active on two sites gets two digests,
each of which reads as coming from its own wiki. Instance-scoped entries (`siteId` null) go out
with the site the recipient last signed in on ([§11](#11-the-site-switch)).
3. Marks the rows `sent` with `emailedAt`, or `failed` after the scheduler's retries are used up. A
failure on one user does not stop the batch.
4. Runs itself again while due rows remain. The safety net is the same minute-by-minute schedule as
fan-out.
Every send goes through the one cached transporter. Turning on nodemailer's `pool` option for this
task is worth measuring once it exists.
Notification mails also carry `Auto-Submitted: auto-generated`, so out-of-office replies are not
sent back to the wiki.
### 10.4 One-click unsubscribe (RFC 8058)
This is a hard requirement. Every notification mail carries:
```
List-Unsubscribe: <https://{host}/_api/notifications/unsubscribe?t={token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
```
**The token.** `base64url(payload) . base64url(HMAC-SHA256(payload))`, where the payload is
`{ v: 1, u: userId, c: [categories in this mail] }`. It is signed with
**`notifications.unsubscribeSecret`**, a 32-byte value seeded at install beside the other secrets in
`models/settings.ts`. It is deliberately not `auth.secret`: that one is rotated whenever an
administrator invalidates every session, which would break every unsubscribe link already sitting
in somebody's mailbox (the reason `apiKeys.ts` gives for keeping the certificate passphrase apart).
The token **does not expire**. A link in an email from last year still has to work, and the only
thing it can do is turn email off for one person.
**`POST`** (the one-click):
- `publicAccess`, no session, no cookie, no CSRF token. The mail client sends none of these.
- Takes `application/x-www-form-urlencoded` with `List-Unsubscribe=One-Click`.
- Verifies the HMAC with `timingSafeEqual`, then turns **email** off for the token's categories,
writing `enabled = false` rows. In-app is left alone: the user only asked to stop receiving mail.
- Idempotent, and answers 200 with no body. An invalid token gets 400, saying nothing about which
part failed.
- Recorded in the audit log as `unsubscribeNotifications` (kind `profile`), with the token's user as
the actor. Like the auth events in `models/users.ts`, this is an action with no session, whose
user is identified only by a credential.
**`GET`** on the same URL **does nothing** but redirect to the frontend page `/_unsubscribe?t=`.
Mail scanners fetch every link in a message, so a GET that acted would unsubscribe people who never
asked. This is the same principle as the welcome mail's verify link. That page names the categories,
offers **Unsubscribe from these** and **Stop all notification email** (the same POST with
`scope=all`), and links to the Profile screen. The footer link in the mail body points to this page.
**Deliverability checks, in the implementing PR:**
- Gmail and Yahoo require the `List-Unsubscribe` headers to be covered by the DKIM signature.
Check which headers nodemailer's DKIM signing covers, and add these two if they are not among
them.
- Gmail honours one-click only for an `https` URL. A site served over plain `http` still gets the
header, which works as an ordinary link elsewhere. Admin → Notifications warns about it
([§12.2](#122-admin--notifications)).
### 10.5 Leaving room for hourly and daily digests
Not built in the first version, but expected to follow: a user choosing **Immediate**, **Hourly** or
**Daily** delivery for their notification email. Nothing built now should have to be reshaped when
it comes. What the design already does for it, and what adding it involves:
**What is in place from the start**
- **The drain only looks at `emailAfter`.** It never asks why a row is due, so a digest is nothing
more than a later `emailAfter`. Claiming, grouping, rendering and marking rows sent stay the same.
- **`emailAfterFor` is the only place that computes it** ([§10.2](#102-cadence)). A schedule is a
change to that one function.
- **The digest template already exists.** `notificationDigest` takes any number of entries,
grouped by category, with the "and N more" cap. A daily digest is the same mail with more in it.
- **Quiet-until-read already holds back repeats.** An entry that went out in a digest stays `sent`
however many more events land on it, so the next digest does not repeat it. It reappears only if
it was read and a new entry was started.
- **The unsubscribe token already covers several categories** ([§10.4](#104-one-click-unsubscribe-rfc-8058)),
which a digest needs.
- **One mail per (user, site)** ([§10.3](#103-the-drain)) is the right unit for a digest as well.
**What adding it involves**
- **A per-user preference**, global like the rest ([§5](#5-preferences)): `emailSchedule` (`immediate`
| `hourly` | `daily`) and, for daily, the hour to send. It lives in `users.prefs` beside `timezone`
rather than in `userNotificationPrefs`. It belongs to the user, not to a category, and the fan-out
already reads each candidate's row for the locale ([§7.2](#72-access-is-checked-per-group-set-not-per-user)),
so it costs no extra query. It appears as one **Email delivery** selector above the grid on
Profile → Notifications.
- **`emailAfterFor` gains the schedule.** Hourly is the next top of the hour. Daily is the next
occurrence of the chosen hour in the user's `prefs.timezone`, falling back to the site's, then
UTC. That is computed with `Temporal.ZonedDateTime` so a daylight-saving change does not move the
slot by an hour, then converted back to an instant for the column.
- **Some categories skip the digest.** A category definition gains `digest: boolean`, defaulting to
true. A mention, a reply or a review request (and any later admin alert) is something a person is
waiting on, so the proposal is that those stay immediate even for a daily-digest user. The
implementer decides that list when the feature is built.
- **Changing the schedule reschedules what is pending.** Saving the preference recomputes
`emailAfter` for that user's `pending` rows, so switching from daily to immediate does not hold
back what has already queued.
- **Spreading the load.** Thousands of users on the default daily hour would all fall due in the
same minute. `emailAfterFor` therefore adds a fixed offset per user within the hour, derived from
the user ID, so the same user always lands at the same minute and the mail runs see a steady trickle
instead of one spike. `mailBatchSize` and the drain re-running itself while rows remain handle the
rest.
- **Retention.** A daily digest needs entries to survive at least a day before they are sent.
`retentionDays` is already far longer, but the Admin → Notifications screen should refuse a value
under 2 once digests exist.
---
## 11. The site switch
`features.notifications`, on by default, under **Admin → General → Features**, beside
`features.comments`.
Turned off for a site:
- `emit` writes nothing for that site's events ([§6.1](#61-the-call)).
- Pending emails for that site are marked `skipped` by the drain rather than sent.
- The inbox entry, the badge and the bell's "you will be notified" wording are hidden on that site.
The **Watching** list stays, since watching is still a list a user keeps.
- Existing entries are kept, not deleted. Turning the switch back on shows them again.
- Preferences are untouched, since they are global ([§5.2](#52-the-profile-screen)).
**Instance-scoped categories ignore it.** None ships at launch, but the admin categories planned
([§13](#13-adding-a-category)) include some that belong to no site (a registration, since users are
per instance). Their entries have `siteId = null`, appear in every site's inbox, and their email
links use the site the recipient last signed in on, falling back to the first site.
---
## 12. Administration and housekeeping
### 12.1 Instance settings
Settings an administrator sets once for the whole instance. They are not user preferences and not
the per-site switch ([§11](#11-the-site-switch)).
| Key | Default | What | Where |
| --- | ------- | ---- | ----- |
| `notifications.retentionDays` | 90 | Entries older than this are purged, read or not | Settings blob, edited on Admin → Notifications |
| `notifications.emailDelay` | `3m` | The window of [§10.2](#102-cadence) | Same |
| `notifications.mailBatchSize` | 100 | Users per mail run. Raise it for a mail server that can take more, lower it for one that throttles. The API refuses anything outside 1–1000 | Same |
| `notifications.unsubscribeSecret` | generated at install | Signs unsubscribe tokens ([§10.4](#104-one-click-unsubscribe-rfc-8058)) | Settings blob, never returned by the API |
### 12.2 Admin → Notifications
`pages/AdminNotifications.vue` at `/_admin/notifications`, listed in the **System** section of
`AdminLayout.vue`'s sidebar. It goes in alphabetical order between Metrics and Rendering, behind
`manage:system` like its neighbours, with `fluent-topic-push-notification.svg` from `_assets/icons/` as
its icon. Its strings go under `admin.notifications.*`.
`GET` / `PUT /_api/system/notifications`, behind `manage:system`. A save is audited (kind `admin`,
action `updateNotificationSettings`) with the fields that changed, as the other configuration
routes are. The screen has two cards:
- **Settings**: retention, the email delay and the mail batch size.
- **Status**, read-only and answered by the same `GET`:
- the outbox backlog (unprocessed events and the age of the oldest one), which is what shows that
fan-out is falling behind;
- emails pending, sent and failed over the last 24 hours;
- whether outgoing mail is configured, linking to Admin → Mail when it is not;
- a warning when a site is served over plain `http`, where Gmail ignores one-click unsubscribe
([§10.4](#104-one-click-unsubscribe-rfc-8058)).
This is also where any later instance-wide setting goes, for example turning a category off for the
whole instance.
### 12.3 Purging
`tasks/simple/purge-notifications.ts` runs daily off `SYSTEM_SCHEDULE`. It deletes entries past
retention and processed outbox events older than a day, in batches of 10,000, checking `signal`
between batches.
---
## 13. Adding a category
Worked example: telling the people who look after storage that a git sync failed.
1. **`notifications/categories/storageSyncFailed.ts`**: `scope: 'site'`, `origins: ['user',
'import', 'bulk']` (the event comes from a scheduled job, which counts as whatever origin it ran
under, and all are fine here), defaults in-app ✓ email ✓, `accessCheck: null`,
`visibleTo: (actor) => actor.permissions.includes('manage:storage')`,
`recipients`: users in a group whose `permissions` contain `manage:storage`, and
`groupKey: storageSyncFailed:<targetId>`, so a target failing every five minutes is one entry
until somebody looks at it.
2. Add its key to `NOTIFICATION_CATEGORIES`.
3. Add `WIKI.models.notifications.emit('storage:syncFailed', …)` in `storage.recordState`, **only
on a transition** from healthy to `error` rather than on every failure.
4. Add `notifications.categories.storageSyncFailed.*`, `notifications.messages.storageSyncFailed.*`
and its mail strings to `en.json`.
Nothing else changes: the Profile screen, fan-out, dedup, coalescing, mail and unsubscribe all come
from the registry.
**An event anyone can trigger must coalesce, or anyone can fill an administrator's mailbox.** A
failed-login category is the clear case: the login endpoint is open to the world, which is why
failed logins are not audited. It would need a `groupKey` per account, so repeated failures bump a
single entry, which the quiet-until-read cadence ([§10.2](#102-cadence)) then holds to a single email.
---
## 14. Behaviour worth pinning down
- **The actor is never a recipient**, including when they watch the page they just edited.
- **Inactive and system accounts are skipped**, and so is the guest account. **Unverified accounts
get in-app entries but no email**, because the address has not been confirmed.
- **A comment edited to add a mention** notifies only the handles that were not in the previous
version. Re-saving a comment does not mention everyone in it again.
- **Replies go to the parent's author only**, not to everyone else in the thread. Someone who
wants to follow a whole discussion watches the page.
- **A scheduled page going live at its `publishStartDate` is not an event.** Visibility is worked
out when the page is read, and no job flips anything at that time. Setting or changing the window
is a `scheduled` variant; the moment it takes effect is silent.
- **`pageCreated` fires on creation whatever the publish state**, with the state in the snapshot
("created as a draft"). That matches what the recipient can see: any signed-in session sees
unpublished pages where it has `read:pages`. It does not fire again when the page is later
published.
- **Password-locked pages** notify like any other, by title and path, which is what the Watching
list already shows. No content is ever part of an entry.
- **A user deleted with entries pending** takes them with them (`on delete cascade`).
---
## 15. Performance
| Where | Cost | Why it holds |
| ----- | ---- | ------------ |
| The request | One `INSERT` (with a sub-select for a deletion) | No recipient is resolved, no access is checked, no SMTP |
| Jobs | About one `dispatchNotifications` per second per instance under load, and one per minute otherwise | Debounced kick ([§6.1](#61-the-call)) |
| Candidates | Proportional to the audience | Watchers, reviewer groups and mentions are indexed lookups. Opt-ins come from a partial index ([§5.1](#51-storage)). The users table is never scanned |
| Access | One rule evaluation per distinct group set | [§7.2](#72-access-is-checked-per-group-set-not-per-user) |
| Writes | Multi-row inserts of 500 | Coalescing keeps a busy page at one row per watcher |
| Badge | Index-only, capped at 100 rows, 304 when unchanged, no polling from hidden tabs | [§9.1](#91-api), [§9.2](#92-polling) |
| Mail | One mail per user and site per drain | Digesting is part of the drain, not an extra step |
| Timeouts | None to hit | Fan-out resumes from a cursor ([§7.6](#76-long-fan-outs)), and the purge works in batches |
| HA | Any instance drains | `SKIP LOCKED` on both queues. No instance-local state except the debounce timer |
---
## 16. Suggested order
1. **Foundation**: the three tables and their migration (`npm run db-generate --
--name=notifications`), the registry with the seven categories, `userNotificationPrefs` with its
API, `ProfileNotifications.vue`, `features.notifications` in General → Features, and the
`notifications` settings blob with `unsubscribeSecret` seeded.
2. **In-app**: `emit()` with origins, the debounce and the emit sites, the deletion snapshot,
`dispatch-notifications.ts`, the inbox API, `stores/notifications.js` with polling, the
`HeaderNav` badge, and `InboxMessages.vue`. Usable on its own: an instance with no mail
configured is complete at this point.
3. **Email**: the `MailSiteContext` refactor, both templates and their strings,
`send-notification-mail.ts` with the quiet-until-read cadence and mark-read-on-view, the
unsubscribe token, routes and `/_unsubscribe` page, the DKIM check, and Admin → Notifications
([§12.2](#122-admin--notifications)).
4. **Housekeeping**: `purge-notifications.ts` and its `SYSTEM_SCHEDULE` entries.
5. **Later**: push ([§9.3](#93-adding-push-later)), hourly and daily digests
([§10.5](#105-leaving-room-for-hourly-and-daily-digests)), and the admin categories
([§13](#13-adding-a-category)).
Verification for each phase: `npm run typecheck` and `npx oxlint` in `backend/`, `npm run build` in
`frontend/`. Phases 2 and 3 are worth a throwaway instance. Mailpit is the mail server for
development: it receives every message without delivering any, which shows the digest, both bodies
and the `List-Unsubscribe` headers. Script two accounts, one watching a page the other edits ten
times, and check that one entry with a count of ten comes out. Then `curl` the one-click POST with
the token from the mail.
Loading…
Cancel
Save