feat: notifications

scarlett 3.0.0-beta.617
NGPixel 2 days ago
parent ba12798381
commit 9e36597965
No known key found for this signature in database

@ -11,6 +11,13 @@ read, tested and reasoned about. This applies to db columns, API payloads, store
config keys alike; only real migrations under `backend/db/migrations/` are exempt, because Drizzle config keys alike; only real migrations under `backend/db/migrations/` are exempt, because Drizzle
needs the history to get a live dev database to the current schema. needs the history to get a live dev database to the current schema.
The one sanctioned exception is a value an existing installation **cannot** have and no static
default can stand in for — a secret generated per installation, say. That goes in a **startup check**
(`core/startupChecks.ts`), which every boot runs once to fill in what is missing; it is the only place
such a value is made, and nothing that reads it falls back on its own. A new setting with a fixed
default needs no check: put the default in `base.yml`, which is merged under the stored settings on
every boot.
Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is
no root package or monorepo tooling): no root package or monorepo tooling):
@ -74,7 +81,10 @@ path in silence.
and nothing else. and nothing else.
- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle - `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle
instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool + instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool +
postgres-backed job queue). postgres-backed job queue), and `startupChecks.ts` — what `preBoot()` makes sure of right after the
settings are loaded, on a fresh install and an upgraded one alike: idempotent, add-only, run in one
transaction under an advisory lock so that the instances of an HA set booting together cannot each
generate a different value.
- `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated). - `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated).
- `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as - `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as
`WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared `WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared
@ -84,6 +94,9 @@ path in silence.
`modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see `modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see
[Storage targets](#storage-targets). `modules/analytics/*` is the odd one out: a pair of YAML files [Storage targets](#storage-targets). `modules/analytics/*` is the odd one out: a pair of YAML files
and no implementation at all — see [Analytics](#analytics). and no implementation at all — see [Analytics](#analytics).
- `notifications/` — the notification categories (`categories/*.ts`, registered in `index.ts`) and
the worker-side halves of delivery: the fan-out and the mail drain. Not under `modules/`, which
expects a `definition.yml` per directory. See [Notifications](#notifications).
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task(payload, { signal })`. - `tasks/simple/` — jobs run in-process by the scheduler; each exports `task(payload, { signal })`.
File name is kebab-case, the task key is its camelCase form. `scheduler.taskTimeout` applies here File name is kebab-case, the task key is its camelCase form. `scheduler.taskTimeout` applies here
as it does to workers, but a promise cannot be killed: at the timeout `signal` is aborted, and a as it does to workers, but a promise cannot be killed: at the timeout `signal` is aborted, and a
@ -92,7 +105,11 @@ path in silence.
is abandoned — failed, left running, and no second copy of that task starts on the instance until is abandoned — failed, left running, and no second copy of that task starts on the instance until
it ends (`executeInProcess` in `core/scheduler.ts`). it ends (`executeInProcess` in `core/scheduler.ts`).
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal - `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. `WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. **A worker's
database pool has ONE connection** (`core/db.ts`), so a transaction held open in a worker while
anything else queries deadlocks the task against itself until the pool aborts it. And a worker reads
the settings once, when its thread first opens the database, and never hears `reloadConfig` — a task
that depends on settings an admin can change re-reads them per run (`refreshWorkerConfig`).
- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines - `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines
the shape merged with `config.yml` and the db `settings` table. the shape merged with `config.yml` and the db `settings` table.
- `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus two that are not: `storageFiles.ts`, - `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus two that are not: `storageFiles.ts`,
@ -1269,8 +1286,11 @@ produce and has to describe.
### Emails ### Emails
The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test The wiki sends a registration confirmation, a forgotten password, the admin area's test, and
button — and `models/mail.ts` is the only place nodemailer is used. `MailTemplateData` is the notifications (one at a time or as a digest — see [Notifications](#notifications)), and
`models/mail.ts` is the only place nodemailer is used. `send()` takes a `MailSite` from its caller
(`mail.siteFor(siteId)`) rather than a site id, because the notification mails are sent from a worker
thread, which has no `WIKI.sites`. `MailTemplateData` is the
closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did
not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain
code there anyway. A wiki with no SMTP settings is the normal case, which is why `isConfigured` is code there anyway. A wiki with no SMTP settings is the normal case, which is why `isConfigured` is
@ -1333,6 +1353,43 @@ has not been built; if it is, the shape to keep is sparse overrides on top of th
rather than a replacement for them, so that a wiki that rewords one sentence keeps getting rather than a replacement for them, so that a wiki that rewords one sentence keeps getting
translations and improvements for everything else. translations and improvements for everything else.
### Notifications
Telling a person about something that happened: a change to a page they watch, a comment or a reply,
a mention, a suggestion to review, and — opt-in — every page created or deleted. In-app (the inbox
and the header badge) and by email, chosen per person and per category under **Profile →
Notifications**. **`dev/specs/notifications.md` is the design**, and says why each part is the shape
it is; what follows is what is easy to get wrong.
- **`notifications.emit()` is one INSERT and never throws**, called from the models (so imports and
git pulls emit too) after the action succeeded, like `hooks.emit`. Nothing is resolved on the request
path. Events go into an outbox (`notificationEvents`) that a worker fans out in batches — not one
scheduler job per event, nor per recipient.
- **A category is a file** under `notifications/categories/` plus a key in `NOTIFICATION_CATEGORIES`
and its strings (`notifications.categories.<key>.*`, `notifications.messages.<key>.<variant>`,
`mail.notification.actions.<key>`). Leaving the actor out, the access check, preferences,
deduplication and coalescing are the fan-out's, done once for every category.
- **Access is checked per group set**, against the rows the worker reads — `rulesAllow` on the pooled
rules of a set of groups, memoized — which is what makes an audience of every account affordable.
- **Origins.** `notifications.withOrigin('bulk' | 'import', work)` marks the events emitted inside it,
and `storage.importingFrom` counts as `import` on its own. A category's `origins` decides whether it
fires: `watchedPage` fires for all of them, `pageCreated` / `pageDeleted` only for `user`.
- **A deletion captures its watchers in the event**, before the page row goes and the watch rows with
it (`watchersOf` on `emit`). Any new path that deletes page rows has to emit the same way first.
- **One unread entry per person per `groupKey`** (a partial unique index), so forty saves are one entry
counting to forty, and a replayed event changes nothing (`lastEventId`).
- **Email is a window, then quiet until read.** `emailAfter` is the only thing the drain looks at, and
`emailAfterFor` in the fan-out the only place it is set — the seam a digest schedule would use.
Opening a page or its Talk tab marks its entries read (`viewer.unreadNotifications`, `markSeen`),
which is what lets the next change email again.
- **The drain claims rows with a lease** (`emailState = 'sending'`, `emailAfter` as the expiry) rather
than a transaction held across the send — see the note on worker connections under `tasks/workers/`.
- **Every mail carries RFC 8058 one-click unsubscribe.** The token is an HMAC with
`notifications.unsubscribeSecret`, generated by a startup check wherever it is missing, and
deliberately not `auth.secret` (which rotating the sessions replaces). A GET of the link only redirects to `/_unsubscribe`, which asks.
- **The site switch is `features.notifications`**; the instance settings (retention, email delay, mail
batch size) are **Admin → Notifications**, `GET`/`PUT /system/notifications`, behind `manage:system`.
### Audit log ### Audit log
Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind`

@ -894,6 +894,8 @@ async function routes(app: FastifyInstance) {
} }
} }
// -> Whether this replaces one already waiting, which its reviewers are told as an update
const previous = await WIKI.models.approvals.getOwnSubmission(page.id, actor?.id ?? null)
const submission = await WIKI.models.approvals.saveSubmission({ const submission = await WIKI.models.approvals.saveSubmission({
siteId: req.params.siteId, siteId: req.params.siteId,
page: pageRef, page: pageRef,
@ -917,6 +919,17 @@ async function routes(app: FastifyInstance) {
path: page.path, path: page.path,
...(actor ? {} : { guestName, guestEmail }) ...(actor ? {} : { guestName, guestEmail })
}) })
await WIKI.models.notifications.emit('submission:new', {
siteId: req.params.siteId,
actorId: actor?.id ?? null,
data: {
variant: previous ? 'updated' : 'new',
submissionId: submission.id,
page: WIKI.models.notifications.pageSnapshot(page),
// -> A guest has no account to look a name up from, only what they typed
...(actor ? {} : { actorName: guestName })
}
})
return { return {
ok: true, ok: true,

@ -418,6 +418,23 @@ async function routes(app: FastifyInstance) {
}, },
content: comment.content content: comment.content
}) })
await WIKI.models.notifications.emit('comment:new', {
siteId: req.params.siteId,
actorId: comment.authorId,
data: {
variant: 'new',
page: WIKI.models.notifications.pageSnapshot(page),
commentId: comment.id,
parentId: comment.parentId,
parentAuthorId: comment.parentId
? await WIKI.models.comments.authorOf(comment.parentId)
: null,
excerpt: WIKI.models.comments.excerptOf(comment.content),
mentionHandles: WIKI.models.comments.mentionedHandles(comment.content),
// -> A guest's name is what they typed; an account's is looked up when the event is sent
...(comment.authorId ? {} : { actorName: comment.authorName })
}
})
reply.code(201) reply.code(201)
return comment return comment
@ -490,6 +507,30 @@ async function routes(app: FastifyInstance) {
}, },
content: updated.content content: updated.content
}) })
// -> Only the handles this edit added: re-saving a comment does not mention everybody in it again
const before = new Set(WIKI.models.comments.mentionedHandles(comment.content))
const added = WIKI.models.comments
.mentionedHandles(updated.content)
.filter((handle) => !before.has(handle))
if (added.length > 0) {
await WIKI.models.notifications.emit('comment:edit', {
siteId: req.params.siteId,
actorId: req.session?.user?.id ?? null,
data: {
variant: 'edited',
page: WIKI.models.notifications.pageSnapshot({
id: comment.pageId,
title: comment.title,
path: comment.path,
locale: comment.locale,
tags: comment.tags
}),
commentId: comment.id,
excerpt: WIKI.models.comments.excerptOf(updated.content),
mentionHandles: added
}
})
}
return updated return updated
} }
) )

@ -22,6 +22,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/locale.ts').then((m) => m.registerSchemas(app)) await import('./schemas/locale.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app)) await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app))
await import('./schemas/notifications.ts').then((m) => m.registerSchemas(app))
await import('./schemas/page.ts').then((m) => m.registerSchemas(app)) await import('./schemas/page.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scim.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scim.ts').then((m) => m.registerSchemas(app))
@ -49,6 +50,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./navigation.ts')) app.register(import('./navigation.ts'))
app.register(import('./notifications.ts'))
app.register(import('./pages.ts')) app.register(import('./pages.ts'))
app.register(import('./ratings.ts')) app.register(import('./ratings.ts'))
app.register(import('./scheduler.ts'), { prefix: '/scheduler' }) app.register(import('./scheduler.ts'), { prefix: '/scheduler' })

@ -208,7 +208,7 @@ async function routes(app: FastifyInstance) {
(await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }))?.id ?? '' (await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }))?.id ?? ''
try { try {
await WIKI.models.mail.send({ await WIKI.models.mail.send({
siteId, site: WIKI.models.mail.siteFor(siteId),
to: req.body.recipient, to: req.body.recipient,
template: 'test', template: 'test',
locale: req.body.locale, locale: req.body.locale,

@ -0,0 +1,550 @@
import { createHash } from 'node:crypto'
import { audit } from '../helpers/audit.ts'
import { NOTIFICATION_CATEGORY_KEYS } from '../notifications/index.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* The account an inbox or a set of preferences belongs to, or a refusal.
*
* Every route here but the unsubscribe pair is about the caller's own rows, so being signed in is the
* whole of the check — there is no permission for reading one's own inbox, and an API key, which acts
* for groups rather than for a person, has no inbox to read.
*/
function ownerOf(req: FastifyRequest, reply: FastifyReply): string | null {
const userId = req.session?.authenticated ? req.session.user?.id : null
if (!userId) {
reply.unauthorized('Notifications belong to a signed-in user.')
return null
}
return userId
}
/**
* The site an inbox is read on, or a refusal. Nothing is shown on a site with notifications switched
* off, which is the same as having none.
*/
function siteOf(req: FastifyRequest<{ Params: { siteId: string } }>, reply: FastifyReply) {
if (!WIKI.sites[req.params.siteId]) {
reply.notFound('This site does not exist.')
return null
}
return req.params.siteId
}
const siteParams = {
type: 'object',
properties: { siteId: { type: 'string', format: 'uuid' } },
required: ['siteId']
}
const preferencesResponse = {
type: 'object',
properties: {
preferences: { type: 'array', items: { $ref: 'NotificationPreference#' } },
emailAvailable: {
type: 'boolean',
description:
'Whether this instance can send email at all. Without it, every email choice is stored but has no effect.'
}
}
}
/**
* Notifications API Routes
*
* The inbox and its badge, the preferences behind Profile → Notifications, the one-click unsubscribe
* a notification email carries, and the instance settings of Admin → Notifications. What becomes a
* notification and who receives it is decided elsewhere — `models/notifications.ts` and the worker
* tasks it queues.
*/
async function routes(app: FastifyInstance) {
/**
* LIST NOTIFICATIONS
*/
app.get<{
Params: { siteId: string }
Querystring: { cursor?: string; unread?: boolean; limit?: number }
}>(
'/sites/:siteId/notifications',
{
// -> No route-level permissions: the caller's own rows, and the session is the whole check
schema: {
summary: "List the caller's notifications",
description:
'Newest activity first: an entry that absorbs another event moves back to the top. Includes the entries of this site and those that belong to no site.\n\nKeyset-paginated: pass the `next` of one page as the `cursor` of the next.',
tags: ['Notifications'],
params: siteParams,
querystring: {
type: 'object',
properties: {
cursor: { type: 'string', maxLength: 128 },
unread: { type: 'boolean', description: 'Only entries not yet read.' },
limit: { type: 'integer', minimum: 1, maximum: 100 }
}
},
response: {
200: {
description: 'One page of the inbox',
type: 'object',
properties: {
entries: { type: 'array', items: { $ref: 'Notification#' } },
next: { type: 'string', nullable: true }
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = ownerOf(req, reply)
const siteId = userId && siteOf(req, reply)
if (!userId || !siteId) {
return reply
}
return WIKI.models.notifications.list(userId, siteId, req.query)
}
)
/**
* NOTIFICATIONS SUMMARY
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/notifications/summary',
{
// -> No route-level permissions: see above
schema: {
summary: "Count the caller's unread notifications",
description:
'What the badge polls. `unread` stops counting at 100. Answers with an `ETag` and `304 Not Modified` when nothing has changed, so a poll that finds nothing new costs no body.',
tags: ['Notifications'],
params: siteParams,
response: {
200: {
description: 'The unread count, and when the inbox last changed',
type: 'object',
properties: {
unread: { type: 'integer' },
latestAt: { type: 'string', format: 'date-time', nullable: true }
}
},
304: { description: 'Nothing has changed since the ETag sent', type: 'null' }
}
}
},
async (req, reply) => {
const userId = ownerOf(req, reply)
const siteId = userId && siteOf(req, reply)
if (!userId || !siteId) {
return reply
}
const summary = await WIKI.models.notifications.summary(userId, siteId)
// -> Built from the answer AND who asked: the same URL answers differently for each person, and a
// tag naming only the count would let one session revalidate against another's
const etag = `"${createHash('sha1')
.update(`${userId}|${summary.unread}|${summary.latestAt ?? ''}`)
.digest('base64url')}"`
reply.header('Cache-Control', 'private, no-cache')
reply.header('ETag', etag)
if (req.headers['if-none-match'] === etag) {
return reply.code(304).send()
}
return summary
}
)
/**
* MARK NOTIFICATIONS READ
*/
app.put<{
Params: { siteId: string }
Body: { ids?: string[]; pageId?: string; categories?: string[] }
}>(
'/sites/:siteId/notifications/read',
{
// -> No route-level permissions: see above
schema: {
summary: 'Mark notifications read',
description:
'Everything, when the body names nothing. Otherwise the entries matching every filter given: particular entries by `ids`, or everything about one page by `pageId` — narrowed by `categories`, which is how opening a page clears what was said about its content while leaving its discussion for the Talk tab.\n\nAn entry read before its email has gone cancels the email.',
tags: ['Notifications'],
params: siteParams,
body: {
type: 'object',
properties: {
ids: { type: 'array', items: { type: 'string', format: 'uuid' }, maxItems: 500 },
pageId: { type: 'string', format: 'uuid' },
categories: { type: 'array', items: { type: 'string', maxLength: 64 }, maxItems: 20 }
}
},
response: {
200: {
description: 'How many entries were marked read',
type: 'object',
properties: {
ok: { type: 'boolean' },
updated: { type: 'integer' }
}
}
}
}
},
async (req, reply) => {
const userId = ownerOf(req, reply)
const siteId = userId && siteOf(req, reply)
if (!userId || !siteId) {
return reply
}
/*
Not audited. Marking one's own inbox read is bookkeeping, and a row per click would bury
everything the audit log is for — the reason page views are not recorded either.
*/
const updated = await WIKI.models.notifications.markRead(userId, siteId, req.body ?? {})
return { ok: true, updated }
}
)
/**
* DISMISS A NOTIFICATION
*/
app.delete<{ Params: { siteId: string; notificationId: string } }>(
'/sites/:siteId/notifications/:notificationId',
{
// -> No route-level permissions: see above
schema: {
summary: 'Dismiss a notification',
description:
'Removes one entry from the caller’s inbox. Not audited, as marking read is not.',
tags: ['Notifications'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
notificationId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'notificationId']
},
response: {
200: {
description: 'Dismissed',
type: 'object',
properties: { ok: { type: 'boolean' } }
}
}
}
},
async (req, reply) => {
const userId = ownerOf(req, reply)
const siteId = userId && siteOf(req, reply)
if (!userId || !siteId) {
return reply
}
if (!(await WIKI.models.notifications.dismiss(userId, siteId, req.params.notificationId))) {
return reply.notFound('This notification does not exist.')
}
return { ok: true }
}
)
/**
* GET OWN NOTIFICATION PREFERENCES
*/
app.get(
'/users/profile/notifications',
{
// -> No route-level permissions: session-scoped like the rest of `/users/profile`
schema: {
summary: "Get the logged in user's notification preferences",
description:
'Every category the caller is offered, with the channel choices in effect — their own where they made one, the category default where they did not. One set for every site.',
tags: ['Notifications'],
response: { 200: preferencesResponse }
}
},
async (req, reply) => {
reply.preventCache()
const userId = ownerOf(req, reply)
if (!userId) {
return reply
}
return {
preferences: await WIKI.models.notifications.getPrefs(userId, {
permissions: req.session.permissions ?? []
}),
emailAvailable: WIKI.models.mail.isConfigured
}
}
)
/**
* UPDATE OWN NOTIFICATION PREFERENCES
*/
app.put<{ Body: { preferences: Record<string, { inApp?: boolean; email?: boolean }> } }>(
'/users/profile/notifications',
{
// -> No route-level permissions: see above
schema: {
summary: "Update the logged in user's notification preferences",
description:
'Keyed by category. A category or a channel left out is left as it is, and a category the caller is not offered is ignored.',
tags: ['Notifications'],
body: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
inApp: { type: 'boolean' },
email: { type: 'boolean' }
}
}
}
}
},
response: { 200: preferencesResponse }
}
},
async (req, reply) => {
const userId = ownerOf(req, reply)
if (!userId) {
return reply
}
const actor = { permissions: req.session.permissions ?? [] }
const changed = await WIKI.models.notifications.setPrefs(userId, actor, req.body.preferences)
if (changed.length > 0) {
await audit(req, 'profile', 'updateNotificationPrefs', { categories: changed.sort() })
}
return {
preferences: await WIKI.models.notifications.getPrefs(userId, actor),
emailAvailable: WIKI.models.mail.isConfigured
}
}
)
/**
* ONE-CLICK UNSUBSCRIBE (RFC 8058)
*/
app.post<{
Querystring: { t?: string }
Body: { t?: string; scope?: 'token' | 'all'; 'List-Unsubscribe'?: string }
}>(
'/notifications/unsubscribe',
{
config: {
publicAccess: true
},
schema: {
summary: 'Unsubscribe from notification email',
description:
'What a mail client posts when somebody presses its own unsubscribe button: the URL in the `List-Unsubscribe` header, with `List-Unsubscribe=One-Click` as a form body (RFC 8058). Needs no session — the token in the URL says who, and all it can do is turn email off for them.\n\nTurns off EMAIL for the categories the mail was about, or for every category with `scope=all`, and cancels whatever email was waiting for them. In-app notifications carry on.',
tags: ['Notifications'],
consumes: ['application/x-www-form-urlencoded', 'application/json'],
querystring: {
type: 'object',
properties: { t: { type: 'string', maxLength: 2048 } }
},
body: {
type: 'object',
additionalProperties: true,
properties: {
t: { type: 'string', maxLength: 2048, description: 'The token, when not in the URL.' },
scope: { type: 'string', enum: ['token', 'all'] }
}
},
response: {
200: {
description: 'Unsubscribed',
type: 'object',
properties: { ok: { type: 'boolean' } }
}
}
}
},
async (req, reply) => {
const scope = req.body?.scope === 'all' ? 'all' : 'token'
const claim = await WIKI.models.notifications.unsubscribe(req.query.t ?? req.body?.t, scope)
if (!claim) {
// -> Says nothing about which part was wrong
return reply.badRequest('This unsubscribe link is not valid.')
}
/*
Recorded as the account the token was issued to, like the auth events that record themselves:
there is no session, and the token is the only thing that identifies anybody.
*/
const user = await WIKI.models.users.getById(claim.userId)
await WIKI.models.auditLog.record({
kind: 'profile',
action: 'unsubscribeNotifications',
actor: {
id: user?.id ?? null,
name: user?.name ?? null,
email: user?.email ?? null,
ip: req.ip
},
meta: { scope, categories: claim.categories }
})
return { ok: true }
}
)
/**
* UNSUBSCRIBE LINK, OPENED
*/
app.get<{ Querystring: { t?: string } }>(
'/notifications/unsubscribe',
{
config: {
publicAccess: true
},
schema: {
summary: 'Open an unsubscribe link',
description:
'Changes nothing, and sends the browser to the page that asks first. Mail scanners fetch every link in a message, so a GET that acted would unsubscribe people who never asked.',
tags: ['Notifications'],
querystring: {
type: 'object',
properties: { t: { type: 'string', maxLength: 2048 } }
},
response: {
302: { description: 'Redirect to the unsubscribe page', type: 'null' }
}
}
},
async (req, reply) => {
return reply.redirect(`/_unsubscribe?t=${encodeURIComponent(req.query.t ?? '')}`)
}
)
/**
* DESCRIBE AN UNSUBSCRIBE LINK
*/
app.get<{ Querystring: { t?: string } }>(
'/notifications/unsubscribe/info',
{
config: {
publicAccess: true
},
schema: {
summary: 'Describe an unsubscribe link',
description:
'Which categories a link would unsubscribe from, for the page that asks before it does. Says nothing about whose it is.',
tags: ['Notifications'],
querystring: {
type: 'object',
properties: { t: { type: 'string', maxLength: 2048 } }
},
response: {
200: {
description: 'What the link would do',
type: 'object',
properties: {
valid: { type: 'boolean' },
categories: { type: 'array', items: { type: 'string' } }
}
}
}
}
},
async (req) => {
const claim = WIKI.models.notifications.readToken(req.query.t)
return { valid: Boolean(claim), categories: claim?.categories ?? [] }
}
)
/**
* GET NOTIFICATION SETTINGS
*/
app.get(
'/system/notifications',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the notification settings and delivery status',
description:
'The instance-wide settings — retention, the email delay, the mail batch size — and how delivery is doing: the backlog of events still to be turned into notifications, and the emails of the last day.',
tags: ['Notifications'],
response: {
200: {
description: 'Settings and status',
type: 'object',
properties: {
settings: { $ref: 'NotificationSettings#' },
status: { $ref: 'NotificationStatus#' },
categories: {
type: 'array',
items: { type: 'string' },
description: 'Every category there is.'
}
}
}
}
}
},
async (_req, reply) => {
reply.preventCache()
return {
settings: WIKI.models.notifications.getConfig(),
status: await WIKI.models.notifications.status(),
categories: NOTIFICATION_CATEGORY_KEYS
}
}
)
/**
* UPDATE NOTIFICATION SETTINGS
*/
app.put<{ Body: Record<string, any> }>(
'/system/notifications',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the notification settings',
description:
'Accepts any subset of the fields, and applies at once on every instance. A change to the email delay applies to notifications written from then on.',
tags: ['Notifications'],
body: { $ref: 'NotificationSettings#' },
response: {
200: {
description: 'Saved',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const patch: Record<string, any> = {}
for (const field of ['retentionDays', 'emailDelay', 'mailBatchSize']) {
if (req.body?.[field] !== undefined) {
patch[field] = req.body[field]
}
}
if (Object.keys(patch).length < 1) {
return reply.badRequest('No valid notification setting was provided.')
}
const invalid = WIKI.models.notifications.validate(patch)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.notifications.updateConfig(patch))) {
return reply.internalServerError('Failed to save the notification settings.')
}
// -> Fields rather than values, as the other configuration routes do
await audit(req, 'admin', 'updateNotificationSettings', {
fields: Object.keys(patch).sort()
})
return { ok: true, message: 'Notification settings saved successfully.' }
}
)
}
export default routes

@ -701,6 +701,13 @@ async function routes(app: FastifyInstance) {
const ratingMode = page.allowRatings const ratingMode = page.allowRatings
? WIKI.models.pageRatings.modeFor(req.params.siteId) ? WIKI.models.pageRatings.modeFor(req.params.siteId)
: null : null
/*
Which of the reader's notifications are about this page and still unread, so that opening it
— or its Talk tab — can mark them read without asking first. One indexed lookup, none for a
guest, and the browser only writes anything when this is not empty. Started here so that it
runs alongside the rest.
*/
const unreadNotifications = WIKI.models.notifications.unreadCategoriesOnPage(page.id, actorId)
const [approvalState, isWatching, commentsCount, blog, ownRating] = await Promise.all([ const [approvalState, isWatching, commentsCount, blog, ownRating] = await Promise.all([
WIKI.models.approvals.pageViewerState(req, req.params.siteId, { WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id, id: page.id,
@ -747,7 +754,8 @@ async function routes(app: FastifyInstance) {
permissions: pagePermissionsFor(req, page), permissions: pagePermissionsFor(req, page),
...approvalState, ...approvalState,
isWatching, isWatching,
rating: ownRating rating: ownRating,
unreadNotifications: await unreadNotifications
} }
} }
} }

@ -0,0 +1,143 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* NOTIFICATION - One entry in somebody's inbox
*/
app.addSchema({
$id: 'Notification',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
category: {
type: 'string',
description:
'What kind of notification this is: `watchedPage`, `watchedPageComment`, `commentReply`, `mention`, `reviewRequested`, `pageCreated` or `pageDeleted`.'
},
variant: {
type: 'string',
description:
'What happened, within the category — `edited`, `moved`, `published`, `unpublished`, `scheduled`, `deleted`, `new`, `updated`, `created`, `restored`. The latest one, for an entry that absorbed several events.'
},
count: {
type: 'integer',
description:
'How many events this entry stands for. A page saved ten times while nobody looked is one entry with a count of ten.'
},
pageId: {
type: 'string',
format: 'uuid',
nullable: true,
description: 'The page it is about, while that page exists.'
},
commentId: {
type: 'string',
format: 'uuid',
nullable: true,
description: 'The comment it is about, while that comment exists.'
},
actorId: {
type: 'string',
format: 'uuid',
nullable: true,
description:
'Who did it, while their account exists. Null for a guest, whose name is in `data.actorName`.'
},
data: {
type: 'object',
additionalProperties: true,
description:
'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists.'
},
isRead: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: {
type: 'string',
format: 'date-time',
description: 'When the entry last absorbed an event, which is what the inbox sorts by.'
}
}
})
/**
* NOTIFICATION PREFERENCE - One category, as Profile → Notifications offers it
*/
app.addSchema({
$id: 'NotificationPreference',
type: 'object',
properties: {
key: { type: 'string' },
section: {
type: 'string',
enum: ['watching', 'discussions', 'reviews', 'everything'],
description: 'The heading the Profile screen lists it under.'
},
inApp: { type: 'boolean' },
email: { type: 'boolean' },
defaults: {
type: 'object',
properties: {
inApp: { type: 'boolean' },
email: { type: 'boolean' }
}
}
}
})
/**
* NOTIFICATION SETTINGS - The instance-wide settings, used both ways
*/
app.addSchema({
$id: 'NotificationSettings',
type: 'object',
properties: {
retentionDays: {
type: 'integer',
minimum: 1,
maximum: 3650,
description: 'How many days a notification is kept, read or not.'
},
emailDelay: {
type: 'string',
maxLength: 16,
description:
'How long a notification waits before it is emailed, e.g. `3m`. Whatever else happens to the same thing in the meantime goes into the same email; once emailed, an entry is not emailed about again until it has been read. At most an hour.'
},
mailBatchSize: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: 'How many people one run of the mail task sends to.'
}
}
})
/**
* NOTIFICATION STATUS - How delivery is doing, for the admin screen
*/
app.addSchema({
$id: 'NotificationStatus',
type: 'object',
properties: {
pendingEvents: {
type: 'integer',
description:
'Events not yet turned into notifications. Anything beyond a handful for long means the fan-out is falling behind.'
},
oldestPendingEventAt: { type: 'string', format: 'date-time', nullable: true },
emailsPending: { type: 'integer', description: 'Emails waiting to be sent.' },
emailsSent24h: { type: 'integer' },
emailsFailed24h: {
type: 'integer',
description: 'Emails given up on after repeated failures to hand them to the relay.'
},
isMailConfigured: { type: 'boolean' },
warnings: {
type: 'array',
items: { type: 'string', enum: ['plainHttp', 'wildcardHostname'] },
description:
'`plainHttp`: the base URL links are built with is not HTTPS, which one-click unsubscribe needs to be honoured by Gmail. `wildcardHostname`: a site answers to any hostname and no base URL is set, so its emails have no address to link to and are not sent.'
}
}
})
}

@ -300,6 +300,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'The requester’s own rating of this page on the site’s current scale, or 0 for none. Always 0 without an account, since a rating belongs to one.' 'The requester’s own rating of this page on the site’s current scale, or 0 for none. Always 0 without an account, since a rating belongs to one.'
}, },
unreadNotifications: {
type: 'array',
items: { type: 'string' },
description:
'The notification categories the requester has unread entries in about this page, so that opening it can mark them read. Empty without an account.'
},
pendingSubmissions: { pendingSubmissions: {
type: 'array', type: 'array',
items: { $ref: 'PageEditSubmission#' }, items: { $ref: 'PageEditSubmission#' },

@ -112,6 +112,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'Whether a page may show who last edited it, in its sidebar. A page can still opt out on its own with `showLastEditedBy`.' 'Whether a page may show who last edited it, in its sidebar. A page can still opt out on its own with `showLastEditedBy`.'
}, },
notifications: {
type: 'boolean',
description:
'Whether this site sends notifications at all — in-app or by email. Off, nothing that happens on the site is recorded for anybody, emails already waiting are dropped, and the inbox is hidden; entries already there are kept for when it is turned back on. What each person receives is theirs to choose, under Profile → Notifications.'
},
reasonForChange: { reasonForChange: {
type: 'string', type: 'string',
enum: ['off', 'optional', 'required'] enum: ['off', 'optional', 'required']

@ -102,6 +102,16 @@ defaults:
# about a dozen database queries. # about a dozen database queries.
includeRuntime: true includeRuntime: true
includeWiki: false includeWiki: false
notifications:
# How many days a notification is kept, read or not. Entries older than this are deleted by the
# `purgeNotifications` task, which runs daily.
retentionDays: 60
# How long a notification waits before it is emailed, so that a burst of activity on one page
# becomes one email rather than one per save. Once emailed, an entry is not emailed about again
# until it has been read.
emailDelay: '3m'
# How many people one run of the mail task sends to. Lower it for a relay that throttles.
mailBatchSize: 100
scim: scim:
# SCIM 2.0 provisioning, served at /_scim/v2. Off by default: it is a directory's write access # SCIM 2.0 provisioning, served at /_scim/v2. Off by default: it is a directory's write access
# to the wiki's user and group lists, and nothing about it is useful until an administrator has # to the wiki's user and group lists, and nothing about it is useful until an administrator has

@ -0,0 +1,110 @@
import crypto from 'node:crypto'
import { eq, sql } from 'drizzle-orm'
import { settings as settingsTable } from '../db/schema.ts'
/**
* What every boot makes sure of before anything else reads the settings.
*
* A release sometimes needs a value that an installation created before it cannot have: a secret
* generated per installation, an identifier, a value derived from what is already stored. A key with
* a static default needs none of this — it belongs in `base.yml`, which is merged under the stored
* settings on every boot. A check is for what cannot be written down in advance, and it is the one
* place such a value is filled in: nothing that reads a setting falls back on its own, and nothing
* at install time duplicates what a check already does, since checks run after the first-run seed
* as well.
*
* **Checks must be idempotent** — they run on every boot, and on every instance of an HA set — and
* should only ever ADD what is missing. A check that changes a value somebody set is a migration of
* meaning, not a startup check, and deserves more thought than this file gives it.
*/
/** Advisory lock held for the length of the checks' transaction. See `runStartupChecks`. */
const STARTUP_CHECKS_LOCK_KEY = 4210002
/** One thing every boot makes sure of. */
interface StartupCheck {
/** What it ensures, as the boot log names it. */
name: string
/**
* Do it, inside the checks' transaction.
*
* @returns Whether anything was written
*/
run: (trx: any) => Promise<boolean>
}
/**
* The common case: a key inside one of the settings blobs.
*
* `fill` is handed the blob as stored (an empty object when there is no row for it yet) and returns
* the fields to add, or nothing when nothing is missing. What it returns is merged over the stored
* blob and written back whole, so the fields it does not name are kept as they were.
*/
function settingsCheck(
name: string,
key: string,
fill: (stored: Record<string, any>) => Record<string, any> | null
): StartupCheck {
return {
name,
async run(trx) {
const [row] = await trx
.select({ value: settingsTable.value })
.from(settingsTable)
.where(eq(settingsTable.key, key))
const stored = (row?.value ?? {}) as Record<string, any>
const added = fill(stored)
if (!added || Object.keys(added).length < 1) {
return false
}
const value = { ...stored, ...added }
await trx
.insert(settingsTable)
.values({ key, value })
.onConflictDoUpdate({ target: settingsTable.key, set: { value } })
return true
}
}
}
/**
* The checks, in the order they run. Add to the end; never remove one that a release has shipped
* while an installation from before it could still be upgraded.
*/
const STARTUP_CHECKS: StartupCheck[] = [
/*
What notification unsubscribe links are signed with (`notifications/unsubscribe.ts`). Its own
secret rather than `auth.secret`, which rotating the sessions replaces — and every unsubscribe
link already in somebody's mailbox with it. Generated here rather than seeded at install, so that
an installation from before notifications existed gets one too.
*/
settingsCheck('notification unsubscribe secret', 'notifications', (stored) =>
stored.unsubscribeSecret ? null : { unsubscribeSecret: crypto.randomBytes(32).toString('hex') }
)
]
/**
* Run every startup check, and say whether the settings need reading again.
*
* All of them in one transaction under an advisory lock, because the instances of an HA set boot
* together: without it, two would each find the secret missing, each generate one, and the second
* write would silently replace the first — after the first instance had already started signing
* links with it. Under the lock, the second finds the first's value and has nothing to do.
*
* @returns Whether anything was written, in which case the caller reloads the settings
*/
export async function runStartupChecks(): Promise<boolean> {
const applied: string[] = []
await WIKI.db.transaction(async (trx: any) => {
await trx.execute(sql`SELECT pg_advisory_xact_lock(${STARTUP_CHECKS_LOCK_KEY}::bigint)`)
for (const check of STARTUP_CHECKS) {
if (await check.run(trx)) {
applied.push(check.name)
}
}
})
if (applied.length > 0) {
WIKI.logger.info(`Startup checks filled in: ${applied.join(', ')} [ OK ]`)
}
return applied.length > 0
}

@ -0,0 +1,62 @@
CREATE TABLE "notificationEvents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"kind" varchar(64) NOT NULL,
"origin" varchar(16) DEFAULT 'user' NOT NULL,
"siteId" uuid,
"actorId" uuid,
"data" jsonb DEFAULT '{}' NOT NULL,
"recipients" uuid[],
"cursor" jsonb,
"claimedAt" timestamp,
"claimedBy" varchar(255),
"processedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" uuid NOT NULL,
"siteId" uuid,
"category" varchar(64) NOT NULL,
"variant" varchar(32) NOT NULL,
"groupKey" varchar(255) NOT NULL,
"pageId" uuid,
"commentId" uuid,
"actorId" uuid,
"data" jsonb DEFAULT '{}' NOT NULL,
"count" integer DEFAULT 1 NOT NULL,
"lastEventId" uuid NOT NULL,
"inApp" boolean DEFAULT true NOT NULL,
"emailState" varchar(16) DEFAULT 'none' NOT NULL,
"emailAfter" timestamp,
"emailedAt" timestamp,
"readAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "userNotificationPrefs" (
"userId" uuid,
"category" varchar(64),
"channel" varchar(16),
"enabled" boolean NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "userNotificationPrefs_pkey" PRIMARY KEY("userId","category","channel")
);
--> statement-breakpoint
CREATE INDEX "notificationEvents_pending_idx" ON "notificationEvents" ("createdAt") WHERE "processedAt" IS NULL;--> statement-breakpoint
CREATE INDEX "notificationEvents_processed_idx" ON "notificationEvents" ("processedAt") WHERE "processedAt" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "notifications_user_updated_idx" ON "notifications" ("userId","updatedAt");--> statement-breakpoint
CREATE INDEX "notifications_unread_idx" ON "notifications" ("userId","siteId") WHERE "readAt" IS NULL AND "inApp";--> statement-breakpoint
CREATE INDEX "notifications_email_idx" ON "notifications" ("emailAfter") WHERE "emailState" IN ('pending', 'sending');--> statement-breakpoint
CREATE UNIQUE INDEX "notifications_user_group_unread_idx" ON "notifications" ("userId","groupKey") WHERE "readAt" IS NULL;--> statement-breakpoint
CREATE INDEX "notifications_pageId_idx" ON "notifications" ("pageId");--> statement-breakpoint
CREATE INDEX "notifications_commentId_idx" ON "notifications" ("commentId");--> statement-breakpoint
CREATE INDEX "notifications_createdAt_idx" ON "notifications" ("createdAt");--> statement-breakpoint
CREATE INDEX "userNotificationPrefs_optin_idx" ON "userNotificationPrefs" ("category","userId") WHERE "enabled";--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_commentId_comments_id_fkey" FOREIGN KEY ("commentId") REFERENCES "comments"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actorId_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "userNotificationPrefs" ADD CONSTRAINT "userNotificationPrefs_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;

@ -630,6 +630,130 @@ export const navigation = pgTable(
] ]
) )
// NOTIFICATION EVENTS -----------------
/**
* The outbox: one row per thing that happened that somebody may need to be told about.
*
* Written by `notifications.emit()` from the request that caused it, and nothing more — working out
* who to tell happens later, in a worker, which claims these in batches (`FOR UPDATE SKIP LOCKED`).
* One row per EVENT rather than one scheduler job per event, because a job costs a `jobs` row and a
* `jobHistory` row each, and a busy wiki saves pages faster than that is worth paying for.
*
* No foreign keys: an event is a record of a moment, and the page, the comment or the actor it names
* may well be gone by the time it is processed — a deletion is the event that guarantees it.
*/
export const notificationEvents = pgTable(
'notificationEvents',
{
// -> Also the idempotency key: a fan-out replayed after a crash writes this id onto every entry
// it touches, and an entry that already carries it is left alone
id: uuid().primaryKey().defaultRandom(),
kind: varchar({ length: 64 }).notNull(),
// -> 'user', 'import' or 'bulk', which a category may decline to fire for
origin: varchar({ length: 16 }).notNull().default('user'),
// -> Null for an event that belongs to no site
siteId: uuid(),
actorId: uuid(),
data: jsonb().notNull().default({}),
/**
* Candidates resolved when the event was written rather than when it is processed. Only a
* deletion needs it: the page's watchers are removed with the page by the foreign key's cascade,
* so they have to be read before the page goes or there is nobody left to tell.
*/
recipients: uuid().array(),
// -> How far an interrupted fan-out got, so that the next run carries on from there
cursor: jsonb(),
claimedAt: timestamp(),
claimedBy: varchar({ length: 255 }),
processedAt: timestamp(),
createdAt: timestamp().notNull().defaultNow()
},
(table) => [
// -> The fan-out's own query: what is still to do, oldest first
index('notificationEvents_pending_idx')
.on(table.createdAt)
.where(sql`"processedAt" IS NULL`),
index('notificationEvents_processed_idx')
.on(table.processedAt)
.where(sql`"processedAt" IS NOT NULL`)
]
)
// NOTIFICATIONS -----------------------
/**
* What one person has been told about: the inbox, and the email queue, in one row.
*
* **A row covers both channels.** `inApp` says whether the inbox shows it and `emailState` where its
* email stands, so a person who only wants email still has a row — hidden from the inbox, and closed
* (`readAt` set) once the email has gone, since there is nowhere for them to read it. One row is what
* gives coalescing and the email cadence a single thing to work on.
*
* **A row coalesces.** Every entry has a `groupKey` (`watchedPage:<pageId>`, `mention:<commentId>`,
* …) and a person has at most one UNREAD entry per key, by the partial unique index below. Forty saves
* to a watched page are one entry with a `count` of forty; reading it frees the key, and the next save
* starts a new one.
*
* **A row carries a snapshot** (`data`) of what it is about — the title, the path, who did it — since
* the page may be renamed or deleted before anybody looks. The ids beside it are `set null` rather
* than cascaded for the same reason: a notification that a page was deleted has to outlive the page.
* A comment's excerpt is shown only while `commentId` is still set, so deleting a comment takes its
* text out of every inbox it reached.
*/
export const notifications = pgTable(
'notifications',
{
id: uuid().primaryKey().defaultRandom(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// -> Null for an entry that belongs to no site, which every site's inbox shows. Cascaded: an
// inbox is not content, and a site's entries mean nothing once the site has gone
siteId: uuid().references(() => sites.id, { onDelete: 'cascade' }),
category: varchar({ length: 64 }).notNull(),
// -> The latest one: an entry absorbing an edit and then a move says it was moved
variant: varchar({ length: 32 }).notNull(),
groupKey: varchar({ length: 255 }).notNull(),
pageId: uuid().references(() => pages.id, { onDelete: 'set null' }),
commentId: uuid().references(() => comments.id, { onDelete: 'set null' }),
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
data: jsonb().notNull().default({}),
count: integer().notNull().default(1),
lastEventId: uuid().notNull(),
inApp: boolean().notNull().default(true),
// -> 'none', 'pending', 'sending', 'sent', 'failed' or 'skipped'. A varchar rather than an enum
// so that a state added later does not need a migration. `sending` is a claim, and its
// `emailAfter` the lease — see `claim` in `notifications/mailer.ts`
emailState: varchar({ length: 16 }).notNull().default('none'),
emailAfter: timestamp(),
emailedAt: timestamp(),
readAt: timestamp(),
createdAt: timestamp().notNull().defaultNow(),
// -> Moved forward whenever the entry absorbs another event, and the inbox's sort key: an entry
// that is still collecting news belongs at the top
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [
// -> The inbox
index('notifications_user_updated_idx').on(table.userId, table.updatedAt),
// -> The badge, which every page view of a signed-in reader polls for
index('notifications_unread_idx')
.on(table.userId, table.siteId)
.where(sql`"readAt" IS NULL AND "inApp"`),
// -> The mail drain: what is due, and what a run that died while sending left behind
index('notifications_email_idx')
.on(table.emailAfter)
.where(sql`"emailState" IN ('pending', 'sending')`),
// -> Coalescing, and idempotency with it: see the note above
uniqueIndex('notifications_user_group_unread_idx')
.on(table.userId, table.groupKey)
.where(sql`"readAt" IS NULL`),
index('notifications_pageId_idx').on(table.pageId),
index('notifications_commentId_idx').on(table.commentId),
// -> The purge, which goes by age alone
index('notifications_createdAt_idx').on(table.createdAt)
]
)
// PAGES ------------------------------ // PAGES ------------------------------
export const pagePublishStateEnum = pgEnum('pagePublishState', ['draft', 'published', 'scheduled']) export const pagePublishStateEnum = pgEnum('pagePublishState', ['draft', 'published', 'scheduled'])
export const pages = pgTable( export const pages = pgTable(
@ -1309,3 +1433,42 @@ export const userGroups = pgTable(
index('userGroups_composite_idx').on(table.userId, table.groupId) index('userGroups_composite_idx').on(table.userId, table.groupId)
] ]
) )
// USER NOTIFICATION PREFS -------------
/**
* Which notifications one person has chosen to receive, and how.
*
* **Only a choice that differs from the category's default is stored.** No row means the default,
* which lives in the category's definition (`notifications/categories/`) and nowhere else, and setting
* a choice back to its default deletes the row rather than writing one that says the same thing.
*
* A table rather than a key in `users.prefs` because of the categories that are off by default: to
* tell everybody who asked about every page created, the question is "who has turned this on?" asked
* of every account on the instance, which is the partial index below rather than a scan of a JSONB
* column. The categories that are on by default ask the other way round — of these few watchers,
* who has turned it off — and that is the primary key.
*
* One row per channel rather than a column each, so that a third channel is a new value and not a
* migration. Global, not per site: a person has one set of preferences for every site they use.
*/
export const userNotificationPrefs = pgTable(
'userNotificationPrefs',
{
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
category: varchar({ length: 64 }).notNull(),
// -> 'inApp' or 'email'
channel: varchar({ length: 16 }).notNull(),
enabled: boolean().notNull(),
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [
primaryKey({ columns: [table.userId, table.category, table.channel] }),
// -> Who has turned a category on, read in userId order: the fan-out pages through it a thousand
// accounts at a time, and an opted-in audience can be every account on the instance
index('userNotificationPrefs_optin_idx')
.on(table.category, table.userId)
.where(sql`"enabled"`)
]
)

@ -34,6 +34,7 @@ import configSvc from './core/config.ts'
import dbManager from './core/db.ts' import dbManager from './core/db.ts'
import logger from './core/logger.ts' import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts' import scheduler from './core/scheduler.ts'
import { runStartupChecks } from './core/startupChecks.ts'
import { renderAppShell } from './helpers/appShell.ts' import { renderAppShell } from './helpers/appShell.ts'
import { import {
isPageUrl, isPageUrl,
@ -165,6 +166,11 @@ async function preBoot() {
throw new Error('Settings table is empty! Could not initialize [ ERROR ]') throw new Error('Settings table is empty! Could not initialize [ ERROR ]')
} }
} }
// -> On a fresh install as much as an upgraded one: the seed leaves to these what has to be
// generated, so there is one place each such value is made
if (await runStartupChecks()) {
await WIKI.configSvc.loadFromDb()
}
} catch (err: any) { } catch (err: any) {
WIKI.logger.error('Database Initialization Error: ' + err.message) WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) { if (WIKI.IS_DEBUG) {

@ -211,6 +211,7 @@
"admin.audit.actions.unassignUserFromGroup": "Removed a user from a group", "admin.audit.actions.unassignUserFromGroup": "Removed a user from a group",
"admin.audit.actions.unlockPage": "Unlocked a password-protected page", "admin.audit.actions.unlockPage": "Unlocked a password-protected page",
"admin.audit.actions.unratePage": "Withdrew a page rating", "admin.audit.actions.unratePage": "Withdrew a page rating",
"admin.audit.actions.unsubscribeNotifications": "Unsubscribed from notification email",
"admin.audit.actions.unwatchPage": "Stopped watching a page", "admin.audit.actions.unwatchPage": "Stopped watching a page",
"admin.audit.actions.updateAnalytics": "Changed the analytics configuration", "admin.audit.actions.updateAnalytics": "Changed the analytics configuration",
"admin.audit.actions.updateApiState": "Turned the API on or off", "admin.audit.actions.updateApiState": "Turned the API on or off",
@ -232,6 +233,8 @@
"admin.audit.actions.updateLocale": "Changed a locale alias", "admin.audit.actions.updateLocale": "Changed a locale alias",
"admin.audit.actions.updateMailConfig": "Updated the mail configuration", "admin.audit.actions.updateMailConfig": "Updated the mail configuration",
"admin.audit.actions.updateMetricsState": "Turned the metrics endpoint on or off", "admin.audit.actions.updateMetricsState": "Turned the metrics endpoint on or off",
"admin.audit.actions.updateNotificationPrefs": "Changed their notification settings",
"admin.audit.actions.updateNotificationSettings": "Changed the notification settings",
"admin.audit.actions.updatePage": "Edited a page", "admin.audit.actions.updatePage": "Edited a page",
"admin.audit.actions.updatePageNavigation": "Changed the navigation of a page", "admin.audit.actions.updatePageNavigation": "Changed the navigation of a page",
"admin.audit.actions.updateProfile": "Updated their profile", "admin.audit.actions.updateProfile": "Updated their profile",
@ -494,6 +497,8 @@
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
"admin.general.allowLastEditedBy": "Allow Last Edited By", "admin.general.allowLastEditedBy": "Allow Last Edited By",
"admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?", "admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?",
"admin.general.allowNotifications": "Allow Notifications",
"admin.general.allowNotificationsHint": "Tell readers about the pages they watch, replies, mentions and suggestions to review. What each person receives is theirs to choose in their profile.",
"admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatings": "Allow Ratings",
"admin.general.allowRatingsHint": "Can logged in users rate the pages they can read, and on which scale? A page can still turn ratings off in its properties.", "admin.general.allowRatingsHint": "Can logged in users rate the pages they can read, and on which scale? A page can still turn ratings off in its properties.",
"admin.general.allowSearch": "Allow Search", "admin.general.allowSearch": "Allow Search",
@ -878,6 +883,36 @@
"admin.nav.site": "Site", "admin.nav.site": "Site",
"admin.nav.system": "System", "admin.nav.system": "System",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.notifications.backlog": "Backlog",
"admin.notifications.backlogHint": "Events not yet turned into notifications. A backlog that keeps growing means delivery is falling behind.",
"admin.notifications.configureMail": "Configure",
"admin.notifications.days": "days",
"admin.notifications.emailDelay": "Email Delay",
"admin.notifications.emailDelayHint": "How long a notification waits before it is emailed, e.g. 3m. Whatever else happens to the same page in the meantime goes into the same email. Once emailed, nothing more is sent about it until it is read.",
"admin.notifications.emails": "Emails",
"admin.notifications.emailsHint": "Waiting to be sent, and sent or given up on in the last 24 hours.",
"admin.notifications.failed": "Failed",
"admin.notifications.loadFailed": "Failed to load the notification settings.",
"admin.notifications.mail": "Outgoing Mail",
"admin.notifications.mailBatchSize": "Mail Batch Size",
"admin.notifications.mailBatchSizeHint": "How many people one run of the mail task sends to. Lower it for a mail server that throttles.",
"admin.notifications.mailConfigured": "Configured. Notification emails are sent.",
"admin.notifications.mailNotConfigured": "Not configured. Only in-app notifications are delivered.",
"admin.notifications.oldest": "oldest {date}",
"admin.notifications.pending": "Pending",
"admin.notifications.refreshSuccess": "Notification settings refreshed.",
"admin.notifications.retentionDays": "Retention",
"admin.notifications.retentionDaysHint": "How long notifications are kept, read or not.",
"admin.notifications.saveFailed": "Failed to save the notification settings.",
"admin.notifications.saveSuccess": "Notification settings saved.",
"admin.notifications.sent": "Sent",
"admin.notifications.settings": "Settings",
"admin.notifications.status": "Status",
"admin.notifications.statusHint": "How delivery is doing on this instance",
"admin.notifications.subtitle": "How notifications are kept and emailed on this instance",
"admin.notifications.title": "Notifications",
"admin.notifications.warnings.plainHttp": "Links in emails are built from a base URL that is not HTTPS. Gmail ignores one-click unsubscribe on such links. Set an HTTPS base URL under Admin → Mail.",
"admin.notifications.warnings.wildcardHostname": "A site answers to any hostname and no base URL is set under Admin → Mail, so its notification emails have no address to link to and are not sent.",
"admin.rendering.subtitle": "Configure the content rendering pipeline", "admin.rendering.subtitle": "Configure the content rendering pipeline",
"admin.rendering.title": "Rendering", "admin.rendering.title": "Rendering",
"admin.scheduler.active": "Active", "admin.scheduler.active": "Active",
@ -2876,8 +2911,18 @@
"iconPicker.selection": "Selected icon", "iconPicker.selection": "Selected icon",
"iconPicker.set": "Set", "iconPicker.set": "Set",
"iconPicker.setsFailed": "Failed to load the icon sets.", "iconPicker.setsFailed": "Failed to load the icon sets.",
"inbox.dismiss": "Dismiss",
"inbox.dismissFailed": "Failed to dismiss the notification.",
"inbox.inbox": "Inbox", "inbox.inbox": "Inbox",
"inbox.inboxInfo": "Nothing here yet.", "inbox.loadFailed": "Failed to load your notifications.",
"inbox.loadMore": "Load more",
"inbox.markAllRead": "Mark all as read",
"inbox.markRead": "Mark as read",
"inbox.markReadFailed": "Failed to mark notifications as read.",
"inbox.none": "You have no notifications.",
"inbox.noneHint": "Watch a page with the bell in its header to hear about changes to it. Choose what else you are told about in your profile.",
"inbox.noneUnread": "You have no unread notifications.",
"inbox.notificationsOff": "Notifications are turned off on this site.",
"inbox.pendingReview": "Pending Review", "inbox.pendingReview": "Pending Review",
"inbox.pendingReviewInfo": "Edit suggestions waiting for your review, oldest first.", "inbox.pendingReviewInfo": "Edit suggestions waiting for your review, oldest first.",
"inbox.reviewApprove": "Approve", "inbox.reviewApprove": "Approve",
@ -2898,7 +2943,10 @@
"inbox.reviewSubmittedBy": "Suggested by {author} on {date}", "inbox.reviewSubmittedBy": "Suggested by {author} on {date}",
"inbox.reviewUnknownAuthor": "Unknown", "inbox.reviewUnknownAuthor": "Unknown",
"inbox.reviewViewPage": "View Page", "inbox.reviewViewPage": "View Page",
"inbox.settings": "Notification settings",
"inbox.title": "Inbox & Notifications", "inbox.title": "Inbox & Notifications",
"inbox.today": "Today",
"inbox.unreadOnly": "Unread only",
"inbox.watching": "Watching", "inbox.watching": "Watching",
"inbox.watchingHint": "Open a page and press the bell in its header to start watching it.", "inbox.watchingHint": "Open a page and press the bell in its header to start watching it.",
"inbox.watchingInfo": "Pages you asked to be told about, most recently added first.", "inbox.watchingInfo": "Pages you asked to be told about, most recently added first.",
@ -2909,6 +2957,7 @@
"inbox.watchingUnwatchFailed": "Could not stop watching this page.", "inbox.watchingUnwatchFailed": "Could not stop watching this page.",
"inbox.watchingUnwatched": "You are no longer watching {title}.", "inbox.watchingUnwatched": "You are no longer watching {title}.",
"inbox.watchingUpdated": "Last modified on {date}", "inbox.watchingUpdated": "Last modified on {date}",
"inbox.yesterday": "Yesterday",
"linkPicker.emptyFolder": "There are no pages in this folder.", "linkPicker.emptyFolder": "There are no pages in this folder.",
"linkPicker.linkUrl": "Link URL", "linkPicker.linkUrl": "Link URL",
"linkPicker.loadFailed": "Failed to load the page tree.", "linkPicker.loadFailed": "Failed to load the page tree.",
@ -2936,6 +2985,22 @@
"localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated", "localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated",
"localeFetchDialog.title": "Fetch Updates", "localeFetchDialog.title": "Fetch Updates",
"mail.common.greeting": "Hi {name},", "mail.common.greeting": "Hi {name},",
"mail.notification.actions.commentReply": "Open the discussion",
"mail.notification.actions.mention": "Open the discussion",
"mail.notification.actions.pageCreated": "View the page",
"mail.notification.actions.pageDeleted": "Open your inbox",
"mail.notification.actions.reviewRequested": "Review the suggestion",
"mail.notification.actions.watchedPage": "View the page",
"mail.notification.actions.watchedPageComment": "Open the discussion",
"mail.notification.footer": "You are receiving this because of your notification settings on {siteName}.",
"mail.notification.manage": "Notification settings",
"mail.notification.subject": "{message} — {siteName}",
"mail.notification.unsubscribe": "Unsubscribe",
"mail.notificationDigest.action": "Open your inbox",
"mail.notificationDigest.body": "Here is what happened since your last notification ({count} in all).",
"mail.notificationDigest.more": "See {count} more in your inbox",
"mail.notificationDigest.subject": "{count} new notifications — {siteName}",
"mail.notificationDigest.title": "What's new on {siteName}",
"mail.resetPwd.action": "Choose a new password", "mail.resetPwd.action": "Choose a new password",
"mail.resetPwd.body": "Somebody asked to reset the password for your account on {siteName}.", "mail.resetPwd.body": "Somebody asked to reset the password for your account on {siteName}.",
"mail.resetPwd.expiry": "This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.", "mail.resetPwd.expiry": "This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.",
@ -3000,6 +3065,44 @@
"navEdit.visibilityAll": "Everyone", "navEdit.visibilityAll": "Everyone",
"navEdit.visibilityHint": "Whether to show the menu item to everyone or just selected groups.", "navEdit.visibilityHint": "Whether to show the menu item to everyone or just selected groups.",
"navEdit.visibilityLimited": "Selected Groups", "navEdit.visibilityLimited": "Selected Groups",
"notifications.categories.commentReply.description": "Somebody answers a comment you wrote.",
"notifications.categories.commentReply.title": "Replies to your comments",
"notifications.categories.mention.description": "Somebody mentions your handle in a comment.",
"notifications.categories.mention.title": "Mentions",
"notifications.categories.pageCreated.description": "A page is created anywhere you can read. This can be busy on a large wiki.",
"notifications.categories.pageCreated.title": "New pages",
"notifications.categories.pageDeleted.description": "A page is deleted anywhere you could read it.",
"notifications.categories.pageDeleted.title": "Deleted pages",
"notifications.categories.reviewRequested.description": "Somebody suggests an edit to a page your group reviews.",
"notifications.categories.reviewRequested.title": "Suggestions to review",
"notifications.categories.watchedPage.description": "A page you watch is edited, moved, published, unpublished, rescheduled or deleted.",
"notifications.categories.watchedPage.title": "Changes to watched pages",
"notifications.categories.watchedPageComment.description": "Somebody comments on a page you watch.",
"notifications.categories.watchedPageComment.title": "Comments on watched pages",
"notifications.count": "{count} updates",
"notifications.messages.commentReply.new": "{actor} replied to your comment on {page}",
"notifications.messages.mention.edited": "{actor} mentioned you in a comment on {page}",
"notifications.messages.mention.new": "{actor} mentioned you in a comment on {page}",
"notifications.messages.pageCreated.created": "{actor} created {page}",
"notifications.messages.pageCreated.restored": "{actor} restored {page}",
"notifications.messages.pageDeleted.deleted": "{actor} deleted {page}",
"notifications.messages.reviewRequested.new": "{actor} suggested an edit to {page}",
"notifications.messages.reviewRequested.updated": "{actor} updated their suggested edit to {page}",
"notifications.messages.watchedPage.deleted": "{actor} deleted {page}",
"notifications.messages.watchedPage.edited": "{actor} edited {page}",
"notifications.messages.watchedPage.moved": "{actor} moved {page}",
"notifications.messages.watchedPage.published": "{actor} published {page}",
"notifications.messages.watchedPage.scheduled": "{actor} changed when {page} is published",
"notifications.messages.watchedPage.unpublished": "{actor} unpublished {page}",
"notifications.messages.watchedPageComment.new": "{actor} commented on {page}",
"notifications.origin.bulk": "as part of a bulk action",
"notifications.origin.import": "via an import",
"notifications.sections.discussions": "Discussions",
"notifications.sections.everything": "Everything on the wiki",
"notifications.sections.reviews": "Reviews",
"notifications.sections.watching": "Pages you watch",
"notifications.someone": "Someone",
"notifications.unreadLabel": "{title} ({count} unread)",
"pageDeleteDialog.confirm": "Are you sure you want to delete the page {name}?", "pageDeleteDialog.confirm": "Are you sure you want to delete the page {name}?",
"pageDeleteDialog.deleteSuccess": "Page deleted successfully.", "pageDeleteDialog.deleteSuccess": "Page deleted successfully.",
"pageDeleteDialog.pageId": "Page ID {id}", "pageDeleteDialog.pageId": "Page ID {id}",
@ -3101,6 +3204,15 @@
"profile.locationHint": "Your city and country; shown on your profile page.", "profile.locationHint": "Your city and country; shown on your profile page.",
"profile.myInfo": "My Info", "profile.myInfo": "My Info",
"profile.notifications": "Notifications", "profile.notifications": "Notifications",
"profile.notificationsEmail": "Email",
"profile.notificationsInApp": "In-App",
"profile.notificationsInfo": "Choose what you are told about, and how. These settings apply on every site of this wiki.",
"profile.notificationsLoadFailed": "Failed to load your notification settings.",
"profile.notificationsNoEmail": "This wiki cannot send email, so only in-app notifications are delivered.",
"profile.notificationsSaveFailed": "Failed to save your notification settings.",
"profile.notificationsSaved": "Notification settings saved.",
"profile.notificationsSiteOff": "Notifications are turned off on this site. Your settings still apply on the other sites of this wiki.",
"profile.notificationsStopEmail": "Turn off all emails",
"profile.pages.emptyList": "No pages to display.", "profile.pages.emptyList": "No pages to display.",
"profile.pages.headerCreatedAt": "Created", "profile.pages.headerCreatedAt": "Created",
"profile.pages.headerPath": "Path", "profile.pages.headerPath": "Path",
@ -3200,6 +3312,17 @@
"tags.sortPopularity": "List tags by popularity", "tags.sortPopularity": "List tags by popularity",
"tags.sortTags": "Sort tags", "tags.sortTags": "Sort tags",
"tags.title": "Tags", "tags.title": "Tags",
"unsubscribe.all": "Stop all notification email",
"unsubscribe.backToWiki": "Go to the wiki",
"unsubscribe.confirm": "Unsubscribe",
"unsubscribe.done": "You will no longer receive email about these notifications.",
"unsubscribe.doneAll": "You will no longer receive any notification email.",
"unsubscribe.failed": "Failed to unsubscribe.",
"unsubscribe.inAppStays": "Notifications in your inbox are not affected.",
"unsubscribe.intro": "Stop receiving email about:",
"unsubscribe.invalid": "This unsubscribe link is not valid. You can still change your notification settings from your profile.",
"unsubscribe.manage": "Notification settings",
"unsubscribe.title": "Unsubscribe",
"userProfile.lastLogin": "Last Login", "userProfile.lastLogin": "Last Login",
"userProfile.loadMore": "Load More", "userProfile.loadMore": "Load More",
"userProfile.loadingFailed": "Failed to load user profile.", "userProfile.loadingFailed": "Failed to load user profile.",

@ -69,7 +69,9 @@ export const AUDIT_ACTIONS = {
'enableTfa', 'enableTfa',
'disableTfa', 'disableTfa',
'registerPasskey', 'registerPasskey',
'deletePasskey' 'deletePasskey',
'updateNotificationPrefs',
'unsubscribeNotifications'
], ],
admin: [ admin: [
'createApiKey', 'createApiKey',
@ -130,6 +132,7 @@ export const AUDIT_ACTIONS = {
'finishImport', 'finishImport',
'updateApiState', 'updateApiState',
'updateMetricsState', 'updateMetricsState',
'updateNotificationSettings',
'updateScimState', 'updateScimState',
'disconnectWebsockets', 'disconnectWebsockets',
'flushCache', 'flushCache',

@ -711,6 +711,7 @@ class Comments {
path: pagesTable.path, path: pagesTable.path,
locale: pagesTable.locale, locale: pagesTable.locale,
tags: pagesTable.tags, tags: pagesTable.tags,
title: pagesTable.title,
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
}) })
.from(commentsTable) .from(commentsTable)
@ -781,6 +782,35 @@ class Comments {
return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0 return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0
} }
/**
* The handles written in a comment, folded to the case the unique index compares them in.
*
* Only what is written — whether each one names anybody is for whoever looks them up.
*/
mentionedHandles(content: string): string[] {
return [
...new Set([...content.matchAll(MENTION_PATTERN)].map((match) => match[1]!.toLowerCase()))
]
}
/**
* The first few lines of a comment, for a notification to quote. Markdown as typed, never HTML, with
* its whitespace run together so that a quote is one line.
*/
excerptOf(content: string, length = 240): string {
const flat = content.replace(/\s+/g, ' ').trim()
return flat.length > length ? `${flat.slice(0, length - 1).trimEnd()}…` : flat
}
/** Who wrote a comment, or null for a guest or a comment that has gone. */
async authorOf(commentId: string): Promise<string | null> {
const [row] = await WIKI.db
.select({ authorId: commentsTable.authorId })
.from(commentsTable)
.where(eq(commentsTable.id, commentId))
return row?.authorId ?? null
}
/** /**
* The users that the handles written in these comments point at. * The users that the handles written in these comments point at.
* *

@ -1763,7 +1763,10 @@ class Import {
const source = editor === 'visual' ? htmlToMarkdown(body) : body const source = editor === 'visual' ? htmlToMarkdown(body) : body
try { try {
const page = await WIKI.models.pages.adoptStoredPage({ const authorId = await this.#authorFor(session, record?.authorId)
// -> A restore, not a person writing pages: nobody who asked about new pages hears of these
const page = await WIKI.models.notifications.withOrigin('import', () =>
WIKI.models.pages.adoptStoredPage({
siteId: target.siteId, siteId: target.siteId,
locale, locale,
path: pagePath, path: pagePath,
@ -1777,9 +1780,10 @@ class Import {
content: editor === 'redirect' ? this.#redirectContent(source) : source, content: editor === 'redirect' ? this.#redirectContent(source) : source,
createdAt: dateOf(record?.createdAt), createdAt: dateOf(record?.createdAt),
updatedAt: dateOf(record?.updatedAt), updatedAt: dateOf(record?.updatedAt),
authorId: await this.#authorFor(session, record?.authorId), authorId,
overwrite: session.overwrite overwrite: session.overwrite
}) })
)
if (!page) { if (!page) {
/* /*
Already here and `overwrite` is off, so nothing was written — but the page IS what that Already here and `overwrite` is off, so nothing was written — but the page IS what that

@ -18,6 +18,7 @@ import { locales } from './locales.ts'
import { mail } from './mail.ts' import { mail } from './mail.ts'
import { metrics } from './metrics.ts' import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts' import { navigation } from './navigation.ts'
import { notifications } from './notifications.ts'
import { pageGraph } from './pageGraph.ts' import { pageGraph } from './pageGraph.ts'
import { pageHistory } from './pageHistory.ts' import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts' import { pageLinks } from './pageLinks.ts'
@ -60,6 +61,7 @@ export default {
mail, mail,
metrics, metrics,
navigation, navigation,
notifications,
pageGraph, pageGraph,
pageHistory, pageHistory,
pageLinks, pageLinks,

@ -43,6 +43,15 @@ export const SYSTEM_SCHEDULE: SystemScheduleEntry[] = [
// -> Same reasoning, and the cutoff is in hours but measured in days // -> Same reasoning, and the cutoff is in hours but measured in days
{ task: 'purgeImportSessions', cron: '25 0 * * *' }, { task: 'purgeImportSessions', cron: '25 0 * * *' },
{ task: 'purgeRateLimits', cron: '10 * * * *' }, { task: 'purgeRateLimits', cron: '10 * * * *' },
// -> Daily, for the same reason as the audit log: retention is counted in days
{ task: 'purgeNotifications', cron: '30 0 * * *' },
/*
Safety nets, not the schedule. Both are queued the moment there is work — an event written, an
email coming due — and each asks for its own next run; these only catch what an instance that
went down still owed, and a claim somebody abandoned.
*/
{ task: 'dispatchNotifications', cron: '*/5 * * * *' },
{ task: 'sendNotificationMail', cron: '*/5 * * * *' },
{ task: 'updateLocales', cron: '0 0 * * *' }, { task: 'updateLocales', cron: '0 0 * * *' },
// -> Every minute, and the task decides which sites are actually due: the interval is a per-site // -> Every minute, and the task decides which sites are actually due: the interval is a per-site
// setting, so the tick has to be as fine as the shortest one anybody can ask for // setting, so the tick has to be as fine as the shortest one anybody can ask for

@ -1,12 +1,14 @@
import { createTransport } from 'nodemailer' import { createTransport } from 'nodemailer'
import type { Transporter } from 'nodemailer' import type { Transporter } from 'nodemailer'
import { locales } from './locales.ts'
import type { Translator } from './locales.ts' import type { Translator } from './locales.ts'
/** /**
* The templates this wiki sends, and what each one needs. * The templates this wiki sends, and what each one needs.
* *
* Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's * Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's
* button. Held as literals rather than rows in a table because nothing sends a mail this wiki did * button; `notification` and `notificationDigest` are what the notification system sends (see
* `notifications/mailer.ts`). Held as literals rather than rows in a table because nothing sends a mail this wiki did
* not ask it to — a template is part of the flow that uses it, and a flow that gained one would * not ask it to — a template is part of the flow that uses it, and a flow that gained one would
* have to gain code here anyway. * have to gain code here anyway.
* *
@ -39,6 +41,38 @@ export interface MailTemplateData {
test: { test: {
baseUrl: string baseUrl: string
} }
/** One notification, on its own. */
notification: MailNotificationData
/** Several, gathered into one mail — whatever came due for one person on one site at once. */
notificationDigest: MailNotificationData
}
/** One notification as a mail describes it. Everything here is already resolved to text and URLs. */
export interface MailNotificationEntry {
/** A category key, which is what picks the strings. */
category: string
variant: string
/** How many events the entry absorbed. */
count: number
actorName: string | null
pageTitle: string
/** A comment's first lines, as typed. */
excerpt?: string
/** Set when nobody did it by hand: `import` or `bulk`. */
origin?: string
/** Where the entry leads. */
url: string
}
export interface MailNotificationData {
baseUrl: string
entries: MailNotificationEntry[]
/** How many more there were than a digest lists. */
more: number
/** Profile → Notifications. */
manageUrl: string
/** The page that asks before it unsubscribes, which is what a link in the body may point at. */
unsubscribeUrl: string
} }
/** A template key, i.e. one of the keys of `MailTemplateData`. */ /** A template key, i.e. one of the keys of `MailTemplateData`. */
@ -59,8 +93,15 @@ interface MailContent {
title: string title: string
/** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */ /** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */
body: string[] body: string[]
/**
* A list of things, each leading somewhere — what a digest is made of. Drawn after the paragraphs
* and before the action.
*/
items?: { text: string; detail?: string; url: string }[]
action?: { label: string; url: string } action?: { label: string; url: string }
footer: string footer: string
/** Small links under the footer: where a notification mail says how to stop receiving it. */
links?: { label: string; url: string }[]
} }
/** /**
@ -86,10 +127,23 @@ interface MailConfig {
dkimPrivateKey?: string dkimPrivateKey?: string
} }
/**
* The site a mail is about, as far as the mail needs to know it.
*
* Passed in rather than looked up, because the notification mails are sent from a worker thread,
* which has no `WIKI.sites`. Everything else builds it with `siteFor`.
*/
export interface MailSite {
/** What the mail calls the wiki. */
name: string
/** The language to write in when nothing is known about the recipient's. */
primaryLocale: string | null
}
/** One outgoing mail, as the models ask for it. */ /** One outgoing mail, as the models ask for it. */
export interface MailRequest<K extends MailTemplate = MailTemplate> { export interface MailRequest<K extends MailTemplate = MailTemplate> {
/** The site the mail is about, which is what names the wiki in it. */ /** The site the mail is about, which is what names the wiki in it. */
siteId: string site: MailSite
to: string to: string
template: K template: K
data: MailTemplateData[K] data: MailTemplateData[K]
@ -104,8 +158,50 @@ export interface MailRequest<K extends MailTemplate = MailTemplate> {
* Left empty, the mail is written in the site's primary locale — see `localeFor`. * Left empty, the mail is written in the site's primary locale — see `localeFor`.
*/ */
locale?: string | null locale?: string | null
/** Extra headers, e.g. `List-Unsubscribe`. */
headers?: Record<string, string>
} }
/**
* The headers a DKIM signature covers: nodemailer's own default list, which is RFC 4871's, plus
* `List-Unsubscribe-Post`.
*
* RFC 8058 requires both unsubscribe headers to be signed, and Gmail and Yahoo will not honour
* one-click unsubscribe without that — nodemailer's default covers `List-Unsubscribe` and not the
* second one. Setting the option replaces the default rather than adding to it, hence the full list.
*/
const DKIM_SIGNED_HEADERS = [
'From',
'Sender',
'Reply-To',
'Subject',
'Date',
'Message-ID',
'To',
'Cc',
'MIME-Version',
'Content-Type',
'Content-Transfer-Encoding',
'Content-ID',
'Content-Description',
'Resent-Date',
'Resent-From',
'Resent-Sender',
'Resent-To',
'Resent-Cc',
'Resent-Message-ID',
'In-Reply-To',
'References',
'List-Id',
'List-Help',
'List-Unsubscribe',
'List-Unsubscribe-Post',
'List-Subscribe',
'List-Post',
'List-Owner',
'List-Archive'
].join(':')
/** /**
* Take a value out of the template language it is being put into. * Take a value out of the template language it is being put into.
* *
@ -132,7 +228,10 @@ function escapeHtml(str: string): string {
* them with it — so a right-to-left mail read there would come out left-aligned, with its * them with it — so a right-to-left mail read there would come out left-aligned, with its
* punctuation at the wrong end, unless the cell that survives carries the direction itself. * punctuation at the wrong end, unless the cell that survives carries the direction itself.
*/ */
function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean): string { function htmlShell(
{ title, body, items, action, footer, links }: MailContent,
isRTL: boolean
): string {
const dir = isRTL ? 'rtl' : 'ltr' const dir = isRTL ? 'rtl' : 'ltr'
const align = isRTL ? 'right' : 'left' const align = isRTL ? 'right' : 'left'
const paragraphs = body const paragraphs = body
@ -141,6 +240,21 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean)
`<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${escapeHtml(p)}</p>` `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${escapeHtml(p)}</p>`
) )
.join('') .join('')
const list = items?.length
? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 16px;">` +
items
.map(
(item) =>
`<tr><td dir="${dir}" style="padding:10px 0;border-bottom:1px solid #eceff1;text-align:${align};">` +
`<a href="${escapeHtml(item.url)}" style="font-size:15px;line-height:1.5;color:#1976d2;text-decoration:none;">${escapeHtml(item.text)}</a>` +
(item.detail
? `<div style="margin-top:4px;font-size:13px;line-height:1.5;color:#78909c;">${escapeHtml(item.detail)}</div>`
: '') +
'</td></tr>'
)
.join('') +
'</table>'
: ''
const button = action const button = action
? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` + ? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` +
// -> The same link in full, for the client that will not render the button and for the reader // -> The same link in full, for the client that will not render the button and for the reader
@ -155,8 +269,17 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean)
`<tr><td dir="${dir}" style="padding:32px;text-align:${align};">`, `<tr><td dir="${dir}" style="padding:32px;text-align:${align};">`,
`<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`, `<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`,
paragraphs, paragraphs,
list,
button, button,
`<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #eceff1;font-size:12px;line-height:1.6;color:#90a4ae;">${escapeHtml(footer)}</p>`, `<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #eceff1;font-size:12px;line-height:1.6;color:#90a4ae;">${escapeHtml(footer)}</p>`,
links?.length
? `<p style="margin:8px 0 0;font-size:12px;line-height:1.6;color:#90a4ae;">${links
.map(
(link) =>
`<a href="${escapeHtml(link.url)}" style="color:#78909c;">${escapeHtml(link.label)}</a>`
)
.join(' &middot; ')}</p>`
: '',
'</td></tr></table></body></html>' '</td></tr></table></body></html>'
].join('') ].join('')
} }
@ -174,8 +297,17 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean)
* so that the two bodies say things in the same order — the button sits after the paragraphs in * so that the two bodies say things in the same order — the button sits after the paragraphs in
* the HTML one for the same reason. * the HTML one for the same reason.
*/ */
function textBody({ title, body, action, footer }: MailContent): string { function textBody({ title, body, items, action, footer, links }: MailContent): string {
return [title, ...body, ...(action ? [action.url] : []), footer].join('\n\n') return [
title,
...body,
...(items ?? []).map((item) =>
[`- ${item.text}`, ...(item.detail ? [` ${item.detail}`] : []), ` ${item.url}`].join('\n')
),
...(action ? [action.url] : []),
footer,
...(links ?? []).map((link) => `${link.label}: ${link.url}`)
].join('\n\n')
} }
/** /**
@ -270,7 +402,8 @@ class Mail {
dkim: { dkim: {
domainName: conf.dkimDomainName ?? '', domainName: conf.dkimDomainName ?? '',
keySelector: conf.dkimKeySelector ?? '', keySelector: conf.dkimKeySelector ?? '',
privateKey: conf.dkimPrivateKey privateKey: conf.dkimPrivateKey,
headerFieldNames: DKIM_SIGNED_HEADERS
} }
}) })
}) })
@ -294,16 +427,22 @@ class Mail {
* *
* @param req The request that triggered the mail, when there is one * @param req The request that triggered the mail, when there is one
* @param siteId The site the mail is about, when it is about one * @param siteId The site the mail is about, when it is about one
* @param hostname That site's hostname, for a caller with no `WIKI.sites` to look it up in
*/ */
baseUrl({ baseUrl({
req, req,
siteId siteId,
}: { req?: { protocol: string; host: string }; siteId?: string } = {}): string { hostname: knownHostname
}: {
req?: { protocol: string; host: string }
siteId?: string
hostname?: string
} = {}): string {
const configured = this.config.defaultBaseURL?.trim() const configured = this.config.defaultBaseURL?.trim()
if (configured) { if (configured) {
return configured.replace(/\/+$/, '') return configured.replace(/\/+$/, '')
} }
const hostname = siteId ? WIKI.sites[siteId]?.hostname : null const hostname = knownHostname ?? (siteId ? WIKI.sites[siteId]?.hostname : null)
if (hostname && hostname !== '*') { if (hostname && hostname !== '*') {
// -> The scheme the caller was reached by, since the hostname alone does not carry one // -> The scheme the caller was reached by, since the hostname alone does not carry one
return `${req?.protocol ?? 'https'}://${hostname}` return `${req?.protocol ?? 'https'}://${hostname}`
@ -315,10 +454,13 @@ class Mail {
} }
/** /**
* What to call this wiki in a mail. Per site, since that is what the reader was looking at. * What a mail needs to know about a site, from the site configurations this process holds.
*
* The name is per site, since that is what the reader was looking at.
*/ */
private siteName(siteId: string): string { siteFor(siteId: string): MailSite {
return WIKI.sites[siteId]?.config?.title || 'Wiki.js' const config = WIKI.sites[siteId]?.config
return { name: config?.title || 'Wiki.js', primaryLocale: config?.locales?.primary || null }
} }
/** /**
@ -329,8 +471,8 @@ class Mail {
* one addressed to a reader with a preference. `translator` takes it from there: a code naming a * one addressed to a reader with a preference. `translator` takes it from there: a code naming a
* locale that is not installed falls back to English rather than sending a mail full of keys. * locale that is not installed falls back to English rather than sending a mail full of keys.
*/ */
private localeFor(locale: string | null | undefined, siteId: string): string | null { private localeFor(locale: string | null | undefined, site: MailSite): string | null {
return locale || WIKI.sites[siteId]?.config?.locales?.primary || null return locale || site.primaryLocale || null
} }
/** /**
@ -384,6 +526,9 @@ class Mail {
footer: t('mail.resetPwd.footer', { siteName }) footer: t('mail.resetPwd.footer', { siteName })
} }
} }
case 'notification':
case 'notificationDigest':
return this.renderNotification(t, siteName, data as MailTemplateData['notification'])
default: { default: {
const d = data as MailTemplateData['test'] const d = data as MailTemplateData['test']
return { return {
@ -397,6 +542,74 @@ class Mail {
} }
} }
/**
* A notification mail: one entry told in full, or several as a list.
*
* The sentence describing an entry is the same one the inbox draws —
* `notifications.messages.<category>.<variant>` — so a person reads the same words in both places,
* and a translator translates them once.
*/
private renderNotification(
t: Translator['t'],
siteName: string,
d: MailNotificationData
): MailContent {
const describe = (entry: MailNotificationEntry) =>
t(`notifications.messages.${entry.category}.${entry.variant}`, {
actor: entry.actorName || t('notifications.someone'),
page: entry.pageTitle
})
const detailOf = (entry: MailNotificationEntry) =>
[
...(entry.count > 1 ? [t('notifications.count', { count: entry.count })] : []),
...(entry.origin ? [t(`notifications.origin.${entry.origin}`)] : [])
].join(' · ')
const footer = t('mail.notification.footer', { siteName })
const links = [
{ label: t('mail.notification.manage'), url: d.manageUrl },
{ label: t('mail.notification.unsubscribe'), url: d.unsubscribeUrl }
]
if (d.entries.length === 1 && d.more < 1) {
const entry = d.entries[0]!
const message = describe(entry)
const detail = detailOf(entry)
return {
subject: t('mail.notification.subject', { siteName, message }),
title: message,
body: [...(entry.excerpt ? [`“${entry.excerpt}”`] : []), ...(detail ? [detail] : [])],
action: {
label: t(`mail.notification.actions.${entry.category}`),
url: entry.url
},
footer,
links
}
}
const total = d.entries.length + d.more
return {
subject: t('mail.notificationDigest.subject', { siteName, count: total }),
title: t('mail.notificationDigest.title', { siteName }),
body: [t('mail.notificationDigest.body', { count: total })],
items: d.entries.map((entry) => {
const detail = [entry.excerpt ? `“${entry.excerpt}”` : '', detailOf(entry)]
.filter(Boolean)
.join(' — ')
return { text: describe(entry), url: entry.url, ...(detail && { detail }) }
}),
action: {
label:
d.more > 0
? t('mail.notificationDigest.more', { count: d.more })
: t('mail.notificationDigest.action'),
url: `${d.baseUrl}/_inbox`
},
footer,
links
}
}
/** /**
* Send one mail, and wait for the relay to have taken it. * Send one mail, and wait for the relay to have taken it.
* *
@ -408,18 +621,20 @@ class Mail {
* nodemailer raises for a send that was attempted and failed * nodemailer raises for a send that was attempted and failed
*/ */
async send<K extends MailTemplate>({ async send<K extends MailTemplate>({
siteId, site,
to, to,
template, template,
data, data,
locale locale,
headers
}: MailRequest<K>): Promise<void> { }: MailRequest<K>): Promise<void> {
if (!this.isConfigured) { if (!this.isConfigured) {
throw new Error('ERR_MAIL_NOT_CONFIGURED') throw new Error('ERR_MAIL_NOT_CONFIGURED')
} }
const conf = this.config const conf = this.config
const siteName = this.siteName(siteId) const siteName = site.name
const translator = await WIKI.models.locales.translator(this.localeFor(locale, siteId)) // -> The model itself rather than `WIKI.models.locales`, which a worker thread does not have
const translator = await locales.translator(this.localeFor(locale, site))
const content = this.render(translator, siteName, template, data) const content = this.render(translator, siteName, template, data)
const { subject } = content const { subject } = content
const text = textBody(content) const text = textBody(content)
@ -433,7 +648,8 @@ class Mail {
to, to,
subject, subject,
text, text,
html html,
...(headers && { headers })
}) })
WIKI.logger.info(`Sent ${template} email to <${to}>.`) WIKI.logger.info(`Sent ${template} email to <${to}>.`)
} }

@ -0,0 +1,731 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { and, count, desc, eq, gte, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm'
import {
notificationEvents as eventsTable,
notifications as notificationsTable,
userNotificationPrefs as prefsTable
} from '../db/schema.ts'
import { durationToSeconds } from '../helpers/common.ts'
import {
NOTIFICATION_CATEGORIES,
NOTIFICATION_CATEGORY_KEYS,
NOTIFICATION_SECTIONS,
categoriesFor,
isCategoryKey
} from '../notifications/index.ts'
import type { NotificationCategoryKey } from '../notifications/index.ts'
import { enqueueOnce } from '../notifications/queue.ts'
import { readUnsubscribeToken } from '../notifications/unsubscribe.ts'
import type { UnsubscribeClaim } from '../notifications/unsubscribe.ts'
import type {
EventOrigin,
NotificationCategory,
NotificationChannel,
NotificationEventData,
NotificationEventKind,
NotificationSection,
PageSnapshot
} from '../notifications/types.ts'
/** Fields stored in the `notifications` settings blob that the admin area may change. */
export const NOTIFICATION_SETTINGS_FIELDS = [
'retentionDays',
'emailDelay',
'mailBatchSize'
] as const
/** How long after the first emit in a burst the fan-out is asked for, in milliseconds. */
const DISPATCH_DEBOUNCE = 1000
/** How long a processed event is kept in the outbox, which is long enough to debug one. */
const PROCESSED_EVENT_RETENTION_HOURS = 24
/** How many rows the purge deletes at a time. */
const PURGE_BATCH_SIZE = 10000
/** Badge counts stop here; the interface shows `99+` above 99. */
const UNREAD_CAP = 100
/** The default inbox page size, and the ceiling a client may ask for. */
const INBOX_PAGE_SIZE = 30
const INBOX_PAGE_MAX = 100
/** How an event came about, when something further up the call chain has said. See `withOrigin`. */
const originScope = new AsyncLocalStorage<EventOrigin>()
/** A page, in whatever shape a model has it in hand. */
interface PageLike {
id: string
title: string
path: string
locale: string
tags?: string[] | null
publishState?: string | null
}
/** One category as Profile → Notifications offers it. */
export interface NotificationPref {
key: NotificationCategoryKey
section: NotificationSection
defaults: Record<NotificationChannel, boolean>
inApp: boolean
email: boolean
}
/** One inbox entry, as the API answers with it. */
export interface InboxEntry {
id: string
category: string
variant: string
count: number
pageId: string | null
commentId: string | null
actorId: string | null
data: Record<string, unknown>
isRead: boolean
createdAt: Date
updatedAt: Date
}
/** What `markRead` narrows by. Nothing at all marks every entry read. */
export interface MarkReadFilter {
ids?: string[]
pageId?: string
categories?: string[]
}
/**
* Notifications model
*
* The request process's side of notifications: writing events, the preferences behind Profile →
* Notifications, the inbox, the instance settings, and the one-click unsubscribe. Turning an event
* into entries happens in a worker (`notifications/fanout.ts`), and so does sending the mail
* (`notifications/mailer.ts`); neither goes through here, because neither has `WIKI.models`.
*/
class Notifications {
private dispatchTimer: NodeJS.Timeout | null = null
// == ORIGIN =========================
/**
* Run some work whose events all came about the same way.
*
* Carried by `AsyncLocalStorage` for the reason `storage.importingFrom` is: the events are emitted
* several models away — a folder deletion reaches `deletePage` once per page — and none of those
* calls should need a parameter that says what is going on above them.
*/
withOrigin<T>(origin: EventOrigin, work: () => Promise<T>): Promise<T> {
return originScope.run(origin, work)
}
/** How the event being emitted now came about. */
originNow(): EventOrigin {
return originScope.getStore() ?? (WIKI.models.storage.isImporting() ? 'import' : 'user')
}
// == EMIT ===========================
/** Whether a site has notifications at all — `features.notifications`, on unless switched off. */
isEnabledFor(siteId: string | null | undefined): boolean {
return !siteId || WIKI.sites[siteId]?.config?.features?.notifications !== false
}
/** A page as an event remembers it. */
pageSnapshot(page: PageLike): PageSnapshot {
return {
id: page.id,
title: page.title,
path: page.path,
locale: page.locale,
tags: page.tags ?? [],
publishState: page.publishState ?? 'published'
}
}
/**
* Record that something happened, for the fan-out to tell whoever it concerns.
*
* One INSERT and a debounced request for a fan-out run — nobody is looked up, no access is checked
* and nothing is sent from here, so the cost on the request that caused it is the same however many
* people end up being told. Like `hooks.emit`, it never throws: a notification problem must not fail
* the action that triggered it. Call it after the action has succeeded.
*
* Nothing is written for a site with notifications switched off, nor for an event no category fires
* for from this origin — a git pull's `page:create`, say — rather than writing it to throw away.
*
* @param watchersOf For a page about to be deleted: capture its watchers into the event now,
* because the watch rows go with the page
*/
async emit(
kind: NotificationEventKind,
{
siteId,
actorId,
data,
watchersOf
}: {
siteId: string
actorId: string | null
data: NotificationEventData
watchersOf?: string
}
): Promise<void> {
try {
if (!this.isEnabledFor(siteId)) {
return
}
const origin = this.originNow()
if (categoriesFor(kind, origin).length < 1) {
return
}
await WIKI.db.insert(eventsTable).values({
kind,
origin,
siteId,
actorId,
data,
// -> In the same statement, so the ids never pass through here
...(watchersOf && {
recipients: sql`ARRAY(SELECT "userId" FROM "pageWatching" WHERE "pageId" = ${watchersOf})`
})
})
this.requestDispatch()
} catch (err: any) {
WIKI.logger.warn(`Failed to record notification event ${kind}: ${err.message}`)
}
}
/**
* Ask for a fan-out run, about a second from now.
*
* The first event of a burst arms the timer and the rest ride on it, so five hundred saves in a
* minute are a handful of runs rather than five hundred jobs. An instance that goes down with the
* timer armed owes a run, which the system schedule's safety net pays.
*/
private requestDispatch(): void {
if (this.dispatchTimer) {
return
}
this.dispatchTimer = setTimeout(() => {
this.dispatchTimer = null
void enqueueOnce('dispatchNotifications')
}, DISPATCH_DEBOUNCE)
this.dispatchTimer.unref?.()
}
// == PREFERENCES ====================
/** The categories somebody is offered, in the order the Profile screen lists them. */
visibleCategories(actor: { permissions: string[] }): NotificationCategoryKey[] {
return NOTIFICATION_CATEGORY_KEYS.filter((key) => {
const category: NotificationCategory = NOTIFICATION_CATEGORIES[key]
return !category.visibleTo || category.visibleTo(actor)
}).sort(
(a, b) =>
NOTIFICATION_SECTIONS.indexOf(NOTIFICATION_CATEGORIES[a].section) -
NOTIFICATION_SECTIONS.indexOf(NOTIFICATION_CATEGORIES[b].section)
)
}
/**
* Somebody's choices, with the default standing in wherever they made none.
*/
async getPrefs(userId: string, actor: { permissions: string[] }): Promise<NotificationPref[]> {
const stored = await WIKI.db
.select({
category: prefsTable.category,
channel: prefsTable.channel,
enabled: prefsTable.enabled
})
.from(prefsTable)
.where(eq(prefsTable.userId, userId))
return this.visibleCategories(actor).map((key) => {
const category = NOTIFICATION_CATEGORIES[key]
const chosen = (channel: NotificationChannel) =>
stored.find((row) => row.category === key && row.channel === channel)?.enabled ??
category.defaults[channel]
return {
key,
section: category.section,
defaults: { ...category.defaults },
inApp: chosen('inApp'),
email: chosen('email')
}
})
}
/**
* Store somebody's choices.
*
* Only what differs from a category's default is kept: a choice set back to its default deletes its
* row rather than writing one that says the same thing, so that a default changed later reaches
* everybody who never chose otherwise. Categories the caller is not offered, or that the request
* does not mention, are left as they are.
*
* @returns The keys of the categories whose stored choice changed
*/
async setPrefs(
userId: string,
actor: { permissions: string[] },
prefs: Record<string, Partial<Record<NotificationChannel, boolean>>>
): Promise<string[]> {
const current = await this.getPrefs(userId, actor)
const changed = new Set<string>()
for (const pref of current) {
const wanted = prefs[pref.key]
if (!wanted) {
continue
}
for (const channel of ['inApp', 'email'] as const) {
if (typeof wanted[channel] !== 'boolean' || wanted[channel] === pref[channel]) {
continue
}
await this.storeChoice(userId, pref.key, channel, wanted[channel])
changed.add(pref.key)
}
}
return [...changed]
}
private async storeChoice(
userId: string,
category: NotificationCategoryKey,
channel: NotificationChannel,
enabled: boolean
): Promise<void> {
if (enabled === NOTIFICATION_CATEGORIES[category].defaults[channel]) {
await WIKI.db
.delete(prefsTable)
.where(
and(
eq(prefsTable.userId, userId),
eq(prefsTable.category, category),
eq(prefsTable.channel, channel)
)
)
return
}
await WIKI.db
.insert(prefsTable)
.values({ userId, category, channel, enabled })
.onConflictDoUpdate({
target: [prefsTable.userId, prefsTable.category, prefsTable.channel],
set: { enabled, updatedAt: sql`now()` }
})
}
/**
* Stop emailing somebody about these categories, and drop whatever email they had waiting for them.
*
* What an unsubscribe does, and nothing more: in-app entries carry on, since the person asked to
* stop receiving MAIL.
*/
async disableEmail(userId: string, categories: NotificationCategoryKey[]): Promise<void> {
for (const category of categories) {
await this.storeChoice(userId, category, 'email', false)
}
if (categories.length > 0) {
await WIKI.db
.update(notificationsTable)
.set({ emailState: 'skipped' })
.where(
and(
eq(notificationsTable.userId, userId),
eq(notificationsTable.emailState, 'pending'),
inArray(notificationsTable.category, categories)
)
)
}
}
// == UNSUBSCRIBE ====================
/**
* Honour an unsubscribe link.
*
* @param scope `token` for the categories the mail was about, `all` for every category there is —
* which the page that asks offers, and which a token may ask for since all it can do is
* turn email off for the person it was issued to
* @returns What the token said, or null when it is not genuine
*/
async unsubscribe(token: unknown, scope: 'token' | 'all'): Promise<UnsubscribeClaim | null> {
const claim = readUnsubscribeToken(token)
if (!claim) {
return null
}
const categories =
scope === 'all' ? NOTIFICATION_CATEGORY_KEYS : claim.categories.filter(isCategoryKey)
await this.disableEmail(claim.userId, categories)
return { userId: claim.userId, categories }
}
/** What a token says, without acting on it — for the page that asks first. */
readToken(token: unknown): UnsubscribeClaim | null {
const claim = readUnsubscribeToken(token)
return claim ? { ...claim, categories: claim.categories.filter(isCategoryKey) } : null
}
// == INBOX ==========================
/** The entries one site's inbox shows: its own, and those that belong to no site. */
private inboxScope(userId: string, siteId: string) {
return and(
eq(notificationsTable.userId, userId),
or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId)),
eq(notificationsTable.inApp, true)
)
}
/**
* One page of somebody's inbox, newest activity first.
*
* Keyset-paginated on `(updatedAt, id)` — the cursor is the last entry of the previous page — since
* an entry that absorbs a new event moves to the top, and an offset would skip or repeat around it.
*/
async list(
userId: string,
siteId: string,
{ cursor, unread, limit }: { cursor?: string; unread?: boolean; limit?: number } = {}
): Promise<{ entries: InboxEntry[]; next: string | null }> {
const size = Math.min(Math.max(limit ?? INBOX_PAGE_SIZE, 1), INBOX_PAGE_MAX)
const conditions = [this.inboxScope(userId, siteId)]
if (unread) {
conditions.push(isNull(notificationsTable.readAt))
}
const after = this.parseCursor(cursor)
if (after) {
conditions.push(
sql`(${notificationsTable.updatedAt}, ${notificationsTable.id}) < (${after.updatedAt}, ${after.id})`
)
}
const rows = await WIKI.db
.select({
id: notificationsTable.id,
category: notificationsTable.category,
variant: notificationsTable.variant,
count: notificationsTable.count,
pageId: notificationsTable.pageId,
commentId: notificationsTable.commentId,
actorId: notificationsTable.actorId,
data: notificationsTable.data,
readAt: notificationsTable.readAt,
createdAt: notificationsTable.createdAt,
updatedAt: notificationsTable.updatedAt
})
.from(notificationsTable)
.where(and(...conditions))
.orderBy(desc(notificationsTable.updatedAt), desc(notificationsTable.id))
.limit(size + 1)
const page = rows.slice(0, size)
const last = page[page.length - 1]
return {
entries: page.map((row) => {
const { readAt, data, ...rest } = row
const { excerpt, ...snapshot } = (data ?? {}) as Record<string, unknown>
return {
...rest,
// -> A comment's text only while the comment exists: deleting it takes it out of every inbox
data: row.commentId && excerpt !== undefined ? { ...snapshot, excerpt } : snapshot,
isRead: readAt !== null
}
}),
next: rows.length > size && last ? `${last.updatedAt.toISOString()}|${last.id}` : null
}
}
private parseCursor(cursor?: string): { updatedAt: string; id: string } | null {
if (!cursor) {
return null
}
const [updatedAt, id] = cursor.split('|')
if (!updatedAt || !id || Number.isNaN(Date.parse(updatedAt)) || !/^[0-9a-f-]{36}$/i.test(id)) {
return null
}
return { updatedAt, id }
}
/**
* What the badge needs, and nothing more: how many unread entries (counted no further than
* `UNREAD_CAP`) and when the inbox last changed.
*
* What every open tab of a signed-in reader polls, so it is two index-only reads. The pair is also
* what the poll's ETag is built from: anything that changes the inbox moves one of them.
*/
async summary(
userId: string,
siteId: string
): Promise<{ unread: number; latestAt: string | null }> {
const capped = WIKI.db
.select({ one: sql`1` })
.from(notificationsTable)
.where(and(this.inboxScope(userId, siteId), isNull(notificationsTable.readAt)))
.limit(UNREAD_CAP)
.as('capped')
const [[unread], [latest]] = await Promise.all([
WIKI.db.select({ total: count() }).from(capped),
WIKI.db
.select({ at: sql<string | null>`max(${notificationsTable.updatedAt})` })
.from(notificationsTable)
.where(this.inboxScope(userId, siteId))
])
return {
unread: Number(unread?.total ?? 0),
latestAt: latest?.at ? new Date(latest.at).toISOString() : null
}
}
/**
* Mark entries read.
*
* Reading an entry before its email has gone also cancels the email: the person has seen it, which
* is all the mail was for. An entry that is read stops holding its coalescing key, so the next event
* about the same thing starts a new entry — and, being new, a new email.
*
* @returns How many entries changed
*/
async markRead(userId: string, siteId: string, filter: MarkReadFilter = {}): Promise<number> {
const conditions = [
eq(notificationsTable.userId, userId),
or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId)),
isNull(notificationsTable.readAt)
]
if (filter.ids) {
if (filter.ids.length < 1) {
return 0
}
conditions.push(inArray(notificationsTable.id, filter.ids))
}
if (filter.pageId) {
conditions.push(eq(notificationsTable.pageId, filter.pageId))
}
if (filter.categories) {
const categories = filter.categories.filter(isCategoryKey)
if (categories.length < 1) {
return 0
}
conditions.push(inArray(notificationsTable.category, categories))
}
const result = await WIKI.db
.update(notificationsTable)
.set({
readAt: sql`now()`,
emailState: sql`CASE WHEN ${notificationsTable.emailState} = 'pending' THEN 'skipped' ELSE ${notificationsTable.emailState} END`
})
.where(and(...conditions))
return result.rowCount ?? 0
}
/**
* Remove one entry from somebody's inbox.
*
* @returns Whether there was one to remove
*/
async dismiss(userId: string, siteId: string, id: string): Promise<boolean> {
const result = await WIKI.db
.delete(notificationsTable)
.where(
and(
eq(notificationsTable.id, id),
eq(notificationsTable.userId, userId),
or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId))
)
)
return (result.rowCount ?? 0) > 0
}
/**
* The categories somebody has unread entries in for one page — what the page view hands the browser
* so that opening the page, or its Talk tab, can mark them read without asking first.
*
* One indexed lookup, and none for a reader with no account. Never throws: it is a convenience on
* the page view, which is started ahead of the page's other lookups and must not take the page down
* — or go unhandled — if it fails.
*/
async unreadCategoriesOnPage(pageId: string, userId: string | null): Promise<string[]> {
if (!userId) {
return []
}
try {
const rows = await WIKI.db
.selectDistinct({ category: notificationsTable.category })
.from(notificationsTable)
.where(
and(
eq(notificationsTable.userId, userId),
eq(notificationsTable.pageId, pageId),
isNull(notificationsTable.readAt)
)
)
return rows.map((row) => row.category)
} catch (err: any) {
WIKI.logger.warn(`Failed to read unread notifications for page ${pageId}: ${err.message}`)
return []
}
}
// == SETTINGS =======================
/** The instance settings as the admin area edits them. The unsubscribe secret is never among them. */
getConfig(): Record<(typeof NOTIFICATION_SETTINGS_FIELDS)[number], any> {
const stored = WIKI.config.notifications ?? {}
return {
retentionDays: stored.retentionDays,
emailDelay: stored.emailDelay,
mailBatchSize: stored.mailBatchSize
}
}
/**
* Check a patch against the settings it will be merged with.
*
* @returns The reason it is invalid, or null when it is fine
*/
validate(patch: Record<string, any>): string | null {
const merged = { ...this.getConfig(), ...patch }
if (
!Number.isInteger(merged.retentionDays) ||
merged.retentionDays < 1 ||
merged.retentionDays > 3650
) {
return 'Retention must be a whole number of days between 1 and 3650.'
}
const delay = durationToSeconds(merged.emailDelay, 0)
if (!/^\d+[smh]$/.test(String(merged.emailDelay ?? '')) || delay < 1 || delay > 3600) {
return 'The email delay must be a duration such as 30s, 3m or 1h, of at most an hour.'
}
if (
!Number.isInteger(merged.mailBatchSize) ||
merged.mailBatchSize < 1 ||
merged.mailBatchSize > 1000
) {
return 'The mail batch size must be a whole number between 1 and 1000.'
}
return null
}
/**
* Save a validated patch. The secret is carried over untouched, since the blob is written whole.
*
* @returns Whether the settings were saved
*/
async updateConfig(patch: Record<string, any>): Promise<boolean> {
const previous = WIKI.config.notifications
const picked: Record<string, any> = {}
for (const field of NOTIFICATION_SETTINGS_FIELDS) {
if (patch[field] !== undefined) {
picked[field] = patch[field]
}
}
WIKI.config.notifications = { ...previous, ...picked }
if (!(await WIKI.configSvc.saveToDb(['notifications']))) {
WIKI.config.notifications = previous
return false
}
return true
}
/**
* How the system is doing, for the admin screen: what is waiting to be fanned out, how email has
* gone over the last day, and anything about the configuration that stops email being sent.
*/
async status(): Promise<{
pendingEvents: number
oldestPendingEventAt: string | null
emailsPending: number
emailsSent24h: number
emailsFailed24h: number
isMailConfigured: boolean
warnings: string[]
}> {
const dayAgo = sql`now() - interval '24 hours'`
const [[backlog], [pending], [sent], [failed]] = await Promise.all([
WIKI.db
.select({
total: count(),
oldest: sql<string | null>`min(${eventsTable.createdAt})`
})
.from(eventsTable)
.where(isNull(eventsTable.processedAt)),
WIKI.db
.select({ total: count() })
.from(notificationsTable)
.where(inArray(notificationsTable.emailState, ['pending', 'sending'])),
WIKI.db
.select({ total: count() })
.from(notificationsTable)
.where(
and(eq(notificationsTable.emailState, 'sent'), gte(notificationsTable.emailedAt, dayAgo))
),
WIKI.db
.select({ total: count() })
.from(notificationsTable)
.where(
and(
eq(notificationsTable.emailState, 'failed'),
gte(notificationsTable.updatedAt, dayAgo)
)
)
])
const warnings: string[] = []
const baseUrl = (WIKI.config.mail?.defaultBaseURL ?? '').trim()
if (baseUrl.toLowerCase().startsWith('http://')) {
warnings.push('plainHttp')
}
if (!baseUrl && Object.values(WIKI.sites).some((site: any) => site.hostname === '*')) {
warnings.push('wildcardHostname')
}
return {
pendingEvents: Number(backlog?.total ?? 0),
oldestPendingEventAt: backlog?.oldest ? new Date(backlog.oldest).toISOString() : null,
emailsPending: Number(pending?.total ?? 0),
emailsSent24h: Number(sent?.total ?? 0),
emailsFailed24h: Number(failed?.total ?? 0),
isMailConfigured: WIKI.models.mail.isConfigured,
warnings
}
}
// == HOUSEKEEPING ===================
/**
* Delete entries past retention, read or not, and processed events past a day — in batches, checking
* between them whether the task has been asked to stop.
*/
async purge(signal?: AbortSignal): Promise<{ notifications: number; events: number }> {
const days = Number(WIKI.config.notifications?.retentionDays) || 60
const purgeBatched = async (table: any, condition: any): Promise<number> => {
let total = 0
while (!signal?.aborted) {
const result = await WIKI.db.execute(sql`
DELETE FROM ${table} WHERE id IN (
SELECT id FROM ${table} WHERE ${condition} LIMIT ${PURGE_BATCH_SIZE}
)
`)
const deleted = result.rowCount ?? 0
total += deleted
if (deleted < PURGE_BATCH_SIZE) {
break
}
}
return total
}
const notifications = await purgeBatched(
notificationsTable,
lt(notificationsTable.createdAt, sql`now() - make_interval(days => ${days})`)
)
const events = await purgeBatched(
eventsTable,
and(
isNotNull(eventsTable.processedAt),
lt(
eventsTable.processedAt,
sql`now() - make_interval(hours => ${PROCESSED_EVENT_RETENTION_HOURS})`
)
)
)
return { notifications, events }
}
}
export const notifications = new Notifications()

@ -1913,6 +1913,11 @@ class Pages {
authorId: actor.id, authorId: actor.id,
metadata: { title: page.title, description: page.description, editor } metadata: { title: page.title, description: page.description, editor }
}) })
await WIKI.models.notifications.emit('page:create', {
siteId,
actorId: actor.id,
data: { variant: 'created', page: WIKI.models.notifications.pageSnapshot(page) }
})
/* /*
Everything a document served to a client that will not run the app says about a page comes out Everything a document served to a client that will not run the app says about a page comes out
@ -2120,11 +2125,52 @@ class Pages {
authorId: actor.id, authorId: actor.id,
metadata: { title: updated.title, description: updated.description } metadata: { title: updated.title, description: updated.description }
}) })
// -> Only a save that changed something: re-publishing a page as it stood is not news
const variants = this.notificationVariants(changedFields, values)
if (variants.length > 0) {
await WIKI.models.notifications.emit('page:edit', {
siteId,
actorId: actor.id,
data: {
variant: variants[0]!,
variants,
page: WIKI.models.notifications.pageSnapshot(updated)
}
})
}
invalidateAppShellCache() invalidateAppShellCache()
return { page: updated, versionId } return { page: updated, versionId }
} }
/**
* What a save did, as a page's watchers are told it — the publishing change first, since being
* published or taken down says more about a page than an edit to it does, and an edit after it when
* anything else changed too.
*
* @param changedFields What `pageHistory.changedFields` made of the save
*/
notificationVariants(changedFields: string[], values: Record<string, any>): string[] {
const variants: string[] = []
const publishing = new Set(['publishState', 'publishStartDate', 'publishEndDate'])
if (changedFields.includes('publishState')) {
// -> Back to a draft from either other state is the page being taken down
variants.push(
values.publishState === 'published'
? 'published'
: values.publishState === 'scheduled'
? 'scheduled'
: 'unpublished'
)
} else if (changedFields.some((field) => publishing.has(field))) {
variants.push('scheduled')
}
if (changedFields.some((field) => !publishing.has(field))) {
variants.push('edited')
}
return variants
}
/** /**
* Open a page with a different editor from now on. * Open a page with a different editor from now on.
* *
@ -2351,6 +2397,16 @@ class Pages {
siteId, siteId,
authorId: actor.id authorId: actor.id
}) })
await WIKI.models.notifications.emit('page:rename', {
siteId,
actorId: actor.id,
data: {
variant: 'moved',
page: WIKI.models.notifications.pageSnapshot(moved),
previousPath: page.path,
previousLocale: page.locale
}
})
invalidateAppShellCache() invalidateAppShellCache()
return { page: moved, versionId } return { page: moved, versionId }
} }
@ -2599,6 +2655,18 @@ class Pages {
authorId: actor.id authorId: actor.id
}) })
/*
Before the row goes, because its watchers go with it: the event captures them as it is written.
The fan-out checks the page really has gone before it tells anybody, so a delete that fails from
here on announces nothing.
*/
await WIKI.models.notifications.emit('page:delete', {
siteId,
actorId: actor.id,
data: { variant: 'deleted', page: WIKI.models.notifications.pageSnapshot(page) },
watchersOf: id
})
// -> Out of its set of translations first, which dissolves the set if this leaves one page in // -> Out of its set of translations first, which dissolves the set if this leaves one page in
// it: a group of one is no group, and the survivor's locale picker would offer nothing // it: a group of one is no group, and the survivor's locale picker would offer nothing
await this.detachFromLocaleGroup(siteId, id) await this.detachFromLocaleGroup(siteId, id)
@ -2835,6 +2903,11 @@ class Pages {
authorId: actor.id, authorId: actor.id,
metadata: { title: page.title, description: page.description, editor } metadata: { title: page.title, description: page.description, editor }
}) })
await WIKI.models.notifications.emit('page:create', {
siteId,
actorId: actor.id,
data: { variant: 'restored', page: WIKI.models.notifications.pageSnapshot(page) }
})
invalidateAppShellCache() invalidateAppShellCache()
@ -2862,11 +2935,14 @@ class Pages {
.where(and(eq(pagesTable.siteId, siteId), sql`${pagesTable.tags} @> ${sql.param([tag])}`)) .where(and(eq(pagesTable.siteId, siteId), sql`${pagesTable.tags} @> ${sql.param([tag])}`))
let deleted = 0 let deleted = 0
// -> One action, however many pages: watchers hear about theirs, nobody else about every one
await WIKI.models.notifications.withOrigin('bulk', async () => {
for (const row of rows) { for (const row of rows) {
if (await this.deletePage(siteId, row.id, actor)) { if (await this.deletePage(siteId, row.id, actor)) {
deleted++ deleted++
} }
} }
})
return deleted return deleted
} }
@ -2898,7 +2974,16 @@ class Pages {
// and guessing would mean reaching for names that may belong to the assets beside them. The // and guessing would mean reaching for names that may belong to the assets beside them. The
// aliases are for the links that name a page by one // aliases are for the links that name a page by one
const doomed = await WIKI.db const doomed = await WIKI.db
.select({ id: pagesTable.id, contentType: pagesTable.contentType, alias: pagesTable.alias }) .select({
id: pagesTable.id,
contentType: pagesTable.contentType,
alias: pagesTable.alias,
title: pagesTable.title,
path: pagesTable.path,
locale: pagesTable.locale,
tags: pagesTable.tags,
publishState: pagesTable.publishState
})
.from(pagesTable) .from(pagesTable)
.where( .where(
inArray( inArray(
@ -2907,6 +2992,18 @@ class Pages {
) )
) )
const contentTypes = new Map(doomed.map((row) => [row.id, row.contentType])) const contentTypes = new Map(doomed.map((row) => [row.id, row.contentType]))
// -> Before the rows go, as `deletePage` does, and as one bulk action: a folder's watchers hear
// about their pages, and nobody who asked about every deletion hears about each of them
await WIKI.models.notifications.withOrigin('bulk', async () => {
for (const row of doomed) {
await WIKI.models.notifications.emit('page:delete', {
siteId,
actorId: actor.id,
data: { variant: 'deleted', page: WIKI.models.notifications.pageSnapshot(row) },
watchersOf: row.id
})
}
})
// -> Out of their sets of translations first, for the same reason `deletePage` does it // -> Out of their sets of translations first, for the same reason `deletePage` does it
await this.detachFromLocaleGroups( await this.detachFromLocaleGroups(
siteId, siteId,

@ -130,6 +130,16 @@ class Settings {
includeWiki: false includeWiki: false
} }
}, },
{
key: 'notifications',
value: {
retentionDays: 60,
emailDelay: '3m',
// -> No `unsubscribeSecret`: generated by the startup checks, which run after this seed
// and on every boot after it, so that an installation from before it gets one too
mailBatchSize: 100
}
},
{ {
key: 'scim', key: 'scim',
value: { value: {

@ -140,6 +140,9 @@ class Sites {
// choice, which is only useful to somebody who has already made it. // choice, which is only useful to somebody who has already made it.
comments: true, comments: true,
lastEditedBy: true, lastEditedBy: true,
// -> On: a reader who watches a page or is mentioned expects to be told. What each person
// receives, and how, is theirs to choose under Profile → Notifications
notifications: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },
@ -463,6 +466,7 @@ class Sites {
ratingsMode: 'off', ratingsMode: 'off',
comments: true, comments: true,
lastEditedBy: true, lastEditedBy: true,
notifications: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },

@ -1351,6 +1351,15 @@ class Storage {
return importSource.run(target.id, work) return importSource.run(target.id, work)
} }
/**
* Whether anything at all is being imported in the current call chain — which is how a page saved by
* an import is told apart from one somebody saved, for the notifications that should not fire for
* the former. See `notifications.originNow`.
*/
isImporting(): boolean {
return importSource.getStore() !== undefined
}
/** Whether this target is the one an import in progress is reading from. */ /** Whether this target is the one an import in progress is reading from. */
private isImportSource(target: StorageTarget): boolean { private isImportSource(target: StorageTarget): boolean {
return importSource.getStore() === target.id return importSource.getStore() === target.id

@ -2223,7 +2223,7 @@ class Users {
}) })
try { try {
await WIKI.models.mail.send({ await WIKI.models.mail.send({
siteId, site: WIKI.models.mail.siteFor(siteId),
to: address, to: address,
template: 'welcome', template: 'welcome',
locale, locale,
@ -2262,7 +2262,7 @@ class Users {
if (WIKI.models.mail.isConfigured) { if (WIKI.models.mail.isConfigured) {
try { try {
await WIKI.models.mail.send({ await WIKI.models.mail.send({
siteId, site: WIKI.models.mail.siteFor(siteId),
to: address, to: address,
template: 'welcome', template: 'welcome',
locale, locale,
@ -2310,7 +2310,7 @@ class Users {
(await WIKI.models.sites.getSiteByHostname({ hostname: req?.hostname ?? '*' }))?.id ?? (await WIKI.models.sites.getSiteByHostname({ hostname: req?.hostname ?? '*' }))?.id ??
'' ''
await WIKI.models.mail.send({ await WIKI.models.mail.send({
siteId: targetSiteId, site: WIKI.models.mail.siteFor(targetSiteId),
to: user.email, to: user.email,
template: 'welcome', template: 'welcome',
// -> Theirs, never the administrator's: the request that triggers this is somebody else's // -> Theirs, never the administrator's: the request that triggers this is somebody else's
@ -2411,7 +2411,7 @@ class Users {
meta: { strategyId: strategy.id, siteId } meta: { strategyId: strategy.id, siteId }
}) })
await WIKI.models.mail.send({ await WIKI.models.mail.send({
siteId, site: WIKI.models.mail.siteFor(siteId),
to: user.email, to: user.email,
template: 'resetPwd', template: 'resetPwd',
// -> Their own preference where they have one, and otherwise the language the wiki was being // -> Their own preference where they have one, and otherwise the language the wiki was being

@ -0,0 +1,30 @@
import { only } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* Somebody answered a comment the recipient wrote.
*
* Only the author of the comment that started the thread — replies are one level deep, so every
* answer in a thread is an answer to it. Others who replied in the thread are not told: following a
* whole discussion is what watching the page is for. A guest's comment has no account to tell.
*/
export const commentReply: NotificationCategory = {
key: 'commentReply',
section: 'discussions',
events: ['comment:new'],
scope: 'site',
origins: ['user'],
defaults: { inApp: true, email: true },
priority: 30,
appliesTo: (event) => Boolean(event.data.parentId && event.data.parentAuthorId),
recipients: (event, after) => only(event.data.parentAuthorId, after),
access: 'read:comments',
groupKey: (event) => `reply:${event.data.parentId}`,
entry: (event) => ({
variant: event.data.variant,
pageId: event.data.page!.id,
commentId: event.data.commentId ?? null,
data: snapshotOf(event)
})
}

@ -0,0 +1,31 @@
import { mentionedIn } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* Somebody wrote the recipient's `@handle` in a comment.
*
* On an edit, only a handle the comment did not already contain counts, so re-saving a comment does
* not mention everybody in it a second time. The recipient still needs `read:comments` on the page:
* a mention in a discussion somebody may not read would otherwise be a way of leaking it to them.
*/
export const mention: NotificationCategory = {
key: 'mention',
section: 'discussions',
events: ['comment:new', 'comment:edit'],
scope: 'site',
origins: ['user'],
defaults: { inApp: true, email: true },
priority: 40,
appliesTo: (event) => (event.data.mentionHandles ?? []).length > 0,
recipients: mentionedIn,
access: 'read:comments',
// -> Per comment: two mentions in two comments are two things to answer
groupKey: (event) => `mention:${event.data.commentId}`,
entry: (event) => ({
variant: event.kind === 'comment:edit' ? 'edited' : 'new',
pageId: event.data.page!.id,
commentId: event.data.commentId ?? null,
data: snapshotOf(event)
})
}

@ -0,0 +1,29 @@
import { optedInTo } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* A page was created anywhere the recipient may read. Off by default, and busy when turned on.
*
* On creation whatever the page's publish state, which the entry says — a signed-in reader with
* `read:pages` sees an unpublished page, so being told about one tells them nothing they could not
* find. Silent for an import or a bulk operation: restoring five thousand pages is not news to
* everybody who asked about new ones.
*/
export const pageCreated: NotificationCategory = {
key: 'pageCreated',
section: 'everything',
events: ['page:create'],
scope: 'site',
origins: ['user'],
defaults: { inApp: false, email: false },
priority: 5,
recipients: (_event, after) => optedInTo('pageCreated', after),
access: 'read:pages',
groupKey: (event) => `pageCreated:${event.data.page!.id}`,
entry: (event) => ({
variant: event.data.variant,
pageId: event.data.page!.id,
data: snapshotOf(event)
})
}

@ -0,0 +1,28 @@
import { optedInTo } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* A page was deleted anywhere the recipient could read it. Off by default.
*
* Access is checked against the page as it was, from the event's snapshot, since there is no page
* left to check. A watcher who also turned this on gets the `watchedPage` entry instead, which
* outranks it. Silent for an import or a bulk operation, as `pageCreated` is.
*/
export const pageDeleted: NotificationCategory = {
key: 'pageDeleted',
section: 'everything',
events: ['page:delete'],
scope: 'site',
origins: ['user'],
defaults: { inApp: false, email: false },
priority: 5,
recipients: (_event, after) => optedInTo('pageDeleted', after),
access: 'read:pages',
groupKey: (event) => `pageDeleted:${event.data.page!.id}`,
entry: (event) => ({
variant: event.data.variant,
pageId: null,
data: snapshotOf(event)
})
}

@ -0,0 +1,29 @@
import { reviewersOf } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* An edit suggestion is waiting for review, on a page whose rules name one of the recipient's groups
* as a reviewer.
*
* No page permission is checked on top: the rule naming the group IS the grant, and an entry says no
* more than the reviewer's own queue already shows them. A submitter revising a suggestion that is
* still open bumps the entry they already have, since it is the same suggestion.
*/
export const reviewRequested: NotificationCategory = {
key: 'reviewRequested',
section: 'reviews',
events: ['submission:new'],
scope: 'site',
origins: ['user'],
defaults: { inApp: true, email: true },
priority: 30,
recipients: reviewersOf,
access: null,
groupKey: (event) => `review:${event.data.submissionId}`,
entry: (event) => ({
variant: event.data.variant,
pageId: event.data.page?.id ?? null,
data: snapshotOf(event)
})
}

@ -0,0 +1,30 @@
import { watchersOf } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* A page somebody watches changed: it was edited, moved, published or unpublished, rescheduled, or
* deleted.
*
* One category for all of it rather than one per kind of change, because watching is one thing a
* reader asked for — "tell me about this page" — and the variants are how the entry says which. It
* fires for every origin: a page a git pull rewrote has changed exactly as much as one somebody saved.
*/
export const watchedPage: NotificationCategory = {
key: 'watchedPage',
section: 'watching',
events: ['page:edit', 'page:rename', 'page:delete'],
scope: 'site',
origins: ['user', 'import', 'bulk'],
defaults: { inApp: true, email: true },
priority: 20,
recipients: watchersOf,
access: 'read:pages',
// -> Per page, so a page saved forty times while nobody looked is one entry counting to forty
groupKey: (event) => `watchedPage:${event.data.page!.id}`,
entry: (event) => ({
variant: event.data.variant,
pageId: event.kind === 'page:delete' ? null : event.data.page!.id,
data: snapshotOf(event)
})
}

@ -0,0 +1,28 @@
import { watchersOf } from '../recipients.ts'
import { snapshotOf } from '../snapshot.ts'
import type { NotificationCategory } from '../types.ts'
/**
* Somebody commented on a page the recipient watches.
*
* Per page rather than per comment, so a lively discussion is one entry counting up rather than a
* stack of them — the Talk tab is where the comments are read, and opening it marks the entry read.
*/
export const watchedPageComment: NotificationCategory = {
key: 'watchedPageComment',
section: 'discussions',
events: ['comment:new'],
scope: 'site',
origins: ['user'],
defaults: { inApp: true, email: true },
priority: 10,
recipients: watchersOf,
access: 'read:comments',
groupKey: (event) => `watchedComment:${event.data.page!.id}`,
entry: (event) => ({
variant: event.data.variant,
pageId: event.data.page!.id,
commentId: event.data.commentId ?? null,
data: snapshotOf(event)
})
}

@ -0,0 +1,512 @@
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import {
groups as groupsTable,
notificationEvents as eventsTable,
notifications as notificationsTable,
pages as pagesTable,
userGroups as userGroupsTable,
userNotificationPrefs as prefsTable,
users as usersTable
} from '../db/schema.ts'
import { durationToSeconds } from '../helpers/common.ts'
import { rulesAllow } from '../helpers/pageRules.ts'
import { mail } from '../models/mail.ts'
import type { GroupRule } from '../models/groups.ts'
import { categoriesFor } from './index.ts'
import { enqueueOnce } from './queue.ts'
import { rulePageOf } from './types.ts'
import type { NotificationCategory, NotificationEvent } from './types.ts'
/**
* The fan-out: turning what happened into who is told.
*
* Runs in a worker thread (`tasks/workers/dispatch-notifications.ts`), so it reads everything it
* needs from the database rather than from `WIKI.models` — the group rules included, which the
* request process keeps in memory and a worker does not have.
*
* For each event, each category that cares about it, in priority order; for each category, its
* candidates a batch at a time; for each batch, the same five steps whatever the category: leave the
* actor out, drop accounts that cannot receive anything, check access, apply preferences, and write
* the entries. Doing those here rather than in the categories is what stops one of them from getting
* one wrong.
*/
/** How many events one claim takes. */
const EVENT_BATCH_SIZE = 50
/** How many entries go into one INSERT. */
const INSERT_CHUNK_SIZE = 500
/** The email delay when nothing is configured, in seconds. */
const DEFAULT_EMAIL_DELAY = 180
/** How far an interrupted fan-out got: which category, and the last candidate written for it. */
interface FanOutCursor {
category: string
after: string | null
}
/** An event as a run holds it: the outbox row, with whatever an earlier run left of its progress. */
type ClaimedEvent = NotificationEvent & { cursor: FanOutCursor | null }
/** A group as an access check needs it. */
interface GroupAccess {
rules: GroupRule[]
permissions: string[]
}
/** A candidate who survived the first cut, with what the rest of the steps need to know. */
interface Recipient {
id: string
isVerified: boolean
groupIds: string[]
}
/**
* Everything one run reads once and every batch uses: the groups' rules and permissions, and the
* memo of access decisions made against them.
*/
class RunContext {
groups = new Map<string, GroupAccess>()
/**
* Access decisions, keyed by permission, page and the set of groups asking.
*
* The whole reason a large audience is cheap: a page permission depends on nothing about a user but
* the groups they are in, and ten thousand accounts on a wiki are usually a handful of combinations
* of groups. Each combination is evaluated once per event, however many people share it.
*/
decisions = new Map<string, boolean>()
emailDelaySeconds = DEFAULT_EMAIL_DELAY
mailConfigured = false
async load(): Promise<void> {
const rows = await WIKI.db
.select({
id: groupsTable.id,
rules: groupsTable.rules,
permissions: groupsTable.permissions
})
.from(groupsTable)
for (const row of rows) {
this.groups.set(row.id, {
rules: (row.rules ?? []) as GroupRule[],
permissions: (row.permissions ?? []) as string[]
})
}
this.emailDelaySeconds = durationToSeconds(
WIKI.config.notifications?.emailDelay,
DEFAULT_EMAIL_DELAY
)
this.mailConfigured = mail.isConfigured
}
/**
* Whether a set of groups may do this to the page the event is about — `groups.checkAccess`, asked
* of the rows this run read rather than of the request process's cache.
*/
mayAccess(event: NotificationEvent, permission: string, groupIds: string[]): boolean {
const page = rulePageOf(event)
if (!page) {
return false
}
const key = `${event.id}|${permission}|${[...groupIds].sort().join(',')}`
const known = this.decisions.get(key)
if (known !== undefined) {
return known
}
const pooled = groupIds.map((id) => this.groups.get(id)).filter(Boolean) as GroupAccess[]
const allowed =
pooled.some((group) => group.permissions.includes('manage:system')) ||
rulesAllow(
pooled.flatMap((group) => group.rules),
permission,
page
)
this.decisions.set(key, allowed)
return allowed
}
}
/**
* Claim the next batch of events.
*
* `SKIP LOCKED` so that several instances can drain the outbox at once without two of them taking
* the same event, and a claim older than the task timeout counts as abandoned: whatever held it has
* stopped without saying so, and the cursor it left is where the next claim carries on.
*/
async function claimEvents(): Promise<ClaimedEvent[]> {
const staleSeconds = (WIKI.config.scheduler?.taskTimeout ?? 300) + 60
const result = await WIKI.db.execute(sql`
UPDATE ${eventsTable}
SET "claimedAt" = now(), "claimedBy" = ${WIKI.INSTANCE_ID}
WHERE id IN (
SELECT id FROM ${eventsTable}
WHERE "processedAt" IS NULL
AND ("claimedAt" IS NULL OR "claimedAt" < now() - make_interval(secs => ${staleSeconds}))
ORDER BY "createdAt"
LIMIT ${EVENT_BATCH_SIZE}
FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, origin, "siteId", "actorId", data, recipients, cursor, "createdAt"
`)
return (result.rows as any[])
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((row) => ({
id: row.id,
kind: row.kind,
origin: row.origin,
siteId: row.siteId,
actorId: row.actorId,
data: row.data ?? {},
recipients: row.recipients ?? null,
cursor: row.cursor ?? null
}))
}
/**
* The candidates in a batch who can receive anything at all: active, not a system account, and not
* whoever caused the event — nobody is told about their own action.
*/
async function recipientsAmong(event: NotificationEvent, ids: string[]): Promise<Recipient[]> {
const candidates = ids.filter((id) => id !== event.actorId)
if (candidates.length < 1) {
return []
}
const rows = await WIKI.db
.select({
id: usersTable.id,
isVerified: usersTable.isVerified,
// -> As text: the driver parses a text[] into an array, but leaves a uuid[] as its literal
groupIds: sql<
string[]
>`coalesce(array_agg(${userGroupsTable.groupId}::text) filter (where ${userGroupsTable.groupId} is not null), '{}')`
})
.from(usersTable)
.leftJoin(userGroupsTable, eq(userGroupsTable.userId, usersTable.id))
.where(
and(
inArray(usersTable.id, candidates),
eq(usersTable.isActive, true),
eq(usersTable.isSystem, false)
)
)
.groupBy(usersTable.id)
return rows
}
/**
* Each recipient's channels for this category: their stored choice where they made one, the
* category's default where they did not.
*/
async function channelsFor(
category: NotificationCategory,
userIds: string[]
): Promise<Map<string, { inApp: boolean; email: boolean }>> {
const stored = await WIKI.db
.select({
userId: prefsTable.userId,
channel: prefsTable.channel,
enabled: prefsTable.enabled
})
.from(prefsTable)
.where(and(eq(prefsTable.category, category.key), inArray(prefsTable.userId, userIds)))
const channels = new Map(userIds.map((id) => [id, { ...category.defaults }]))
for (const row of stored) {
const entry = channels.get(row.userId)
if (entry && (row.channel === 'inApp' || row.channel === 'email')) {
entry[row.channel] = row.enabled
}
}
return channels
}
/**
* Who in this batch already has an entry for this very event.
*
* Covers both ways that happens. A higher-priority category of the same event got to them first — a
* comment that mentions somebody watching the page is their mention and not also a watched-page
* comment, which is why categories are processed highest first. And a fan-out that died after
* writing part of a batch is being replayed. Reading it back rather than holding a set in memory is
* what makes both survive a fan-out that is carried on by a different run.
*/
async function alreadyNotified(event: NotificationEvent, userIds: string[]): Promise<Set<string>> {
const rows = await WIKI.db
.select({ userId: notificationsTable.userId })
.from(notificationsTable)
.where(
and(inArray(notificationsTable.userId, userIds), eq(notificationsTable.lastEventId, event.id))
)
return new Set(rows.map((row) => row.userId))
}
/**
* When a new entry's email may go out — the one place that is decided.
*
* Today it is the instance's email delay from now, the window in which whatever else happens to the
* same thing joins the same mail. It is also where a per-user digest schedule (hourly, daily) would
* attach: such a schedule is a later answer here, and nothing in the mail drain has to change for it,
* since the drain sends whatever `emailAfter` says is due and never asks why.
*/
function emailAfterFor(ctx: RunContext): Date {
return new Date(Date.now() + ctx.emailDelaySeconds * 1000)
}
/**
* One batch of candidates for one category, written.
*
* @returns Whether anything was written with an email to send
*/
async function deliverBatch(
ctx: RunContext,
event: NotificationEvent,
category: NotificationCategory,
ids: string[]
): Promise<boolean> {
let recipients = await recipientsAmong(event, ids)
if (category.access) {
recipients = recipients.filter((r) => ctx.mayAccess(event, category.access!, r.groupIds))
}
if (recipients.length < 1) {
return false
}
const channels = await channelsFor(
category,
recipients.map((r) => r.id)
)
recipients = recipients.filter((r) => {
const chosen = channels.get(r.id)!
return chosen.inApp || chosen.email
})
if (recipients.length < 1) {
return false
}
const done = await alreadyNotified(
event,
recipients.map((r) => r.id)
)
recipients = recipients.filter((r) => !done.has(r.id))
if (recipients.length < 1) {
return false
}
const entry = category.entry(event)
const groupKey = category.groupKey(event)
const siteId = category.scope === 'site' ? event.siteId : null
const emailAfter = emailAfterFor(ctx)
let anyEmail = false
const values = recipients.map((r) => {
const chosen = channels.get(r.id)!
// -> Unverified: the address has not been confirmed, so nothing is sent to it yet
const email = chosen.email && ctx.mailConfigured && r.isVerified
anyEmail ||= email
return {
userId: r.id,
siteId,
category: category.key,
variant: entry.variant,
groupKey,
pageId: entry.pageId ?? null,
commentId: entry.commentId ?? null,
actorId: event.actorId,
data: entry.data,
lastEventId: event.id,
inApp: chosen.inApp,
emailState: email ? 'pending' : 'none',
emailAfter: email ? emailAfter : null
}
})
for (let i = 0; i < values.length; i += INSERT_CHUNK_SIZE) {
await WIKI.db
.insert(notificationsTable)
.values(values.slice(i, i + INSERT_CHUNK_SIZE))
.onConflictDoUpdate({
target: [notificationsTable.userId, notificationsTable.groupKey],
targetWhere: sql`"readAt" IS NULL`,
/*
An entry nobody has read yet absorbs the event instead of being joined by a second one.
Email follows the cadence in the spec (§10.2): one already due stays due and takes this event
with it; one already SENT stays sent, so a page edited all afternoon is one email until it is
looked at. An entry with no email waiting — the channel was off, or the last attempt was
given up on — gets one if this event asks for it.
*/
set: {
count: sql`${notificationsTable.count} + 1`,
variant: sql`excluded.variant`,
pageId: sql`excluded."pageId"`,
commentId: sql`excluded."commentId"`,
actorId: sql`excluded."actorId"`,
data: sql`excluded.data || jsonb_build_object('variants', (
SELECT coalesce(jsonb_agg(DISTINCT v), '[]'::jsonb)
FROM jsonb_array_elements(
coalesce(${notificationsTable.data} -> 'variants', '[]'::jsonb) ||
coalesce(excluded.data -> 'variants', '[]'::jsonb)
) AS v
))`,
lastEventId: sql`excluded."lastEventId"`,
inApp: sql`excluded."inApp"`,
emailState: sql`CASE
WHEN ${notificationsTable.emailState} IN ('none', 'failed', 'skipped') AND excluded."emailState" = 'pending'
THEN 'pending' ELSE ${notificationsTable.emailState} END`,
emailAfter: sql`CASE
WHEN ${notificationsTable.emailState} IN ('none', 'failed', 'skipped') AND excluded."emailState" = 'pending'
THEN excluded."emailAfter" ELSE ${notificationsTable.emailAfter} END`,
updatedAt: sql`now()`
},
// -> The same event a second time is a replay, and changes nothing
setWhere: sql`${notificationsTable.lastEventId} IS DISTINCT FROM excluded."lastEventId"`
})
}
return anyEmail
}
/**
* Whether an event still describes something that happened.
*
* A deletion is written before the page goes, because the watchers go with it. If the page is still
* there, the delete failed after the event was written, and nobody is told about a deletion that did
* not happen. (A page deleted and restored before this ran is indistinguishable, and is not told
* about either — the page is there.)
*/
async function stillHappened(event: NotificationEvent): Promise<boolean> {
if (event.kind !== 'page:delete' || !event.data.page?.id) {
return true
}
const rows = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, event.data.page.id))
.limit(1)
return rows.length < 1
}
/**
* The actor's name as it stands now, which is close enough to "at the time" — this runs seconds after
* the event. Looked up here rather than when the event is written so that writing one stays a single
* INSERT on the request path.
*/
async function withActorName(event: NotificationEvent): Promise<NotificationEvent> {
if (event.data.actorName !== undefined || !event.actorId) {
return event
}
const rows = await WIKI.db
.select({ name: usersTable.name })
.from(usersTable)
.where(eq(usersTable.id, event.actorId))
.limit(1)
return { ...event, data: { ...event.data, actorName: rows[0]?.name ?? null } }
}
/**
* Fan one event out, from wherever an earlier run left it.
*
* @returns The cursor to carry on from, or null when the event is done
*/
async function processEvent(
ctx: RunContext,
claimed: ClaimedEvent,
signal: AbortSignal,
onEmail: () => void
): Promise<FanOutCursor | null> {
if (!(await stillHappened(claimed))) {
return null
}
const event = await withActorName(claimed)
const categories = categoriesFor(event.kind, event.origin).filter(
(category) => !category.appliesTo || category.appliesTo(event)
)
let start = 0
if (claimed.cursor) {
const index = categories.findIndex((category) => category.key === claimed.cursor!.category)
start = index < 0 ? categories.length : index
}
for (let i = start; i < categories.length; i++) {
const category = categories[i]!
const after = i === start ? (claimed.cursor?.after ?? null) : null
for await (const ids of category.recipients(event, after)) {
if (await deliverBatch(ctx, event, category, ids)) {
onEmail()
}
if (signal.aborted) {
return { category: category.key, after: ids[ids.length - 1]! }
}
}
}
return null
}
/**
* Drain the outbox, as far as the run is allowed to go.
*
* Stops between batches when `signal` is aborted, leaving each event it had not finished with the
* cursor it reached and its claim released, so that the next run carries on rather than starting
* the event again. Asks for that next run itself, and for a mail run once there is something to send.
*/
export async function dispatchPending(signal: AbortSignal): Promise<void> {
const ctx = new RunContext()
await ctx.load()
let anyEmail = false
let processed = 0
while (!signal.aborted) {
const events = await claimEvents()
if (events.length < 1) {
break
}
for (const event of events) {
if (signal.aborted) {
// -> Not started: let it go for the next run to claim
await WIKI.db
.update(eventsTable)
.set({ claimedAt: null, claimedBy: null })
.where(eq(eventsTable.id, event.id))
continue
}
let cursor: FanOutCursor | null
try {
cursor = await processEvent(ctx, event, signal, () => {
anyEmail = true
})
} catch (err: any) {
// -> Released rather than marked done, so the next run tries it again from its cursor
WIKI.logger.warn(`Failed to dispatch notification event ${event.id}: ${err.message}`)
await WIKI.db
.update(eventsTable)
.set({ claimedAt: null, claimedBy: null })
.where(eq(eventsTable.id, event.id))
continue
}
await WIKI.db
.update(eventsTable)
.set(
cursor
? { cursor, claimedAt: null, claimedBy: null }
: { cursor: null, processedAt: sql`now()` }
)
.where(eq(eventsTable.id, event.id))
if (!cursor) {
processed++
}
}
}
if (processed > 0) {
WIKI.logger.debug(`Dispatched ${processed} notification event(s).`)
}
if (anyEmail) {
await enqueueOnce('sendNotificationMail', emailAfterFor(ctx))
}
// -> Something is still waiting and nobody holds it, which is either more than one run could take
// or what this one let go when it was told to stop. A claim somebody abandoned is picked up by
// the scheduled run once it has gone stale
const remaining = await WIKI.db
.select({ id: eventsTable.id })
.from(eventsTable)
.where(and(isNull(eventsTable.processedAt), isNull(eventsTable.claimedAt)))
.limit(1)
if (remaining.length > 0) {
await enqueueOnce('dispatchNotifications')
}
}

@ -0,0 +1,66 @@
import { commentReply } from './categories/commentReply.ts'
import { mention } from './categories/mention.ts'
import { pageCreated } from './categories/pageCreated.ts'
import { pageDeleted } from './categories/pageDeleted.ts'
import { reviewRequested } from './categories/reviewRequested.ts'
import { watchedPage } from './categories/watchedPage.ts'
import { watchedPageComment } from './categories/watchedPageComment.ts'
import type {
EventOrigin,
NotificationCategory,
NotificationEventKind,
NotificationSection
} from './types.ts'
/**
* The notification categories, in full.
*
* Closed, the way `AUDIT_ACTIONS` is: `NotificationCategoryKey` is its union, so `npm run typecheck`
* refuses a key that is not here. Each key is also its translation prefix — the Profile screen reads
* `notifications.categories.<key>.title` / `.description`, an entry `notifications.messages.<key>.*`
* and a mail `mail.notification.<key>.*` — so a category added here is a set of strings added to
* `locales/en.json` as well.
*
* Adding one is a file under `categories/`, a line here, an `emit()` for any event that does not
* exist yet, and its strings. Nothing else reads a category by name.
*/
export const NOTIFICATION_CATEGORIES = {
watchedPage,
watchedPageComment,
commentReply,
mention,
reviewRequested,
pageCreated,
pageDeleted
} as const satisfies Record<string, NotificationCategory>
export type NotificationCategoryKey = keyof typeof NOTIFICATION_CATEGORIES
export const NOTIFICATION_CATEGORY_KEYS = Object.keys(
NOTIFICATION_CATEGORIES
) as NotificationCategoryKey[]
/** The order the Profile screen lists its headings in. */
export const NOTIFICATION_SECTIONS: NotificationSection[] = [
'watching',
'discussions',
'reviews',
'everything'
]
export function isCategoryKey(key: string): key is NotificationCategoryKey {
return Object.hasOwn(NOTIFICATION_CATEGORIES, key)
}
/**
* The categories an event concerns, highest priority first — which is the order the fan-out has to
* write them in for the higher one to win a person both would reach.
*/
export function categoriesFor(
kind: NotificationEventKind,
origin: EventOrigin
): NotificationCategory[] {
return Object.values(NOTIFICATION_CATEGORIES)
.filter((category) => category.events.includes(kind) && category.origins.includes(origin))
.sort((a, b) => b.priority - a.priority)
}

@ -0,0 +1,390 @@
import NodeCache from 'node-cache'
import { and, asc, eq, inArray, lte, min, sql } from 'drizzle-orm'
import {
notifications as notificationsTable,
sites as sitesTable,
userNotificationPrefs as prefsTable,
users as usersTable
} from '../db/schema.ts'
import { locales } from '../models/locales.ts'
import { mail } from '../models/mail.ts'
import type { MailNotificationEntry, MailSite } from '../models/mail.ts'
import { NOTIFICATION_CATEGORIES, isCategoryKey } from './index.ts'
import { enqueueOnce } from './queue.ts'
import { createUnsubscribeToken } from './unsubscribe.ts'
/**
* The mail drain: notifications whose email is due, sent.
*
* Runs in a worker thread (`tasks/workers/send-notification-mail.ts`). What is due is decided by one
* column, `emailAfter`, and nothing else — the cadence (a short window, then quiet until the entry is
* read) is entirely in how the fan-out sets it, which is what lets an hourly or daily digest be added
* later as a different `emailAfter` without anything here changing.
*
* **One mail per person per site.** A mail names the wiki it comes from and its links use that site's
* hostname, so a person active on two sites gets two, each of which reads as coming from its own
* wiki. Everything due for that pair goes into the one mail — a single entry told in full, or a list.
*/
/** Entries listed in one digest; above this it says how many more and links to the inbox. */
const DIGEST_MAX_ENTRIES = 50
/** How many times an email is tried before it is given up on. */
const MAX_ATTEMPTS = 3
/** How long a failed send waits before it is tried again, in seconds. */
const RETRY_DELAY = 600
/**
* How long a claim on an email lasts, in seconds — longer than any relay takes to accept one, so that
* only a run that died mid-send ever lets it lapse.
*/
const SEND_LEASE = 600
/** How many recipients one run sends to when nothing is configured. */
const DEFAULT_BATCH_SIZE = 100
interface SiteInfo {
hostname: string
isEnabled: boolean
mailSite: MailSite
}
/** A due entry, with what drawing it in a mail needs. */
interface DueRow {
id: string
category: string
variant: string
count: number
pageId: string | null
commentId: string | null
data: Record<string, any>
inApp: boolean
}
/**
* The sites a run may send for: what the mail calls each, where its links point, and whether its
* notifications are switched on. Read once per run — a worker has no `WIKI.sites`.
*/
async function loadSites(): Promise<Map<string, SiteInfo>> {
const rows = await WIKI.db
.select({ id: sitesTable.id, hostname: sitesTable.hostname, config: sitesTable.config })
.from(sitesTable)
return new Map(
rows.map((row) => {
const config = (row.config ?? {}) as Record<string, any>
return [
row.id,
{
hostname: row.hostname,
isEnabled: config.features?.notifications !== false,
mailSite: {
name: config.title || 'Wiki.js',
primaryLocale: config.locales?.primary || null
}
}
]
})
)
}
/** Where an entry leads, from the site the mail is about. */
function urlOf(baseUrl: string, row: DueRow): string {
if (row.category === 'reviewRequested' && row.data.submissionId) {
return `${baseUrl}/_inbox/review/${row.data.submissionId}`
}
if (!row.pageId) {
// -> The page has gone: the inbox still says what it was
return `${baseUrl}/_inbox`
}
const section = NOTIFICATION_CATEGORIES[row.category as keyof typeof NOTIFICATION_CATEGORIES]
return `${baseUrl}/i/${row.pageId}${section?.section === 'discussions' ? '#talk' : ''}`
}
function entryOf(baseUrl: string, row: DueRow): MailNotificationEntry {
return {
category: row.category,
variant: row.variant,
count: row.count,
actorName: row.data.actorName ?? null,
pageTitle: row.data.page?.title ?? '',
// -> Only while the comment exists, the same rule the inbox applies
...(row.commentId && row.data.excerpt && { excerpt: row.data.excerpt }),
...(row.data.origin && { origin: row.data.origin }),
url: urlOf(baseUrl, row)
}
}
/**
* The (user, site) pairs that have something due, oldest first.
*
* Not locked: the rows themselves are claimed, one pair at a time, in `claim`. Two instances choosing
* the same pair is therefore harmless — whichever comes second finds the rows taken and moves on.
*/
async function duePairs(limit: number): Promise<{ userId: string; siteId: string | null }[]> {
return WIKI.db
.select({ userId: notificationsTable.userId, siteId: notificationsTable.siteId })
.from(notificationsTable)
.where(
and(
inArray(notificationsTable.emailState, ['pending', 'sending']),
lte(notificationsTable.emailAfter, sql`now()`)
)
)
.groupBy(notificationsTable.userId, notificationsTable.siteId)
.orderBy(asc(min(notificationsTable.emailAfter)))
.limit(limit)
}
/**
* Take what is due for one person on one site, so that nobody else sends it.
*
* One statement, and no transaction held open across the send: a worker thread has a single database
* connection (`core/db.ts`), so a transaction kept open while the mail is rendered and sent would
* leave nothing for the queries that rendering makes — the locale strings, the person's preferences —
* and the run would wait on itself until the pool aborted it.
*
* Claimed rows are marked `sending`, and their `emailAfter` becomes the lease: a run that dies mid-send
* leaves them `sending` with a lease that runs out, and the next run takes them again, which is what
* `duePairs` and this both look for. At least once rather than at most once, then: a duplicate
* notification is the better failure.
*/
async function claim(pair: { userId: string; siteId: string | null }): Promise<DueRow[]> {
const result = await WIKI.db.execute(sql`
UPDATE ${notificationsTable}
SET "emailState" = 'sending', "emailAfter" = now() + make_interval(secs => ${SEND_LEASE})
WHERE id IN (
SELECT id FROM ${notificationsTable}
WHERE "userId" = ${pair.userId}
AND "siteId" IS NOT DISTINCT FROM ${pair.siteId}
AND "emailState" IN ('pending', 'sending')
AND "emailAfter" <= now()
FOR UPDATE SKIP LOCKED
)
RETURNING id, category, variant, count, "pageId", "commentId", data, "inApp", "updatedAt"
`)
return (result.rows as any[])
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime())
.map((row) => ({
id: row.id,
category: row.category,
variant: row.variant,
count: row.count,
pageId: row.pageId,
commentId: row.commentId,
data: row.data ?? {},
inApp: row.inApp
}))
}
/**
* The categories, among these, that the user has since turned email off for.
*
* Asked again at send time rather than trusted from when the entry was written: somebody who
* unsubscribes while a mail is waiting should not receive it.
*/
async function emailOffFor(userId: string, categories: string[]): Promise<Set<string>> {
const stored = await WIKI.db
.select({ category: prefsTable.category, enabled: prefsTable.enabled })
.from(prefsTable)
.where(
and(
eq(prefsTable.userId, userId),
eq(prefsTable.channel, 'email'),
inArray(prefsTable.category, categories)
)
)
const off = new Set<string>()
for (const category of categories) {
const row = stored.find((entry) => entry.category === category)
const enabled = row
? row.enabled
: isCategoryKey(category) && NOTIFICATION_CATEGORIES[category].defaults.email
if (!enabled) {
off.add(category)
}
}
return off
}
/** Give claimed rows up as not to be emailed, saying why in the log. */
async function skip(userId: string, ids: string[], reason: string): Promise<void> {
if (ids.length < 1) {
return
}
WIKI.logger.debug(`Skipped ${ids.length} notification email(s) for ${userId}: ${reason}`)
await WIKI.db
.update(notificationsTable)
.set({ emailState: 'skipped' })
.where(inArray(notificationsTable.id, ids))
}
/**
* Send whatever is due for one person on one site, as one mail.
*
* @returns Whether a mail was sent
*/
async function sendTo(
pair: { userId: string; siteId: string | null },
sites: Map<string, SiteInfo>
): Promise<boolean> {
const rows = await claim(pair)
if (rows.length < 1) {
return false
}
const allIds = rows.map((row) => row.id)
const [user] = await WIKI.db
.select({
email: usersTable.email,
isActive: usersTable.isActive,
isVerified: usersTable.isVerified,
prefs: usersTable.prefs
})
.from(usersTable)
.where(eq(usersTable.id, pair.userId))
/*
An entry with no site belongs to no wiki in particular. None of the launch categories writes one;
when one does, it is sent as coming from the first site, which is as good a guess as any about
where this person reads.
*/
const site = pair.siteId ? sites.get(pair.siteId) : sites.values().next().value
if (!user?.isActive || !user.isVerified) {
await skip(pair.userId, allIds, 'the account cannot receive mail')
return false
}
if (!site?.isEnabled) {
await skip(pair.userId, allIds, 'notifications are switched off on the site')
return false
}
const baseUrl = mail.baseUrl({ hostname: site.hostname })
if (!baseUrl) {
WIKI.logger.warn(
'Notification emails cannot be sent for a site with no hostname unless a base URL is set under Admin → Mail.'
)
await skip(pair.userId, allIds, 'there is no base URL to build links with')
return false
}
const off = await emailOffFor(pair.userId, [...new Set(rows.map((row) => row.category))])
await skip(
pair.userId,
rows.filter((row) => off.has(row.category)).map((row) => row.id),
'email was turned off since'
)
const sending = rows.filter((row) => !off.has(row.category))
if (sending.length < 1) {
return false
}
const ids = sending.map((row) => row.id)
const listed = sending.slice(0, DIGEST_MAX_ENTRIES)
const categories = [...new Set(sending.map((row) => row.category))]
const token = createUnsubscribeToken({ userId: pair.userId, categories })
try {
await mail.send({
site: site.mailSite,
to: user.email,
template: sending.length > 1 ? 'notificationDigest' : 'notification',
locale: (user.prefs as Record<string, any>)?.locale,
data: {
baseUrl,
entries: listed.map((row) => entryOf(baseUrl, row)),
more: sending.length - listed.length,
manageUrl: `${baseUrl}/_profile/notifications`,
unsubscribeUrl: `${baseUrl}/_unsubscribe?t=${token}`
},
/*
RFC 8058. The POST is what a mail client sends when somebody presses its own unsubscribe
button, and it acts at once; a GET of the same URL only redirects to the page that asks,
because mail scanners fetch every link in a message.
*/
headers: {
'List-Unsubscribe': `<${baseUrl}/_api/notifications/unsubscribe?t=${token}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
// -> So that an out-of-office reply is not sent back to the wiki
'Auto-Submitted': 'auto-generated'
}
})
} catch (err: any) {
WIKI.logger.warn(`Failed to send a notification email to <${user.email}>: ${err.message}`)
/*
Tried again later, up to a limit, rather than given up on at once: a relay that is down for ten
minutes should not cost everybody the notifications that came due in those ten minutes.
*/
await WIKI.db
.update(notificationsTable)
.set({
data: sql`jsonb_set(${notificationsTable.data}, '{mailAttempts}', to_jsonb(coalesce((${notificationsTable.data} ->> 'mailAttempts')::int, 0) + 1))`,
emailState: sql`CASE WHEN coalesce((${notificationsTable.data} ->> 'mailAttempts')::int, 0) + 1 >= ${MAX_ATTEMPTS} THEN 'failed' ELSE 'pending' END`,
emailAfter: sql`now() + make_interval(secs => ${RETRY_DELAY})`
})
.where(inArray(notificationsTable.id, ids))
return false
}
/*
Sent. An entry the inbox does not show is closed as well: there is nowhere for it to be read, and
while it stayed unread it would keep absorbing events without ever emailing about them again.
*/
await WIKI.db
.update(notificationsTable)
.set({
emailState: 'sent',
emailedAt: sql`now()`,
readAt: sql`CASE WHEN ${notificationsTable.inApp} THEN ${notificationsTable.readAt} ELSE now() END`
})
.where(inArray(notificationsTable.id, ids))
return true
}
/**
* Send everything that is due, as far as the run is allowed to go, then ask for the next run at the
* moment the next email comes due.
*/
export async function sendPendingMail(signal: AbortSignal): Promise<void> {
if (!mail.isConfigured) {
// -> Nothing is written as pending while mail is not configured, so this is only what was
// waiting when it was switched off. It waits on: configuring mail again sends it
return
}
/*
A fresh cache each run, for the translator. A worker thread outlives many runs and never hears the
`reloadLocales` event the request process does, so a cache it kept would go on writing mails from
whatever strings were installed when the thread started.
*/
WIKI.cache = new NodeCache({ checkperiod: 0 })
await locales.getLocales()
const sites = await loadSites()
const batchSize = Number(WIKI.config.notifications?.mailBatchSize) || DEFAULT_BATCH_SIZE
/*
One pass over a batch of recipients, not a loop until nothing is left: a pair another instance is
in the middle of sending stays due until it is done, and a loop would keep finding it. Whatever is
still due afterwards is the next run's — asked for below.
*/
let sent = 0
for (const pair of await duePairs(batchSize)) {
if (signal.aborted) {
break
}
try {
if (await sendTo(pair, sites)) {
sent++
}
} catch (err: any) {
WIKI.logger.warn(`Failed to process notification emails for ${pair.userId}: ${err.message}`)
}
}
if (sent > 0) {
WIKI.logger.info(`Sent ${sent} notification email(s).`)
}
const [next] = await WIKI.db
.select({ at: min(notificationsTable.emailAfter) })
.from(notificationsTable)
.where(inArray(notificationsTable.emailState, ['pending', 'sending']))
if (next?.at) {
await enqueueOnce('sendNotificationMail', new Date(next.at))
}
}

@ -0,0 +1,72 @@
import { and, eq, isNull, lte, or } from 'drizzle-orm'
import { toMerged } from 'es-toolkit/object'
import { v4 as uuid } from 'uuid'
import { jobs as jobsTable } from '../db/schema.ts'
/** The two worker tasks notifications are delivered by. */
export type NotificationTask = 'dispatchNotifications' | 'sendNotificationMail'
/**
* Ask for a task to run, unless a run already waiting will do.
*
* One pending job is enough: each of these tasks drains everything that is due when it runs and asks
* for its own next run before it stops, so a second copy queued behind the first would find nothing
* left to do. A pending job that runs no later than `waitUntil` therefore counts. Two instances
* deciding at the same moment can still both add one, which costs a run that finds nothing — the
* rows themselves are claimed with `SKIP LOCKED`.
*
* Called from the request process and from the worker threads alike, which is why it is not just
* `scheduler.addJob`: a worker has no scheduler. There the row is written directly and picked up on
* the next poll (`scheduler.pollingCheck`) rather than announced, which costs a few seconds on
* something that waits minutes on purpose.
*/
export async function enqueueOnce(task: NotificationTask, waitUntil?: Date): Promise<void> {
try {
const pending = await WIKI.db
.select({ id: jobsTable.id })
.from(jobsTable)
.where(
and(
eq(jobsTable.task, task),
waitUntil
? or(isNull(jobsTable.waitUntil), lte(jobsTable.waitUntil, waitUntil))
: isNull(jobsTable.waitUntil)
)
)
.limit(1)
if (pending.length > 0) {
return
}
// -> Only the request process has a scheduler — see the note on the worker's WIKI in `worker.ts`
if (typeof WIKI.scheduler?.addJob === 'function') {
await WIKI.scheduler.addJob({ task, waitUntil, notify: !waitUntil })
return
}
await WIKI.db.insert(jobsTable).values({
id: uuid(),
task,
useWorker: true,
payload: {},
maxRetries: WIKI.config.scheduler.maxRetries,
waitUntil,
createdBy: WIKI.INSTANCE_ID
})
} catch (err: any) {
WIKI.logger.warn(`Failed to queue ${task}: ${err.message}`)
}
}
/**
* Bring a worker thread's copy of the settings up to date.
*
* A worker reads the settings table once, when its thread first opens the database, and never hears
* the `reloadConfig` event the request process does — and a thread lives for many runs. Without
* this, mail configured after the thread started would never be seen (nothing would be emailed),
* and a changed email delay or batch size would be ignored until a restart. One small query per run.
*/
export async function refreshWorkerConfig(): Promise<void> {
const stored = await WIKI.models.settings.getConfig()
if (stored) {
WIKI.config = toMerged(WIKI.config, stored)
}
}

@ -0,0 +1,198 @@
import { and, asc, eq, gt, inArray, sql } from 'drizzle-orm'
import {
approvalRules as approvalRulesTable,
pageWatching as watchingTable,
userGroups as userGroupsTable,
userNotificationPrefs as prefsTable,
users as usersTable
} from '../db/schema.ts'
import { approvals } from '../models/approvals.ts'
import type { ApprovalRule } from '../models/approvals.ts'
import type { NotificationEvent } from './types.ts'
/**
* Where categories find their candidates.
*
* Every function here yields user ids in ascending order, a batch at a time, starting strictly after
* `after` — the contract `NotificationCategory.recipients` states, and what lets a fan-out that ran
* out of time carry on from the last id it wrote. Keyset rather than OFFSET because the audience of
* an opt-in category can be every account on the instance, and an offset re-reads everything before it.
*
* Imported by the categories, which run in a worker thread, so nothing here reaches for `WIKI.models`.
*/
/** How many candidates a category hands the fan-out at once. */
export const RECIPIENT_BATCH_SIZE = 1000
/**
* Page through a query that answers ids in ascending order, given where to start.
*/
async function* keyset(
after: string | null,
fetch: (after: string | null, limit: number) => Promise<string[]>
): AsyncIterable<string[]> {
let cursor = after
while (true) {
const ids = await fetch(cursor, RECIPIENT_BATCH_SIZE)
if (ids.length > 0) {
yield ids
}
if (ids.length < RECIPIENT_BATCH_SIZE) {
return
}
cursor = ids[ids.length - 1]!
}
}
/** The same contract, over a list already in hand. */
async function* fromList(ids: Iterable<string>, after: string | null): AsyncIterable<string[]> {
const sorted = [...new Set(ids)].filter((id) => !after || id > after).sort()
for (let i = 0; i < sorted.length; i += RECIPIENT_BATCH_SIZE) {
yield sorted.slice(i, i + RECIPIENT_BATCH_SIZE)
}
}
/**
* Everybody watching the page an event is about.
*
* For a deletion that is the list the event was written with: the watch rows were removed with the
* page, so the outbox row is the only place they still exist.
*/
export function watchersOf(
event: NotificationEvent,
after: string | null
): AsyncIterable<string[]> {
if (event.kind === 'page:delete') {
return fromList(event.recipients ?? [], after)
}
const pageId = event.data.page?.id
if (!pageId) {
return fromList([], after)
}
return keyset(after, async (cursor, limit) => {
const rows = await WIKI.db
.select({ userId: watchingTable.userId })
.from(watchingTable)
.where(
cursor
? and(eq(watchingTable.pageId, pageId), gt(watchingTable.userId, cursor))
: eq(watchingTable.pageId, pageId)
)
.orderBy(asc(watchingTable.userId))
.limit(limit)
return rows.map((row) => row.userId)
})
}
/**
* Everybody who has turned a category on, on either channel.
*
* Off the partial index on `(category, userId) WHERE enabled`, which is the whole reason the
* preferences are a table: the users table is never scanned.
*/
export function optedInTo(category: string, after: string | null): AsyncIterable<string[]> {
return keyset(after, async (cursor, limit) => {
const rows = await WIKI.db
.selectDistinct({ userId: prefsTable.userId })
.from(prefsTable)
.where(
and(
eq(prefsTable.category, category),
eq(prefsTable.enabled, true),
...(cursor ? [gt(prefsTable.userId, cursor)] : [])
)
)
.orderBy(asc(prefsTable.userId))
.limit(limit)
return rows.map((row) => row.userId)
})
}
/**
* The members of the reviewer groups of every enabled rule covering the page.
*
* Only the groups a rule names. Holding `review:pages` at the page, or `manage:system`, makes
* someone able to answer the queue but not a recipient: on a large wiki every administrator would
* otherwise hear about every suggestion, and an administrator who wants to is put in a reviewer group
* like anybody else.
*
* Read from the table rather than from the approvals model's cache, which a worker thread does not
* have. `matchesPage` itself is pure, so it is the model's own.
*/
export async function* reviewersOf(
event: NotificationEvent,
after: string | null
): AsyncIterable<string[]> {
const page = event.data.page
if (!page || !event.siteId) {
return
}
const rules = (await WIKI.db
.select({
id: approvalRulesTable.id,
name: approvalRulesTable.name,
isEnabled: approvalRulesTable.isEnabled,
match: approvalRulesTable.match,
path: approvalRulesTable.path,
submitterGroups: approvalRulesTable.submitterGroups,
reviewerGroups: approvalRulesTable.reviewerGroups
})
.from(approvalRulesTable)
.where(
and(eq(approvalRulesTable.siteId, event.siteId), eq(approvalRulesTable.isEnabled, true))
)) as ApprovalRule[]
const groupIds = new Set<string>()
for (const rule of rules) {
if (approvals.matchesPage(rule, { path: page.path, tags: page.tags ?? [] })) {
for (const id of rule.reviewerGroups ?? []) {
groupIds.add(id)
}
}
}
if (groupIds.size < 1) {
return
}
yield* keyset(after, async (cursor, limit) => {
const rows = await WIKI.db
.selectDistinct({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(
and(
inArray(userGroupsTable.groupId, [...groupIds]),
...(cursor ? [gt(userGroupsTable.userId, cursor)] : [])
)
)
.orderBy(asc(userGroupsTable.userId))
.limit(limit)
return rows.map((row) => row.userId)
})
}
/**
* The accounts the handles written in a comment point at.
*
* The handles were taken out of the text by the request that posted it, so all that is left is the
* lookup — on the folded form the unique index is on, so `@Ana` and `@ana` are one person.
*/
export async function* mentionedIn(
event: NotificationEvent,
after: string | null
): AsyncIterable<string[]> {
const handles = event.data.mentionHandles ?? []
if (handles.length < 1) {
return
}
const rows = await WIKI.db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(sql`lower(${usersTable.handle})`, handles))
yield* fromList(
rows.map((row) => row.id),
after
)
}
/** A single id, or nobody. */
export function only(id: string | null | undefined, after: string | null): AsyncIterable<string[]> {
return fromList(id ? [id] : [], after)
}

@ -0,0 +1,26 @@
import type { NotificationEvent } from './types.ts'
/**
* What an entry remembers about an event, so that it can still be drawn after the page has been
* renamed, moved or deleted and the actor's account has gone.
*
* The page is copied without its tags — they are what access is checked against, not something an
* inbox shows — and `variants` is what lets a coalesced entry say "edited 4 times and moved" rather
* than only what the last event did.
*/
export function snapshotOf(event: NotificationEvent): Record<string, unknown> {
const { page, variants, variant, previousPath, previousLocale, actorName, excerpt } = event.data
return {
...(page && {
page: { id: page.id, title: page.title, path: page.path, locale: page.locale }
}),
variants: variants ?? [variant],
...(previousPath !== undefined && { previousPath, previousLocale }),
actorName: actorName ?? null,
...(excerpt !== undefined && { excerpt }),
...(event.data.submissionId && { submissionId: event.data.submissionId }),
// -> Said in the entry when nobody did it by hand, which is the case where the actor named is
// the account the sync runs as rather than whoever made the change
...(event.origin !== 'user' && { origin: event.origin })
}
}

@ -0,0 +1,157 @@
import type { RulePageRef } from '../helpers/pageRules.ts'
/**
* The events a notification can be about. Every one of them has a `notifications.emit()` call
* somewhere in the server; add the call in the same change that adds a key here.
*/
export const NOTIFICATION_EVENT_KINDS = [
'page:create',
'page:edit',
'page:rename',
'page:delete',
'submission:new',
'comment:new',
'comment:edit'
] as const
export type NotificationEventKind = (typeof NOTIFICATION_EVENT_KINDS)[number]
/**
* How an event came about, which a category may decline to fire for.
*
* `import` is anything adopted from outside — a storage target's import, a git pull, a backup being
* restored. `bulk` is one action that removes many pages at once — a folder, a tag. `user` is
* everything else: somebody doing one thing to one page.
*/
export const EVENT_ORIGINS = ['user', 'import', 'bulk'] as const
export type EventOrigin = (typeof EVENT_ORIGINS)[number]
/** The channels a notification can be delivered on, which is also what a preference row names. */
export const NOTIFICATION_CHANNELS = ['inApp', 'email'] as const
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]
/** A page as an event remembers it: what an entry displays, and what access is checked against. */
export interface PageSnapshot {
id: string
title: string
path: string
locale: string
tags: string[]
publishState: string
}
/**
* What an event carries, beyond who and where.
*
* One shape for every kind rather than one per kind: an event is written by the request process and
* read by a worker thread, through a JSONB column, so nothing about it is checked on the way anyway.
* Each field says which kinds set it.
*/
export interface NotificationEventData {
/** What happened: `edited`, `moved`, `deleted`, `created`, `new`, … Every kind sets it. */
variant: string
/** Every page event, and the page a comment or a suggestion is on. */
page?: PageSnapshot
/** `page:edit`: everything the save did, of which `variant` is the one that leads. */
variants?: string[]
/** `page:rename`: where the page was before. */
previousPath?: string
previousLocale?: string
/**
* Who did it, as they were called at the time. Also the only name a guest has — a guest comment or
* suggestion has no actor id.
*/
actorName?: string | null
/** `comment:*` */
commentId?: string
/** `comment:new`: the thread it answers, and who started it. */
parentId?: string | null
parentAuthorId?: string | null
/** `comment:*`: the first few lines, as typed. Markdown, never HTML. */
excerpt?: string
/** `comment:*`: the handles written in it, lowercased. On an edit, only the ones that are new. */
mentionHandles?: string[]
/** `submission:new` */
submissionId?: string
}
/** An event as the fan-out reads it back out of the outbox. */
export interface NotificationEvent {
id: string
kind: NotificationEventKind
origin: EventOrigin
siteId: string | null
actorId: string | null
data: NotificationEventData
recipients: string[] | null
}
/** What one entry in somebody's inbox is made of, as a category describes it for an event. */
export interface NotificationEntry {
variant: string
pageId?: string | null
commentId?: string | null
/** The snapshot the inbox and the email are drawn from. */
data: Record<string, unknown>
}
/** The headings the Profile screen groups the categories under. */
export type NotificationSection = 'watching' | 'discussions' | 'reviews' | 'everything'
/**
* One kind of notification: who it goes to, what it says, and how it is offered.
*
* A category is a file under `notifications/categories/` and a key in `NOTIFICATION_CATEGORIES`.
* Everything that is the same for every category — leaving the actor out, checking access, applying
* preferences, deduplicating, coalescing, mailing — is done once by the fan-out, so a category only
* says what is particular to it.
*/
export interface NotificationCategory {
/** Also the preference key and the translation prefix (`notifications.categories.<key>.*`). */
key: string
section: NotificationSection
events: readonly NotificationEventKind[]
/**
* `site` for something that happened on a site, `instance` for something that belongs to none (an
* account being created). An instance entry has no `siteId`, ignores the site switch, and is shown
* in every site's inbox.
*/
scope: 'site' | 'instance'
/** Which origins this fires for. A category that leaves `import` out is silent during an import. */
origins: readonly EventOrigin[]
defaults: Record<NotificationChannel, boolean>
/**
* Which category wins when one event reaches the same person more than once — a comment that
* mentions somebody watching the page. The higher one is sent and the lower one is not.
*/
priority: number
/** Whether the Profile screen offers this category to someone. Every launch category is offered. */
visibleTo?: (actor: { permissions: string[] }) => boolean
/** Whether this particular event concerns the category at all, e.g. a reply needs a parent. */
appliesTo?: (event: NotificationEvent) => boolean
/**
* The people who may need to hear about it, in batches, **sorted by id and each batch strictly
* after `after`**: the last id of a batch is the cursor an interrupted fan-out carries on from.
*
* Candidates only. The fan-out takes the actor out, checks access and applies preferences.
*/
recipients: (event: NotificationEvent, after: string | null) => AsyncIterable<string[]>
/**
* The page permission a recipient must hold, at the time they are told, on the page the event is
* about. Null when being a candidate is itself the grant — a reviewer is named by the rule.
*/
access: string | null
groupKey: (event: NotificationEvent) => string
entry: (event: NotificationEvent) => NotificationEntry
}
/** The page an event is about, in the form an access rule is checked against. */
export function rulePageOf(event: NotificationEvent): RulePageRef | null {
const page = event.data.page
if (!page || !event.siteId) {
return null
}
return { siteId: event.siteId, path: page.path, locale: page.locale, tags: page.tags ?? [] }
}

@ -0,0 +1,78 @@
import crypto from 'node:crypto'
import { timingSafeCompare } from '../helpers/common.ts'
/**
* The token an unsubscribe link carries: who it is for, and which categories the mail was about.
*
* `payload.signature`, both base64url, with an HMAC-SHA256 over the payload. Stateless on purpose —
* there is nothing to store and nothing to look up, so the one-click endpoint answers a mail client
* without a session, a cookie or a query beyond the write it makes.
*
* **It does not expire.** A link in a mail from last year has to work, and the only thing it can ever
* do is turn email off for one person, which is not something anybody gains by forging.
*
* **Signed with its own secret**, `notifications.unsubscribeSecret`, generated on boot by the startup
* checks (`core/startupChecks.ts`) wherever it is missing. Not `auth.secret`: that is rotated
* whenever an administrator invalidates every session, and every unsubscribe link already sitting in
* somebody's mailbox would stop working with it — the same reason `models/apiKeys.ts` gives the
* signing certificates a passphrase of their own.
*/
/** The format version, so that a token written by a later shape can be told apart. */
const TOKEN_VERSION = 1
export interface UnsubscribeClaim {
userId: string
categories: string[]
}
function secret(): string {
const value = WIKI.config.notifications?.unsubscribeSecret
if (!value) {
throw new Error('ERR_NOTIFICATIONS_NO_SECRET')
}
return value
}
function sign(payload: string): string {
return crypto.createHmac('sha256', secret()).update(payload).digest('base64url')
}
export function createUnsubscribeToken({ userId, categories }: UnsubscribeClaim): string {
const payload = Buffer.from(
JSON.stringify({ v: TOKEN_VERSION, u: userId, c: [...new Set(categories)].sort() })
).toString('base64url')
return `${payload}.${sign(payload)}`
}
/**
* What a token says, if it is genuine.
*
* @returns Null for anything that was not signed here, without saying which part was wrong
*/
export function readUnsubscribeToken(token: unknown): UnsubscribeClaim | null {
if (typeof token !== 'string' || token.length > 2048) {
return null
}
const [payload, signature, ...rest] = token.split('.')
if (!payload || !signature || rest.length > 0) {
return null
}
if (!timingSafeCompare(signature, sign(payload))) {
return null
}
try {
const claim = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
if (
claim?.v !== TOKEN_VERSION ||
typeof claim.u !== 'string' ||
!Array.isArray(claim.c) ||
!claim.c.every((key: unknown) => typeof key === 'string')
) {
return null
}
return { userId: claim.u, categories: claim.c }
} catch {
return null
}
}

@ -0,0 +1,17 @@
import type { TaskContext } from '../../core/scheduler.ts'
export async function task(_payload: unknown, { signal }: TaskContext): Promise<void> {
WIKI.logger.info('Purging expired notifications...')
try {
const { notifications, events } = await WIKI.models.notifications.purge(signal)
WIKI.logger.info(
`Purged ${notifications} expired notification(s) and ${events} processed event(s): [ COMPLETED ]`
)
} catch (err: any) {
WIKI.logger.error('Purging expired notifications: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
}
}

@ -0,0 +1,26 @@
import { dispatchPending } from '../../notifications/fanout.ts'
import { refreshWorkerConfig } from '../../notifications/queue.ts'
/**
* Turn whatever has happened into notifications for the people it concerns.
*
* Queued by `notifications.emit()` — debounced, so a burst of saves is one run rather than one each —
* and by the system schedule as a safety net, for an instance that went down with a run still owed.
* Each run drains the outbox (`notificationEvents`) as far as it can; see `notifications/fanout.ts`.
*
* In a worker thread because the audience of one event can be every account on the instance: an
* access check per group set and a few thousand rows written is time the event loop would otherwise
* spend not serving pages. A worker starts with nothing but config and a logger, so the database is
* opened here and everything the fan-out needs is imported by it rather than taken off `WIKI.models`.
*/
export async function task(): Promise<void> {
await WIKI.ensureDb!()
await refreshWorkerConfig()
/*
The pool aborts a worker at `scheduler.taskTimeout` without telling the task, so the task keeps
its own deadline a little inside that: the fan-out stops between batches when it passes, writes
down how far each event got, and the next run carries on from there.
*/
const timeout = Number(WIKI.config.scheduler?.taskTimeout) || 300
await dispatchPending(AbortSignal.timeout(Math.max(timeout - 30, timeout / 2) * 1000))
}

@ -0,0 +1,20 @@
import { sendPendingMail } from '../../notifications/mailer.ts'
import { refreshWorkerConfig } from '../../notifications/queue.ts'
/**
* Send the notification emails that are due.
*
* Queued for the moment the next email comes due — by the fan-out when it writes one, and by each
* run for whatever it leaves pending — and by the system schedule as a safety net. See
* `notifications/mailer.ts`.
*
* In a worker thread, like webhook deliveries, because what it does is wait on somebody else's
* server: a relay may take seconds to accept each mail, and a digest run goes through a hundred.
*/
export async function task(): Promise<void> {
await WIKI.ensureDb!()
await refreshWorkerConfig()
// -> Its own deadline inside the pool's, as `dispatch-notifications.ts` explains
const timeout = Number(WIKI.config.scheduler?.taskTimeout) || 300
await sendPendingMail(AbortSignal.timeout(Math.max(timeout - 30, timeout / 2) * 1000))
}

@ -1,8 +1,8 @@
# Notifications # Notifications
**Status:** a proposal. Nothing here is implemented yet. What already exists and is built on (page **Status:** implemented — phases 1 to 4 of [§16](#16-suggested-order); push, digests and the admin
watching, the inbox shell, the disabled Profile entry) is listed in [§2](#2-what-exists-today). Table, categories are still to come. [§2](#2-what-exists-today) describes what the system was built on, as it
column, route and file names are proposals until the first phase lands. stood before. Where building it changed the design, this document was changed with it.
**Covers:** what a user is notified about, how they choose which notifications they get and how, **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 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. how new kinds of notification are added later.
@ -238,7 +238,7 @@ holds the two opt-in categories, with a note that they can be busy.
- A **Stop all email** action at the foot of the screen turns off email for every category in one - 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. go. It is the same thing the unsubscribe page offers.
`GET /_api/users/me/notifications` answers with every visible category, its effective `GET /_api/users/profile/notifications` answers with every visible category, its effective
`{ inApp, email }`, its defaults, and `emailAvailable`. `PUT` takes the full map back, stores only `{ 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 rows that differ from the defaults, and audits `updateNotificationPrefs` (kind `profile`) with
the categories that changed. the categories that changed.
@ -433,7 +433,7 @@ data jsonb -- snapshot: title, path, locale, actor name, excerpt, va
count integer default 1 count integer default 1
lastEventId uuid lastEventId uuid
inApp boolean inApp boolean
emailState varchar(16) default 'none' -- none | pending | sent | failed | skipped emailState varchar(16) default 'none' -- none | pending | sending | sent | failed | skipped
emailAfter timestamp null emailAfter timestamp null
emailedAt timestamp null emailedAt timestamp null
readAt timestamp null readAt timestamp null
@ -442,7 +442,7 @@ updatedAt timestamp -- bumped on coalescing, the inbox's so
INDEX (userId, updatedAt DESC) -- the inbox INDEX (userId, updatedAt DESC) -- the inbox
INDEX (userId) WHERE readAt IS NULL AND inApp -- the badge INDEX (userId) WHERE readAt IS NULL AND inApp -- the badge
INDEX (emailAfter) WHERE emailState = 'pending' -- the mail drain INDEX (emailAfter) WHERE emailState IN ('pending', 'sending') -- the mail drain
UNIQUE (userId, groupKey) WHERE readAt IS NULL -- §8.3 UNIQUE (userId, groupKey) WHERE readAt IS NULL -- §8.3
``` ```
@ -506,7 +506,7 @@ No route declares `config.permissions`. Each comments `No route-level permission
| `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?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 | | `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/: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)) | | `PUT /_api/sites/:siteId/notifications/read` | Marks everything read, or what matches every filter given: `ids`, `pageId`, `categories` (mark-read-on-view, [§10.2](#102-cadence)). An entry read before its email has gone cancels the email |
| `DELETE /_api/sites/:siteId/notifications/:id` | Dismisses an entry | | `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 Marking read and dismissing are **not audited**. They are bookkeeping on one's own inbox, and a
@ -567,12 +567,18 @@ for a comment the excerpt, one action button linking to the target, and a footer
notifications** (linking to `/_profile/notifications`) and **Unsubscribe**. No page content is ever notifications** (linking to `/_profile/notifications`) and **Unsubscribe**. No page content is ever
included. included.
**The mail model has to run in a worker.** `mail.ts` currently reads `WIKI.sites[siteId]` (hostname, **The mail model runs in a worker.** It used to read `WIKI.sites[siteId]` (title, primary locale)
title, primary locale) and goes through `WIKI.models.locales`, and a worker has neither. `send()` is and go through `WIKI.models.locales`, and a worker has neither. `send()` now takes a `MailSite`
changed to take a `MailSiteContext` (`{ baseUrl, title, primaryLocale }`) from its caller. The (`{ name, primaryLocale }`) from its caller instead of a site id — the existing callers build one
existing callers build it from `WIKI.sites`; the drain builds it from the `sites` rows it loads with `mail.siteFor(siteId)`, the drain from the `sites` rows it reads once per run — and
once per run. The translator is checked to work with the worker's lazily opened database, and `baseUrl()` accepts a `hostname` for a caller with nothing to look one up in. The translator is the
imported directly the way `dispatch-webhook.ts` imports `hooks`. locales model imported directly. It caches through `WIKI.cache`, which a worker does not have, so the
drain gives each run a fresh one: a worker thread outlives many runs and never hears `reloadLocales`.
**A worker's settings go stale.** A worker thread reads the settings table once, when it first opens
the database, and does not hear `reloadConfig`. Both notification tasks therefore re-read it at the
start of every run (`refreshWorkerConfig`), or mail configured after the thread started would never
be seen and a changed email delay would be ignored until a restart.
### 10.2 Cadence ### 10.2 Cadence
@ -596,17 +602,19 @@ entry reads "edited 12 times". Sending an email per event would have meant thirt
window alone about ten, since Bob's pace keeps opening new windows. 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, **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 ever". So **opening a page marks its `watchedPage` and `pageCreated` entries read**, and opening its
marks its `watchedPageComment` entries read. The page payload already answers `isWatching`, so it Talk tab marks its `watchedPageComment`, `commentReply` and `mention` entries read. The page payload
also answers `hasUnreadNotifications`, and the browser calls `PUT …/notifications/read { pageId }` answers `viewer.unreadNotifications`, the categories the reader has unread entries in about that
only when that is true. A page view therefore writes nothing unless there was something to clear. page, and the browser calls `PUT …/notifications/read { pageId, categories }` only for the ones it has
just shown (`markSeen` in `stores/notifications.js`). 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)), **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 because it has no inbox to be read in. Its recipient therefore gets one email per window rather than
one ever. one ever.
**`emailAfter` is computed in exactly one place**, `emailAfterFor(user, now)` in the notifications **`emailAfter` is computed in exactly one place**, `emailAfterFor` in `notifications/fanout.ts`, and
model, and both the fan-out insert and the coalesce go through it. Today it answers 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 `now + emailDelay`. It is the seam where hourly and daily digests attach later
([§10.5](#105-leaving-room-for-hourly-and-daily-digests)). ([§10.5](#105-leaving-room-for-hourly-and-daily-digests)).
@ -614,19 +622,23 @@ model, and both the fan-out insert and the coalesce go through it. Today it answ
`tasks/workers/send-notification-mail.ts`: `tasks/workers/send-notification-mail.ts`:
1. Claims up to `notifications.mailBatchSize` (default 100) **(user, site) pairs** with rows due, 1. Picks up to `notifications.mailBatchSize` (default 100) **(user, site) pairs** with rows due.
using `SELECT DISTINCT "userId", "siteId" … WHERE "emailState" = 'pending' AND "emailAfter" <= 2. For each pair, **claims** the due rows with one `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
now() … FOR UPDATE SKIP LOCKED`. LOCKED)` that marks them `sending` and makes `emailAfter` a ten-minute lease. No transaction is held
2. For each pair, loads the due rows (capped at 50; above that, the digest says "and N more" and across the send: a worker thread has a single database connection, and a transaction kept open
while the mail is rendered leaves none for the queries rendering makes — the run waits on itself.
A run that dies mid-send leaves rows `sending` with a lease that lapses, and the next run takes
them again; at least once, rather than at most once.
3. Renders the claimed 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 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 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, 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 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)). as coming from the first site ([§11](#11-the-site-switch)).
3. Marks the rows `sent` with `emailedAt`, or `failed` after the scheduler's retries are used up. A 4. Marks the rows `sent` with `emailedAt`. A send that fails puts them back to `pending` ten minutes
failure on one user does not stop the batch. on, and after three attempts marks them `failed`. 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 5. Makes one pass and stops, then asks for the next run at the moment the next email comes due. A
fan-out. five-minute schedule is the safety net, for fan-out as well.
Every send goes through the one cached transporter. Turning on nodemailer's `pool` option for this Every send goes through the one cached transporter. Turning on nodemailer's `pool` option for this
task is worth measuring once it exists. task is worth measuring once it exists.
@ -645,8 +657,9 @@ List-Unsubscribe-Post: List-Unsubscribe=One-Click
**The token.** `base64url(payload) . base64url(HMAC-SHA256(payload))`, where the payload is **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 `{ 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 **`notifications.unsubscribeSecret`**, a 32-byte value generated on boot wherever it is missing, by
`models/settings.ts`. It is deliberately not `auth.secret`: that one is rotated whenever an the startup checks in `core/startupChecks.ts` — so an installation from before notifications gets
one as well as a new one. It is deliberately not `auth.secret`: that one is rotated whenever an
administrator invalidates every session, which would break every unsubscribe link already sitting 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). 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 The token **does not expire**. A link in an email from last year still has to work, and the only
@ -666,15 +679,17 @@ thing it can do is turn email off for one person.
**`GET`** on the same URL **does nothing** but redirect to the frontend page `/_unsubscribe?t=`. **`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 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, 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 (from `GET /_api/notifications/unsubscribe/info`, which says what a token would do and nothing about
whose it is), 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. `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:** **Deliverability:**
- Gmail and Yahoo require the `List-Unsubscribe` headers to be covered by the DKIM signature. - Gmail and Yahoo require both `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 Nodemailer's default list covers `List-Unsubscribe` and not `List-Unsubscribe-Post`, so the
them. transport signs its own list (`DKIM_SIGNED_HEADERS` in `models/mail.ts`), which is the default plus
the second header.
- Gmail honours one-click only for an `https` URL. A site served over plain `http` still gets the - 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 header, which works as an ordinary link elsewhere. Admin → Notifications warns about it
([§12.2](#122-admin--notifications)). ([§12.2](#122-admin--notifications)).
@ -747,7 +762,8 @@ Turned off for a site:
**Instance-scoped categories ignore it.** None ships at launch, but the admin categories planned **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 ([§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 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. links use the first site for now. Nothing records which site a person last used; the first
instance-scoped category is the one to decide whether that is worth recording.
--- ---
@ -760,10 +776,10 @@ the per-site switch ([§11](#11-the-site-switch)).
| Key | Default | What | Where | | Key | Default | What | Where |
| --- | ------- | ---- | ----- | | --- | ------- | ---- | ----- |
| `notifications.retentionDays` | 90 | Entries older than this are purged, read or not | Settings blob, edited on Admin → Notifications | | `notifications.retentionDays` | 60 | 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.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.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 | | `notifications.unsubscribeSecret` | generated on boot when missing | Signs unsubscribe tokens ([§10.4](#104-one-click-unsubscribe-rfc-8058)) | Settings blob, never returned by the API |
### 12.2 Admin → Notifications ### 12.2 Admin → Notifications
@ -866,12 +882,12 @@ single entry, which the quiet-until-read cadence ([§10.2](#102-cadence)) then h
1. **Foundation**: the three tables and their migration (`npm run db-generate -- 1. **Foundation**: the three tables and their migration (`npm run db-generate --
--name=notifications`), the registry with the seven categories, `userNotificationPrefs` with its --name=notifications`), the registry with the seven categories, `userNotificationPrefs` with its
API, `ProfileNotifications.vue`, `features.notifications` in General → Features, and the API, `ProfileNotifications.vue`, `features.notifications` in General → Features, and the
`notifications` settings blob with `unsubscribeSecret` seeded. `notifications` settings blob, with `unsubscribeSecret` generated by a startup check.
2. **In-app**: `emit()` with origins, the debounce and the emit sites, the deletion snapshot, 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 `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 `HeaderNav` badge, and `InboxMessages.vue`. Usable on its own: an instance with no mail
configured is complete at this point. configured is complete at this point.
3. **Email**: the `MailSiteContext` refactor, both templates and their strings, 3. **Email**: the `MailSite` refactor, both templates and their strings,
`send-notification-mail.ts` with the quiet-until-read cadence and mark-read-on-view, the `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 unsubscribe token, routes and `/_unsubscribe` page, the DKIM check, and Admin → Notifications
([§12.2](#122-admin--notifications)). ([§12.2](#122-admin--notifications)).

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
290 icons. 300 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -57,6 +57,7 @@ export const BUNDLED_ICONS = {
"la:ellipsis-h": {"body":"<path fill=\"currentColor\" d=\"M6 14a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32}, "la:ellipsis-h": {"body":"<path fill=\"currentColor\" d=\"M6 14a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32}, "la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32}, "la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32},
"la:envelope-open": {"body":"<path fill=\"currentColor\" d=\"m16 3l-.531.344l-12 7.812L3 11.47V29h26V11.469l-.469-.313l-12-7.812zm0 2.375L26.188 12L16 18.594L5.812 12zM5 13.844l10.469 6.781l.531.344l.531-.344L27 13.844V27H5z\"/>","width":32,"height":32},
"la:eraser": {"body":"<path fill=\"currentColor\" d=\"M18.906 4.094c-.804 0-1.64.273-2.281.843v.032L16.594 5L4.906 16.594c-1.21 1.21-1.203 3.183-.062 4.468l.031.032h.031l6 6c1.211 1.21 3.184 1.203 4.469.062v-.031L27 15.5c1.266-1.266 1.305-3.29.094-4.5l-6-6a3.06 3.06 0 0 0-2.188-.906m-.031 2.031c.32 0 .617.086.813.281l6 6c.386.387.44 1.153-.094 1.688l-5.032 5.031l-7.656-7.656l5.063-5.031l.031-.032c.254-.21.57-.281.875-.281m-7.406 6.781l7.656 7.656l-5.094 5.094c-.011.008-.02.024-.031.032c-.516.43-1.309.378-1.688 0L6.345 19.75c-.016-.02-.016-.043-.032-.063c-.41-.515-.375-1.312 0-1.687z\"/>","width":32,"height":32}, "la:eraser": {"body":"<path fill=\"currentColor\" d=\"M18.906 4.094c-.804 0-1.64.273-2.281.843v.032L16.594 5L4.906 16.594c-1.21 1.21-1.203 3.183-.062 4.468l.031.032h.031l6 6c1.211 1.21 3.184 1.203 4.469.062v-.031L27 15.5c1.266-1.266 1.305-3.29.094-4.5l-6-6a3.06 3.06 0 0 0-2.188-.906m-.031 2.031c.32 0 .617.086.813.281l6 6c.386.387.44 1.153-.094 1.688l-5.032 5.031l-7.656-7.656l5.063-5.031l.031-.032c.254-.21.57-.281.875-.281m-7.406 6.781l7.656 7.656l-5.094 5.094c-.011.008-.02.024-.031.032c-.516.43-1.309.378-1.688 0L6.345 19.75c-.016-.02-.016-.043-.032-.063c-.41-.515-.375-1.312 0-1.687z\"/>","width":32,"height":32},
"la:exclamation-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 4v8h2v-8zm0 10v2h2v-2z\"/>","width":32,"height":32}, "la:exclamation-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 4v8h2v-8zm0 10v2h2v-2z\"/>","width":32,"height":32},
"la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32}, "la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32},
@ -181,6 +182,7 @@ export const BUNDLED_ICONS = {
"mdi:alert-box-outline": {"body":"<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8 12h2v2h-2zm0-8h2v6h-2z\"/>","width":24,"height":24}, "mdi:alert-box-outline": {"body":"<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8 12h2v2h-2zm0-8h2v6h-2z\"/>","width":24,"height":24},
"mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24}, "mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24},
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24}, "mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
"mdi:at": {"body":"<path fill=\"currentColor\" d=\"M12 15c.81 0 1.5-.3 2.11-.89c.59-.61.89-1.3.89-2.11s-.3-1.5-.89-2.11C13.5 9.3 12.81 9 12 9s-1.5.3-2.11.89C9.3 10.5 9 11.19 9 12s.3 1.5.89 2.11c.61.59 1.3.89 2.11.89m0-13c2.75 0 5.1 1 7.05 2.95S22 9.25 22 12v1.45c0 1-.35 1.85-1 2.55c-.7.67-1.5 1-2.5 1c-1.2 0-2.19-.5-2.94-1.5c-1 1-2.18 1.5-3.56 1.5c-1.37 0-2.55-.5-3.54-1.46C7.5 14.55 7 13.38 7 12c0-1.37.5-2.55 1.46-3.54C9.45 7.5 10.63 7 12 7c1.38 0 2.55.5 3.54 1.46C16.5 9.45 17 10.63 17 12v1.45c0 .41.16.77.46 1.08s.65.47 1.04.47c.42 0 .77-.16 1.07-.47s.43-.67.43-1.08V12c0-2.19-.77-4.07-2.35-5.65S14.19 4 12 4s-4.07.77-5.65 2.35S4 9.81 4 12s.77 4.07 2.35 5.65S9.81 20 12 20h5v2h-5c-2.75 0-5.1-1-7.05-2.95S2 14.75 2 12s1-5.1 2.95-7.05S9.25 2 12 2\"/>","width":24,"height":24},
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24}, "mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24}, "mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24},
"mdi:bell-off-outline": {"body":"<path fill=\"currentColor\" d=\"M22.11 21.46L2.39 1.73L1.11 3l4.72 4.72A7 7 0 0 0 5 11v6l-2 2v1h15.11l2.73 2.73zM7 18v-7c0-.61.11-1.21.34-1.77L16.11 18zm3 3h4a2 2 0 0 1-2 2a2 2 0 0 1-2-2M8.29 5.09c.53-.34 1.11-.59 1.71-.8V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v4.8l-2-2V11a5 5 0 0 0-5-5c-.78 0-1.55.2-2.24.56z\"/>","width":24,"height":24}, "mdi:bell-off-outline": {"body":"<path fill=\"currentColor\" d=\"M22.11 21.46L2.39 1.73L1.11 3l4.72 4.72A7 7 0 0 0 5 11v6l-2 2v1h15.11l2.73 2.73zM7 18v-7c0-.61.11-1.21.34-1.77L16.11 18zm3 3h4a2 2 0 0 1-2 2a2 2 0 0 1-2-2M8.29 5.09c.53-.34 1.11-.59 1.71-.8V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v4.8l-2-2V11a5 5 0 0 0-5-5c-.78 0-1.55.2-2.24.56z\"/>","width":24,"height":24},
@ -196,6 +198,7 @@ export const BUNDLED_ICONS = {
"mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24}, "mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:chevron-right": {"body":"<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>","width":24,"height":24}, "mdi:chevron-right": {"body":"<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>","width":24,"height":24},
"mdi:clear-circle-multiple-outline": {"body":"<path fill=\"currentColor\" d=\"m18.54 9.88l-1.42-1.41L15 10.59l-2.12-2.12l-1.41 1.41L13.59 12l-2.12 2.12l1.41 1.42L15 13.41l2.12 2.13l1.42-1.42L16.41 12M2 12c0-2.79 1.64-5.2 4-6.32V3.5C2.5 4.76 0 8.09 0 12s2.5 7.24 6 8.5v-2.18C3.64 17.2 2 14.79 2 12m13-9c-4.96 0-9 4.04-9 9s4.04 9 9 9s9-4.04 9-9s-4.04-9-9-9m0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7s7 3.14 7 7s-3.14 7-7 7\"/>","width":24,"height":24}, "mdi:clear-circle-multiple-outline": {"body":"<path fill=\"currentColor\" d=\"m18.54 9.88l-1.42-1.41L15 10.59l-2.12-2.12l-1.41 1.41L13.59 12l-2.12 2.12l1.41 1.42L15 13.41l2.12 2.13l1.42-1.42L16.41 12M2 12c0-2.79 1.64-5.2 4-6.32V3.5C2.5 4.76 0 8.09 0 12s2.5 7.24 6 8.5v-2.18C3.64 17.2 2 14.79 2 12m13-9c-4.96 0-9 4.04-9 9s4.04 9 9 9s9-4.04 9-9s-4.04-9-9-9m0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7s7 3.14 7 7s-3.14 7-7 7\"/>","width":24,"height":24},
"mdi:clipboard-check-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.4 1.84 13.3 1 12 1s-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm.5 6.5L9 12l2 2l4.5-4.5L17 11l-6 6z\"/>","width":24,"height":24},
"mdi:clipboard-text-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.25 1.44 12.53.64 11 1.2c-.86.3-1.5.96-1.82 1.8H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm10 4H7V9h10zm-2 4H7v-2h8z\"/>","width":24,"height":24}, "mdi:clipboard-text-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.25 1.44 12.53.64 11 1.2c-.86.3-1.5.96-1.82 1.8H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm10 4H7V9h10zm-2 4H7v-2h8z\"/>","width":24,"height":24},
"mdi:clock-outline": {"body":"<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z\"/>","width":24,"height":24}, "mdi:clock-outline": {"body":"<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z\"/>","width":24,"height":24},
"mdi:close": {"body":"<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>","width":24,"height":24}, "mdi:close": {"body":"<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>","width":24,"height":24},
@ -203,16 +206,22 @@ export const BUNDLED_ICONS = {
"mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24}, "mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24},
"mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24}, "mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24}, "mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24},
"mdi:comment-text-outline": {"body":"<path fill=\"currentColor\" d=\"M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12zM6 7h12v2H6zm0 4h9v2H6z\"/>","width":24,"height":24},
"mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24}, "mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24},
"mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24}, "mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24},
"mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24}, "mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24},
"mdi:email-open-multiple-outline": {"body":"<path fill=\"currentColor\" d=\"M2 8v14h18v2H2c-1.105 0-2-.89-2-2V8zm21.03-1.71L14 .64L4.97 6.29C4.39 6.64 4 7.27 4 8v10c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-.73-.39-1.36-.97-1.71M22 18H6v-8l8 5l8-5zm-8-5L6 8l8-5l8 5z\"/>","width":24,"height":24},
"mdi:email-open-outline": {"body":"<path fill=\"currentColor\" d=\"M21.03 6.29L12 .64L2.97 6.29C2.39 6.64 2 7.27 2 8v10c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-.73-.39-1.36-.97-1.71M20 18H4v-8l8 5l8-5zm-8-5L4 8l8-5l8 5z\"/>","width":24,"height":24},
"mdi:emoticon-outline": {"body":"<path fill=\"currentColor\" d=\"M12 17.5c2.33 0 4.3-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5M8.5 11A1.5 1.5 0 0 0 10 9.5A1.5 1.5 0 0 0 8.5 8A1.5 1.5 0 0 0 7 9.5A1.5 1.5 0 0 0 8.5 11m7 0A1.5 1.5 0 0 0 17 9.5A1.5 1.5 0 0 0 15.5 8A1.5 1.5 0 0 0 14 9.5a1.5 1.5 0 0 0 1.5 1.5M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24}, "mdi:emoticon-outline": {"body":"<path fill=\"currentColor\" d=\"M12 17.5c2.33 0 4.3-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5M8.5 11A1.5 1.5 0 0 0 10 9.5A1.5 1.5 0 0 0 8.5 8A1.5 1.5 0 0 0 7 9.5A1.5 1.5 0 0 0 8.5 11m7 0A1.5 1.5 0 0 0 17 9.5A1.5 1.5 0 0 0 15.5 8A1.5 1.5 0 0 0 14 9.5a1.5 1.5 0 0 0 1.5 1.5M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:emoticon-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M15 18h3v-3h2v3h3v2h-3v3h-2v-3h-3zm-3-.5c-2.33 0-4.31-1.46-5.11-3.5h8.8a5.94 5.94 0 0 0-2.46 3.36c-.4.09-.81.14-1.23.14M8.5 11C7.67 11 7 10.33 7 9.5S7.67 8 8.5 8s1.5.67 1.5 1.5S9.33 11 8.5 11m7 0c-.83 0-1.5-.67-1.5-1.5S14.67 8 15.5 8s1.5.67 1.5 1.5s-.67 1.5-1.5 1.5M12 20l1.07-.07c.11.68.33 1.33.65 1.92c-.56.1-1.14.15-1.72.15c-5.53 0-10-4.5-10-10S6.47 2 12 2c5.5 0 10 4.5 10 10c0 .59-.05 1.16-.15 1.72c-.59-.32-1.23-.54-1.92-.65L20 12c0-4.42-3.58-8-8-8s-8 3.58-8 8s3.58 8 8 8\"/>","width":24,"height":24}, "mdi:emoticon-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M15 18h3v-3h2v3h3v2h-3v3h-2v-3h-3zm-3-.5c-2.33 0-4.31-1.46-5.11-3.5h8.8a5.94 5.94 0 0 0-2.46 3.36c-.4.09-.81.14-1.23.14M8.5 11C7.67 11 7 10.33 7 9.5S7.67 8 8.5 8s1.5.67 1.5 1.5S9.33 11 8.5 11m7 0c-.83 0-1.5-.67-1.5-1.5S14.67 8 15.5 8s1.5.67 1.5 1.5s-.67 1.5-1.5 1.5M12 20l1.07-.07c.11.68.33 1.33.65 1.92c-.56.1-1.14.15-1.72.15c-5.53 0-10-4.5-10-10S6.47 2 12 2c5.5 0 10 4.5 10 10c0 .59-.05 1.16-.15 1.72c-.59-.32-1.23-.54-1.92-.65L20 12c0-4.42-3.58-8-8-8s-8 3.58-8 8s3.58 8 8 8\"/>","width":24,"height":24},
"mdi:exclamation": {"body":"<path fill=\"currentColor\" d=\"M11 4h2v11h-2zm2 14v2h-2v-2z\"/>","width":24,"height":24}, "mdi:exclamation": {"body":"<path fill=\"currentColor\" d=\"M11 4h2v11h-2zm2 14v2h-2v-2z\"/>","width":24,"height":24},
"mdi:eye": {"body":"<path fill=\"currentColor\" d=\"M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5\"/>","width":24,"height":24}, "mdi:eye": {"body":"<path fill=\"currentColor\" d=\"M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5\"/>","width":24,"height":24},
"mdi:eye-off": {"body":"<path fill=\"currentColor\" d=\"M11.83 9L15 12.16V12a3 3 0 0 0-3-3zm-4.3.8l1.55 1.55c-.05.21-.08.42-.08.65a3 3 0 0 0 3 3c.22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53a5 5 0 0 1-5-5c0-.79.2-1.53.53-2.2M2 4.27l2.28 2.28l.45.45C3.08 8.3 1.78 10 1 12c1.73 4.39 6 7.5 11 7.5c1.55 0 3.03-.3 4.38-.84l.43.42L19.73 22L21 20.73L3.27 3M12 7a5 5 0 0 1 5 5c0 .64-.13 1.26-.36 1.82l2.93 2.93c1.5-1.25 2.7-2.89 3.43-4.75c-1.73-4.39-6-7.5-11-7.5c-1.4 0-2.74.25-4 .7l2.17 2.15C10.74 7.13 11.35 7 12 7\"/>","width":24,"height":24}, "mdi:eye-off": {"body":"<path fill=\"currentColor\" d=\"M11.83 9L15 12.16V12a3 3 0 0 0-3-3zm-4.3.8l1.55 1.55c-.05.21-.08.42-.08.65a3 3 0 0 0 3 3c.22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53a5 5 0 0 1-5-5c0-.79.2-1.53.53-2.2M2 4.27l2.28 2.28l.45.45C3.08 8.3 1.78 10 1 12c1.73 4.39 6 7.5 11 7.5c1.55 0 3.03-.3 4.38-.84l.43.42L19.73 22L21 20.73L3.27 3M12 7a5 5 0 0 1 5 5c0 .64-.13 1.26-.36 1.82l2.93 2.93c1.5-1.25 2.7-2.89 3.43-4.75c-1.73-4.39-6-7.5-11-7.5c-1.4 0-2.74.25-4 .7l2.17 2.15C10.74 7.13 11.35 7 12 7\"/>","width":24,"height":24},
"mdi:eye-off-outline": {"body":"<path fill=\"currentColor\" d=\"M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13\"/>","width":24,"height":24}, "mdi:eye-off-outline": {"body":"<path fill=\"currentColor\" d=\"M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13\"/>","width":24,"height":24},
"mdi:file-document-edit-outline": {"body":"<path fill=\"currentColor\" d=\"M8 12h8v2H8zm2 8H6V4h7v5h5v3.1l2-2V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4zm-2-2h4.1l.9-.9V16H8zm12.2-5c.1 0 .3.1.4.2l1.3 1.3c.2.2.2.6 0 .8l-1 1l-2.1-2.1l1-1c.1-.1.2-.2.4-.2m0 3.9L14.1 23H12v-2.1l6.1-6.1z\"/>","width":24,"height":24},
"mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24}, "mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24},
"mdi:file-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13.81 22H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h8l6 6v5.09c-.33-.05-.66-.09-1-.09s-.67.04-1 .09V9h-5V4H6v16h7.09c.12.72.37 1.39.72 2M23 18h-3v-3h-2v3h-3v2h3v3h2v-3h3z\"/>","width":24,"height":24},
"mdi:file-remove-outline": {"body":"<path fill=\"currentColor\" d=\"M13.81 22H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h8l6 6v5.09c-.33-.05-.66-.09-1-.09s-.67.04-1 .09V9h-5V4H6v16h7.09c.12.72.37 1.39.72 2m8.73-.88L20.41 19l2.13-2.12l-1.42-1.41L19 17.59l-2.12-2.12l-1.41 1.41L17.59 19l-2.12 2.12l1.41 1.42L19 20.41l2.12 2.13z\"/>","width":24,"height":24},
"mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24}, "mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24},
"mdi:file-tree": {"body":"<path fill=\"currentColor\" d=\"M3 3h6v4H3zm12 7h6v4h-6zm0 7h6v4h-6zm-2-4H7v5h6v2H5V9h2v2h6z\"/>","width":24,"height":24}, "mdi:file-tree": {"body":"<path fill=\"currentColor\" d=\"M3 3h6v4H3zm12 7h6v4h-6zm0 7h6v4h-6zm-2-4H7v5h6v2H5V9h2v2h6z\"/>","width":24,"height":24},
"mdi:file-tree-outline": {"body":"<path fill=\"currentColor\" d=\"M12 13H7v5h5v2H5V10h2v1h5zM8 4v2H4V4zm2-2H2v6h8zm10 9v2h-4v-2zm2-2h-8v6h8zm-2 9v2h-4v-2zm2-2h-8v6h8z\"/>","width":24,"height":24}, "mdi:file-tree-outline": {"body":"<path fill=\"currentColor\" d=\"M12 13H7v5h5v2H5V10h2v1h5zM8 4v2H4V4zm2-2H2v6h8zm10 9v2h-4v-2zm2-2h-8v6h8zm-2 9v2h-4v-2zm2-2h-8v6h8z\"/>","width":24,"height":24},
@ -271,6 +280,7 @@ export const BUNDLED_ICONS = {
"mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24}, "mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24},
"mdi:radar": {"body":"<path fill=\"currentColor\" d=\"m19.07 4.93l-1.41 1.41A8 8 0 0 1 20 12a8 8 0 0 1-8 8a8 8 0 0 1-8-8c0-4.08 3.05-7.44 7-7.93v2.02C8.16 6.57 6 9.03 6 12a6 6 0 0 0 6 6a6 6 0 0 0 6-6c0-1.66-.67-3.16-1.76-4.24l-1.41 1.41C15.55 9.9 16 10.9 16 12a4 4 0 0 1-4 4a4 4 0 0 1-4-4c0-1.86 1.28-3.41 3-3.86v2.14c-.6.35-1 .98-1 1.72a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.38-1-1.72V2h-1A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-2.76-1.12-5.26-2.93-7.07\"/>","width":24,"height":24}, "mdi:radar": {"body":"<path fill=\"currentColor\" d=\"m19.07 4.93l-1.41 1.41A8 8 0 0 1 20 12a8 8 0 0 1-8 8a8 8 0 0 1-8-8c0-4.08 3.05-7.44 7-7.93v2.02C8.16 6.57 6 9.03 6 12a6 6 0 0 0 6 6a6 6 0 0 0 6-6c0-1.66-.67-3.16-1.76-4.24l-1.41 1.41C15.55 9.9 16 10.9 16 12a4 4 0 0 1-4 4a4 4 0 0 1-4-4c0-1.86 1.28-3.41 3-3.86v2.14c-.6.35-1 .98-1 1.72a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.38-1-1.72V2h-1A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-2.76-1.12-5.26-2.93-7.07\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24}, "mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
"mdi:reply-outline": {"body":"<path fill=\"currentColor\" d=\"M8 9.8v.9l1.7.3c2.6.4 4.5 1.4 5.9 2.7c-1.7-.5-3.5-.8-5.6-.8H8v1.3L5.8 12zM10 5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11\"/>","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24}, "mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
"mdi:sort-alphabetical-descending-variant": {"body":"<path fill=\"currentColor\" d=\"m15.75 19l-3.25 3.25L9.25 19zm-6.86-4.7H6L5.28 17H2.91L6 7h3l3.13 10H9.67zm-2.56-1.62h2.23l-.63-2.12l-.26-.97l-.25-.96h-.03l-.22.97l-.24.98zM13.05 17v-1.26l4.75-6.77v-.06h-4.3V7h7.23v1.34L16.09 15v.08h4.71V17z\"/>","width":24,"height":24}, "mdi:sort-alphabetical-descending-variant": {"body":"<path fill=\"currentColor\" d=\"m15.75 19l-3.25 3.25L9.25 19zm-6.86-4.7H6L5.28 17H2.91L6 7h3l3.13 10H9.67zm-2.56-1.62h2.23l-.63-2.12l-.26-.97l-.25-.96h-.03l-.22.97l-.24.98zM13.05 17v-1.26l4.75-6.77v-.06h-4.3V7h7.23v1.34L16.09 15v.08h4.71V17z\"/>","width":24,"height":24},
"mdi:sort-numeric-descending-variant": {"body":"<path fill=\"currentColor\" d=\"M7.78 7c1.3.04 2.22.53 2.79 1.46c.56.94.84 2.1.82 3.49c.01 1.55-.3 2.78-.89 3.67c-.62.88-1.55 1.35-2.79 1.38c-1.26-.04-2.17-.5-2.75-1.44c-.58-.93-.87-2.11-.87-3.56s.3-2.64.91-3.56C5.59 7.5 6.5 7.04 7.78 7m-.03 1.63c-.44 0-.79.27-1.05.83c-.26.54-.38 1.41-.38 2.54c-.01 1.15.12 2 .37 2.54c.26.56.62.83 1.08.83c.92 0 1.39-1.13 1.4-3.37c0-2.23-.47-3.35-1.42-3.37M13.33 17v-1.78l.43.02l.54-.02l1.04-.19c.34-.11.66-.25.92-.45c.33-.23.6-.5.81-.82c.22-.31.37-.64.46-.98l-.03-.01c-.45.42-1.12.63-2.03.64c-.85-.01-1.56-.26-2.13-.76s-.84-1.22-.88-2.15c.01-1 .35-1.81 1.01-2.47c.67-.66 1.53-1 2.65-1.03c1.25.04 2.17.45 2.76 1.24c.59.76.88 1.76.88 2.95c-.01.96-.15 1.81-.44 2.57c-.29.74-.68 1.37-1.2 1.88c-.46.42-1.01.74-1.65.97q-.96.33-2.13.39zm2.73-8.37c-.41.01-.74.17-1 .48c-.25.31-.38.73-.38 1.25c0 .44.12.8.35 1.1c.24.31.6.46 1.08.47c.32 0 .59-.07.81-.19c.22-.13.38-.28.49-.46c.09-.11.12-.31.12-.57c.01-.55-.1-1.02-.33-1.43s-.61-.63-1.14-.65M15.75 19l-3.25 3.25L9.25 19z\"/>","width":24,"height":24}, "mdi:sort-numeric-descending-variant": {"body":"<path fill=\"currentColor\" d=\"M7.78 7c1.3.04 2.22.53 2.79 1.46c.56.94.84 2.1.82 3.49c.01 1.55-.3 2.78-.89 3.67c-.62.88-1.55 1.35-2.79 1.38c-1.26-.04-2.17-.5-2.75-1.44c-.58-.93-.87-2.11-.87-3.56s.3-2.64.91-3.56C5.59 7.5 6.5 7.04 7.78 7m-.03 1.63c-.44 0-.79.27-1.05.83c-.26.54-.38 1.41-.38 2.54c-.01 1.15.12 2 .37 2.54c.26.56.62.83 1.08.83c.92 0 1.39-1.13 1.4-3.37c0-2.23-.47-3.35-1.42-3.37M13.33 17v-1.78l.43.02l.54-.02l1.04-.19c.34-.11.66-.25.92-.45c.33-.23.6-.5.81-.82c.22-.31.37-.64.46-.98l-.03-.01c-.45.42-1.12.63-2.03.64c-.85-.01-1.56-.26-2.13-.76s-.84-1.22-.88-2.15c.01-1 .35-1.81 1.01-2.47c.67-.66 1.53-1 2.65-1.03c1.25.04 2.17.45 2.76 1.24c.59.76.88 1.76.88 2.95c-.01.96-.15 1.81-.44 2.57c-.29.74-.68 1.37-1.2 1.88c-.46.42-1.01.74-1.65.97q-.96.33-2.13.39zm2.73-8.37c-.41.01-.74.17-1 .48c-.25.31-.38.73-.38 1.25c0 .44.12.8.35 1.1c.24.31.6.46 1.08.47c.32 0 .59-.07.81-.19c.22-.13.38-.28.49-.46c.09-.11.12-.31.12-.57c.01-.55-.1-1.02-.33-1.43s-.61-.63-1.14-.65M15.75 19l-3.25 3.25L9.25 19z\"/>","width":24,"height":24},

@ -0,0 +1,77 @@
import { watch } from 'vue'
import { useNotificationsStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/** How often a visible tab asks, in milliseconds. */
const POLL_INTERVAL = 60_000
/** The least time between two refreshes a navigation may cause, in milliseconds. */
const NAVIGATION_THROTTLE = 15_000
/**
* When the notification badge is brought up to date.
*
* Polling, and deliberately only while somebody can see it: a hundred tabs left open in the
* background should not be a hundred requests a minute, so the timer runs while the tab is visible
* and a tab that becomes visible again asks at once. A navigation asks too, at most every fifteen
* seconds, which is when somebody is most likely to look at the badge.
*
* This is the only part that would change if push is added: a socket that says "something changed"
* calls the same `refresh()`, and the timer here relaxes to a slow safety net while it is open.
*/
export function initializeNotifications(router) {
const notificationsStore = useNotificationsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
let timer = null
let lastRefreshAt = 0
const refresh = () => {
lastRefreshAt = Date.now()
notificationsStore.refresh()
}
const stop = () => {
clearInterval(timer)
timer = null
}
const start = () => {
stop()
if (notificationsStore.isActive && document.visibilityState === 'visible') {
timer = setInterval(refresh, POLL_INTERVAL)
}
}
// -> Signing in or out, or the site config arriving, changes whether there is anything to ask about
watch(
() => [userStore.authenticated, userStore.id, siteStore.id, siteStore.features.notifications],
() => {
notificationsStore.reset()
if (notificationsStore.isActive) {
refresh()
}
start()
}
)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (notificationsStore.isActive) {
refresh()
}
start()
} else {
stop()
}
})
router.afterEach(() => {
if (notificationsStore.isActive && Date.now() - lastRefreshAt > NAVIGATION_THROTTLE) {
refresh()
}
})
}

@ -6,6 +6,8 @@
dense dense
icon="la:ellipsis-v" icon="la:ellipsis-v"
aria-label="More Actions"> aria-label="More Actions">
<!-- -> A dot for the unread notifications behind this menu, whose own row carries the count -->
<w-badge v-if="notificationsStore.badge" color="negative" rounded floating />
<w-menu ref="menu" class="translucent-menu" anchor="bottom right" self="top right"> <w-menu ref="menu" class="translucent-menu" anchor="bottom right" self="top right">
<!-- <!--
Every row's icon takes its colour as a literal `text-*` class rather than through `WIcon`'s Every row's icon takes its colour as a literal `text-*` class rather than through `WIcon`'s
@ -71,6 +73,9 @@
<w-icon name="mdi:inbox-full" class="text-amber" /> <w-icon name="mdi:inbox-full" class="text-amber" />
</w-item-section> </w-item-section>
<w-item-section>{{ t('inbox.title') }}</w-item-section> <w-item-section>{{ t('inbox.title') }}</w-item-section>
<w-item-section v-if="notificationsStore.badge" side>
<w-badge color="negative" rounded :label="notificationsStore.badge" />
</w-item-section>
</w-item> </w-item>
<w-item v-if="userStore.can(`access:admin`)" clickable to="/_admin" @click="close"> <w-item v-if="userStore.can(`access:admin`)" clickable to="/_admin" @click="close">
<w-item-section avatar> <w-item-section avatar>
@ -118,6 +123,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useNotificationsStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -135,6 +141,7 @@ import PageNewMenu from '@/components/PageNewMenu.vue'
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
const notificationsStore = useNotificationsStore()
// I18N // I18N

@ -85,8 +85,15 @@
icon="mdi:inbox-full" icon="mdi:inbox-full"
color="amber" color="amber"
to="/_inbox" to="/_inbox"
:aria-label="t(`inbox.title`)"> :aria-label="inboxLabel">
<w-tooltip>{{ t('inbox.title') }}</w-tooltip> <!-- -> The unread count, which `boot/notifications.js` keeps current -->
<w-badge
v-if="notificationsStore.badge"
color="negative"
rounded
floating
:label="notificationsStore.badge" />
<w-tooltip>{{ inboxLabel }}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
v-if="userStore.can(`access:admin`)" v-if="userStore.can(`access:admin`)"
@ -144,6 +151,7 @@ import { splitLocalePath } from '@/helpers/pagePaths'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useNotificationsStore } from '@/stores/notifications'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -167,6 +175,7 @@ const editorStore = useEditorStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
const notificationsStore = useNotificationsStore()
// ROUTER // ROUTER
@ -206,6 +215,13 @@ const homePath = computed(() => {
const isSearchCollapsed = computed(() => !isAtLeastSm.value) const isSearchCollapsed = computed(() => !isAtLeastSm.value)
/** The inbox button's name, which says how many are unread so that a screen reader hears the badge. */
const inboxLabel = computed(() =>
notificationsStore.badge
? t('notifications.unreadLabel', { title: t('inbox.title'), count: notificationsStore.badge })
: t('inbox.title')
)
/** /**
* Below 900px, where the five action buttons become the one overflow menu. * Below 900px, where the five action buttons become the one overflow menu.
* *

@ -6,7 +6,10 @@
:aria-label="label ? undefined : ariaLabel" :aria-label="label ? undefined : ariaLabel"
:disabled="isDisabled" :disabled="isDisabled"
class="w-toggle w-unstyled inline-flex flex-nowrap items-center gap-2 rounded outline-offset-2 focus-visible:outline-2" class="w-toggle w-unstyled inline-flex flex-nowrap items-center gap-2 rounded outline-offset-2 focus-visible:outline-2"
:class="isDisabled ? 'w-toggle--disabled pointer-events-none' : 'cursor-pointer'" :class="[
isDisabled ? 'w-toggle--disabled pointer-events-none' : 'cursor-pointer',
{ 'w-toggle--dark': dark }
]"
@click="toggle"> @click="toggle">
<span <span
class="w-toggle__track relative inline-flex shrink-0 items-center rounded-full" class="w-toggle__track relative inline-flex shrink-0 items-center rounded-full"
@ -83,6 +86,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
/**
* Draws the switch for a dark surface whatever the app theme -- for one sitting on a dark panel
* in light mode, which the `body--dark` rule below cannot know about.
*/
dark: {
type: Boolean,
default: false
},
disable: { disable: {
type: Boolean, type: Boolean,
default: false default: false
@ -183,7 +194,8 @@ function toggle() {
--w-toggle-status: var(--color-positive); --w-toggle-status: var(--color-positive);
} }
:global(body.body--dark .w-toggle) { :global(body.body--dark .w-toggle),
.w-toggle--dark {
--w-toggle-track: #262c38; --w-toggle-track: #262c38;
--w-toggle-rim: #39414f; --w-toggle-rim: #39414f;
--w-toggle-knob-rim: rgb(255 255 255 / 0.1); --w-toggle-knob-rim: rgb(255 255 255 / 0.1);

@ -363,6 +363,12 @@
:color="adminStore.info.isMetricsEnabled ? `positive` : `negative`" /> :color="adminStore.info.isMetricsEnabled ? `positive` : `negative`" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item to="/_admin/notifications" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-topic-push-notification.svg" />
</w-item-section>
<w-item-section>{{ t('admin.notifications.title') }}</w-item-section>
</w-item>
<w-item <w-item
to="/_admin/rendering" to="/_admin/rendering"
active-class="bg-primary text-white" active-class="bg-primary text-white"

@ -19,6 +19,9 @@
<w-item-section> <w-item-section>
<w-item-label>{{ navItem.label }}</w-item-label> <w-item-label>{{ navItem.label }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section v-if="navItem.badge" side>
<w-badge color="negative" rounded :label="navItem.badge" />
</w-item-section>
</w-item> </w-item>
</w-list> </w-list>
</div> </div>
@ -36,6 +39,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { useNotificationsStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -52,6 +56,7 @@ import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
// STORES // STORES
const notificationsStore = useNotificationsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -88,7 +93,8 @@ const sidenav = computed(() => [
{ {
key: 'messages', key: 'messages',
label: t('inbox.inbox'), label: t('inbox.inbox'),
icon: 'mdi:inbox-full' icon: 'mdi:inbox-full',
badge: notificationsStore.badge
}, },
{ {
key: 'watching', key: 'watching',

@ -147,8 +147,7 @@ const sidenav = computed(() => [
{ {
key: 'notifications', key: 'notifications',
label: t('profile.notifications'), label: t('profile.notifications'),
icon: 'la:bell', icon: 'la:bell'
disabled: true
}, },
// { // {
// key: 'pages', // key: 'pages',

@ -8,6 +8,7 @@ import { initializeExternals } from './boot/externals'
import { initializeI18n } from './boot/i18n' import { initializeI18n } from './boot/i18n'
import { initializeIconify } from './boot/iconify' import { initializeIconify } from './boot/iconify'
import { initializeMonaco } from './boot/monaco' import { initializeMonaco } from './boot/monaco'
import { initializeNotifications } from './boot/notifications'
import { initializeTemporal } from './boot/temporal' import { initializeTemporal } from './boot/temporal'
import { initializeHairlines } from './helpers/hairline' import { initializeHairlines } from './helpers/hairline'
@ -43,6 +44,7 @@ initializeIconify()
initializeMonaco() initializeMonaco()
initializeExternals(router, store) initializeExternals(router, store)
initializeI18n(app, store) initializeI18n(app, store)
initializeNotifications(router)
// The server's copy of the page, for clients that never get this far -- see // The server's copy of the page, for clients that never get this far -- see
// `backend/helpers/appShell.ts`. It has done its job by now, and Vue is about to draw the real thing. // `backend/helpers/appShell.ts`. It has done its job by now, and Vue is about to draw the real thing.
document.getElementById('wiki-prerender')?.remove() document.getElementById('wiki-prerender')?.remove()

@ -223,6 +223,32 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="person" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowLastEditedBy`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowLastEditedByHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.lastEditedBy"
:aria-label="t(`admin.general.allowLastEditedBy`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="inbox" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowNotifications`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowNotificationsHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.notifications"
:aria-label="t(`admin.general.allowNotifications`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item> <w-item>
<blueprint-icon icon="star-half-empty" /> <blueprint-icon icon="star-half-empty" />
<w-item-section> <w-item-section>
@ -240,19 +266,6 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="person" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowLastEditedBy`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowLastEditedByHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.lastEditedBy"
:aria-label="t(`admin.general.allowLastEditedBy`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label"> <w-item tag="label">
<blueprint-icon icon="search" /> <blueprint-icon icon="search" />
<w-item-section> <w-item-section>
@ -701,6 +714,7 @@ function defaultConfig() {
ratingsMode: 'off', ratingsMode: 'off',
comments: true, comments: true,
lastEditedBy: true, lastEditedBy: true,
notifications: true,
reasonForChange: 'required' reasonForChange: 'required'
}, },
discoverable: false, discoverable: false,
@ -842,6 +856,7 @@ async function save() {
collaborativeEditing: state.config.features?.collaborativeEditing ?? false, collaborativeEditing: state.config.features?.collaborativeEditing ?? false,
comments: state.config.features?.comments ?? true, comments: state.config.features?.comments ?? true,
lastEditedBy: state.config.features?.lastEditedBy ?? true, lastEditedBy: state.config.features?.lastEditedBy ?? true,
notifications: state.config.features?.notifications ?? true,
ratingsMode: state.config.features?.ratingsMode ?? 'off', ratingsMode: state.config.features?.ratingsMode ?? 'off',
reasonForChange: state.config.features?.reasonForChange ?? 'required', reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false search: state.config.features?.search ?? false

@ -0,0 +1,310 @@
<template>
<w-page class="admin-notifications">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img
class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-topic-push-notification.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.notifications.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.notifications.subtitle') }}
</div>
</div>
<div class="flex-none">
<w-btn
class="mr-2 ml-4 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/notifications`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn mr-2"
icon="la:redo-alt"
flat
color="secondary"
:loading="state.loading > 0"
:aria-label="t(`common.actions.refresh`)"
@click="refresh">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<w-btn
unelevated
icon="mdi:check"
:label="t(`common.actions.apply`)"
color="secondary"
:loading="state.loading > 0"
@click="save" />
</div>
</div>
<w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Settings -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>{{ t('admin.notifications.settings') }}</w-card-header>
<w-item>
<blueprint-icon icon="timer" top />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.emailDelay`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.notifications.emailDelayHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 160px">
<w-input
v-model="state.config.emailDelay"
outlined
dense
placeholder="3m"
:aria-label="t(`admin.notifications.emailDelay`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="historical" top />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.retentionDays`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.notifications.retentionDaysHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 160px">
<w-input
v-model="state.config.retentionDays"
type="number"
outlined
dense
:suffix="t(`admin.notifications.days`)"
:aria-label="t(`admin.notifications.retentionDays`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="email" top />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.mailBatchSize`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.notifications.mailBatchSizeHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 160px">
<w-input
v-model="state.config.mailBatchSize"
type="number"
outlined
dense
:aria-label="t(`admin.notifications.mailBatchSize`)" />
</w-item-section>
</w-item>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Status -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>
{{ t('admin.notifications.status') }}
<template #hint>{{ t('admin.notifications.statusHint') }}</template>
</w-card-header>
<w-item>
<blueprint-icon
icon="email-open"
:indicator="state.status.isMailConfigured ? `positive` : `negative`" />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.mail`) }}</w-item-label>
<w-item-label caption>
{{
state.status.isMailConfigured
? t(`admin.notifications.mailConfigured`)
: t(`admin.notifications.mailNotConfigured`)
}}
</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
:label="t(`admin.notifications.configureMail`)"
to="/_admin/mail" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="workflow" />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.backlog`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.notifications.backlogHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-right">
<div class="text-h6">{{ state.status.pendingEvents }}</div>
<div v-if="state.status.oldestPendingEventAt" class="text-caption text-grey">
{{
t('admin.notifications.oldest', {
date: relativeDate(state.status.oldestPendingEventAt)
})
}}
</div>
</div>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="received" />
<w-item-section>
<w-item-label>{{ t(`admin.notifications.emails`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.notifications.emailsHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="flex gap-6 text-center">
<div>
<div class="text-h6">{{ state.status.emailsPending }}</div>
<div class="text-caption text-grey">{{ t('admin.notifications.pending') }}</div>
</div>
<div>
<div class="text-h6 text-positive">{{ state.status.emailsSent24h }}</div>
<div class="text-caption text-grey">{{ t('admin.notifications.sent') }}</div>
</div>
<div>
<div
class="text-h6"
:class="state.status.emailsFailed24h > 0 ? `text-negative` : ``">
{{ state.status.emailsFailed24h }}
</div>
<div class="text-caption text-grey">{{ t('admin.notifications.failed') }}</div>
</div>
</div>
</w-item-section>
</w-item>
<template v-for="warning of state.status.warnings" :key="warning">
<w-separator class="my-2" inset />
<w-item>
<w-item-section>
<div class="text-caption text-deep-orange flex items-start">
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
<span>{{ t(`admin.notifications.warnings.${warning}`) }}</span>
</div>
</w-item-section>
</w-item>
</template>
</w-card>
</div>
</div>
</w-page>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { loading } from '@/composables/loading'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { relativeDate } from '@/helpers/datetime'
import { useSiteStore } from '@/stores/site'
/**
* Admin → Notifications: the instance-wide settings of the notification system, and how delivery is
* doing. Whether a SITE has notifications is under its General → Features; what each person receives
* is theirs to choose, under Profile → Notifications.
*/
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('admin.notifications.title')
}))
// DATA
const state = reactive({
loading: 0,
config: {
retentionDays: 60,
emailDelay: '3m',
mailBatchSize: 100
},
status: {
pendingEvents: 0,
oldestPendingEventAt: null,
emailsPending: 0,
emailsSent24h: 0,
emailsFailed24h: 0,
isMailConfigured: true,
warnings: []
}
})
// METHODS
async function load() {
state.loading++
loading.show()
try {
const resp = await API_CLIENT.get('system/notifications').json()
state.config = { ...state.config, ...resp.settings }
state.status = { ...state.status, ...resp.status }
} catch (err) {
notify({
type: 'negative',
message: t('admin.notifications.loadFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
state.loading--
}
async function refresh() {
await load()
notify({
type: 'positive',
message: t('admin.notifications.refreshSuccess')
})
}
async function save() {
state.loading++
try {
await API_CLIENT.put('system/notifications', {
json: {
retentionDays: Number(state.config.retentionDays),
emailDelay: `${state.config.emailDelay ?? ''}`.trim(),
mailBatchSize: Number(state.config.mailBatchSize)
}
})
notify({
type: 'positive',
message: t('admin.notifications.saveSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.notifications.saveFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
// MOUNTED
onMounted(load)
</script>

@ -1,16 +1,216 @@
<template> <template>
<w-page class="py-4"> <w-page class="py-4">
<div class="w-section-header">{{ t('inbox.inbox') }}</div> <div class="w-section-header flex items-center">
<span>{{ t('inbox.inbox') }}</span>
<w-space />
<!--
-> A dark tab notched into the card's top right corner, so its controls are drawn for a dark
surface in both themes. See `.inbox-tab` for how it fills the corner without growing the
heading.
-->
<div v-if="siteStore.features.notifications" class="inbox-tab">
<w-toggle
v-model="notificationsStore.unreadOnly"
dense
dark
class="mr-4"
:label="t(`inbox.unreadOnly`)"
@update:model-value="reload" />
<!--
-> The tooltip is on a wrapper rather than on the button: a disabled button takes no pointer
events, and the name is still worth showing when there is nothing to mark
-->
<span class="inline-flex" data-tooltip-anchor>
<w-btn
flat
dense
round
icon="mdi:email-open-multiple-outline"
color="white"
:aria-label="t(`inbox.markAllRead`)"
:disable="notificationsStore.unread < 1"
@click="markAllRead" />
<w-tooltip>{{ t('inbox.markAllRead') }}</w-tooltip>
</span>
<w-btn
class="ml-2"
flat
dense
round
icon="la:cog"
color="grey-4"
to="/_profile/notifications"
:aria-label="t(`inbox.settings`)">
<w-tooltip>{{ t('inbox.settings') }}</w-tooltip>
</w-btn>
</div>
</div>
<div class="p-4"> <div class="p-4">
<div class="text-body2">{{ t('inbox.inboxInfo') }}</div> <w-banner
v-if="!siteStore.features.notifications"
rounded
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
{{ t('inbox.notificationsOff') }}
</w-banner>
<w-banner
v-else-if="notificationsStore.listLoaded && notificationsStore.entries.length < 1"
rounded
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
<div>
{{ notificationsStore.unreadOnly ? t('inbox.noneUnread') : t('inbox.none') }}
</div>
<div class="text-caption mt-1 opacity-70">{{ t('inbox.noneHint') }}</div>
</w-banner>
<template v-else>
<template v-for="group of groups" :key="group.key">
<div class="inbox-day text-caption">{{ group.label }}</div>
<w-list bordered separator class="mb-4">
<w-item
v-for="entry of group.entries"
:key="entry.id"
:clickable="Boolean(targetOf(entry))"
:class="{ 'inbox-entry--unread': !entry.isRead }"
@click="open(entry)">
<w-item-section avatar>
<w-avatar
:color="entry.isRead ? `grey-5` : `primary`"
text-color="white"
rounded
size="36px">
<w-icon :name="iconOf(entry)" size="20px" />
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label>
<i18n-t
:keypath="`notifications.messages.${entry.category}.${entry.variant}`"
tag="span"
scope="global">
<template #actor>
<strong>{{ entry.data.actorName || t('notifications.someone') }}</strong>
</template>
<template #page>
<strong>{{ entry.data.page?.title ?? '' }}</strong>
</template>
</i18n-t>
</w-item-label>
<w-item-label v-if="entry.data.excerpt" caption class="inbox-excerpt">
“{{ entry.data.excerpt }}”
</w-item-label>
<w-item-label caption>
<!-- -> Anchored on the date itself, or WTooltip climbs to the row's `.w-item` -->
<span data-tooltip-anchor>
{{ relativeDate(entry.updatedAt) }}
<w-tooltip>{{ userStore.formatDateTime(t, entry.updatedAt) }}</w-tooltip>
</span>
<template v-if="entry.count > 1">
&middot; {{ t('notifications.count', { count: entry.count }) }}
</template>
<template v-if="entry.data.origin">
&middot; {{ t(`notifications.origin.${entry.data.origin}`) }}
</template>
<template v-if="entry.data.page?.path">
&middot; /{{ entry.data.page.path }}
</template>
</w-item-label>
</w-item-section>
<w-item-section side>
<div class="flex flex-nowrap items-center">
<!-- -> `.stop` on both, so acting on an entry does not also follow it -->
<w-btn
v-if="!entry.isRead"
class="acrylic-btn"
flat
dense
icon="mdi:email-open-outline"
color="primary"
:aria-label="t(`inbox.markRead`)"
@click.stop="markRead(entry)">
<w-tooltip>{{ t('inbox.markRead') }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn ml-2"
flat
dense
icon="mdi:close"
color="grey"
:aria-label="t(`inbox.dismiss`)"
@click.stop="dismiss(entry)">
<w-tooltip>{{ t('inbox.dismiss') }}</w-tooltip>
</w-btn>
</div>
</w-item-section>
</w-item>
</w-list>
</template>
<div v-if="notificationsStore.next" class="flex justify-center">
<w-btn
flat
no-caps
color="primary"
icon="mdi:chevron-down"
:label="t(`inbox.loadMore`)"
:loading="notificationsStore.listLoading"
@click="loadMore" />
</div>
</template>
</div> </div>
<w-inner-loading :showing="notificationsStore.listLoading && !notificationsStore.listLoaded" />
</w-page> </w-page>
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { relativeDate } from '@/helpers/datetime'
import { useNotificationsStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/**
* The inbox: what this reader has been told about on this site, newest activity first, grouped by
* day.
*
* Each entry's sentence is `notifications.messages.<category>.<variant>`, the same string the email
* about it is written from, drawn from the snapshot the entry carries — so it still reads right after
* the page has been renamed or deleted. Opening an entry marks it read and follows it; an entry about
* a page that has gone has nowhere to lead and only says what happened.
*/
/** The categories whose entries lead to a page's discussion rather than to the page itself. */
const DISCUSSION_CATEGORIES = new Set(['watchedPageComment', 'commentReply', 'mention'])
/** The picture beside each kind of entry. Literal names, so that the build bundles them. */
const ICONS = {
watchedPage: 'mdi:file-document-edit-outline',
watchedPageComment: 'mdi:comment-text-outline',
commentReply: 'mdi:reply-outline',
mention: 'mdi:at',
reviewRequested: 'mdi:clipboard-check-outline',
pageCreated: 'mdi:file-plus-outline',
pageDeleted: 'mdi:file-remove-outline'
}
// COMPOSABLES
const dark = useDark()
// ROUTER
const router = useRouter()
// STORES
const notificationsStore = useNotificationsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N // I18N
@ -21,4 +221,223 @@ const { t } = useI18n()
useMeta(() => ({ useMeta(() => ({
title: t('inbox.inbox') title: t('inbox.inbox')
})) }))
// COMPUTED
/** The loaded entries under a heading per day, in the reader's own time zone. */
const groups = computed(() => {
const zone = userStore.timezoneId()
const today = Temporal.Now.plainDateISO(zone)
const yesterday = today.subtract({ days: 1 })
const result = []
for (const entry of notificationsStore.entries) {
const day = Temporal.Instant.from(entry.updatedAt).toZonedDateTimeISO(zone).toPlainDate()
const key = day.toString()
let group = result.find((g) => g.key === key)
if (!group) {
group = {
key,
label: day.equals(today)
? t('inbox.today')
: day.equals(yesterday)
? t('inbox.yesterday')
: userStore.formatDate(entry.updatedAt),
entries: []
}
result.push(group)
}
group.entries.push(entry)
}
return result
})
// METHODS
function iconOf(entry) {
return entry.variant === 'deleted' ? ICONS.pageDeleted : (ICONS[entry.category] ?? 'mdi:bell')
}
/**
* Where an entry leads: the review it asks for, the discussion it is about, or the page. `/i/<id>`
* rather than the path, because the page may have moved since — which is what an id link survives.
*/
function targetOf(entry) {
if (entry.category === 'reviewRequested' && entry.data.submissionId) {
return `/_inbox/review/${entry.data.submissionId}`
}
if (!entry.pageId) {
return null
}
return {
path: `/i/${entry.pageId}`,
hash: DISCUSSION_CATEGORIES.has(entry.category) ? '#talk' : ''
}
}
async function reload() {
try {
await notificationsStore.loadList()
} catch (err) {
notify({
type: 'negative',
message: t('inbox.loadFailed'),
caption: apiErrorMessage(err)
})
}
}
async function loadMore() {
try {
await notificationsStore.loadList({ append: true })
} catch (err) {
notify({
type: 'negative',
message: t('inbox.loadFailed'),
caption: apiErrorMessage(err)
})
}
}
async function markRead(entry) {
try {
await notificationsStore.markRead({ ids: [entry.id] })
} catch (err) {
notify({
type: 'negative',
message: t('inbox.markReadFailed'),
caption: apiErrorMessage(err)
})
}
}
async function markAllRead() {
try {
await notificationsStore.markRead()
} catch (err) {
notify({
type: 'negative',
message: t('inbox.markReadFailed'),
caption: apiErrorMessage(err)
})
}
}
async function dismiss(entry) {
try {
await notificationsStore.dismiss(entry.id)
} catch (err) {
notify({
type: 'negative',
message: t('inbox.dismissFailed'),
caption: apiErrorMessage(err)
})
}
}
/** Follow an entry, marking it read on the way. Not awaited: the page need not wait for the write. */
function open(entry) {
const target = targetOf(entry)
if (!target) {
return
}
if (!entry.isRead) {
markRead(entry)
}
router.push(target)
}
// MOUNTED
onMounted(() => {
if (siteStore.features.notifications) {
reload()
}
})
</script> </script>
<style lang="scss" scoped>
/*
The header's controls, as a folder tab hanging off the card's top edge.
It stretches to the header's height and then pulls out past it with negative margins: up through
the page's `py-4` to the card's top edge, right through the heading's 16px padding to the card's
side, and down through its 6px padding to the hairline. Negative margins take nothing from the
line it sits on, so the heading stays exactly as tall as on the other inbox sections -- level
with the rail's first item.
The angled edge is a mask on a piece hung off its left rather than a `clip-path` on the tab
itself, which would also clip the card's corner radius and the buttons' focus rings. Its path
rounds the free corner at the foot of the slant, and flares the top of it into the card's edge
the way a browser tab meets its strip; `::after` is the same flare where the tab's bottom meets
the card's right side. The mask is stretched to the tab's height, which is the 48 its viewBox
is drawn at to within a pixel, so the curves come out round.
*/
.inbox-tab {
--inbox-tab-bg: #{$dark-2};
position: relative;
align-self: stretch;
display: flex;
align-items: center;
margin: -16px -16px -6px 0;
padding: 0 12px 0 4px;
border-top-right-radius: 7px;
background-color: var(--inbox-tab-bg);
color: #fff;
// -> Overlaps the tab by a pixel, so no seam of the card shows through between the two
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: calc(100% - 1px);
width: 36px;
background-color: var(--inbox-tab-bg);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 48' preserveAspectRatio='none'%3E%3Cpath d='M0 0H36V48H32Q26 48 23.7 42.5L8.3 5.5Q6 0 0 0Z'/%3E%3C/svg%3E")
no-repeat 0 0 / 100% 100%;
}
&::after {
content: '';
position: absolute;
top: 100%;
right: 0;
width: 6px;
height: 6px;
background: radial-gradient(circle at 0 100%, transparent 6px, var(--inbox-tab-bg) 6.5px);
}
// -> A shade up from the card in dark mode, where `dark-2` is barely apart from `dark-3`
@at-root .body--dark & {
--inbox-tab-bg: #{$dark-1};
}
}
.inbox-day {
margin: 0 0 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $grey-7;
@at-root .body--dark & {
color: $grey-5;
}
}
.inbox-entry--unread {
background: linear-gradient(to right, rgba($primary, 0.08), transparent);
@at-root .body--dark & {
background: linear-gradient(to right, rgba($primary, 0.18), transparent);
}
}
.inbox-excerpt {
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

@ -547,6 +547,7 @@ import { parseBlog } from '@/helpers/pageBlog'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { useNotificationsStore } from '@/stores/notifications'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -636,6 +637,7 @@ const editorComponents = {
const commonStore = useCommonStore() const commonStore = useCommonStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const notificationsStore = useNotificationsStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -1071,6 +1073,20 @@ watch(
} }
) )
/*
Reading the page is reading what the reader was told about it: its content as soon as it is on
screen, its discussion once the Talk tab is open. This is what lets an email go out once and then
stay quiet until it is read, without anybody having to visit the inbox. It writes nothing unless the
page came with unread entries of that kind.
*/
watch(
() => [pageStore.id, pageStore.unreadNotifications, activeView.value],
() => {
notificationsStore.markSeen({ inDiscussion: activeView.value === 'talk' })
},
{ immediate: true }
)
/* /*
A protected page asks for its password the moment it arrives: the reader followed a link to read it, A protected page asks for its password the moment it arrives: the reader followed a link to read it,
and making them press a button first would only add a step. Keyed on the page rather than on the and making them press a button first would only add a step. Keyed on the page rather than on the

@ -0,0 +1,213 @@
<template>
<w-page class="py-4">
<div class="w-section-header">{{ t('profile.notifications') }}</div>
<div class="px-4 pt-4">
<div class="text-body2">{{ t('profile.notificationsInfo') }}</div>
</div>
<!--
Said rather than hidden: the preferences are the person's for every site, so they stay editable
here, and what this site does with them is what the banner explains.
-->
<w-item v-if="!siteStore.features.notifications">
<w-item-section>
<w-banner rounded class="bg-warning text-white">
{{ t('profile.notificationsSiteOff') }}
</w-banner>
</w-item-section>
</w-item>
<w-item v-if="!state.emailAvailable && state.loaded">
<w-item-section>
<w-banner
rounded
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
{{ t('profile.notificationsNoEmail') }}
</w-banner>
</w-item-section>
</w-item>
<template v-for="section of sections" :key="section.key">
<div class="w-section-header mt-6">{{ t(`notifications.sections.${section.key}`) }}</div>
<template v-for="(pref, idx) of section.prefs" :key="pref.key">
<w-separator v-if="idx > 0" inset spaced="sm" />
<w-item>
<blueprint-icon :icon="CATEGORY_ICONS[pref.key] ?? `inbox`" />
<w-item-section>
<w-item-label>{{ t(`notifications.categories.${pref.key}.title`) }}</w-item-label>
<w-item-label caption>
{{ t(`notifications.categories.${pref.key}.description`) }}
</w-item-label>
</w-item-section>
<w-item-section side>
<div class="flex flex-nowrap items-center gap-4">
<w-toggle
v-model="pref.inApp"
dense
:label="t(`profile.notificationsInApp`)"
:aria-label="`${t(`notifications.categories.${pref.key}.title`)}: ${t(`profile.notificationsInApp`)}`" />
<w-toggle
v-model="pref.email"
dense
:disable="!state.emailAvailable"
:label="t(`profile.notificationsEmail`)"
:aria-label="`${t(`notifications.categories.${pref.key}.title`)}: ${t(`profile.notificationsEmail`)}`" />
</div>
</w-item-section>
</w-item>
</template>
</template>
<div class="actions-bar mt-6">
<w-btn
class="acrylic-btn self-center"
icon="la:envelope-open"
flat
size="sm"
:label="t(`profile.notificationsStopEmail`)"
color="pink"
:disable="state.loading > 0 || !state.emailAvailable"
@click="stopAllEmail" />
<w-space />
<w-btn
icon="la:check"
unelevated
:label="t(`common.actions.saveChanges`)"
color="secondary"
:disable="state.loading > 0"
@click="save" />
</div>
<w-inner-loading :showing="state.loading > 0" />
</w-page>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useSiteStore } from '@/stores/site'
/**
* Profile → Notifications: what this person is told about, and how.
*
* One row per category the server offers them, each with an In-App and an Email switch; both off is
* how somebody says "never". The categories, their headings and their defaults all come from the
* server (`notifications/index.ts`), so a category added there appears here without a change — what
* this file knows of them is a picture for each, and the strings, which are keyed by category.
*
* One set of preferences for every site, which is why this sits in the profile rather than in a site.
*/
/** The picture beside each category. One that has none gets the inbox. */
const CATEGORY_ICONS = {
watchedPage: 'activity-feed',
watchedPageComment: 'comments',
commentReply: 'chat',
mention: 'contact',
reviewRequested: 'todo-list',
pageCreated: 'new-document',
pageDeleted: 'trash'
}
// COMPOSABLES
const dark = useDark()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('profile.notifications')
}))
// DATA
const state = reactive({
prefs: [],
emailAvailable: false,
loaded: false,
loading: 0
})
// COMPUTED
/** The rows grouped under their headings, in the order the server sent them in. */
const sections = computed(() => {
const grouped = []
for (const pref of state.prefs) {
let section = grouped.find((s) => s.key === pref.section)
if (!section) {
section = { key: pref.section, prefs: [] }
grouped.push(section)
}
section.prefs.push(pref)
}
return grouped
})
// METHODS
function apply(resp) {
state.prefs = resp.preferences ?? []
state.emailAvailable = resp.emailAvailable ?? false
state.loaded = true
}
async function load() {
state.loading++
try {
apply(await API_CLIENT.get('users/profile/notifications').json())
} catch (err) {
notify({
type: 'negative',
message: t('profile.notificationsLoadFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
async function save() {
state.loading++
try {
const preferences = Object.fromEntries(
state.prefs.map((pref) => [pref.key, { inApp: pref.inApp, email: pref.email }])
)
apply(await API_CLIENT.put('users/profile/notifications', { json: { preferences } }).json())
notify({
type: 'positive',
message: t('profile.notificationsSaved')
})
} catch (err) {
notify({
type: 'negative',
message: t('profile.notificationsSaveFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
/** Every email switch off, and saved — the same thing the unsubscribe page offers. */
async function stopAllEmail() {
for (const pref of state.prefs) {
pref.email = false
}
await save()
}
// MOUNTED
onMounted(load)
</script>

@ -0,0 +1,209 @@
<template>
<div class="unsubscribe">
<div class="unsubscribe-card">
<div class="unsubscribe-logo">
<img :src="`/_site/current/logo`" :alt="siteStore.title" />
</div>
<h1 class="text-h6 mb-2">{{ t('unsubscribe.title') }}</h1>
<template v-if="state.status === 'loading'">
<w-spinner size="32px" color="primary" />
</template>
<template v-else-if="state.status === 'invalid'">
<p class="text-body2">{{ t('unsubscribe.invalid') }}</p>
<div class="unsubscribe-actions">
<w-btn
unelevated
color="primary"
:label="t(`unsubscribe.manage`)"
@click="goToSettings" />
</div>
</template>
<template v-else-if="state.status === 'done'">
<p class="text-body2">
{{ state.scope === 'all' ? t('unsubscribe.doneAll') : t('unsubscribe.done') }}
</p>
<div class="unsubscribe-actions">
<w-btn flat color="primary" :label="t(`unsubscribe.manage`)" @click="goToSettings" />
<w-btn unelevated color="primary" :label="t(`unsubscribe.backToWiki`)" to="/" />
</div>
</template>
<!--
The page that asks. Nothing has happened by the time it is drawn: a mail scanner following the
link gets this far and no further, which is the reason it exists.
-->
<template v-else>
<p class="text-body2">{{ t('unsubscribe.intro') }}</p>
<ul class="unsubscribe-list text-body2">
<li v-for="category of state.categories" :key="category">
{{ t(`notifications.categories.${category}.title`) }}
</li>
</ul>
<p class="text-caption opacity-70">{{ t('unsubscribe.inAppStays') }}</p>
<div class="unsubscribe-actions">
<w-btn
flat
color="negative"
:label="t(`unsubscribe.all`)"
:disable="state.busy"
@click="unsubscribe('all')" />
<w-btn
unelevated
color="primary"
:label="t(`unsubscribe.confirm`)"
:loading="state.busy"
@click="unsubscribe('token')" />
</div>
</template>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/**
* Where an unsubscribe link in a notification email lands — the one in the mail body, and the one in
* the `List-Unsubscribe` header when it is opened rather than posted.
*
* It asks before it acts. A mail client's own unsubscribe button posts to the API directly and is done
* (RFC 8058); this page is for a person who clicked, and for the scanners that fetch every link in a
* message, which must not unsubscribe anybody by doing so. No session is needed: the token in the URL
* says whose email it is, and all it can do is turn that email off.
*/
// ROUTER
const route = useRoute()
const router = useRouter()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('unsubscribe.title')
}))
// DATA
const state = reactive({
/** `loading`, `ready`, `invalid` or `done`. */
status: 'loading',
categories: [],
scope: 'token',
busy: false
})
// METHODS
async function load() {
try {
const info = await API_CLIENT.get('notifications/unsubscribe/info', {
searchParams: { t: route.query.t ?? '' }
}).json()
state.categories = info.categories ?? []
state.status = info.valid ? 'ready' : 'invalid'
} catch {
state.status = 'invalid'
}
}
async function unsubscribe(scope) {
state.busy = true
try {
await API_CLIENT.post('notifications/unsubscribe', {
json: { t: route.query.t ?? '', scope }
})
state.scope = scope
state.status = 'done'
} catch (err) {
notify({
type: 'negative',
message: t('unsubscribe.failed'),
caption: apiErrorMessage(err)
})
}
state.busy = false
}
/** The full settings, which need a session — the login screen comes first for somebody without one. */
function goToSettings() {
router.push(userStore.authenticated ? '/_profile/notifications' : '/login')
}
// MOUNTED
onMounted(load)
</script>
<style lang="scss" scoped>
.unsubscribe {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background-color: $grey-2;
color: var(--color-black);
@at-root .body--dark & {
background-color: $dark-6;
color: var(--color-white);
}
&-card {
width: 100%;
max-width: 480px;
padding: 32px;
border-radius: 8px;
background-color: #fff;
box-shadow: $shadow-2;
@at-root .body--dark & {
background-color: $dark-3;
}
}
&-logo {
margin-bottom: 16px;
img {
height: 48px;
}
}
&-list {
margin: 12px 0;
padding-inline-start: 20px;
list-style: disc;
}
&-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
}
</style>

@ -20,7 +20,7 @@ const routes = [
beforeEnter: async (to) => { beforeEnter: async (to) => {
const pageStore = usePageStore() const pageStore = usePageStore()
try { try {
return await pageStore.pageAlias(to.params.alias) return { path: await pageStore.pageAlias(to.params.alias), hash: to.hash }
} catch { } catch {
return '/_error/notfound' return '/_error/notfound'
} }
@ -32,7 +32,9 @@ const routes = [
beforeEnter: async (to) => { beforeEnter: async (to) => {
const pageStore = usePageStore() const pageStore = usePageStore()
try { try {
return await pageStore.pageById(to.params.pageId) // -> The fragment survives the redirect: `#talk` is how a notification about a comment
// opens the page on its discussion
return { path: await pageStore.pageById(to.params.pageId), hash: to.hash }
} catch { } catch {
return '/_error/notfound' return '/_error/notfound'
} }
@ -46,9 +48,19 @@ const routes = [
{ path: 'info', component: () => import('@/pages/ProfileInfo.vue') }, { path: 'info', component: () => import('@/pages/ProfileInfo.vue') },
{ path: 'avatar', component: () => import('@/pages/ProfileAvatar.vue') }, { path: 'avatar', component: () => import('@/pages/ProfileAvatar.vue') },
{ path: 'auth', component: () => import('@/pages/ProfileAuth.vue') }, { path: 'auth', component: () => import('@/pages/ProfileAuth.vue') },
{ path: 'groups', component: () => import('@/pages/ProfileGroups.vue') } { path: 'groups', component: () => import('@/pages/ProfileGroups.vue') },
{ path: 'notifications', component: () => import('@/pages/ProfileNotifications.vue') }
] ]
}, },
/*
Where an unsubscribe link in a notification email lands. Outside the profile because it needs no
session -- the token in the link is what says whose email it is.
*/
{
path: '/_unsubscribe',
component: () => import('@/layouts/AuthLayout.vue'),
children: [{ path: '', component: () => import('@/pages/Unsubscribe.vue') }]
},
{ {
path: '/_inbox', path: '/_inbox',
component: () => import('@/layouts/InboxLayout.vue'), component: () => import('@/layouts/InboxLayout.vue'),
@ -117,6 +129,7 @@ const routes = [
{ path: 'mail', component: () => import('@/pages/AdminMail.vue') }, { path: 'mail', component: () => import('@/pages/AdminMail.vue') },
{ path: 'mcp', component: () => import('@/pages/AdminMcp.vue') }, { path: 'mcp', component: () => import('@/pages/AdminMcp.vue') },
{ path: 'metrics', component: () => import('@/pages/AdminMetrics.vue') }, { path: 'metrics', component: () => import('@/pages/AdminMetrics.vue') },
{ path: 'notifications', component: () => import('@/pages/AdminNotifications.vue') },
{ path: 'rendering', component: () => import('@/pages/AdminRendering.vue') }, { path: 'rendering', component: () => import('@/pages/AdminRendering.vue') },
{ path: 'scheduler', component: () => import('@/pages/AdminScheduler.vue') }, { path: 'scheduler', component: () => import('@/pages/AdminScheduler.vue') },
{ path: 'search', component: () => import('@/pages/AdminSearch.vue') }, { path: 'search', component: () => import('@/pages/AdminSearch.vue') },

@ -0,0 +1,162 @@
import { defineStore } from 'pinia'
import { usePageStore } from './page'
import { useSiteStore } from './site'
import { useUserStore } from './user'
/** What reading a page's content counts as having seen. */
const CONTENT_CATEGORIES = ['watchedPage', 'pageCreated']
/** What reading a page's discussion counts as having seen. */
const DISCUSSION_CATEGORIES = ['watchedPageComment', 'commentReply', 'mention']
/**
* The signed-in reader's notifications on this site: the badge count, and the inbox once it is opened.
*
* `refresh()` is the one way the count is brought up to date, and what decides WHEN it is called lives
* outside this store, in `boot/notifications.js`. That split is the point: today a timer and a few
* events call it; a push channel added later will only ever say "something changed" and call the same
* method, so the state a pushed update leaves behind is exactly the state a poll would have — the
* summary endpoint stays the single source of truth either way.
*/
export const useNotificationsStore = defineStore('notifications', {
state: () => ({
/** Unread entries, counted by the server no further than 100. */
unread: 0,
/** When the inbox last changed, which is how a refresh knows the list it holds is stale. */
latestAt: null,
/** The loaded part of the inbox, newest activity first. */
entries: [],
/** The cursor of the next page, or null at the end. */
next: null,
/** Whether the inbox has been opened, so that a refresh knows there is a list to keep current. */
listLoaded: false,
listLoading: false,
/** Only unread entries in the list. */
unreadOnly: false
}),
getters: {
/** What the badge says: nothing at zero, `99+` once the count is capped. */
badge: (state) => (state.unread > 99 ? '99+' : state.unread > 0 ? String(state.unread) : ''),
/** Whether there is anything to poll for: a signed-in reader, on a site that has notifications. */
isActive: () => {
const userStore = useUserStore()
const siteStore = useSiteStore()
return Boolean(userStore.authenticated && siteStore.id && siteStore.features.notifications)
}
},
actions: {
/**
* Bring the count up to date, and the list too if one is showing and the inbox has moved.
*
* The request revalidates against the ETag the server sent last time — the browser does that for
* a `no-cache` response on its own — so a poll that finds nothing new costs a 304 and no body.
*/
async refresh() {
if (!this.isActive) {
this.reset()
return
}
const siteStore = useSiteStore()
try {
const summary = await API_CLIENT.get(`sites/${siteStore.id}/notifications/summary`).json()
const moved = summary.latestAt !== this.latestAt
this.unread = summary.unread ?? 0
this.latestAt = summary.latestAt ?? null
if (moved && this.listLoaded) {
await this.loadList()
}
} catch (err) {
// -> A missed poll is not worth interrupting anybody for: the next one tries again
console.warn(`Could not refresh notifications: ${err.message}`)
}
},
/**
* Load the inbox from the top, or the next page of it.
*/
async loadList({ append = false } = {}) {
const siteStore = useSiteStore()
if (append && !this.next) {
return
}
this.listLoading = true
try {
const resp = await API_CLIENT.get(`sites/${siteStore.id}/notifications`, {
searchParams: {
...(append && this.next ? { cursor: this.next } : {}),
...(this.unreadOnly ? { unread: true } : {})
}
}).json()
this.entries = append ? [...this.entries, ...resp.entries] : resp.entries
this.next = resp.next ?? null
this.listLoaded = true
} finally {
this.listLoading = false
}
},
/**
* Mark entries read — particular ones, everything about a page, or everything.
*
* The list is updated in place rather than reloaded, since what changed is known; the count is
* asked for again, since the server is what knows it.
*
* @param {{ ids?: string[], pageId?: string, categories?: string[] }} filter
*/
async markRead(filter = {}) {
const siteStore = useSiteStore()
await API_CLIENT.put(`sites/${siteStore.id}/notifications/read`, { json: filter })
const matches = (entry) =>
(!filter.ids || filter.ids.includes(entry.id)) &&
(!filter.pageId || entry.pageId === filter.pageId) &&
(!filter.categories || filter.categories.includes(entry.category))
for (const entry of this.entries) {
if (matches(entry)) {
entry.isRead = true
}
}
await this.refresh()
},
/**
* Mark what the reader has now seen of the page in front of them as read: its content on opening
* it, its discussion on opening the Talk tab.
*
* This is what makes "emailed once, then quiet until read" work without anybody visiting the
* inbox — reading the page IS reading the notification about it. Nothing is written unless the
* page came with unread entries of the kind just seen, which is the page payload's
* `unreadNotifications`, so an ordinary page view costs no request.
*/
async markSeen({ inDiscussion = false } = {}) {
const pageStore = usePageStore()
const unread = pageStore.unreadNotifications ?? []
const seen = unread.filter(
(category) =>
CONTENT_CATEGORIES.includes(category) ||
(inDiscussion && DISCUSSION_CATEGORIES.includes(category))
)
if (!pageStore.id || seen.length < 1 || !this.isActive) {
return
}
// -> Taken off first, so that whatever watches this does not ask a second time
pageStore.unreadNotifications = unread.filter((category) => !seen.includes(category))
try {
await this.markRead({ pageId: pageStore.id, categories: seen })
} catch (err) {
console.warn(`Could not mark notifications read: ${err.message}`)
}
},
async dismiss(id) {
const siteStore = useSiteStore()
await API_CLIENT.delete(`sites/${siteStore.id}/notifications/${id}`)
this.entries = this.entries.filter((entry) => entry.id !== id)
await this.refresh()
},
/** Forget everything: a different reader, a different site, or a site without notifications. */
reset() {
this.unread = 0
this.latestAt = null
this.entries = []
this.next = null
this.listLoaded = false
}
}
})

@ -176,6 +176,12 @@ export const usePageStore = defineStore('page', {
* a watch belongs to an account, which is what a notification would eventually be sent to. * a watch belongs to an account, which is what a notification would eventually be sent to.
*/ */
isWatching: false, isWatching: false,
/**
* The notification categories this reader has unread entries in about this page. Opening the page
* marks the ones about its content read, and opening its Talk tab the ones about its discussion —
* see `markSeen` in the notifications store.
*/
unreadNotifications: [],
/** /**
* How readers have rated this page, as `{ mode, count, average, up, down }` on the site's current * How readers have rated this page, as `{ mode, count, average, up, down }` on the site's current
* scale, or null when ratings are off for the site or for the page. * scale, or null when ratings are off for the site or for the page.
@ -436,6 +442,7 @@ export const usePageStore = defineStore('page', {
canReview: viewer.canReview === true, canReview: viewer.canReview === true,
pendingSubmissions: viewer.pendingSubmissions ?? [], pendingSubmissions: viewer.pendingSubmissions ?? [],
isWatching: viewer.isWatching === true, isWatching: viewer.isWatching === true,
unreadNotifications: viewer.unreadNotifications ?? [],
viewerRating: viewer.rating ?? 0 viewerRating: viewer.rating ?? 0
}) })
}, },
@ -484,6 +491,7 @@ export const usePageStore = defineStore('page', {
canReview: false, canReview: false,
pendingSubmissions: [], pendingSubmissions: [],
isWatching: false, isWatching: false,
unreadNotifications: [],
rating: null, rating: null,
viewerRating: 0, viewerRating: 0,
blog: null, blog: null,

@ -120,6 +120,9 @@ export const useSiteStore = defineStore('site', {
browse: false, browse: false,
collaborativeEditing: false, collaborativeEditing: false,
lastEditedBy: true, lastEditedBy: true,
// -> On, for the reason `backlinks` is: the server reads a missing key as on, and the badge
// must not disagree with it
notifications: true,
ratingsMode: 'off', ratingsMode: 'off',
reasonForChange: 'required', reasonForChange: 'required',
search: false search: false

Loading…
Cancel
Save