Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bfe9396b4 | ||
|
|
d1731efdb9 | ||
|
|
6ba89696ee | ||
|
|
a7d36e1962 | ||
|
|
8ce8f0590a | ||
|
|
8639e9885b | ||
|
|
d3fef4acb7 | ||
|
|
66892c794d | ||
|
|
bb27501d70 | ||
|
|
2e2724c55c | ||
|
|
74982068b2 | ||
|
|
53d9cd37fb | ||
|
|
5f28393039 | ||
|
|
37a0e28871 | ||
|
|
5a7a8019e9 | ||
|
|
d17c9a6e1f | ||
|
|
5f70e5e8d1 | ||
|
|
40df0f658b | ||
|
|
ce5eb04c2d | ||
|
|
fd104a1f34 | ||
|
|
7e46ddeb7d | ||
|
|
a00d07b430 | ||
|
|
627422d794 | ||
|
|
79afc334ab | ||
|
|
e2a531b615 | ||
|
|
4787e8bf12 | ||
|
|
0054b8a3ce | ||
|
|
bb8d6eb92d | ||
|
|
877566f1cd | ||
|
|
8e9ca0498a | ||
|
|
95395200a3 | ||
|
|
14e22c21fe | ||
|
|
b7b2455994 | ||
|
|
7d96cf22ac | ||
|
|
a4022b37ce | ||
|
|
33d966ba44 | ||
|
|
1bf6350d68 | ||
|
|
b735685416 | ||
|
|
0859936f27 | ||
|
|
1a4377de4a | ||
|
|
860e89fa6f | ||
|
|
61916b3a3a | ||
|
|
307752921f | ||
|
|
7940a15a43 | ||
|
|
fecae2acd3 | ||
|
|
aa57c59703 | ||
|
|
ac4c4bd267 | ||
|
|
af092b3942 | ||
|
|
f0039c87fe | ||
|
|
abb07acc80 | ||
|
|
e1f0904184 | ||
|
|
1dc0caeae9 | ||
|
|
9647ead8d4 | ||
|
|
c587268c97 | ||
|
|
ce683f94bd | ||
|
|
3dd8c7c2dd | ||
|
|
de71572b9d | ||
|
|
029afc21c4 | ||
|
|
64dbb30e70 | ||
|
|
28acad6865 | ||
|
|
5f808f3033 | ||
|
|
15b1838e21 | ||
|
|
822314e8b7 | ||
|
|
5027bd1e73 | ||
|
|
f44987e391 | ||
|
|
b6cc762d23 | ||
|
|
f1638b2fee | ||
|
|
7c0e278ee8 | ||
|
|
93c9660421 | ||
|
|
430fc2673c | ||
|
|
b79db9098a | ||
|
|
16e0761ddf | ||
|
|
5f5672fed3 | ||
|
|
1dafb4bc79 | ||
|
|
d279fe8f90 | ||
|
|
82e217155b | ||
|
|
b5c073955d | ||
|
|
b0564679e6 | ||
|
|
724ff0b077 | ||
|
|
a666cdbcdc | ||
|
|
5855da0ba9 | ||
|
|
c3d2dc2418 | ||
|
|
54b316ae36 | ||
|
|
3c4f6a9f8e | ||
|
|
2a323d6270 | ||
|
|
d282813bc5 | ||
|
|
d599e7404f |
@@ -61,6 +61,12 @@ MAX_UPLOAD_BYTES=52428800
|
||||
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
||||
IMAGE_PROXY=1
|
||||
|
||||
# In-app administration, for accounts whose Stalwart role manages accounts and
|
||||
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
||||
# Stalwart's registry methods beyond an account's own password, app passwords
|
||||
# and settings. Stalwart's own admin interface is not affected.
|
||||
ADMINISTRATION=1
|
||||
|
||||
# Branding
|
||||
APP_NAME=ihasmail
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
# Without this there is no way to re-run a check that never started: a run
|
||||
# GitHub queues and then orphans -- as it did to every run created during the
|
||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
||||
# nor cancelled ("already completed"), and the workflow has no other trigger
|
||||
# nor canceled ("already completed"), and the workflow has no other trigger
|
||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
|
||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
||||
# exist. This is the workflow that makes those instructions true. It is also
|
||||
# the prerequisite for the self-hosted app catalogues -- TrueNAS and Unraid
|
||||
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
||||
# both install by pulling an image and neither builds from source.
|
||||
#
|
||||
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
||||
@@ -44,7 +44,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
||||
# orphans can be neither rerun nor cancelled, and this workflow otherwise
|
||||
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
||||
# only fires on a release -- which is not something to cut twice because a
|
||||
# runner died. `ref` also allows publishing an image for a tag that predates
|
||||
# this workflow, which is how the first one gets built.
|
||||
|
||||
@@ -13,13 +13,21 @@ name: Weekly release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 09:00 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||
# can delay a run by a good while when the queue is busy, so do not read
|
||||
# the exact minute as a promise. Note also that GitHub disables scheduled
|
||||
# workflows in a repository with no activity for 60 days -- not a concern
|
||||
# while this one is being worked on weekly, but it is why a silent stop is
|
||||
# worth checking for before assuming the file is broken.
|
||||
- cron: "0 9 * * 1"
|
||||
# the exact minute as a promise. The odd minute is deliberate: the top of
|
||||
# the hour is when most schedules fire, and at 09:00 the first scheduled
|
||||
# run started almost six hours late and the second had not started at all
|
||||
# four and a half hours in. Moving off the hour does not make GitHub keep
|
||||
# time, but it stops competing for the busiest slot. A missed week can be
|
||||
# cut by hand with workflow_dispatch; a late scheduled run that follows
|
||||
# finds the tag already there and does nothing.
|
||||
#
|
||||
# Note also that GitHub disables scheduled workflows in a repository with
|
||||
# no activity for 60 days -- not a concern while this one is being worked
|
||||
# on weekly, but it is why a silent stop is worth checking for before
|
||||
# assuming the file is broken.
|
||||
- cron: "17 9 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
|
||||
@@ -84,23 +84,23 @@ violet #6c71c4 · blue #268bd2 · cyan #2aa198 · green #859900
|
||||
The accents are shared by both modes by design. Two tiers are **derived**: the
|
||||
sunken dark surface #001f28 (below base03) and the raised light surface
|
||||
#fffdf6 (above base3), neither of which Solarized publishes, plus the two
|
||||
rule colours #0d4552 and #e6dfc8.
|
||||
rule colors #0d4552 and #e6dfc8.
|
||||
|
||||
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
|
||||
### Dark
|
||||
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
|
||||
fg #d3c6aa · grey1 #859289
|
||||
fg #d3c6aa · gray1 #859289
|
||||
red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092
|
||||
blue #7fbbb3 · purple #d699b6
|
||||
|
||||
### Light
|
||||
bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af
|
||||
fg #5c6a72 · grey1 #939f91
|
||||
fg #5c6a72 · gray1 #939f91
|
||||
red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c
|
||||
blue #3a94c5 · purple #df69ba
|
||||
|
||||
Light uses bg_dim as the page and bg0 as the raised surface, so the card the
|
||||
reader looks at is the colour Everforest calls its background.
|
||||
reader looks at is the color Everforest calls its background.
|
||||
|
||||
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
|
||||
### Wave (dark)
|
||||
@@ -118,7 +118,7 @@ lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79
|
||||
## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml)
|
||||
The YAMLs give the base palette and the surfaces as literals but express syntax
|
||||
roles as references (`$palette.indigo.l2`), and the resolved files are not
|
||||
committed. The two signature accents are taken from the same organisation's
|
||||
committed. The two signature accents are taken from the same organization's
|
||||
MIT-licensed ayu-theme/vscode-ayu build.
|
||||
|
||||
### Dark
|
||||
@@ -134,7 +134,7 @@ red #F07171 · orange #FA8532 · yellow #EBA400 · green #86B300 · teal #4CBF99
|
||||
indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu)
|
||||
|
||||
## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light})
|
||||
Named "Primer" after the design system. The colour values are MIT; "GitHub"
|
||||
Named "Primer" after the design system. The color values are MIT; "GitHub"
|
||||
and the Invertocat are trademarks, and nothing here is endorsed by them.
|
||||
|
||||
### Dark
|
||||
|
||||
+81
-8
@@ -1,6 +1,6 @@
|
||||
# Contributing to ihasmail
|
||||
|
||||
Thanks for your interest in contributing to **ihasmail** — a Gmail-style, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
||||
Thanks for your interest in contributing to **ihasmail** — an immutable, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -30,7 +30,7 @@ Before opening a new issue, please search [existing issues](https://github.com/C
|
||||
Open an issue describing:
|
||||
|
||||
- The problem you're trying to solve (not just the solution)
|
||||
- How it fits with ihasmail's JMAP-only, Gmail-style design philosophy
|
||||
- How it fits with ihasmail's JMAP-only, nothing-to-persist design
|
||||
- Any relevant JMAP RFC references (RFC 8620, RFC 8621) if the feature touches protocol behavior
|
||||
|
||||
For larger changes, please open an issue to discuss the approach **before** submitting a pull request — this saves everyone time if the direction needs adjusting.
|
||||
@@ -74,11 +74,11 @@ failing, so an untranslated string is invisible until somebody reading that
|
||||
language finds it.
|
||||
|
||||
**Any change that adds or alters a user-visible string adds work in all nine
|
||||
catalogues.** Say so explicitly in the PR — how many keys, and the fallback
|
||||
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
|
||||
count before and after — and say so just as explicitly when a change adds none,
|
||||
so it is never left to be inferred.
|
||||
|
||||
#### The catalogue key for a plural is the `other` form
|
||||
#### The catalog key for a plural is the `other` form
|
||||
|
||||
`plural()` looks the entry up by `forms.other`, so a call site written as
|
||||
|
||||
@@ -86,13 +86,13 @@ so it is never left to be inferred.
|
||||
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||
```
|
||||
|
||||
is keyed on **`"Deleted {n} contacts"`**. Keying the catalogue on the `one`
|
||||
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
|
||||
form type-checks, builds, passes every test, and silently falls back to English
|
||||
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||
going up, so read it:
|
||||
|
||||
```sh
|
||||
npm run i18n:check # literals wrapped, and catalogue health
|
||||
npm run i18n:check # literals wrapped, and catalog health; exits 1 on a finding
|
||||
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
|
||||
```
|
||||
|
||||
@@ -122,10 +122,83 @@ examples in `web/src/views/*/__tests__/`.
|
||||
git clone https://github.com/YOUR-USERNAME/ihasmail.git
|
||||
cd ihasmail
|
||||
```
|
||||
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox).
|
||||
3. Follow the setup instructions in the repository's `README.md` for installing dependencies and running the app locally.
|
||||
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below.
|
||||
3. Install and run, as below.
|
||||
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
|
||||
|
||||
Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
||||
|
||||
npm run typecheck # tsc for both packages
|
||||
npm test # vitest (web) + node:test (server)
|
||||
npm run build # web/dist + server/dist
|
||||
npm start # serve the production build
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||
build.
|
||||
|
||||
#### Architecture
|
||||
|
||||
```
|
||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||
React SPA • session cookie ⇄ Basic auth
|
||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||
```
|
||||
|
||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …).
|
||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
||||
|
||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
||||
`urn:stalwart:jmap`. Features degrade gracefully when one is missing.
|
||||
|
||||
#### The mock
|
||||
|
||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's.
|
||||
|
||||
| Switch | What it does |
|
||||
| --- | --- |
|
||||
| `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold |
|
||||
| `MOCK_NO_REGISTRY=1` | Omits the Stalwart capability, so the sign-in refusal can be tested |
|
||||
| `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission |
|
||||
| `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` |
|
||||
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as Community does |
|
||||
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs |
|
||||
|
||||
It tracks the current Stalwart release rather than 0.16 in general, and each
|
||||
behavior is confirmed against a real server before it is copied here — the
|
||||
comments say which version and on what date. Where a release changes something
|
||||
a client can see, the mock changes with it, and the test that pinned the old
|
||||
behavior is rewritten rather than deleted, so the reversal stays on the record.
|
||||
|
||||
#### Version numbers
|
||||
|
||||
`2026.8.30+pr129` is the date of the commit a build came from and the pull
|
||||
request that commit arrived through; a commit that did not come through one
|
||||
carries its short SHA instead (`2026.8.30+g1fa6578`). It is worked out from git
|
||||
at build time — nothing writes a version into the tree, and `package.json` stays
|
||||
at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout.
|
||||
|
||||
The PR number sits after the `+` as build metadata because it records where a
|
||||
build came from, not how new it is. The version says nothing about Stalwart on
|
||||
purpose: what a build needs from the server is stated in the README badge and
|
||||
in [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Building an image with the version on it,
|
||||
and the single-host `deploy.example.sh`, are covered in
|
||||
[Installing](https://docs.ihasmail.org/install/).
|
||||
|
||||
## Review Process
|
||||
|
||||
- A maintainer will review your PR and may request changes.
|
||||
|
||||
+348
-62
@@ -12,10 +12,12 @@ questions:
|
||||
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec |
|
||||
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
|
||||
|
||||
Written against the tree at Stalwart **0.16.21**, which is the version the live
|
||||
instance runs. Behaviours carrying an older version below were checked against
|
||||
that one and have not changed since; where 0.16.21 changed something, the entry
|
||||
says so and names both. ihasmail
|
||||
Written against the tree at Stalwart **0.16.22**, which is the version the live
|
||||
instance runs. Behaviors carrying an older version below were checked against
|
||||
that one and have not changed since; where a later release changed something,
|
||||
the entry says so and names both. 0.16.22 changed nothing described here: its
|
||||
client-visible changes are in what `CalendarEvent/get` and `ContactCard/get`
|
||||
return, and [KNOWN-ISSUES.md](KNOWN-ISSUES.md) lists them. ihasmail
|
||||
requires 0.16 or newer and refuses older servers at sign-in, by name.
|
||||
|
||||
## The shape of it
|
||||
@@ -69,9 +71,11 @@ per-account shape.
|
||||
## Layout
|
||||
|
||||
Three panes: folder tree, message list, reading pane. The splitter between the
|
||||
list and the reading pane is dragged to resize, and the size is remembered per
|
||||
device — a width chosen on a 27" monitor is wrong on a laptop, so it is one of
|
||||
the few settings that does not follow the account.
|
||||
list and the reading pane is dragged to resize, and so is the edge of the
|
||||
sidebar, between 240 and 480px; arrow keys move either one, and a double-click
|
||||
puts it back. The sizes are remembered per device — a width chosen on a 27"
|
||||
monitor is wrong on a laptop, so they are among the few settings that do not
|
||||
follow the account.
|
||||
|
||||
- **Reading pane** right of the list, below it, or off (messages open full width).
|
||||
- **Density** comfortable, cozy or compact, which changes row height as well as padding.
|
||||
@@ -99,7 +103,7 @@ every drag.
|
||||
archives and left deletes by default, which is what the mail app the phone
|
||||
came with already does. Either direction can be set to archive, delete,
|
||||
report spam, read/unread, star or move to… — or to nothing, which turns that
|
||||
direction off. The coloured strip revealed behind the row names what will
|
||||
direction off. The colored strip revealed behind the row names what will
|
||||
actually happen *in the folder it is happening in*: "Delete forever" out of
|
||||
Deleted Items, "Not spam" inside Junk Mail. Where an action
|
||||
is meaningless there — archiving out of the archive, calling your own drafts
|
||||
@@ -139,14 +143,14 @@ it is silently nothing there.
|
||||
|
||||
The arithmetic lives in `web/src/lib/touch.ts`, away from the components and
|
||||
under test, because the numbers are the whole thing. The axis lock is
|
||||
deliberately biased towards the vertical: scrolling is what a finger on a
|
||||
deliberately biased toward the vertical: scrolling is what a finger on a
|
||||
message list is doing almost every time, and a scroll misread as a swipe grabs
|
||||
the list out from under the reader, while a swipe misread as a scroll costs one
|
||||
more attempt. A drag that is merely more sideways than not stays a scroll.
|
||||
|
||||
## The message list
|
||||
|
||||
- **Virtualised** — rows are windowed with `@tanstack/react-virtual`, so a
|
||||
- **Virtualized** — rows are windowed with `@tanstack/react-virtual`, so a
|
||||
folder of 100,000 messages scrolls at the same speed as one of ten. Row
|
||||
height follows density and the one- or two-line layout.
|
||||
- **Infinite scroll** with server-side paging, 50 at a time by default.
|
||||
@@ -232,7 +236,7 @@ for that one, and the dialog says so.
|
||||
|
||||
## Folders
|
||||
|
||||
Real JMAP mailboxes, with the server's roles honoured.
|
||||
Real JMAP mailboxes, with the server's roles honored.
|
||||
|
||||
- Create, rename, create a subfolder, delete (with or without its mail).
|
||||
- **Drag a folder onto another** to reparent it. Folders with a server role
|
||||
@@ -242,18 +246,18 @@ Real JMAP mailboxes, with the server's roles honoured.
|
||||
unsubscribed folder still exists and still receives; it is just out of the
|
||||
way. Inbox cannot be hidden.
|
||||
- **Mark all as read**, optionally including subfolders.
|
||||
- **Folder colours**, per mailbox id.
|
||||
- **Folder colors**, per mailbox id.
|
||||
- **Unread counts** per folder, live.
|
||||
- **Storage quota** bar under the tree where the server reports one.
|
||||
- Rights are respected per folder: rename, delete, create-child and share each
|
||||
grey out when `myRights` says no.
|
||||
gray out when `myRights` says no.
|
||||
- A folder in the address that this account does not have says *this folder is
|
||||
missing*, rather than drawing an empty folder — a stale link should not read
|
||||
as a folder that emptied itself.
|
||||
|
||||
## Labels
|
||||
|
||||
Labels are **IMAP keywords** with a colour and a display name kept in settings.
|
||||
Labels are **IMAP keywords** with a color and a display name kept in settings.
|
||||
Because they are keywords, every other client that reads the mailbox sees them,
|
||||
and they survive ihasmail entirely. A message can carry any number. They are
|
||||
managed in Settings › Labels, applied from `l` or the context menu, and
|
||||
@@ -307,7 +311,7 @@ same query string — so what it builds can be read, edited and learned from.
|
||||
|
||||
## Reading a message
|
||||
|
||||
- **Sanitised HTML**, rendered inside a **Shadow DOM** so the sender's CSS
|
||||
- **Sanitized HTML**, rendered inside a **Shadow DOM** so the sender's CSS
|
||||
cannot reach the app. DOMPurify strips scripts, event handlers, forms and
|
||||
anything that could navigate the top window.
|
||||
- **Remote images blocked by default**, with a banner offering *Show images* or
|
||||
@@ -366,14 +370,14 @@ same query string — so what it builds can be read, edited and learned from.
|
||||
since the filter applies policy ihasmail cannot see. Mail that arrived without
|
||||
these headers shows nothing.
|
||||
- **Message body theming** is off by default — sender HTML is left exactly as it
|
||||
was designed, on a light card. One setting lets mail that brings no colours of
|
||||
was designed, on a light card. One setting lets mail that brings no colors of
|
||||
its own follow the app's theme instead. That is a low bar in practice: one
|
||||
`color:#FFFFFF` on one button label opts a whole message out, so for mail
|
||||
built from a template it changed nothing. A second setting, off unless the
|
||||
first is on, forces the theme over the sender's own colours. It tells a
|
||||
first is on, forces the theme over the sender's own colors. It tells a
|
||||
*sheet* the design sits on, like a white wrapper table, from a *painted
|
||||
surface* like a button or a banner, by relative luminance: the first is
|
||||
neutralised so the bright card goes away, the second is kept whole so its
|
||||
neutralized so the bright card goes away, the second is kept whole so its
|
||||
label stays readable on it. Nothing the sender wrote is removed, so the
|
||||
switch is reversible, and print is unaffected either way.
|
||||
|
||||
@@ -393,7 +397,7 @@ same query string — so what it builds can be read, edited and learned from.
|
||||
|
||||
- **Invitations (iTIP)** render an invite card: what, when, where, the guest
|
||||
list with each person's status, and Yes / Maybe / No. The reply is written to
|
||||
the event and sent back to the organiser. Cancellations are recognised too.
|
||||
the event and sent back to the organizer. Cancellations are recognized too.
|
||||
- **vCard attachments** render a card offering to add the person to an address
|
||||
book.
|
||||
- **Right-click anyone named** in the message — From, To, Cc, Bcc or Reply-To —
|
||||
@@ -423,9 +427,9 @@ Requesting one on your own outgoing mail is a separate switch.
|
||||
## Composing
|
||||
|
||||
**Multiple composers at once**, floating in a dock at the bottom right, each
|
||||
minimisable and maximisable; full-screen on mobile.
|
||||
minimizable and maximizable; full-screen on mobile.
|
||||
|
||||
- **Rich text**: bold, italic, underline, strikethrough, text colour, highlight,
|
||||
- **Rich text**: bold, italic, underline, strikethrough, text color, highlight,
|
||||
font size, alignment, bulleted and numbered lists, indent/outdent, blockquote,
|
||||
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
|
||||
formatting. Tab and Shift+Tab indent inside the body.
|
||||
@@ -464,7 +468,7 @@ minimisable and maximisable; full-screen on mobile.
|
||||
- **Attachments** by picking or dragging onto the composer, with progress per
|
||||
file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by
|
||||
default). A pasted image is inserted inline instead, and pasted HTML is
|
||||
sanitised on the way in.
|
||||
sanitized on the way in.
|
||||
- **Attach from Files** — anything the server already holds attaches with **no
|
||||
upload at all**, however large. A file from someone else's shared folder is
|
||||
copied to your account first, because a message can only carry blobs from the
|
||||
@@ -505,7 +509,7 @@ out whether or not ihasmail is open, or ever opened again.
|
||||
|
||||
Held messages wait in a **Scheduled** folder ihasmail maintains itself (JMAP has
|
||||
no role for one), and reconciles when you next open it: released messages move
|
||||
to Sent, cancelled ones back to Drafts. The picker offers presets and an exact
|
||||
to Sent, canceled ones back to Drafts. The picker offers presets and an exact
|
||||
date and time, bounded by the maximum delay the server advertises.
|
||||
|
||||
> If Stalwart's `futureRelease` is not configured, a "scheduled" message is sent
|
||||
@@ -532,14 +536,14 @@ are settings.
|
||||
|
||||
The sidebar keeps three groups apart:
|
||||
|
||||
- **My calendars** — yours, each with a colour, each hideable with a click.
|
||||
- **My calendars** — yours, each with a color, each hideable with a click.
|
||||
- **Shared with me** — other people's, once added.
|
||||
- **Available to add** — shared with you but not yet added, with a plus beside
|
||||
each. An unadded calendar draws nothing. This is deliberate: the server
|
||||
reports every collection in an account you can reach, whether or not anyone
|
||||
meant to share it, so being handed one is not evidence that it was offered.
|
||||
|
||||
Right-click your own to rename, recolour, share, stop sharing or delete;
|
||||
Right-click your own to rename, recolor, share, stop sharing or delete;
|
||||
right-click one of someone else's to remove it from your view, which changes
|
||||
nothing for anybody else.
|
||||
|
||||
@@ -547,7 +551,7 @@ nothing for anybody else.
|
||||
events), from the calendar's own menu, into that calendar. The events are
|
||||
filed rather than scheduled: no invitations go out to anyone named in them.
|
||||
- **Re-importing updates rather than duplicates**, as a contacts import does.
|
||||
An event is recognised by its UID, per calendar, and what the file carries
|
||||
An event is recognized by its UID, per calendar, and what the file carries
|
||||
wins -- so a corrected export corrects what the first attempt got wrong.
|
||||
|
||||
Two things are deliberately left alone: **who accepted**, and **edits to a
|
||||
@@ -562,11 +566,11 @@ nothing for anybody else.
|
||||
since the last import does not arrive, because nothing here can tell that
|
||||
apart from an answer given in ihasmail. And an import still sends no
|
||||
scheduling messages, so an event a re-import moves is moved *here* --
|
||||
everybody else's copy still says the old time until whoever is organising
|
||||
everybody else's copy still says the old time until whoever is organizing
|
||||
sends the update from the event itself.
|
||||
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
||||
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
||||
with their own colour.
|
||||
with their own color.
|
||||
|
||||
**Nothing is stored.** The document is fetched when you open the calendar and
|
||||
parsed in the browser; the server keeps no copy, no cache and no schedule,
|
||||
@@ -604,7 +608,7 @@ nothing for anybody else.
|
||||
They cannot be edited or deleted, and that falls out of the design rather
|
||||
than being special-cased: the virtual calendar reports no write rights, so
|
||||
every control that asks before offering Edit or Delete already declines. The
|
||||
store refuses a synthesised id as well, whatever calls it.
|
||||
store refuses a synthesized id as well, whatever calls it.
|
||||
|
||||
A card that records only a day and month — the common case — gets a birthday
|
||||
with no age rather than no birthday. And 29 February falls on the 28th in a
|
||||
@@ -619,16 +623,16 @@ empty space offers a timed or all-day event at that moment, or *Go to day* /
|
||||
|
||||
The editor covers title, start and end (all-day or timed, with a time zone),
|
||||
calendar, location, meeting link, guests, description, reminders, repeat,
|
||||
status (confirmed / tentative / cancelled), show-as (busy / free), visibility
|
||||
(default / private / secret), category and colour.
|
||||
status (confirmed / tentative / canceled), show-as (busy / free), visibility
|
||||
(default / private / secret), category and color.
|
||||
|
||||
- **Recurrence** — none, daily, weekly, weekdays, monthly, yearly, or a custom
|
||||
builder: interval, by-weekday, by-month-day, and an end by count or by date.
|
||||
- **Reminders** — one or more alerts before the start, with a default in settings.
|
||||
- **Colour categories**, Outlook-style: named colours managed in Settings ›
|
||||
- **Color categories**, Outlook-style: named colors managed in Settings ›
|
||||
Calendar, assigned from the editor or the context menu, and stored as
|
||||
JSCalendar `categories` so other clients see them. (The per-event colour
|
||||
picker that predated them is gone; a colour comes from the category, or the
|
||||
JSCalendar `categories` so other clients see them. (The per-event color
|
||||
picker that predated them is gone; a color comes from the category, or the
|
||||
calendar.)
|
||||
- **Duplicate** an event from the context menu.
|
||||
- **Create event…** from a message, in its context menu and its ⋮ menu (and,
|
||||
@@ -645,7 +649,7 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility
|
||||
## Attendees, invitations and free/busy
|
||||
|
||||
Invitations go out as iTIP when guests are added, replies come back and are
|
||||
applied to the event, and cancelling notifies the guests. Guests are added by
|
||||
applied to the event, and canceling notifies the guests. Guests are added by
|
||||
name or address with the same autocomplete the composer uses.
|
||||
|
||||
Where the server implements `Principal/getAvailability`, the event editor grows
|
||||
@@ -752,24 +756,24 @@ JMAP Contacts and JSContact.
|
||||
afterwards is what the server confirmed rather than what was asked for.
|
||||
- **Letter index** down the list, with `#` for everything that does not start
|
||||
with a letter.
|
||||
- **Search** across name, address, organisation and notes, in one book or all.
|
||||
- **Search** across name, address, organization and notes, in one book or all.
|
||||
- **vCard import** through `ContactCard/parse` (a file of any number of cards),
|
||||
and **export** of one card or the whole book as `.vcf`.
|
||||
- **LDIF import**, for address books coming from SOGo, Thunderbird or an LDAP
|
||||
directory. Nothing on the server reads LDIF, so the file is read here:
|
||||
RFC 2849 for the syntax, [Mozilla's address book schema][ldif-schema] for what
|
||||
the attributes mean, which is the one such exports almost always use. Work and
|
||||
home addresses, every phone kind, second email, organisation and units, job
|
||||
home addresses, every phone kind, second email, organization and units, job
|
||||
title, nickname, web pages and the custom fields all come across. The import
|
||||
control takes either format and decides by what is in the file, not by what it
|
||||
is called.
|
||||
- **Re-importing updates rather than duplicates.** A vCard is recognised by its
|
||||
- **Re-importing updates rather than duplicates.** A vCard is recognized by its
|
||||
UID; an LDIF entry, whose schema has none, by its distinguished name. The card
|
||||
already here is merged with the file's version -- what the file carries wins,
|
||||
what it does not mention is left alone -- so a corrected export can correct
|
||||
what the first attempt got wrong. Matching is per address book, which is also
|
||||
how two directories that each hold a `cn=John Smith` stay two people. An entry
|
||||
no longer recognisable, because its `dn` moved between exports, is imported
|
||||
no longer recognizable, because its `dn` moved between exports, is imported
|
||||
again and counted: *"3 of them look like contacts you already had."*
|
||||
|
||||
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
||||
@@ -894,7 +898,7 @@ individual rights by hand.
|
||||
|
||||
Preferences live in a `settings.json` in the account's own JMAP Files, beside
|
||||
the signature images. So identity, signatures, locale, date and time formats,
|
||||
theme, labels, templates, folder colours, trusted image senders and added shares
|
||||
theme, labels, templates, folder colors, trusted image senders and added shares
|
||||
are the same wherever you sign in, private windows included — and they are
|
||||
backed up with the mail store, because they *are* in the mail store. ihasmail
|
||||
still stores nothing of its own.
|
||||
@@ -919,14 +923,14 @@ not reach another that already has ihasmail open until it signs in again.
|
||||
| --- | --- |
|
||||
| **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
|
||||
| **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered; the three warnings and the domains they measure against; undo-send window, attachment reminder, confirm-before-delete |
|
||||
| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language |
|
||||
| **Appearance** | Theme, accent color, density, font size, sidebar, swipe actions, interface language |
|
||||
| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker |
|
||||
| **Filters & rules** | The visual builder and raw Sieve editor |
|
||||
| **Out of office** | Vacation response |
|
||||
| **Folders** | Create, rename, colour, subscribe |
|
||||
| **Labels** | Keyword, display name, colour |
|
||||
| **Folders** | Create, rename, color, subscribe |
|
||||
| **Labels** | Keyword, display name, color |
|
||||
| **Templates** | Named subject + body |
|
||||
| **Calendar & contacts** | Colour categories, working hours, default view, default duration, default reminder |
|
||||
| **Calendar & contacts** | Color categories, working hours, default view, default duration, default reminder |
|
||||
| **Notifications** | In-tab notifications, notify-when-closed (Web Push), sound |
|
||||
| **Security & sessions** | Password, two-factor state, app passwords, active webmail sessions |
|
||||
| **Keyboard shortcuts** | The full list, grouped |
|
||||
@@ -936,7 +940,7 @@ not reach another that already has ihasmail open until it signs in again.
|
||||
between them is worth stating because two similar words in one nav is how a
|
||||
menu becomes something people hunt through. Security & sessions is credentials
|
||||
and access: password, two-factor state, app passwords, live sessions. Privacy &
|
||||
safety is how the app behaves towards the reader and towards senders: what
|
||||
safety is how the app behaves toward the reader and toward senders: what
|
||||
loads, what leaks, and what asks before it happens. These had been spread
|
||||
through General, which had grown five unrelated headings — remote images filed
|
||||
under "Reading", the read-receipt policy under "Composing", the undo-send window
|
||||
@@ -1006,12 +1010,12 @@ is why they are two settings and not one.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| English | the source language, and what every other catalogue falls back to |
|
||||
| English | the source language, and what every other catalog falls back to |
|
||||
| Deutsch · Español · Français · Nederlands · Português (Brasil) | Beta |
|
||||
| Русский · Українська · 简体中文 · 日本語 | Beta |
|
||||
|
||||
**All nine translations are marked Beta, and the label is not modesty.**
|
||||
The catalogues were produced by AI against standard dictionaries and have not
|
||||
The catalogs were produced by AI against standard dictionaries and have not
|
||||
been read by anybody who speaks the language. That is stated in Settings, next
|
||||
to a link for reporting anything that reads wrongly, because the alternative —
|
||||
shipping them quietly — would ask people to trust text nobody has checked. A
|
||||
@@ -1021,7 +1025,7 @@ deliberate act by a person and not something a percentage earns.
|
||||
Two things follow from the design rather than the translation:
|
||||
|
||||
- **A missing entry renders its English source.** So deleting a bad line is a
|
||||
valid fix, not a regression, and a catalogue is never in a half-broken state.
|
||||
valid fix, not a regression, and a catalog is never in a half-broken state.
|
||||
- **Plurals are asked for, never assumed.** `Intl.PluralRules` decides the form,
|
||||
so Russian and Ukrainian get their three (1 письмо, 2–4 письма, 5+ писем) and
|
||||
Japanese and Chinese get the one they actually have — with counters doing the
|
||||
@@ -1033,7 +1037,7 @@ The interface language also feeds the *automatic* date locale, so choosing
|
||||
page that is already in the reader's language — and accepting that offer is
|
||||
what rewrites the DOM underneath React.
|
||||
|
||||
Only languages with a catalogue shipped appear in the picker. A language
|
||||
Only languages with a catalog shipped appear in the picker. A language
|
||||
offered without strings behind it would leave the page claiming to be in a
|
||||
language it is not, which is worse than not offering it: it stops a browser
|
||||
offering to translate a page the reader cannot read.
|
||||
@@ -1057,28 +1061,28 @@ at two.
|
||||
| **Ayu** | |
|
||||
| **Kanagawa** | Wave, with Lotus as its light half |
|
||||
| **Everforest** | The medium-contrast variant of each side |
|
||||
| **Primer** | The colours behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here |
|
||||
| **Primer** | The colors behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here |
|
||||
|
||||
Every one has both halves, so the top-bar toggle only ever changes the side and
|
||||
never the colours. Accent colours still sit on top of any of them.
|
||||
never the colors. Accent colors still sit on top of any of them.
|
||||
|
||||
The ten borrowed palettes are the work of their own projects and are used
|
||||
under the MIT licence — see [NOTICE](NOTICE). Only the published colour values
|
||||
under the MIT license — see [NOTICE](NOTICE). Only the published color values
|
||||
are used, taken from each project's own repository; the values as fetched are
|
||||
recorded in `.palette-sources/palettes-upstream.md`.
|
||||
|
||||
**The shades between those values are derived, and every one is checked.**
|
||||
ihasmail needs about thirty tokens and these projects publish between twelve
|
||||
and twenty, so the tiers in between are computed by
|
||||
`scripts/build-palettes.py`, which then measures every text colour against the
|
||||
`scripts/build-palettes.py`, which then measures every text color against the
|
||||
surface it sits on — 4.5:1 for prose, 3:1 for borders and marks — and lifts
|
||||
anything that falls short, towards white on a dark ground and towards black on
|
||||
anything that falls short, toward white on a dark ground and toward black on
|
||||
a light one so the hue survives. The script refuses to write a palette that
|
||||
would not pass.
|
||||
|
||||
That check is not a formality. **Twenty-one of the twenty-two palette halves
|
||||
needed at least one lift**, because these palettes are designed for code
|
||||
editors rather than for prose at this size: Dracula's comment grey is 3.03:1 on
|
||||
editors rather than for prose at this size: Dracula's comment gray is 3.03:1 on
|
||||
its own background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as
|
||||
published would have quietly ended the WCAG AA claim two sections down.
|
||||
|
||||
@@ -1093,6 +1097,273 @@ needed nothing in either half.
|
||||
|
||||
---
|
||||
|
||||
# Administration
|
||||
|
||||
An account whose Stalwart role manages other accounts finds **Administration**
|
||||
in the account menu, top right. Nobody else sees the entry, and the page
|
||||
redirects them to their mail if they type its address in.
|
||||
|
||||
## What it offers is what the role allows
|
||||
|
||||
At sign-in the server already asks Stalwart's `GET /api/account` for the
|
||||
edition; it now keeps the account's **permissions** from the same answer and
|
||||
hands them to the browser with the session. The menu appears for an account
|
||||
that can count something the dashboard shows — accounts (`sysAccountQuery`),
|
||||
domains (`sysDomainQuery`), the delivery queue (`sysQueuedMessageQuery`) or the
|
||||
metric history (`sysMetricQuery` with `sysMetricGet`) — and each section and
|
||||
control inside is there only when the matching permission is:
|
||||
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
||||
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
||||
administrator and a custom helpdesk role each see the same screen shaped to
|
||||
what they can do.
|
||||
|
||||
None of that is the security boundary. Every read and write is a JMAP `x:`
|
||||
call through the ordinary `/api/jmap` proxy, authenticated as the signed-in
|
||||
account, and Stalwart decides each one — scoping a tenant administrator's
|
||||
queries to their own tenant and refusing anything the role does not allow.
|
||||
The client's gating only avoids offering what would fail.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Administration opens on a grid of cards, one for each number the role can read:
|
||||
|
||||
| Card | What it counts | Needs |
|
||||
|---|---|---|
|
||||
| **Users** | user accounts, not groups | `sysAccountQuery` |
|
||||
| **Domains** | mail domains | `sysDomainQuery` |
|
||||
| **Pending** | messages waiting in the delivery queue | `sysQueuedMessageQuery` |
|
||||
| **Server memory** | the latest reading, and when it was taken | `sysMetricQuery`, `sysMetricGet` |
|
||||
| **Received** | messages queued for delivery in the last 24 hours | the same |
|
||||
| **Sent** | authenticated submissions, bounces and reports queued in the last 24 hours | the same |
|
||||
|
||||
Users and Domains open their sections when the role can. The counts are what
|
||||
Stalwart answers for the signed-in account, so a **tenant administrator sees
|
||||
their tenancy**: its accounts, its domains, and the queued messages that touch
|
||||
them. The last three come from Stalwart's metric history, which has no tenant
|
||||
in it and which the Tenant Administrator role Stalwart creates does not hold,
|
||||
so a tenant's dashboard is Users, Domains and Pending. A helpdesk role that can
|
||||
read accounts and domains sees those two cards.
|
||||
|
||||
The history is an Enterprise feature that has to be switched on. A server that
|
||||
refuses it — Community does — leaves those three cards off rather than showing
|
||||
them broken, and one that records nothing says *Not recorded on this server*
|
||||
rather than showing a day of zeroes. Received and sent add up the same metric
|
||||
names Stalwart's own dashboard uses. The columns follow the number of cards,
|
||||
so rows come out even: six are three over three, and fall to two and then one
|
||||
as the space narrows. **Refresh** reads everything again; nothing is polled.
|
||||
|
||||
Below the cards, a line says where the rest is: detailed metrics, the delivery
|
||||
queue, logs and server settings are in Stalwart's own administration, and it
|
||||
links there. The address is found rather than configured: the public host
|
||||
Stalwart advertises in its own session — the one people reach it at, even when
|
||||
ihasmail talks to it on a private address — and the prefix its web interface is
|
||||
installed under, read from its `x:Application` objects (`/admin` unless it was
|
||||
moved). A server whose web interface is disabled or moved away gets no link, and
|
||||
an administrator who may not read applications gets Stalwart's default `/admin`.
|
||||
`STALWART_ADMIN_URL`, or a servers file entry's `adminUrl`, overrides it for an
|
||||
administration that lives somewhere else.
|
||||
|
||||
## Accounts
|
||||
|
||||
- **List and search** by name or address, fifty to a page, newest first — the
|
||||
server's own order. Role, storage used against the limit, and groups at a
|
||||
glance.
|
||||
- **Create** an account on any domain the role can see: display name, address,
|
||||
a generated password to copy and pass on, role, and storage limit.
|
||||
- **Edit** the display name, other addresses (aliases), role and storage limit.
|
||||
One save sends only what changed.
|
||||
- **Set a new password.** It goes into the account's existing password
|
||||
credential, and signs the person out of every app and device using the old
|
||||
one, because Stalwart ties every token to the password.
|
||||
- **Delete**, after typing the address to confirm. Stalwart removes the
|
||||
mailbox's data in the background, and says so.
|
||||
|
||||
Roles are offered only when the viewer holds every permission they carry,
|
||||
which is the check Stalwart makes on a grant. It does **not** make that check
|
||||
when only a password changes, or on a delete, so an account allowed to edit
|
||||
accounts could otherwise reset the password of one that can do more and sign
|
||||
in as it. ihasmail shows any account that outranks the viewer read-only, and
|
||||
counts a role it cannot read as outranking rather than not. Nobody can change
|
||||
their own role or delete the account they are signed in with.
|
||||
|
||||
## Groups
|
||||
|
||||
A group is a shared address and mailbox, and the people who share it. To
|
||||
Stalwart it is an account whose type is Group, so it takes the same
|
||||
permissions as Accounts (`sysAccountQuery`, `sysAccountGet`) and sits beside
|
||||
it in the menu.
|
||||
|
||||
- **List and search** by name or address, with each group's member count.
|
||||
- **Create** a group on a domain, with a display name, a role and a storage
|
||||
limit; **edit** those and its other addresses, which save together.
|
||||
- **Members** are added by searching for a person and removed one at a time,
|
||||
and each change applies straight away. Stalwart keeps a membership on the
|
||||
member rather than the group, so every change is a one-line update to that
|
||||
person's account that leaves their other groups alone. Nobody can add or
|
||||
remove themselves.
|
||||
- **What a group gives its members** is what has been shared with it — its
|
||||
mailbox, a calendar — not its permissions: a person's permissions come from
|
||||
their own role. The group's own role says what the group may do, and only
|
||||
roles whose permissions the viewer holds are offered, as for accounts.
|
||||
- **Delete** asks for the address to be typed. Stalwart keeps anything that
|
||||
something else still names, and every member's account names its groups, so
|
||||
deleting takes the members out first and then deletes the group — the same
|
||||
order a domain's keys go before the domain. A role that cannot change the
|
||||
members' accounts is not offered a delete it could only half finish.
|
||||
|
||||
Groups do not contain groups; Stalwart has no nesting.
|
||||
|
||||
## Mailing lists
|
||||
|
||||
A mailing list is an address that passes mail on to everyone on it, on this
|
||||
server or anywhere else. For a role with `sysMailingListQuery` and
|
||||
`sysMailingListGet`, under Directory after Groups:
|
||||
|
||||
- **List and search** by name or address, with each list's recipient count.
|
||||
- **Create** a list on a domain; **edit** its display name, recipients and
|
||||
other addresses, which save together.
|
||||
- **Recipients** can be pasted several at a time — a column from a
|
||||
spreadsheet, a line of addresses separated by commas, `Name <address>` —
|
||||
and anything with an @ that is not an address stays in the box with a note
|
||||
rather than being dropped. Past a dozen, a filter narrows them. Saving sends
|
||||
only the addresses added and removed, so a recipient someone else added while
|
||||
the panel was open is not lost.
|
||||
- **Delete** asks for the address to be typed. The recipients' own mail is not
|
||||
touched.
|
||||
|
||||
That is all a list is in Stalwart: there are no owners, moderators or posting
|
||||
rules to set.
|
||||
|
||||
## Roles
|
||||
|
||||
A role is a named set of permissions that accounts, groups and tenants are
|
||||
given. For a role with `sysRoleQuery` and `sysRoleGet`, under Access:
|
||||
|
||||
- **List and search** every role, with the number of permissions each grants
|
||||
once the roles it builds on are followed, what it builds on, and a note on the
|
||||
ones Stalwart hands out by default.
|
||||
- **Builds on** other roles, and gets everything they grant. A role cannot build
|
||||
on itself or on one already built on it.
|
||||
- **Permissions** come from Stalwart's own list — every permission the server
|
||||
knows, grouped under its headings, searchable, and filterable to the ones
|
||||
granted or set on this role. Each is not set, allowed or denied; one it
|
||||
inherits says which role it comes from. A denial wins over anything allowed,
|
||||
here or on any role underneath, as it does in Stalwart. Only permissions the
|
||||
viewer holds can be allowed: Stalwart refuses the rest.
|
||||
- **Saving** sends only the permissions and roles that changed.
|
||||
- **A role that carries permissions the viewer lacks opens read-only**, with no
|
||||
delete — Stalwart checks a grant but not a delete, so this stands in for it.
|
||||
- **A default role** — one Stalwart gives new users, groups, tenant
|
||||
administrators or administrators — says so before anything is changed, and
|
||||
cannot be deleted from here. A role still in use is kept by the server, and
|
||||
the refusal names what uses it.
|
||||
|
||||
The list of permissions is Stalwart's schema (`GET /api/schema`), fetched by
|
||||
ihasmail's server as the signed-in account and cut down to names and labels.
|
||||
Its labels are English only, so ihasmail ships its own translation of every one
|
||||
of them, loaded only when the Roles screen opens; a permission added by a later
|
||||
Stalwart shows the server's English until it is translated.
|
||||
|
||||
## Tenants
|
||||
|
||||
A tenant is a separate organization on the same server — its own people,
|
||||
domains and limits, and an administrator who manages only what is in it. It is
|
||||
a Stalwart Enterprise feature. On a server that does not report Enterprise — or
|
||||
reports no edition at all — the page is only the notice *Tenants are a Stalwart
|
||||
Enterprise feature.*: no list, no search, nothing to create. On Enterprise the
|
||||
notice is left out, unless `SHOW_ENTERPRISE_NOTICES=1` asks for it above the
|
||||
list, as the public demo does. On Enterprise, for a role with `sysTenantQuery`
|
||||
and `sysTenantGet`, under Access:
|
||||
|
||||
- **List and search** tenants, with each one's storage and account limit.
|
||||
- **Create and edit** a tenant's name, logo (an https address, drawn through the
|
||||
image proxy, or an image data URL), role and limits — accounts, groups,
|
||||
mailing lists, domains, roles, DKIM keys and storage. An empty limit is no
|
||||
limit, and a limit ihasmail does not offer keeps whatever it had.
|
||||
- **The tenant's role** is the most anyone inside it can be allowed: their own
|
||||
roles are cut down to it.
|
||||
- **What it holds** is counted, each against its limit. Stalwart keeps no list
|
||||
on the tenant; each account, group, domain, list, role and DKIM key names its
|
||||
tenant, so the counts are queries for those. A domain created in a tenant
|
||||
brings its keys with it.
|
||||
- **Domains** are added to a tenant, or taken out, from its panel. Only a domain
|
||||
in no tenant can be added, and the accounts already on it stay where they
|
||||
are. A domain comes out only once none of the tenant's accounts are on it —
|
||||
Stalwart would allow it, and strand them.
|
||||
- **An account's tenant** is chosen on the account's own panel, which is how a
|
||||
tenant gets its first administrator: an Administrator inside a tenant
|
||||
administers that tenant. Stalwart puts something in a tenant only on a domain
|
||||
in that tenant, so the choice is between no tenant and the domain's own, and
|
||||
a new account starts in its domain's tenant.
|
||||
- **Delete** is offered once the tenant holds nothing.
|
||||
|
||||
Only an administrator outside every tenant can put anything into one; Stalwart
|
||||
refuses anyone else, and inside a tenant it scopes every list to that tenant.
|
||||
|
||||
## Domains
|
||||
|
||||
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||
|
||||
- **List and search**, with how many accounts use each domain and whether its
|
||||
DNS records, DKIM keys and certificate are managed automatically or by hand.
|
||||
- **Add** a domain. Stalwart gives a new one automatic DKIM, so it has keys
|
||||
straight away.
|
||||
- **Edit** the description, other names for the domain, the catch-all address,
|
||||
and plus addressing (`name+anything@`). A plus-addressing rule set on the
|
||||
server is shown and left alone.
|
||||
- **DNS records**, one per row with a copy button each, and the lot as a zone
|
||||
file. Stalwart computes them per domain — MX, SPF, DKIM, DMARC, the service
|
||||
records, MTA-STS, TLS reporting, CAA — and ihasmail joins a long DKIM record
|
||||
back into the single value a DNS provider's form wants.
|
||||
- **DKIM keys** with their stage — signing, published and waiting, retiring —
|
||||
read-only, because the server creates and rotates them itself when DKIM is
|
||||
automatic, and a key added by hand needs its private key.
|
||||
- **Remove** a domain once nothing uses it. While accounts do, removal says how
|
||||
many and stays unavailable. The domain's own DKIM keys go with it, since the
|
||||
server will not remove a domain its keys still name — which also means a role
|
||||
that cannot delete keys cannot remove a domain that has any.
|
||||
|
||||
Switching DNS, DKIM or certificate management between automatic and manual,
|
||||
and choosing a DNS or ACME provider, stay in Stalwart's own interface for now.
|
||||
|
||||
## Only on your own device
|
||||
|
||||
Administration is available only to a session signed in with **"This is my own
|
||||
device"** ticked. A borrowed laptop or a shared machine is exactly where nobody
|
||||
should be able to reset a password or remove a domain, and that tickbox is the
|
||||
one question the sign-in page already asks about where it is being used.
|
||||
|
||||
It is enforced the same way as the switch below: an untrusted session is sent
|
||||
no permissions, and the JMAP proxy refuses registry methods beyond the account's
|
||||
own. The menu still shows **Administration** to an administrator in that
|
||||
session, grayed out, with the reason and what to do about it — signing in again
|
||||
with the box ticked — rather than losing the entry without a word. All the
|
||||
server tells that session is that the account administers, never what it may do.
|
||||
|
||||
## An operator can turn it off
|
||||
|
||||
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||
menu. The permissions are no longer sent to the browser, and the JMAP proxy
|
||||
refuses Stalwart registry methods except the ones about the signed-in account
|
||||
itself — its password, app passwords, API keys, public keys, masked addresses
|
||||
and account settings. Without that, hiding the menu would leave an
|
||||
administrator's browser console able to make every call the menu made.
|
||||
Stalwart's own interface is unaffected; this decides what ihasmail offers.
|
||||
|
||||
## Stateless, as everything else
|
||||
|
||||
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
|
||||
no database and no cache beyond the permissions list that rides along with the
|
||||
session information already kept for thirty minutes — so a role granted or
|
||||
taken away shows in the menu at the next sign-in or within half an hour, and in
|
||||
the meantime Stalwart refuses what is no longer allowed.
|
||||
|
||||
The dashboard, accounts, groups, mailing lists, tenants, roles and domains are
|
||||
the sections so far. Beyond the dashboard's counts, managing queues, logs and
|
||||
server settings is deliberately out of scope.
|
||||
|
||||
---
|
||||
|
||||
# Live updates and notifications
|
||||
|
||||
- **JMAP push over EventSource**, proxied by ihasmail's server so the browser
|
||||
@@ -1192,7 +1463,7 @@ needed nothing in either half.
|
||||
worker genuinely cannot reach is anything a *tab* holds in memory — and the
|
||||
API asks for none of it.
|
||||
|
||||
What it cannot reach is a catalogue. The worker is plain JavaScript outside
|
||||
What it cannot reach is a catalog. The worker is plain JavaScript outside
|
||||
the bundle, with no i18n and no idea which mailbox is the archive, so the app
|
||||
writes both down for it whenever the language, the account or the folder list
|
||||
changes. Where there is no such note — between installing a new worker and
|
||||
@@ -1276,9 +1547,10 @@ costs something to get wrong is the one that assumes the machine is yours.
|
||||
| Idle sign-out | after 5 minutes | none |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||
| Background notifications | refused | available |
|
||||
| Administration | unavailable | available, if the role allows it |
|
||||
|
||||
Local storage is gated on that answer for **reads** as well as writes — a
|
||||
machine trusted once still has residue, and honouring it would let a previous
|
||||
machine trusted once still has residue, and honoring it would let a previous
|
||||
session's data surface in a later untrusted one. Signing out clears the settings
|
||||
cache and recent addresses and tears down the push subscription, whichever
|
||||
answer was given.
|
||||
@@ -1342,10 +1614,10 @@ This is S/MIME only, and it stops at reading: nothing here signs, encrypts or
|
||||
decrypts anything.
|
||||
|
||||
**What it checks.** For a `multipart/signed` message carrying a PKCS#7
|
||||
signature, the exact bytes of the signed part — headers included, canonicalised
|
||||
signature, the exact bytes of the signed part — headers included, canonicalized
|
||||
to CRLF — are hashed and compared against the `messageDigest` the signature
|
||||
covers, and the signature over the signed attributes is verified with WebCrypto
|
||||
against the certificate travelling inside the message. RSA (PKCS#1 v1.5) and
|
||||
against the certificate traveling inside the message. RSA (PKCS#1 v1.5) and
|
||||
ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or
|
||||
SHA-512.
|
||||
|
||||
@@ -1362,12 +1634,12 @@ authority:
|
||||
|
||||
| what happened | what you see |
|
||||
|---|---|
|
||||
| first signed message from this address | *"Signed by X, seen here for the first time"* — grey, and deliberately not congratulatory |
|
||||
| first signed message from this address | *"Signed by X, seen here for the first time"* — gray, and deliberately not congratulatory |
|
||||
| same certificate as before | *"the same signer as before"* — the only case that gets a tick |
|
||||
| **different certificate than before** | **loud**: both names, and told to check by some other route |
|
||||
| valid signature, certificate for a different address | **loud**: the signature is not for this sender |
|
||||
| body changed after signing | **loud**: the signature does not check out |
|
||||
| signed, but uncheckable | grey, and careful to say *could not check* rather than *did not check out* |
|
||||
| signed, but uncheckable | gray, and careful to say *could not check* rather than *did not check out* |
|
||||
|
||||
The pins live in the account's settings file rather than in the browser, so the
|
||||
same correspondent is not greeted as new on every device — which is what trains
|
||||
@@ -1417,7 +1689,7 @@ docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
`IMMUTABLE=1` is an **assertion the server checks at startup**, not a switch
|
||||
that changes behaviour. It refuses to boot if `SESSION_FILE` is still set, or if
|
||||
that changes behavior. It refuses to boot if `SESSION_FILE` is still set, or if
|
||||
the filesystem it is installed on turns out to be writable after all. Without
|
||||
it, the same misconfiguration is silent — sessions are held in memory and
|
||||
persisting them is best-effort, so a read-only `/data` costs one warning at the
|
||||
@@ -1446,6 +1718,8 @@ wizard, because either would be state.
|
||||
| Variable | Default | Does |
|
||||
| --- | --- | --- |
|
||||
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
|
||||
| `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off |
|
||||
| `STALWART_ADMIN_URL` | found | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Unset, it is found: the host Stalwart advertises and its web interface's prefix. Set it only when the administration lives somewhere else |
|
||||
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
|
||||
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
|
||||
| `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below |
|
||||
@@ -1459,6 +1733,7 @@ wizard, because either would be state.
|
||||
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
||||
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
||||
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
||||
| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu |
|
||||
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
||||
| `COOKIE_NAME` | `ihm_session` | |
|
||||
| `APP_NAME` | `ihasmail` | Branding |
|
||||
@@ -1544,6 +1819,17 @@ moves an occurrence renumbering the ids around it. Two switches:
|
||||
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
|
||||
tested.
|
||||
|
||||
Administration works against it too, with a directory of about thirty accounts,
|
||||
three domains with their DKIM keys and zone files, nine queued messages and
|
||||
thirty hours of metric history ending in the current hour, behind the same
|
||||
permission names Stalwart uses. `MOCK_ROLE` decides who the
|
||||
demo user is: `admin` (the default), `tenant-admin` (the queue but not the
|
||||
history), `helpdesk` — a custom role that may view and edit accounts but not
|
||||
create or delete them, and read domains — or `user`, who is not offered the
|
||||
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
|
||||
does, and `MOCK_EDITION=enterprise` reports Enterprise so Tenants can be
|
||||
worked on (the default, `oss`, shows only its notice). Two mailing lists round it out.
|
||||
|
||||
---
|
||||
|
||||
# What it does not do
|
||||
|
||||
+87
-11
@@ -4,7 +4,7 @@ What was checked, against which server, and when. For a failure you are hitting
|
||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
The live instance runs **0.16.21**, and as of **2026-08-26 there is nothing
|
||||
The live instance runs **0.16.22**, and as of **2026-08-26 there is nothing
|
||||
left pending**. Most entries below were exercised against 0.16.19 on the date
|
||||
they name, and the dates still say so: each upgrade since was read against the
|
||||
diff rather than re-run, and nothing in those diffs touches the session
|
||||
@@ -13,10 +13,26 @@ The calendar entries carrying a 2026-08-31 date were exercised against a live
|
||||
0.16.20 directly, as were the public-key entries dated 2026-09-05.
|
||||
|
||||
**0.16.21 was different and was re-run rather than read.** It changed four
|
||||
things a client can see, one of which resolved an entry below outright. The app
|
||||
things a client can see, one of which resolved an entry below outright: an
|
||||
occurrence of a recurring event is identified by its recurrence id rather than
|
||||
its position in the series, so an id held across a write no longer names a
|
||||
different date; `Calendar/get` and `AddressBook/get` return every property when
|
||||
none are named; EventSource advertises its ping interval in seconds rather than
|
||||
milliseconds; and a calendar write that asks for scheduling messages is refused
|
||||
when the account may not send them. The mock reproduces all four. The app
|
||||
was run against a real 0.16.21 with mail, calendar and contacts exercised by
|
||||
hand, including editing one occurrence of a recurring series through the
|
||||
interface and confirming the rest of the series stayed where it was.
|
||||
|
||||
**0.16.22 (2026-09-13) was tested too.** The app has been tested against it on
|
||||
the live instance. Its changes a client can see are all in `CalendarEvent/get`
|
||||
and `ContactCard/get`, and were read from its source before the mock was made
|
||||
to follow them: `baseEventId` is `null` for an event read by its stored id,
|
||||
`recurrenceRule` and `recurrenceOverrides` asked for on a synthetic id come back
|
||||
`null`, `useDefaultAlerts` belongs to the reader and reads `false` until set,
|
||||
and an empty `properties` list returns `id` alone. None of them contradicts an
|
||||
entry below.
|
||||
|
||||
What remains here is not a list of unknowns but of things worth knowing — where
|
||||
Stalwart departs from a spec, where a setting has to be turned on for a feature
|
||||
to work, and what ihasmail deliberately does not do.
|
||||
@@ -32,9 +48,69 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||
|
||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
|
||||
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
||||
- **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved.
|
||||
- **An account reads back in the shapes the code expects**: `credentials` as `{"0": {"@type": "Password", …}}`, aliases and group memberships as objects, the disk limit under `quotas.maxDiskQuota`.
|
||||
- **A new domain gets automatic DKIM straight away** — an Ed25519 and an RSA key, both `active`, with their records already in the zone file — and manual DNS and certificates.
|
||||
- **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other.
|
||||
- **`x:DkimSignature/query` accepts a `domainId` filter.**
|
||||
- **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*.
|
||||
- **A domain its keys still name cannot be destroyed**: `objectIsLinked`, with `linkedObjects` listing each as `{"object": "DkimSignature", "id": …}` and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards.
|
||||
- **A reserved TLD is refused**: `example` as a domain's top level comes back `invalidPatch`, *"Invalid domain name"*, naming `name`.
|
||||
|
||||
The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials/<index>/secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason.
|
||||
|
||||
- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below:
|
||||
|
||||
- **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500.
|
||||
- **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them.
|
||||
- **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`.
|
||||
- **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`.
|
||||
|
||||
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
|
||||
|
||||
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway group on one of the server's domains, created and removed, its only member the administrator's own account:
|
||||
|
||||
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
|
||||
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
|
||||
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
|
||||
|
||||
The same run tried a throwaway mailing list before the Mailing lists section was written. It was created with `recipients` as a set, `{"[email protected]": true}`, and read back as `name`, `domainId`, `description`, `aliases`, `memberTenantId`, `recipients` and a computed `emailAddress`. `"recipients/<address>": true` added one and left the other; a `text` filter found it; it was destroyed with nothing linked. Not tried: removing a recipient with `null` (the same set patch as a group membership, which was), and how the server words a recipient that is not an address.
|
||||
|
||||
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
|
||||
|
||||
- **Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15)** with throwaway `ihasmail-role-test` roles, created and removed:
|
||||
|
||||
- **A role is created** with `description`, `roleIds`, `enabledPermissions` and `disabledPermissions` as sets, and reads back with them and `memberTenantId`.
|
||||
- **Pointers change one entry each**: `enabledPermissions/<p>` and `disabledPermissions/<p>` with `true` or `null`, `roleIds/<id>` likewise, and `description` in the same update, all applied together.
|
||||
- **A name that is not a permission fails the whole update** as `invalidPatch`, *"Invalid value for object property"*, naming the pointer — which is how a probe using the mock's made-up `jmapEmailSet` found that the mock had carried a permission Stalwart does not have since Accounts was built; it is `jmapEmailUpdate` now, and the mock refuses unknown names.
|
||||
- **A grant the caller does not hold is refused**: `forbidden`, *"You are not authorized to grant permissions: scimAccess"*.
|
||||
- **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child.
|
||||
- **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
|
||||
|
||||
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed.
|
||||
|
||||
- **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice:
|
||||
|
||||
- **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/<name>` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
|
||||
- **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
|
||||
- **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
|
||||
- **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
|
||||
- **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
|
||||
|
||||
Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
|
||||
|
||||
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
|
||||
|
||||
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
||||
|
||||
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
|
||||
|
||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
||||
|
||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalog key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
|
||||
@@ -42,7 +118,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
|
||||
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
|
||||
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
|
||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
|
||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. **0.16.21 fixed this for calendars and address books**: with `properties` omitted, `Calendar/get` and `AddressBook/get` now return every property, `shareWith` included — **confirmed live on 0.16.21 (2026-09-06)**. `Mailbox/get` on the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere.
|
||||
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
|
||||
|
||||
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
|
||||
@@ -52,22 +128,22 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
||||
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
||||
|
||||
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is grey rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
||||
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
||||
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
|
||||
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
||||
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defence stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behaviour, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
|
||||
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
|
||||
|
||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by
|
||||
other people that ships inside it and the terms it comes under.
|
||||
|
||||
## Colour palettes
|
||||
## Color palettes
|
||||
|
||||
Ten of the palettes offered in Settings › Appearance are the work of their own
|
||||
projects and are used under the MIT licence. Only the published colour values
|
||||
projects and are used under the MIT license. Only the published color values
|
||||
are used — no code, and nothing from anyone else's reimplementation of them.
|
||||
The values as fetched from each project are recorded in
|
||||
`.palette-sources/palettes-upstream.md`, and the shades between them are
|
||||
@@ -16,66 +16,66 @@ not meet the contrast ihasmail claims.
|
||||
### Dracula and Alucard
|
||||
|
||||
Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme
|
||||
Licensed under the MIT licence. "Dracula" is the dark variant and "Alucard" the
|
||||
Licensed under the MIT license. "Dracula" is the dark variant and "Alucard" the
|
||||
light one; both are published in that repository's own "Color Palette (OSS)"
|
||||
section.
|
||||
|
||||
### Gruvbox
|
||||
|
||||
Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox
|
||||
Licensed under the MIT licence.
|
||||
Licensed under the MIT license.
|
||||
|
||||
### Rosé Pine
|
||||
|
||||
Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme
|
||||
Licensed under the MIT licence. The light variant is "Dawn".
|
||||
Licensed under the MIT license. The light variant is "Dawn".
|
||||
|
||||
### Tokyo Night
|
||||
|
||||
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
|
||||
Licensed under the MIT licence. The light variant is "Day".
|
||||
Licensed under the MIT license. The light variant is "Day".
|
||||
|
||||
### Catppuccin
|
||||
|
||||
Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
|
||||
Licensed under the MIT licence. "Mocha" is the dark variant and "Latte" the
|
||||
Licensed under the MIT license. "Mocha" is the dark variant and "Latte" the
|
||||
light one; both are published in that repository's palette.json.
|
||||
|
||||
### Solarized
|
||||
|
||||
Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
|
||||
Licensed under the MIT licence. Light and dark are both original to it, and
|
||||
Licensed under the MIT license. Light and dark are both original to it, and
|
||||
share one set of accent values by design.
|
||||
|
||||
### Ayu
|
||||
|
||||
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
|
||||
Licensed under the MIT licence. The two signature accent colours come from the
|
||||
Licensed under the MIT license. The two signature accent colors come from the
|
||||
same author's ayu-theme/vscode-ayu, also MIT.
|
||||
|
||||
### Kanagawa
|
||||
|
||||
Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
|
||||
Licensed under the MIT licence. "Wave" is the dark variant and "Lotus" the
|
||||
Licensed under the MIT license. "Wave" is the dark variant and "Lotus" the
|
||||
light one. The theme takes its name from Hokusai's print.
|
||||
|
||||
### Everforest
|
||||
|
||||
Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
|
||||
Licensed under the MIT licence. The medium-contrast variant of each mode is
|
||||
Licensed under the MIT license. The medium-contrast variant of each mode is
|
||||
the one used here.
|
||||
|
||||
### Primer
|
||||
|
||||
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
|
||||
Licensed under the MIT licence, which covers the colour values. "GitHub" and
|
||||
Licensed under the MIT license, which covers the color values. "GitHub" and
|
||||
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
|
||||
"Primer" after the design system and is neither affiliated with nor endorsed
|
||||
by GitHub.
|
||||
|
||||
---
|
||||
|
||||
The MIT licence, under which all ten are used:
|
||||
The MIT license, under which all ten are used:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
|
||||
@@ -8,22 +8,21 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.21" src="https://img.shields.io/badge/Stalwart-0.16.21-6366f1?style=flat-square"></a>
|
||||
<a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.22" src="https://img.shields.io/badge/Stalwart-0.16.22-6366f1?style=flat-square"></a>
|
||||
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
|
||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
|
||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art) — a container
|
||||
with nothing to persist, and a Gmail-class client on top of it.**
|
||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art).** Mail,
|
||||
calendars, contacts, files and filters in one app that works as well on a phone
|
||||
as on a desktop — and a container with nothing to persist.
|
||||
|
||||
Mail, calendars, contacts, files and filters in a responsive single-page app
|
||||
that works equally well on a desktop monitor and a phone. It talks only JMAP
|
||||
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
|
||||
database, and with `IMMUTABLE=1` no writable filesystem either. Everything
|
||||
durable belongs to Stalwart; the container is disposable.
|
||||
ihasmail talks only JMAP to Stalwart. There is no database, no IMAP or SMTP,
|
||||
and with `IMMUTABLE=1` no writable filesystem either: everything durable,
|
||||
settings included, belongs to Stalwart, so the container is disposable.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
@@ -33,73 +32,41 @@ durable belongs to Stalwart; the container is disposable.
|
||||
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
||||
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
||||
|
||||
> **Releases are weekly, so `latest` normally lags `main`.** Automation builds
|
||||
> and publishes the GHCR image every **Monday at 09:00 UTC**, in a week that had
|
||||
> changes. Between one Monday and the next, `main` is ahead of the newest image
|
||||
> — a fix merged on Tuesday is a `docker pull` away only after the following
|
||||
> Monday. GitHub runs scheduled workflows on a best-effort basis, so treat the
|
||||
> hour as approximate.
|
||||
>
|
||||
> This is worth knowing when a closed issue says a fix is *live*: that means the
|
||||
> QA webmail server, which deploys from `main`, and not the image you have. If
|
||||
> you want a change before the next Monday, build from `main` — see
|
||||
> [Container images](#container-images). Otherwise pull after it, and the dated
|
||||
> tag tells you exactly which build you are on.
|
||||
|
||||
This file is for people working *on* ihasmail. Everything about running it
|
||||
lives in the docs.
|
||||
|
||||
## Screenshots
|
||||
|
||||
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
|
||||
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
Taken against the built-in mock with sample data. More, including the phone
|
||||
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
|
||||
## What's in it
|
||||
|
||||
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, an event made from a message with its guests already in it, multi-composer rich-text editing with signatures, scheduled send and undo send
|
||||
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||
- **Signature checking** — S/MIME signed mail is verified as you read it, and the signer is remembered: a later message from the same address signed by somebody else is called out loudly. No certificate authority is involved and none is bundled, so ihasmail never claims more than it can show — see [Checking a signature](FEATURES.md#checking-a-signature)
|
||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files. The format is ihasmail's; the file is the account's, under its quota, and outlives any container that read it. ihasmail holds none of it
|
||||
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
- **Mail** — conversations, labels, search operators, keyboard shortcuts, scheduled and undo send, invitations and RSVP, filters made from a message
|
||||
- **Calendar** — month, week, day and agenda views, recurrence, attendees and free-busy
|
||||
- **Contacts** — address books, groups, vCard import and export
|
||||
- **Files** — browse, upload, move, share
|
||||
- **Signature checking** — S/MIME signed mail verified as you read it
|
||||
- **Settings that follow the account**, kept in the account's own storage on Stalwart
|
||||
- **On a phone** — swipe to archive or delete, pull to refresh, hold to select
|
||||
- **Administration** — a dashboard, accounts, groups, mailing lists, roles, tenants and domains, each shown only when the Stalwart role allows it
|
||||
- **Ten interface languages and twelve themes** — the nine translations are marked Beta until a native speaker has read them
|
||||
- **Platform** — installable PWA, Web Push, `mailto:` handler, no credentials in the browser, strict CSP
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
|
||||
The long version is [FEATURES.md](FEATURES.md) and
|
||||
[ihasmail.org](https://ihasmail.org/#features).
|
||||
|
||||
## Requires Stalwart 0.16 or newer
|
||||
## Requirements
|
||||
|
||||
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
|
||||
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
|
||||
and moved configuration into the store; supporting both generations meant a
|
||||
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
||||
reached production. With one supported generation a wrong guess is a loud error
|
||||
on the first call.
|
||||
**Stalwart 0.16 or newer** — sign-in refuses anything older, by name. Tested
|
||||
against 0.16.22; what changed in each release is in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
||||
|
||||
**Validated against 0.16.21**, released 6 September 2026: the app was run
|
||||
against a real instance of it and the mail, calendar and contacts paths were
|
||||
exercised by hand. Four of that release's JMAP changes are visible to a client
|
||||
— an occurrence of a recurring event is now identified by its recurrence id
|
||||
rather than by its position in the series, so an id held across a write no
|
||||
longer silently names a different date; `Calendar/get` and `AddressBook/get`
|
||||
return every property when none are named; EventSource advertises its ping
|
||||
interval in seconds rather than milliseconds; and a calendar write that asks
|
||||
for scheduling messages is refused when the account may not send them. The mock
|
||||
reproduces all four.
|
||||
|
||||
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
- **No Stalwart yet?** [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command.
|
||||
- **On Stalwart 0.15?** [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support) release.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
@@ -107,336 +74,31 @@ reproduces all four.
|
||||
cp .env.example .env
|
||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||
docker compose up --build -d
|
||||
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
||||
```
|
||||
|
||||
Users sign in with their Stalwart mailbox credentials. **An account with
|
||||
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are
|
||||
weekly, so it is usually a few days behind `main`.
|
||||
|
||||
People sign in with their Stalwart mailbox credentials. **An account with
|
||||
two-factor authentication needs an app password**, created in Stalwart's own
|
||||
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
|
||||
password grant, so no client holding a username and password can exchange them
|
||||
plus a code for a token.
|
||||
settings.
|
||||
|
||||
Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### Container images
|
||||
|
||||
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`.
|
||||
Releases are cut weekly — Mondays, 09:00 UTC, in a week that had changes — so
|
||||
the newest image is normally behind `main`:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/coffey-labs/ihasmail:latest
|
||||
```
|
||||
|
||||
| Tag | What it is |
|
||||
| --- | --- |
|
||||
| `latest` | The newest release. Prereleases never move it |
|
||||
| `2026.9.2-pr243` | One specific build — the [version](#version-numbers) with `+` written as `-`, because a Docker tag may not contain `+` |
|
||||
|
||||
Pin the dated tag in anything you care about. `latest` is a moving target by
|
||||
definition, and rolling back to a named tag is a `docker run` rather than a
|
||||
rebuild.
|
||||
|
||||
Building it yourself stays fully supported and is what `docker compose up
|
||||
--build` above does — the image is a convenience, not a new requirement. If you
|
||||
build by hand, pass the version in, because `.dockerignore` excludes `.git` and
|
||||
the build cannot work out what it is:
|
||||
|
||||
```bash
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:local .
|
||||
```
|
||||
|
||||
### Running immutably
|
||||
|
||||
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
|
||||
and there is nothing left to write, so the container can run with no writable
|
||||
filesystem at all:
|
||||
|
||||
```bash
|
||||
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||
or if the filesystem it is installed on turns out to be writable after all.
|
||||
Without it the same misconfiguration is silent — sessions are held in memory
|
||||
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||
the first sign-in and nothing else until the instance is replaced and everyone
|
||||
is signed out.
|
||||
|
||||
That sign-out is the standing cost of this mode today, since sessions have
|
||||
nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
### Several Stalwart servers
|
||||
|
||||
One ihasmail can front more than one Stalwart, choosing by the domain somebody
|
||||
signs in with. **`STALWART_URL` stays required and stays the default**, so an
|
||||
installation that sets nothing else behaves exactly as it always has.
|
||||
|
||||
```bash
|
||||
-e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
|
||||
-v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": "https://jmap.customer-b.test"
|
||||
}
|
||||
```
|
||||
|
||||
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
|
||||
with the rules written in it.
|
||||
|
||||
A domain nobody listed — and a bare username, which Stalwart accepts and which
|
||||
has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
|
||||
back.** If its server is unreachable that sign-in fails rather than retrying
|
||||
against the default, because falling back would authenticate somebody against a
|
||||
server their domain was deliberately routed away from; if the same account name
|
||||
existed there they would land in another tenant's mailbox.
|
||||
|
||||
Read once at startup, so editing it means restarting the container. Malformed
|
||||
JSON, a duplicate domain once lower-cased, or a value that is not an `http(s)`
|
||||
URL stops the server rather than failing quietly at somebody's sign-in. The
|
||||
servers themselves are not contacted at boot — a mapping is a routing table,
|
||||
not a health check, and one customer's outage must not stop ihasmail starting
|
||||
for everybody else.
|
||||
|
||||
This is one server per *person*, chosen at sign-in. Several servers at once for
|
||||
one person, with unified or cross-account views, is not supported: JMAP account
|
||||
ids are only unique within a server, so it would mean namespacing ids through
|
||||
the proxy. Reading somebody else's mail, calendars or files on the *same* server
|
||||
already works through JMAP sharing.
|
||||
|
||||
### Settings the installation decides
|
||||
|
||||
A deployment can seed and lock user settings, which is what a school wanting
|
||||
"warn about outside senders" on for three thousand pupils needs — asking three
|
||||
thousand pupils is not a plan.
|
||||
|
||||
```bash
|
||||
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
|
||||
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
|
||||
```
|
||||
|
||||
Three powers, and the differences between them matter:
|
||||
|
||||
| Section | Applies to | Reader can change it |
|
||||
| --- | --- | --- |
|
||||
| `defaults` | accounts that have never had settings of their own | yes, at any time |
|
||||
| `enforced` | everyone, on every load | no — the control goes dead |
|
||||
| `changes` | everyone, **once each**, including existing accounts | yes, afterwards, and it stays changed |
|
||||
|
||||
`changes` is the one that needs explaining. It turns something on for people who
|
||||
are *already here* — the reason a plain default is not enough — while still
|
||||
leaving them the last word. Each entry carries its own `version`, which every
|
||||
account remembers once it has had it, so the change is applied exactly once per
|
||||
person and a reader who turns it back off keeps it off. It is a schema migration
|
||||
in shape, and that is deliberately whose idea it was ([#207]).
|
||||
|
||||
Nothing is configured by default: an installation that sets none of these
|
||||
behaves exactly as ihasmail always has.
|
||||
|
||||
### Passing a policy to Docker
|
||||
|
||||
Where a file is easier to manage than JSON quoted in a unit file — and it
|
||||
usually is once there are `changes` in it — mount one and name it:
|
||||
|
||||
```bash
|
||||
docker run -d --name ihasmail \
|
||||
-e STALWART_URL=https://mail.example.org \
|
||||
-e APP_SECRET="$(openssl rand -hex 32)" \
|
||||
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \
|
||||
-v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \
|
||||
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": { "externalSenderBanner": true },
|
||||
"enforced": { "externalRecipientConfirm": true },
|
||||
"changes": [
|
||||
{ "version": "20260902084513", "settings": { "externalSenderBanner": true } },
|
||||
{ "version": "20261014091500", "settings": { "externalLinkWarning": true } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[`settings-policy.example.json`](settings-policy.example.json) in this repo is
|
||||
that file with every section explained in it — copy it and delete what you do
|
||||
not want.
|
||||
|
||||
Mount it read-only: the server only ever reads it, and `:ro` keeps that true
|
||||
under `--read-only` as well.
|
||||
|
||||
Or without a file at all, which is what an immutable deployment with no volume
|
||||
wants:
|
||||
|
||||
```bash
|
||||
docker run -d --name ihasmail --read-only --tmpfs /tmp \
|
||||
-e IMMUTABLE=1 -e SESSION_FILE= \
|
||||
-e STALWART_URL=https://mail.example.org \
|
||||
-e APP_SECRET="$(openssl rand -hex 32)" \
|
||||
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
|
||||
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \
|
||||
-e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \
|
||||
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
|
||||
```
|
||||
|
||||
In `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ihasmail:
|
||||
image: ghcr.io/coffey-labs/ihasmail:latest
|
||||
environment:
|
||||
SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json
|
||||
volumes:
|
||||
- ./policy.json:/etc/ihasmail/policy.json:ro
|
||||
```
|
||||
|
||||
A policy is read once at startup, so **editing it means restarting the
|
||||
container**. There is no reload signal, deliberately: an installation-wide
|
||||
setting changing under a running instance would be harder to reason about than
|
||||
one that changes when you say so.
|
||||
|
||||
### Writing a policy
|
||||
|
||||
Both sections take the same names and values a settings export uses, so
|
||||
`Settings → General → Export` on one account you have configured by hand is the
|
||||
quickest way to write one — copy the keys you care about out of the file.
|
||||
|
||||
Three checks worth knowing about, because they fail loudly rather than quietly:
|
||||
|
||||
- **Malformed JSON stops the server at startup.** A policy that silently did not
|
||||
apply is indistinguishable from the feature not working.
|
||||
- **Every change needs a unique `version`.** Two changes sharing one, or a change
|
||||
with no `version` or no `settings`, is a startup error.
|
||||
- **Keys this build does not have are dropped**, the same rule an imported
|
||||
settings file gets. A `changes` entry whose keys are *all* unknown is dropped
|
||||
whole rather than recorded as applied, so it still runs on an ihasmail that
|
||||
does have the setting.
|
||||
|
||||
Enforcement is applied in the settings store rather than only on the controls,
|
||||
so an imported settings file, a settings file synced from a device that predates
|
||||
the policy, and "reset to defaults" cannot get around it. Reset returns to your
|
||||
defaults, not to ihasmail's.
|
||||
|
||||
[#207]: https://github.com/Coffey-Labs/ihasmail/issues/207
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||
React SPA • session cookie ⇄ Basic auth
|
||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||
```
|
||||
|
||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
|
||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
||||
|
||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
||||
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
|
||||
missing.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
||||
|
||||
npm run typecheck # tsc for both packages
|
||||
npm test # vitest (web) + node:test (server)
|
||||
npm run build # web/dist + server/dist
|
||||
npm start # serve the production build
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||
build. Running it for real is covered in
|
||||
Everything else — TLS, running immutably, several Stalwart servers, settings
|
||||
the installation decides, every environment variable — is in
|
||||
[Installing](https://docs.ihasmail.org/install/) and
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### The mock
|
||||
|
||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's. Three switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
||||
calendar write that asks for scheduling messages, the way an account without
|
||||
that permission is refused.
|
||||
|
||||
It tracks the current release rather than 0.16 in general, and each behaviour
|
||||
is confirmed against a real server before it is copied here — the comments say
|
||||
which version and on what date. Where a release changes something a client can
|
||||
see, the mock changes with it, and the test that pinned the old behaviour is
|
||||
rewritten rather than deleted, so the reversal stays on the record.
|
||||
|
||||
### Version numbers
|
||||
|
||||
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
|
||||
the pull request that commit arrived through. A commit that did not arrive
|
||||
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
|
||||
from git at build time; nothing writes a version into the tree, and
|
||||
`package.json` sits at `0.0.0` because it is no longer the source of anything.
|
||||
|
||||
The date is the commit's own rather than today's, so rebuilding an old commit
|
||||
gives the version it had the first time.
|
||||
## Development
|
||||
|
||||
```bash
|
||||
node scripts/version.mjs # the version for the current checkout
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
|
||||
npm install
|
||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo)
|
||||
npm test
|
||||
```
|
||||
|
||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
|
||||
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
|
||||
not pass one.
|
||||
|
||||
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
|
||||
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
|
||||
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
|
||||
image and About screen would read as a downgrade. Which Stalwart a build needs is
|
||||
stated where it can be precise, in the badge at the top of this file and in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
|
||||
|
||||
The pull request lives after the `+`, as build metadata, because it is
|
||||
provenance rather than a rank: at the rate they merge here it climbs without
|
||||
bound and says nothing about how new a build is. Everything after the `+` is
|
||||
ignored when versions are compared, which is the right reading — two builds from
|
||||
the same day differ in where they came from, not in age. Nothing here depends on
|
||||
that comparison: images are pruned oldest-first by creation time, and a rollback
|
||||
names a git ref.
|
||||
|
||||
### Deploying
|
||||
|
||||
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
||||
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
|
||||
introduced and asks, rebuilds with the right version baked in, replaces the
|
||||
container, waits for healthy, then prunes all but the newest
|
||||
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
|
||||
|
||||
```bash
|
||||
./deploy.sh # origin/main, asks before shipping new commits
|
||||
./deploy.sh --dry-run # run the guards and stop
|
||||
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
```
|
||||
|
||||
`--yes` does not override a hold; clearing one means deleting its line.
|
||||
Architecture, the mock's switches and how versions are numbered are in
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -445,14 +107,8 @@ container, waits for healthy, then prunes all but the newest
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
|
||||
[LICENSE](LICENSE).
|
||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
||||
nearly always run as a network service rather than handed to anyone as a binary,
|
||||
and the AGPL's section 13 closes that gap.
|
||||
|
||||
That offer has to point at *your* source, not this one. If you run a modified
|
||||
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
|
||||
Settings › About both show it. See
|
||||
If you run a modified ihasmail, set `SOURCE_URL` to your own repository: the
|
||||
sign-in page and Settings › About both show it. See
|
||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
||||
|
||||
+4
-3
@@ -8,12 +8,13 @@ the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
||||
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
|
||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
||||
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
|
||||
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
||||
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
|
||||
|
||||
@@ -25,8 +26,8 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
|
||||
|
||||
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
|
||||
|
||||
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponised in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
|
||||
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponized in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
|
||||
|
||||
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defence, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
|
||||
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defense, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
|
||||
|
||||
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
|
||||
|
||||
+17
-1
@@ -214,7 +214,23 @@ prune_old_images() {
|
||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# The version is the same sum scripts/version.mjs does -- the commit's own
|
||||
# date, plus the pull request it arrived through or its short SHA -- done here
|
||||
# in shell because a host that only runs containers has git and docker and no
|
||||
# node. Given IHASMAIL_VERSION, use it as given, as the script would.
|
||||
version_from_git() {
|
||||
local date subject sha y m d
|
||||
date="$(git show -s --format=%cs HEAD)"
|
||||
subject="$(git show -s --format=%s HEAD)"
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
IFS=- read -r y m d <<<"$date"
|
||||
if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then
|
||||
printf '%d.%d.%d+pr%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "${BASH_REMATCH[1]}"
|
||||
else
|
||||
printf '%d.%d.%d+g%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "$sha"
|
||||
fi
|
||||
}
|
||||
VERSION="${IHASMAIL_VERSION:-$(version_from_git)}"
|
||||
# A Docker tag may not contain "+", and every version has one now:
|
||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||
|
||||
@@ -128,7 +128,7 @@ const waitFor = async (jsExpr, what, ms = 15000) => {
|
||||
* capture — twice, silently, producing a "light" screenshot of the dark theme.
|
||||
* A MutationObserver puts it back faster than anything can take it away.
|
||||
*
|
||||
* The check is the rendered background colour: the attribute is what lied.
|
||||
* The check is the rendered background color: the attribute is what lied.
|
||||
*/
|
||||
const themeTest = (want) => want === "light"
|
||||
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
|
||||
|
||||
Generated
+413
-539
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -23,11 +23,12 @@
|
||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
||||
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs",
|
||||
"i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
|
||||
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^7.0.2"
|
||||
"concurrently": "^10.0.5",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-ast": "npm:typescript@^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function baseUrlOf(basePath) {
|
||||
*
|
||||
* The comparison is deliberately not `startsWith(base)`: that would let
|
||||
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
|
||||
* both wrong and a small open door for a neighbouring site on the same host.
|
||||
* both wrong and a small open door for a neighboring site on the same host.
|
||||
*/
|
||||
export function stripBasePath(basePath, pathname) {
|
||||
const base = normalizeBasePath(basePath);
|
||||
|
||||
+22
-22
@@ -2,15 +2,15 @@
|
||||
"""
|
||||
Generate the palette CSS blocks in web/src/styles/app.css.
|
||||
|
||||
Every colour here comes from the palette's own project (all MIT); the values
|
||||
Every color here comes from the palette's own project (all MIT); the values
|
||||
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
|
||||
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
|
||||
between twelve and twenty, so the tiers in between are computed rather than
|
||||
guessed, and every text colour is then checked against the surface it sits on.
|
||||
guessed, and every text color is then checked against the surface it sits on.
|
||||
|
||||
The check is the reason this is a script and not a hand-written block. ihasmail
|
||||
claims WCAG AA, and several of these palettes do not meet it as published --
|
||||
Dracula's comment grey on its own background is about 3.0:1, well under the 4.5
|
||||
Dracula's comment gray on its own background is about 3.0:1, well under the 4.5
|
||||
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
|
||||
being true; here it is arithmetic, and the script fails loudly if a token it
|
||||
emitted would not pass.
|
||||
@@ -30,7 +30,7 @@ BEGIN = "/* === generated palettes: begin === */"
|
||||
END = "/* === generated palettes: end === */"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- colour maths
|
||||
# ---------------------------------------------------------------- color maths
|
||||
|
||||
def parse(hex_: str) -> tuple[float, float, float]:
|
||||
h = hex_.lstrip("#")
|
||||
@@ -66,18 +66,18 @@ def rgba(hex_: str, alpha: float) -> str:
|
||||
return f"rgba({r}, {g}, {b}, {alpha})"
|
||||
|
||||
|
||||
def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
|
||||
"""Nudge `colour` away from `bg` until it clears `target`.
|
||||
def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
|
||||
"""Nudge `color` away from `bg` until it clears `target`.
|
||||
|
||||
Towards white on a dark background and towards black on a light one, so a
|
||||
lifted tier keeps its hue instead of washing out to grey.
|
||||
Toward white on a dark background and toward black on a light one, so a
|
||||
lifted tier keeps its hue instead of washing out to gray.
|
||||
"""
|
||||
if contrast(colour, bg) >= target:
|
||||
return colour
|
||||
if contrast(color, bg) >= target:
|
||||
return color
|
||||
anchor = "#ffffff" if dark_ui else "#000000"
|
||||
best = colour
|
||||
best = color
|
||||
for i in range(1, 101):
|
||||
candidate = mix(colour, anchor, i / 100)
|
||||
candidate = mix(color, anchor, i / 100)
|
||||
best = candidate
|
||||
if contrast(candidate, bg) >= target:
|
||||
return candidate
|
||||
@@ -90,7 +90,7 @@ def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
|
||||
|
||||
# ihasmail's own palette has a hand-written dark block further up the file --
|
||||
# it is the identity this project is painted in, and regenerating it would
|
||||
# quietly move colours nobody asked to move. Only its light half is derived
|
||||
# quietly move colors nobody asked to move. Only its light half is derived
|
||||
# here, which is why it appears in LIGHT_ONLY.
|
||||
LIGHT_ONLY = {"ihasmail"}
|
||||
|
||||
@@ -261,17 +261,17 @@ def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], lis
|
||||
bg, fg = src["bg"], src["fg"]
|
||||
notes: list[str] = []
|
||||
|
||||
def lift(name: str, colour: str, target: float) -> str:
|
||||
out = toward_contrast(colour, bg, target, dark)
|
||||
if out != colour:
|
||||
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
|
||||
def lift(name: str, color: str, target: float) -> str:
|
||||
out = toward_contrast(color, bg, target, dark)
|
||||
if out != color:
|
||||
notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
|
||||
return out
|
||||
|
||||
# Body text is lifted like every other text tone rather than exempted.
|
||||
# Most of these palettes publish a body colour around 4.5:1 -- their own
|
||||
# Most of these palettes publish a body color around 4.5:1 -- their own
|
||||
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
|
||||
# Rejecting a palette over that would have cost five of the six added in
|
||||
# 2026-09; nudging the published colour along its own hue costs nothing a
|
||||
# 2026-09; nudging the published color along its own hue costs nothing a
|
||||
# reader can name, and the shift is recorded in the header of the
|
||||
# generated block like every other one.
|
||||
fg = lift("fg", fg, TEXT_ON_BG["fg"])
|
||||
@@ -365,11 +365,11 @@ def main() -> int:
|
||||
"/*",
|
||||
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
|
||||
" *",
|
||||
" * Every colour is from the palette's own project (all MIT); the published",
|
||||
" * Every color is from the palette's own project (all MIT); the published",
|
||||
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
|
||||
" * between them are derived, and every text colour is checked against the",
|
||||
" * between them are derived, and every text color is checked against the",
|
||||
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
|
||||
" * of these palettes do not meet that as published -- Dracula's comment grey",
|
||||
" * of these palettes do not meet that as published -- Dracula's comment gray",
|
||||
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
|
||||
" * is why this is arithmetic rather than a hand-written block.",
|
||||
" */",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Check a catalogue against the strings the code actually asks for.
|
||||
* Check a catalog against the strings the code actually asks for.
|
||||
*
|
||||
* Two failures, and only one of them is visible without this.
|
||||
*
|
||||
@@ -8,24 +8,70 @@
|
||||
* as an untranslated word on screen, which somebody will eventually notice.
|
||||
*
|
||||
* A *stale* key -- one whose English no longer exists, usually because it was
|
||||
* mistyped when the catalogue was written -- is silent. The translation sits
|
||||
* mistyped when the catalog was written -- is silent. The translation sits
|
||||
* in the file looking correct, is never looked up, and the app renders English
|
||||
* for ever. Nothing warns, because a catalogue is only ever read by key.
|
||||
* for ever. Nothing warns, because a catalog is only ever read by key.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
/*
|
||||
* The parser, not the compiler.
|
||||
*
|
||||
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||
* binary and nothing else, so `typescript` now exports `version` and
|
||||
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||
* in this directory started throwing "Cannot read properties of undefined
|
||||
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||
* workflow runs these.
|
||||
*
|
||||
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||
*/
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/*
|
||||
* Two sets, because there are two questions and they need different nets.
|
||||
*
|
||||
* `wanted` is what a catalog *owes*: the strings that actually reach t(),
|
||||
* tc() or plural(). Coverage is measured against it, so it has to stay strict
|
||||
* -- widening it would count every CSS class and JMAP method name as an
|
||||
* untranslated string.
|
||||
*
|
||||
* `seen` is every string literal in the source, and answers only "is this
|
||||
* catalog key still written down anywhere". Stale detection needs the wide
|
||||
* net: a key reaches t() as a variable often enough that a strict set reports
|
||||
* mostly false alarms.
|
||||
*/
|
||||
const wanted = new Set();
|
||||
const seen = new Set();
|
||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const visit = (n) => {
|
||||
/*
|
||||
* Labels held in a constant and translated where they render -- t(s.label)
|
||||
* -- reach t() as a variable, so there is no literal for this to find and
|
||||
* every one of them looked "stale". They are collected from the constants
|
||||
* instead: a `label:` property, or a value in an object of them. Without
|
||||
* this the stale check cried wolf 33 times and would have been switched
|
||||
* off, which is the only outcome worse than not having it.
|
||||
* Anything held in a constant and translated where it renders -- t(s.label),
|
||||
* t(b.description), t(group) -- reaches t() as a variable, so there is no
|
||||
* literal at the call site and every one of them looked "stale".
|
||||
*
|
||||
* This used to chase the shapes one at a time: a `label:` property, then an
|
||||
* object named *_LABELS. It still cried wolf, because the shapes kept
|
||||
* coming -- `description:` and `group:` on keyboard bindings, the calendar's
|
||||
* view names, the read-receipt refusals, the palette names. 41 reported,
|
||||
* 10 of them real. A report that is three-quarters false is one nobody acts
|
||||
* on, which is how these sat unread long enough to be worth a commit of
|
||||
* their own.
|
||||
*
|
||||
* So: any string literal anywhere in the source counts as a use. That
|
||||
* under-reports -- a literal that exists but is never passed to t() will not
|
||||
* be flagged -- and that is the right way round. A missed stale key costs a
|
||||
* line of dead translation; a false one costs the credibility of the whole
|
||||
* check, and then every real finding with it.
|
||||
*/
|
||||
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) seen.add(n.text);
|
||||
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
|
||||
/*
|
||||
* A `label:` in a constant is still a string somebody has to translate --
|
||||
* it reaches t() one render later -- so it stays part of what a catalog
|
||||
* owes, and out of coverage it would flatter the number.
|
||||
*/
|
||||
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
|
||||
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
|
||||
@@ -35,15 +81,16 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
||||
const fn = n.expression.text, a0 = n.arguments[0];
|
||||
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
|
||||
// tc(context, source) keys the catalogue on both, joined by the same
|
||||
// tc(context, source) keys the catalog on both, joined by the same
|
||||
// control character tc() uses. Without this the contextual entries all
|
||||
// looked stale, which is the checker's own false alarm rather than a
|
||||
// catalogue problem.
|
||||
// catalog problem.
|
||||
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
|
||||
// Only the contextual key is required. The plain one is tc()'s
|
||||
// fallback, not a second obligation -- asking for both would report
|
||||
// work that does not exist.
|
||||
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||
seen.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||
}
|
||||
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
||||
for (const p of n.arguments[1].properties) {
|
||||
@@ -57,8 +104,8 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
}
|
||||
|
||||
/*
|
||||
* A catalogue and a picker entry are two halves of one thing, and either half
|
||||
* alone is dead weight. A catalogue with no entry in UI_LANGUAGES never
|
||||
* A catalog and a picker entry are two halves of one thing, and either half
|
||||
* alone is dead weight. A catalog with no entry in UI_LANGUAGES never
|
||||
* reaches a reader -- it builds, it passes every test, and the language simply
|
||||
* is not offered. That happened to Dutch: the entry was added by a text
|
||||
* replacement anchored on a line that did not exist on that branch, so it was
|
||||
@@ -66,17 +113,17 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
*/
|
||||
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
|
||||
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
|
||||
const catalogues = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
|
||||
const catalogs = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
|
||||
|
||||
let failed = false;
|
||||
for (const tag of catalogues) {
|
||||
for (const tag of catalogs) {
|
||||
if (!registered.has(tag)) {
|
||||
failed = true;
|
||||
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
|
||||
}
|
||||
}
|
||||
for (const tag of registered) {
|
||||
if (tag !== "en" && !catalogues.has(tag)) {
|
||||
if (tag !== "en" && !catalogs.has(tag)) {
|
||||
failed = true;
|
||||
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
|
||||
}
|
||||
@@ -91,15 +138,14 @@ for (const file of globSync("web/src/locales/*.ts")) {
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(src);
|
||||
const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
||||
const stale = [...have].filter((k) => !seen.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
||||
const missing = [...wanted].filter((k) => !have.has(k));
|
||||
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
|
||||
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
|
||||
if (stale.length) {
|
||||
failed = true;
|
||||
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
|
||||
for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`);
|
||||
if (stale.length > 25) console.log(` …and ${stale.length - 25} more`);
|
||||
for (const k of stale) console.log(` ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (process.argv.includes("--missing")) {
|
||||
console.log(`\n missing:`);
|
||||
|
||||
@@ -11,7 +11,21 @@
|
||||
* exits non-zero only with --check, so CI can be told to fail on regressions
|
||||
* later, once the number is low enough for that to mean something.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
/*
|
||||
* The parser, not the compiler.
|
||||
*
|
||||
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||
* binary and nothing else, so `typescript` now exports `version` and
|
||||
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||
* in this directory started throwing "Cannot read properties of undefined
|
||||
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||
* workflow runs these.
|
||||
*
|
||||
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||
*/
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/** Attributes a person reads. `className` and `key` are not among them. */
|
||||
|
||||
@@ -12,7 +12,21 @@
|
||||
* node scripts/i18n-extract.mjs <file...> rewrite in place
|
||||
* node scripts/i18n-extract.mjs --dry <file...>
|
||||
*/
|
||||
import ts from "typescript";
|
||||
/*
|
||||
* The parser, not the compiler.
|
||||
*
|
||||
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||
* binary and nothing else, so `typescript` now exports `version` and
|
||||
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||
* in this directory started throwing "Cannot read properties of undefined
|
||||
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||
* workflow runs these.
|
||||
*
|
||||
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||
*/
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
|
||||
|
||||
+61
-15
@@ -11,14 +11,28 @@
|
||||
* A string reaches a reader translated if either is true:
|
||||
*
|
||||
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
|
||||
* 2. it is a catalogue key, translated somewhere else
|
||||
* 2. it is a catalog key, translated somewhere else
|
||||
*
|
||||
* The second case is a real convention here, not a loophole: constant tables
|
||||
* hold English and the render site calls `t(s.label)`. What this refuses is a
|
||||
* string that is neither -- one no catalogue has a key for, which therefore
|
||||
* string that is neither -- one no catalog has a key for, which therefore
|
||||
* cannot be translated at all, however many languages ship.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
/*
|
||||
* The parser, not the compiler.
|
||||
*
|
||||
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||
* binary and nothing else, so `typescript` now exports `version` and
|
||||
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||
* in this directory started throwing "Cannot read properties of undefined
|
||||
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||
* workflow runs these.
|
||||
*
|
||||
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||
*/
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/* Where a string literal in this position is shown to somebody. */
|
||||
@@ -26,7 +40,13 @@ const UI_PROPS = new Set([
|
||||
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
|
||||
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
|
||||
]);
|
||||
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]);
|
||||
/*
|
||||
* A JSX attribute is shown whether it lands on an element or on a component:
|
||||
* `<MenuItem label="Collapse all">` renders its label as given, exactly as
|
||||
* `<button title="…">` does. Checking only the DOM spellings let every
|
||||
* component prop through, so the props are checked here too.
|
||||
*/
|
||||
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt", ...UI_PROPS]);
|
||||
const TOASTS = new Set(["error", "success", "info", "show"]);
|
||||
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
|
||||
const EQUALITY = new Set([
|
||||
@@ -36,7 +56,7 @@ const EQUALITY = new Set([
|
||||
|
||||
/*
|
||||
* Product names, example addresses and URL scaffolding. These reach t() and
|
||||
* are deliberately absent from every catalogue -- translating "ihasmail" or
|
||||
* are deliberately absent from every catalog -- translating "ihasmail" or
|
||||
* "[email protected]" would be a bug, not a feature -- so they would otherwise
|
||||
* be reported for ever.
|
||||
*/
|
||||
@@ -60,8 +80,15 @@ const keys = new Set();
|
||||
const found = [];
|
||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const report = (node, text) => {
|
||||
if (!looksLikeUi(text) || keys.has(text) || NEVER_TRANSLATED.has(text)) return;
|
||||
/*
|
||||
* `strict` withdraws the catalog-key exemption. It exists for English held
|
||||
* in a constant and translated where it renders; a literal written straight
|
||||
* into a JSX attribute has no later render site to be translated at -- no
|
||||
* component here passes its props through t() -- so being a key only means
|
||||
* a translation exists that this string never reaches.
|
||||
*/
|
||||
const report = (node, text, strict = false) => {
|
||||
if (!looksLikeUi(text) || (!strict && keys.has(text)) || NEVER_TRANSLATED.has(text)) return;
|
||||
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
|
||||
found.push({ file, line: line + 1, text });
|
||||
};
|
||||
@@ -89,14 +116,33 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
mark(src);
|
||||
const wrapped = exempt;
|
||||
|
||||
/*
|
||||
* English assembled around values: `aria-label={`Remove ${email}`}`.
|
||||
*
|
||||
* The literal cannot be a catalog key as written, so whether it is a key is
|
||||
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
|
||||
* `${name} — shared by ${owner}` opens with a value and its words come after.
|
||||
* Any run of letters between the values counts. The only template literals
|
||||
* that reach a UI attribute and are not prose are pure punctuation around
|
||||
* values, like `${name} (${size})`, and those have none.
|
||||
*/
|
||||
const reportTemplate = (x) => {
|
||||
const parts = ts.isNoSubstitutionTemplateLiteral(x) ? [x.text] : [x.head.text, ...x.templateSpans.map((s) => s.literal.text)];
|
||||
if (!/[A-Za-z]{2,}/.test(parts.join(""))) return;
|
||||
const { line } = src.getLineAndCharacterOfPosition(x.getStart(src));
|
||||
found.push({ file, line: line + 1, text: parts.join("{}") });
|
||||
};
|
||||
const isTemplate = (x) => ts.isTemplateExpression(x) || ts.isNoSubstitutionTemplateLiteral(x);
|
||||
|
||||
const visit = (n) => {
|
||||
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)
|
||||
&& UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
|
||||
report(n.initializer, n.initializer.text);
|
||||
if (ts.isPropertyAssignment(n) && UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
|
||||
if (ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)) report(n.initializer, n.initializer.text);
|
||||
if (isTemplate(n.initializer)) reportTemplate(n.initializer);
|
||||
}
|
||||
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
|
||||
const walk = (x) => {
|
||||
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text);
|
||||
if (isTemplate(x)) reportTemplate(x);
|
||||
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
|
||||
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
|
||||
};
|
||||
walk(n.initializer);
|
||||
@@ -105,7 +151,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
|
||||
const a0 = n.arguments[0];
|
||||
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
|
||||
/* A template literal cannot be a catalogue key at all, so it is always a find. */
|
||||
/* A template literal cannot be a catalog key at all, so it is always a find. */
|
||||
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
@@ -114,11 +160,11 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
||||
}
|
||||
|
||||
if (!found.length) {
|
||||
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
|
||||
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalog key");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
|
||||
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalog can translate:\n`);
|
||||
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
|
||||
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
|
||||
console.log("translated where it renders -- make sure the English is a catalogue key.");
|
||||
console.log("translated where it renders -- make sure the English is a catalog key.");
|
||||
process.exit(process.argv.includes("--check") ? 1 : 0);
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Every source string a catalogue needs, straight out of the calls.
|
||||
* Every source string a catalog needs, straight out of the calls.
|
||||
*
|
||||
* The English text is the key, so the catalogue's keys are not a list somebody
|
||||
* The English text is the key, so the catalog's keys are not a list somebody
|
||||
* maintains -- they are whatever t(), tNode() and plural() are actually asked
|
||||
* for. Reading them from the code means a catalogue can never drift out of
|
||||
* for. Reading them from the code means a catalog can never drift out of
|
||||
* step with the app in the one direction that matters: a key that no longer
|
||||
* exists is dead weight, but a call with no key is an untranslated string
|
||||
* nobody noticed.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
/*
|
||||
* The parser, not the compiler.
|
||||
*
|
||||
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||
* binary and nothing else, so `typescript` now exports `version` and
|
||||
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||
* in this directory started throwing "Cannot read properties of undefined
|
||||
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||
* workflow runs these.
|
||||
*
|
||||
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||
*/
|
||||
import ts from "typescript-ast";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
const strings = new Set();
|
||||
|
||||
@@ -156,7 +156,7 @@ test("with 2FA on, a password change needs the current code too", async () => {
|
||||
test("2FA is switched off with the password and a current code", async () => {
|
||||
const state = await call("/api/account/security");
|
||||
assert.equal(state.body.otpEnabled, true);
|
||||
// The enrolment secret is known only to the client, so disabling uses a code
|
||||
// The enrollment secret is known only to the client, so disabling uses a code
|
||||
// from the authenticator - here, the one the mock stored.
|
||||
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||
const params = parseOtpauthUrl(stored!);
|
||||
|
||||
@@ -169,10 +169,10 @@ export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
|
||||
* Start enrollment: mint a secret and hand back the URL to show as a QR code.
|
||||
* Nothing is stored until the user proves they can produce a code from it.
|
||||
*/
|
||||
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
||||
export function beginOtpEnrollment(ctx: Ctx): { secret: string; url: string } {
|
||||
const secret = generateSecret();
|
||||
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
||||
* the new secret, so without this an authenticator that was mistyped or out of
|
||||
* step would lock the user out of their mailbox at the next sign-in.
|
||||
*/
|
||||
export function assertEnrolmentCode(url: string, code: string): void {
|
||||
export function assertEnrollmentCode(url: string, code: string): void {
|
||||
const params = parseOtpauthUrl(url);
|
||||
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
|
||||
if (!verifyTotp(params, code)) {
|
||||
@@ -193,7 +193,7 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
||||
}
|
||||
|
||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
assertEnrollmentCode(opts.url, opts.code);
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
|
||||
@@ -33,8 +33,8 @@ test("an account with no locale set yields none, rather than a guess", () => {
|
||||
});
|
||||
|
||||
test("neither answering leaves the locale unknown", () => {
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
@@ -48,7 +48,7 @@ test("a server without the registry is not asked for anything", async () => {
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
assert.deepEqual(info, { locale: null, edition: null });
|
||||
assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
|
||||
});
|
||||
|
||||
test("no capabilities at all is treated the same way", async () => {
|
||||
@@ -73,14 +73,14 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
|
||||
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
|
||||
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a 0.16 server is recognised from an account's capabilities", () => {
|
||||
test("a 0.16 server is recognized from an account's capabilities", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
@@ -100,7 +100,7 @@ test("a server that advertises it nowhere is one we do not support", () => {
|
||||
assert.equal(hasStalwartRegistry(undefined), false);
|
||||
});
|
||||
|
||||
test("a shared account carrying the capability is enough to recognise the server", () => {
|
||||
test("a shared account carrying the capability is enough to recognize the server", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
@@ -110,3 +110,32 @@ test("a shared account carrying the capability is enough to recognise the server
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* With a domain mapped to its own Stalwart (#238), everything asked about the
|
||||
* account has to go to that server. The locale lookup resolved Stalwart's
|
||||
* `apiUrl` against the default server instead, so a mapped account's locale
|
||||
* was requested from a server that had never heard of it.
|
||||
*/
|
||||
test("account info is asked of the server that issued the session", async () => {
|
||||
const seen: string[] = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
seen.push(String(input instanceof Request ? input.url : input));
|
||||
return new Response(JSON.stringify({ methodResponses: [], edition: "oss" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const session = {
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: { [STALWART]: {} } } },
|
||||
primaryAccounts: { [STALWART]: "a1" },
|
||||
apiUrl: "https://mail.mapped.test/jmap/",
|
||||
baseUrl: "https://mail.mapped.test",
|
||||
};
|
||||
await getAccountInfo("session-mapped-domain", "Basic x", session as never);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
assert.ok(seen.length >= 2, "asks for both the locale and the edition");
|
||||
for (const url of seen) assert.ok(url.startsWith("https://mail.mapped.test/"), `${url} went to the wrong server`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
|
||||
|
||||
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
||||
|
||||
/**
|
||||
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
|
||||
* operator's decision. Hiding the menu would leave the proxy forwarding the
|
||||
* very calls the menu made.
|
||||
*/
|
||||
test("mail, calendars and the rest pass untouched", () => {
|
||||
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test("the account's own registry objects pass", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||
});
|
||||
|
||||
test("directory and server objects are refused, and named", () => {
|
||||
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
|
||||
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
|
||||
}
|
||||
});
|
||||
|
||||
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
|
||||
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { ok: false, method: null });
|
||||
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
|
||||
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
|
||||
});
|
||||
|
||||
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
|
||||
// Most traffic from a session that may not administer: no parse, no rewrite.
|
||||
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), false);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
|
||||
});
|
||||
|
||||
test("a method name hidden behind a unicode escape is still found", () => {
|
||||
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
|
||||
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), true);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The operator's rule: administration only from a session signed in with
|
||||
* "This is my own device" ticked, and never when the installation turned it off.
|
||||
*/
|
||||
test("administration needs both the installation and a device marked as the person's own", () => {
|
||||
assert.equal(administrationAllowed(true, true), true);
|
||||
assert.equal(administrationAllowed(true, false), false);
|
||||
assert.equal(administrationAllowed(false, true), false);
|
||||
});
|
||||
|
||||
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||
// The dashboard opens on less than a list: a count is only a query.
|
||||
assert.equal(grantsAdministration(["sysAccountQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery"]), false);
|
||||
assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false);
|
||||
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||
});
|
||||
|
||||
test("what is forwarded is what was checked", () => {
|
||||
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
|
||||
// means the server cannot read it the other way.
|
||||
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
|
||||
const r = gateAdministration(raw);
|
||||
assert.equal(r.ok, true);
|
||||
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* What the JMAP proxy lets through for a session that may not administer:
|
||||
* the operator turned it off (`ADMINISTRATION=0`), or the session was signed
|
||||
* in without "This is my own device".
|
||||
*
|
||||
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
|
||||
* browser sends, and Stalwart's registry answers whatever the credential's role
|
||||
* allows -- so without this, an administrator could still manage accounts, or
|
||||
* the whole server, from the browser console of an installation whose operator
|
||||
* said no. With it off, the proxy refuses every `x:` method except the few that
|
||||
* are about the signed-in account itself.
|
||||
*
|
||||
* An allowlist rather than a list of administrative objects, because the
|
||||
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
||||
* and a new release adds more. An object not named here is refused, which errs
|
||||
* toward the operator's decision.
|
||||
*
|
||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||
* touched: they act on what the account can already reach.
|
||||
*/
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||
|
||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||
|
||||
/**
|
||||
* Whether a session may administer at all: the installation allows it, and
|
||||
* the person signing in said the device is their own.
|
||||
*
|
||||
* The second half is the operator's rule, not Stalwart's. A borrowed laptop or
|
||||
* a library machine is exactly where a session should not be able to reset a
|
||||
* password or remove a domain, and "This is my own device" is the one thing
|
||||
* the sign-in form already asks that says where it is being used. An untrusted
|
||||
* session is also signed out when idle and wipes its local data, so nothing
|
||||
* about it suits an administrator's work.
|
||||
*/
|
||||
export function administrationAllowed(enabled: boolean, remember: boolean): boolean {
|
||||
return enabled && remember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's permissions would put Administration in its menu --
|
||||
* the same test the client makes, so the server can say why it is missing
|
||||
* without handing over the permissions themselves.
|
||||
*
|
||||
* The client's test is whether any section opens, and the dashboard opens on
|
||||
* less than a list does: a count needs only the query, the metric history its
|
||||
* query and get. The account and domain lists need more than their counts, so
|
||||
* they add nothing here.
|
||||
*/
|
||||
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||
const has = new Set(permissions);
|
||||
return has.has("sysAccountQuery") || has.has("sysDomainQuery") || has.has("sysQueuedMessageQuery") || (has.has("sysMetricQuery") && has.has("sysMetricGet"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a body could hold a registry method name at all, so the common case
|
||||
* -- mail, calendars, contacts from a session that may not administer -- skips
|
||||
* the parse. A method name is a JSON string starting `x:`, which appears in the
|
||||
* text as `"x:` unless written with a `\u` escape; a body with neither cannot
|
||||
* contain one, and is forwarded exactly as it came.
|
||||
*/
|
||||
export function mayNameRegistryMethod(raw: string): boolean {
|
||||
return raw.includes('"x:') || raw.includes("\\u");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a JMAP request body. On success, hands back the body to forward --
|
||||
* serialized from what was inspected, so the server can never be sent
|
||||
* something different from what was checked (a duplicate key, say, read one
|
||||
* way here and another way there).
|
||||
*/
|
||||
export function gateAdministration(raw: string): GateResult {
|
||||
if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, method: null };
|
||||
}
|
||||
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
|
||||
if (!Array.isArray(calls)) return { ok: false, method: null };
|
||||
for (const call of calls) {
|
||||
const name = Array.isArray(call) ? call[0] : undefined;
|
||||
if (typeof name !== "string") return { ok: false, method: null };
|
||||
if (!name.startsWith("x:")) continue;
|
||||
const object = name.slice(2).split("/")[0] ?? "";
|
||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||
}
|
||||
return { ok: true, body: JSON.stringify(parsed) };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "ihasmail-servers-"));
|
||||
const file = join(dir, "servers.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
_comment: ["A note, as the example file has."],
|
||||
"plain.test": "https://mail.plain.test/",
|
||||
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
|
||||
}),
|
||||
);
|
||||
process.env.STALWART_URL = "https://default.example";
|
||||
process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
|
||||
process.env.STALWART_SERVERS_FILE = file;
|
||||
|
||||
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
|
||||
const { config, parseStalwartServers } = await import("./config.js");
|
||||
|
||||
/**
|
||||
* Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this
|
||||
* server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its
|
||||
* administration, and follows the same domain routing.
|
||||
*/
|
||||
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
|
||||
assert.deepEqual(config.stalwartServers, { "plain.test": "https://mail.plain.test", "linked.test": "https://mail.linked.test" });
|
||||
assert.deepEqual(config.stalwartAdminUrls, { "linked.test": "https://admin.linked.test" });
|
||||
assert.equal(upstreamFor("[email protected]"), "https://mail.linked.test");
|
||||
});
|
||||
|
||||
test("an unmapped domain and a bare username open the default administration", () => {
|
||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.default.example");
|
||||
assert.equal(adminUrlFor("demo"), "https://admin.default.example");
|
||||
});
|
||||
|
||||
test("a routed domain opens its own server's administration, and never the default's", () => {
|
||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
|
||||
// Routed away, with no adminUrl of its own and nothing found: no link rather than the wrong server.
|
||||
assert.equal(adminUrlFor("[email protected]"), null);
|
||||
});
|
||||
|
||||
test("what the operator configured wins over what was found, and what was found fills the gap", () => {
|
||||
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.default.example");
|
||||
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.linked.test");
|
||||
// The routed domain without an adminUrl takes what its own server said.
|
||||
assert.equal(adminUrlFor("[email protected]", "https://mail.plain.test/admin/"), "https://mail.plain.test/admin/");
|
||||
});
|
||||
|
||||
/** Finding the administration on the server itself, as production's answered on 2026-09-15. */
|
||||
test("the web interface's prefix is read from the applications Stalwart has installed", () => {
|
||||
const got = (list: unknown[]) => adminPrefixFrom([["x:Application/query", { ids: ["a"] }, "q"], ["x:Application/get", { list }, "g"]]);
|
||||
assert.equal(got([{ enabled: true, description: "Stalwart Web Interface", urlPrefix: { "/admin": true, "/account": true } }]), "/admin");
|
||||
assert.equal(got([{ enabled: false, urlPrefix: { "/admin": true } }]), null);
|
||||
assert.equal(got([{ enabled: true, urlPrefix: { "/console": true } }]), null);
|
||||
assert.equal(got([]), null);
|
||||
// May not read applications: not an answer, so Stalwart's own default.
|
||||
assert.equal(adminPrefixFrom([["error", { type: "forbidden" }, "q"], ["error", { type: "forbidden" }, "g"]]), "/admin");
|
||||
});
|
||||
|
||||
test("the origin is the one Stalwart advertises, even when it is reached on a private address", () => {
|
||||
assert.equal(advertisedOrigin({ apiUrl: "https://mail.example.com/jmap/", baseUrl: "http://127.0.0.1:8080" }), "https://mail.example.com");
|
||||
assert.equal(advertisedOrigin({ apiUrl: "/jmap/", baseUrl: "https://mail.example.com" }), "https://mail.example.com");
|
||||
});
|
||||
|
||||
test("the shipped example loads through the parser that reads it", () => {
|
||||
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
||||
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
||||
assert.ok(Object.keys(parsed.urls).length > 0);
|
||||
assert.ok(!("_comment" in parsed.urls));
|
||||
assert.equal(Object.keys(parsed.adminUrls).length, 1);
|
||||
});
|
||||
+99
-11
@@ -8,6 +8,8 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { fetchPermissions } from "./permissionSchema.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
@@ -22,12 +24,13 @@ import {
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
upstreamFor,
|
||||
adminUrlFor,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
AccountError,
|
||||
assertEnrolmentCode,
|
||||
beginOtpEnrolment,
|
||||
assertEnrollmentCode,
|
||||
beginOtpEnrollment,
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
@@ -401,7 +404,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
);
|
||||
}
|
||||
/*
|
||||
* A 401 is a judgement about the password and stays counted. Anything
|
||||
* A 401 is a judgment about the password and stays counted. Anything
|
||||
* else -- refused, timed out, DNS, TLS -- is the upstream failing to
|
||||
* answer, which says nothing about the credentials and must not spend
|
||||
* somebody's attempts while they wait for it to come back (#239).
|
||||
@@ -459,7 +462,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
// The account's own server. Without it, the first fetch after the cached
|
||||
// session expires goes to STALWART_URL -- which, for a domain mapped
|
||||
// elsewhere, either refuses the password or knows a different account by
|
||||
// the same name (#238).
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
return { authorization: session.authorization, session: upstream, username: session.username };
|
||||
};
|
||||
|
||||
@@ -552,7 +559,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
api.post("/account/2fa/begin", requireSession, async (c) => {
|
||||
try {
|
||||
// Nothing is stored yet; the client hands the URL back to confirm.
|
||||
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
||||
return c.json(beginOtpEnrollment(await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -577,7 +584,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
* the moment 2FA is enabled this session can no longer authenticate at all.
|
||||
*/
|
||||
try {
|
||||
assertEnrolmentCode(body.url, code);
|
||||
assertEnrollmentCode(body.url, code);
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -633,6 +640,30 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||
return c.json({ error: "unsupported_media_type" }, 415);
|
||||
}
|
||||
/*
|
||||
* For a session that may not administer -- administration switched off, or
|
||||
* a device not marked as the person's own -- the body is read and checked
|
||||
* before it goes anywhere. A session that may streams straight through as
|
||||
* it always has, and pays nothing for this.
|
||||
*/
|
||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
let raw: string;
|
||||
try {
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||
} catch {
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
const gate = gateAdministration(raw);
|
||||
if (!gate.ok) {
|
||||
if (!gate.method) return c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
|
||||
return config.administration
|
||||
? c.json({ error: "administration_needs_own_device", message: `Administration is only available when signed in on a device marked as your own (${gate.method}).` }, 403)
|
||||
: c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403);
|
||||
}
|
||||
body = gate.body;
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
@@ -642,7 +673,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: c.req.raw.body,
|
||||
body,
|
||||
duplex: "half",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
@@ -658,6 +689,30 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Administration: Stalwart's permission list ----------
|
||||
/*
|
||||
* The one administration read that is not a JMAP call: the labeled list of
|
||||
* permissions from Stalwart's schema, for the Roles picker. Behind the same
|
||||
* two gates as the registry methods, so a session that may not administer
|
||||
* learns nothing from it.
|
||||
*/
|
||||
api.get("/admin/permissions", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
return config.administration
|
||||
? c.json({ error: "administration_needs_own_device" }, 403)
|
||||
: c.json({ error: "administration_disabled" }, 403);
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const permissions = await fetchPermissions(session.authorization, upstream.baseUrl);
|
||||
if (!permissions) return c.json({ error: "upstream_error" }, 502);
|
||||
return c.json({ permissions });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Blob upload ----------
|
||||
api.post("/upload/:accountId", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
@@ -824,7 +879,7 @@ function appPasswordName(c: Context): string {
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null, permissions: [] }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -836,8 +891,35 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
remember: session.remember,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
/**
|
||||
* What the upstream server would tell us about itself, and -- for a
|
||||
* session that may administer -- where the operator says its own
|
||||
* administration is.
|
||||
*/
|
||||
server: {
|
||||
edition: info.edition,
|
||||
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username, info.adminUrl ?? null) : null,
|
||||
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
|
||||
enterpriseNotices: config.showEnterpriseNotices,
|
||||
},
|
||||
/**
|
||||
* Whether this session may administer: the installation offers it
|
||||
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||
*/
|
||||
administration: administrationAllowed(config.administration, session.remember),
|
||||
/**
|
||||
* An administrator signed in on a device not marked as their own, so the
|
||||
* menu can say why Administration is unavailable rather than lose it
|
||||
* without a word. Says only that the account administers, never what it
|
||||
* may do.
|
||||
*/
|
||||
administrationNeedsOwnDevice: config.administration && !session.remember && grantsAdministration(info.permissions),
|
||||
/**
|
||||
* The account's permissions on that server, so the client can offer
|
||||
* administration to those who have it. Stalwart still decides every call.
|
||||
* Withheld from a session that may not administer: nothing in it needs them.
|
||||
*/
|
||||
permissions: administrationAllowed(config.administration, session.remember) ? info.permissions : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -847,6 +929,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
* denylist: everything else it might set — cookies, auth challenges, CORS
|
||||
* grants — would be landing on *our* origin, where it means something else.
|
||||
*/
|
||||
/**
|
||||
* The largest JMAP request read into memory for the administration check.
|
||||
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||
*/
|
||||
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
/**
|
||||
@@ -862,7 +950,7 @@ const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "con
|
||||
* small IncomingMessage/ServerResponse pair.
|
||||
*
|
||||
* Returns a Response Hono treats as already sent: the raw bindings are
|
||||
* written to directly, and the returned value is never serialised.
|
||||
* written to directly, and the returned value is never serialized.
|
||||
*/
|
||||
const SSE_HEADERS = {
|
||||
"content-type": "text/event-stream",
|
||||
|
||||
@@ -77,7 +77,7 @@ test("junk in the chain is discarded rather than used as a key", () => {
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
|
||||
});
|
||||
|
||||
test("bracketed and IPv4-mapped forms are normalised", () => {
|
||||
test("bracketed and IPv4-mapped forms are normalized", () => {
|
||||
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
|
||||
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
|
||||
});
|
||||
|
||||
+62
-17
@@ -202,9 +202,9 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
||||
* having an outage would take the other four down with it. What happens when
|
||||
* one is unreachable is a sign-in question, answered in #239.
|
||||
*/
|
||||
function readStalwartServers(): Record<string, string> {
|
||||
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||
const file = process.env.STALWART_SERVERS_FILE;
|
||||
if (!file) return {};
|
||||
if (!file) return { urls: {}, adminUrls: {} };
|
||||
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
|
||||
|
||||
let raw: unknown;
|
||||
@@ -213,33 +213,55 @@ function readStalwartServers(): Record<string, string> {
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||
}
|
||||
return parseStalwartServers(raw, file);
|
||||
}
|
||||
|
||||
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
|
||||
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const adminUrls: Record<string, string> = {};
|
||||
for (const [rawDomain, rawValue] of Object.entries(raw as Record<string, unknown>)) {
|
||||
/* The example file explains itself in a `_comment` key, and a copy of it
|
||||
used to stop the server as "not a URL". No mail domain starts with an
|
||||
underscore, so a key that does is a note, not a mapping. */
|
||||
if (rawDomain.startsWith("_")) continue;
|
||||
/* Lower-cased and stripped of the root dot, because that is how a domain
|
||||
taken off a username will arrive and comparing them any other way means
|
||||
a mapping that silently never matches. */
|
||||
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
|
||||
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
|
||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
|
||||
/* A domain's value is its server's URL, or an object that also names where
|
||||
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
||||
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`);
|
||||
if (value.adminUrl !== undefined) {
|
||||
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
|
||||
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
|
||||
}
|
||||
out[domain] = rawUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return out;
|
||||
return { urls: out, adminUrls };
|
||||
}
|
||||
|
||||
/** An absolute http(s) URL without its trailing slash, or a startup error naming where it came from. */
|
||||
function httpUrl(raw: string, where: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${where}: not an absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
const stalwartServers = readStalwartServers();
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
@@ -275,7 +297,23 @@ export const config = {
|
||||
*/
|
||||
basePath: normalizeBasePath(process.env.BASE_PATH),
|
||||
stalwartUrl,
|
||||
stalwartServers: readStalwartServers(),
|
||||
stalwartServers: stalwartServers.urls,
|
||||
/**
|
||||
* Where an administrator reaches Stalwart's own administration, for the
|
||||
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
|
||||
* which is how *this server* reaches Stalwart -- often an address no browser
|
||||
* can open. Unset, the dashboard names Stalwart's administration without a
|
||||
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
|
||||
*/
|
||||
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
|
||||
stalwartAdminUrls: stalwartServers.adminUrls,
|
||||
/**
|
||||
* Say that an Enterprise-only section is Enterprise-only even on an
|
||||
* Enterprise server. Off, as a real installation wants it; the public demo
|
||||
* turns it on, because it reports Enterprise to show those sections and
|
||||
* should not suggest they come without the license.
|
||||
*/
|
||||
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
@@ -295,6 +333,13 @@ export const config = {
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
/*
|
||||
* Whether ihasmail offers administration to accounts whose Stalwart role
|
||||
* allows it. Off means off: no menu, no permissions sent to the browser, and
|
||||
* the JMAP proxy refuses registry methods beyond the account's own -- see
|
||||
* adminGate.ts. Stalwart's own interface is unaffected either way.
|
||||
*/
|
||||
administration: bool("ADMINISTRATION", true),
|
||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||
@@ -311,7 +356,7 @@ export const config = {
|
||||
compressJmap: process.env.COMPRESS_JMAP !== "0",
|
||||
/*
|
||||
* How push reaches the browser. "relay" holds one upstream stream per tab
|
||||
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
|
||||
* (today's behavior). "subscribe" registers one JMAP PushSubscription per
|
||||
* account and fans Stalwart's POSTs out to that account's tabs, holding no
|
||||
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
|
||||
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
|
||||
|
||||
@@ -45,7 +45,7 @@ test("an unmapped domain still goes to the default while others are mapped", ()
|
||||
});
|
||||
|
||||
test("the domain is matched however it was typed", () => {
|
||||
// Keys are normalised on load; the username has to be normalised the same
|
||||
// Keys are normalized on load; the username has to be normalized the same
|
||||
// way or a mapping silently never matches.
|
||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,7 @@ const { createApp } = await import("./app.js");
|
||||
* it is pointed at.
|
||||
*/
|
||||
|
||||
test("addresses we must never reach are recognised", () => {
|
||||
test("addresses we must never reach are recognized", () => {
|
||||
for (const a of [
|
||||
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||||
"169.254.169.254", // cloud metadata, the classic SSRF target
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createDirectory, permissionsFor, type MockRole } from "./directory.js";
|
||||
|
||||
class Refused extends Error {
|
||||
constructor(readonly type: string, description?: string) { super(description ?? type); }
|
||||
}
|
||||
|
||||
const make = (role: MockRole, extra: { metricsOff?: boolean; now?: Date } = {}) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d), ...extra });
|
||||
|
||||
/**
|
||||
* The mock stands in for a server that decides what each account may do, so
|
||||
* the client's administration can be developed against refusals as well as
|
||||
* successes. These pin the refusals.
|
||||
*/
|
||||
test("an ordinary user is refused the directory outright", () => {
|
||||
const dir = make("user");
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
assert.ok(!permissionsFor("user").some((p) => p.startsWith("sysAccountQuery")));
|
||||
});
|
||||
|
||||
test("helpdesk may read and edit but not create or delete", () => {
|
||||
const dir = make("helpdesk");
|
||||
const { ids } = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" } }) as { ids: string[] };
|
||||
assert.ok(ids.length > 20);
|
||||
assert.throws(() => dir.handlers["x:Account/set"]!({ create: { n: { name: "x", domainId: "d1" } } }), (e: Refused) => e.type === "forbidden");
|
||||
assert.throws(() => dir.handlers["x:Account/set"]!({ destroy: [ids[0]] }), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
test("queries page, count and match text the way the client asks", () => {
|
||||
const dir = make("admin");
|
||||
const all = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
const page = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, position: 10, limit: 5, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.equal(page.total, all.total);
|
||||
assert.deepEqual(page.ids, all.ids.slice(10, 15));
|
||||
const ada = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", text: "lovelace" } }) as { ids: string[] };
|
||||
assert.equal(ada.ids.length, 1);
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { operator: "OR", conditions: [] } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||
});
|
||||
|
||||
test("an address already used as an alias cannot be taken", () => {
|
||||
const dir = make("admin");
|
||||
const res = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "User", name: "postmaster", domainId: "d1", credentials: { "0": { "@type": "Password", secret: "long enough secret" } }, roles: { "@type": "User" } } } }) as { notCreated?: Record<string, { type: string }> };
|
||||
assert.equal(res.notCreated?.n?.type, "primaryKeyViolation");
|
||||
});
|
||||
|
||||
test("a password is set through its credential's pointer, and a weak one is refused", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Account/set"]!;
|
||||
assert.equal((set({ update: { a1: { "credentials/0/secret": "short" } } }) as { notUpdated?: Record<string, { properties: string[] }> }).notUpdated?.a1?.properties[0], "secret");
|
||||
assert.deepEqual((set({ update: { a1: { "credentials/0/secret": "a much longer secret" } } }) as { updated: object }).updated, { a1: null });
|
||||
const got = dir.handlers["x:Account/get"]!({ ids: ["a1"], properties: ["credentials"] }) as { list: Array<{ credentials: Record<string, { secret: string }> }> };
|
||||
assert.equal(got.list[0]!.credentials["0"]!.secret, "[********]", "never echoed back");
|
||||
});
|
||||
|
||||
test("a grant the caller does not hold is refused", () => {
|
||||
const dir = make("helpdesk");
|
||||
const res = dir.handlers["x:Account/set"]!({ update: { u101: { roles: { "@type": "Admin" } } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(res.notUpdated?.u101?.type, "forbidden");
|
||||
});
|
||||
|
||||
test("an administrator can delete an account, and a group with members is kept", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Account/set"]!;
|
||||
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
|
||||
assert.equal((set({ destroy: ["g1"] }) as { notDestroyed?: Record<string, { type: string }> }).notDestroyed?.g1?.type, "objectIsLinked");
|
||||
});
|
||||
|
||||
test("a domain in use is kept, and names what uses it", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Domain/set"]!;
|
||||
const res = set({ destroy: ["d1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(res.notDestroyed?.d1?.type, "objectIsLinked");
|
||||
const kinds = new Set(res.notDestroyed?.d1?.linkedObjects.map((o) => o.object));
|
||||
assert.deepEqual([...kinds].sort(), ["Account", "DkimSignature"]);
|
||||
});
|
||||
|
||||
test("an unused domain goes once its keys do", () => {
|
||||
const dir = make("admin");
|
||||
const created = dir.handlers["x:Domain/set"]!({ create: { n: { name: "fresh.example.net" } } }) as { created: Record<string, { id: string }> };
|
||||
const id = created.created.n!.id;
|
||||
const keys = dir.handlers["x:DkimSignature/query"]!({ filter: { domainId: id } }) as { ids: string[] };
|
||||
assert.equal(keys.ids.length, 1, "automatic DKIM makes a key straight away");
|
||||
assert.equal((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { notDestroyed?: object }).notDestroyed !== undefined, true);
|
||||
dir.handlers["x:DkimSignature/set"]!({ destroy: keys.ids });
|
||||
assert.deepEqual((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { destroyed: string[] }).destroyed, [id]);
|
||||
});
|
||||
|
||||
test("a domain's zone file is computed on read, with long keys split as the server splits them", () => {
|
||||
const dir = make("admin");
|
||||
const got = dir.handlers["x:Domain/get"]!({ ids: ["d1"], properties: ["name", "dnsZoneFile"] }) as { list: Array<{ dnsZoneFile: string }> };
|
||||
const zone = got.list[0]!.dnsZoneFile;
|
||||
assert.match(zone, /IN MX 10 /);
|
||||
assert.match(zone, /_domainkey\.example\.com\. IN TXT \(\n {4}"/);
|
||||
});
|
||||
|
||||
test("a filter on a name the registry does not index is refused, as the live server refuses it", () => {
|
||||
const dir = make("admin");
|
||||
// Seen on a live 0.16 server: "x:Account/query: unsupportedFilter - type".
|
||||
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { type: "User" } }), (e: Refused) => e.type === "unsupportedFilter" && e.message === "type");
|
||||
assert.doesNotThrow(() => dir.handlers["x:Account/query"]!({ filter: { "@type": "Group", domainId: "d1", text: "x" } }));
|
||||
});
|
||||
|
||||
test("the domain validators refuse what the live server refused, in its words", () => {
|
||||
const dir = make("admin");
|
||||
const set = dir.handlers["x:Domain/set"]!;
|
||||
const created = set({ create: { n: { name: "admin-test.example" } } }) as { notCreated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([created.notCreated?.n?.type, created.notCreated?.n?.description], ["invalidPatch", "Invalid domain name"]);
|
||||
const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]);
|
||||
});
|
||||
|
||||
/** The dashboard's feeds: counts, the queue, and the metric history. */
|
||||
test("counts come back with no ids when the client asks for a total and no page", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:QueuedMessage/query"]!({ limit: 0, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.deepEqual(r.ids, []);
|
||||
assert.equal(r.total, 9);
|
||||
});
|
||||
|
||||
test("the metric history answers the filter the dashboard sends, newest first", () => {
|
||||
const dir = make("admin", { now: new Date("2026-09-15T14:25:00Z") });
|
||||
const q = dir.handlers["x:Metric/query"]!({
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T14:25:00Z", metric: ["server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
}) as { ids: string[] };
|
||||
const { list } = dir.handlers["x:Metric/get"]!({ ids: q.ids }) as { list: Array<{ metric: string; timestamp: string }> };
|
||||
assert.equal(list.length, 24);
|
||||
assert.ok(list.every((m) => m.metric === "server.memory"));
|
||||
const newest = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[0]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
const next = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[1]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
assert.equal(newest.timestamp, "2026-09-15T14:00:00Z");
|
||||
assert.ok(newest.timestamp > next.timestamp);
|
||||
// A bare timestamp is what a live server refuses.
|
||||
assert.throws(() => dir.handlers["x:Metric/query"]!({ filter: { timestamp: "2026-09-15T00:00:00Z" } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||
});
|
||||
|
||||
test("a tenant administrator gets the queue but not the history, and Community refuses the history", () => {
|
||||
const tenant = make("tenant-admin");
|
||||
assert.equal((tenant.handlers["x:QueuedMessage/query"]!({ calculateTotal: true }) as { total: number }).total, 9);
|
||||
assert.throws(() => tenant.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
const community = make("admin", { metricsOff: true });
|
||||
assert.throws(() => community.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden" && /Enterprise/.test(e.message));
|
||||
});
|
||||
|
||||
test("helpdesk may count domains, which is what the demo's helpdesk may do", () => {
|
||||
assert.ok(permissionsFor("helpdesk").includes("sysDomainQuery"));
|
||||
assert.ok(!permissionsFor("helpdesk").includes("sysMetricQuery"));
|
||||
});
|
||||
|
||||
/** Groups: accounts of type Group, whose members carry the membership. */
|
||||
test("a group's members are the users whose memberships name it", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberGroupIds: "g2" }, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.ok(r.total >= 2);
|
||||
const { list } = dir.handlers["x:Account/get"]!({ ids: r.ids, properties: ["memberGroupIds"] }) as { list: Array<{ memberGroupIds: Record<string, boolean> }> };
|
||||
assert.ok(list.every((a) => a.memberGroupIds.g2));
|
||||
});
|
||||
|
||||
test("a membership pointer moves only that membership, and a group cannot join one", () => {
|
||||
const dir = make("admin");
|
||||
const [ada] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", text: "lovelace" } }) as { ids: string[] }).ids;
|
||||
dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/g1": true } } });
|
||||
const read = () => ((dir.handlers["x:Account/get"]!({ ids: [ada], properties: ["memberGroupIds"] }) as { list: Array<{ memberGroupIds: Record<string, boolean> }> }).list[0]!.memberGroupIds);
|
||||
assert.deepEqual(Object.keys(read()).sort(), ["g1", "g2"]);
|
||||
dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/g2": null } } });
|
||||
assert.deepEqual(Object.keys(read()), ["g1"]);
|
||||
const nested = dir.handlers["x:Account/set"]!({ update: { g1: { "memberGroupIds/g2": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(nested.notUpdated?.g1?.type, "invalidProperties");
|
||||
const bogus = dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/u1": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(bogus.notUpdated?.[ada!]?.type, "invalidForeignKey");
|
||||
});
|
||||
|
||||
test("a group is kept while members name it, and goes once they are out", () => {
|
||||
const dir = make("admin");
|
||||
const refused = dir.handlers["x:Account/set"]!({ destroy: ["g2"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(refused.notDestroyed?.g2?.type, "objectIsLinked");
|
||||
assert.ok(refused.notDestroyed!.g2!.linkedObjects.every((l) => l.object === "Account"));
|
||||
const members = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberGroupIds: "g2" } }) as { ids: string[] }).ids;
|
||||
dir.handlers["x:Account/set"]!({ update: Object.fromEntries(members.map((id) => [id, { "memberGroupIds/g2": null }])) });
|
||||
const done = dir.handlers["x:Account/set"]!({ destroy: ["g2"] }) as { destroyed: string[] };
|
||||
assert.deepEqual(done.destroyed, ["g2"]);
|
||||
});
|
||||
|
||||
test("a group is created without a password, with Default roles", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "Group", name: "sales", domainId: "d1", roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, aliases: {} } } }) as { created: Record<string, { id: string }> };
|
||||
const id = r.created.n!.id;
|
||||
const { list } = dir.handlers["x:Account/get"]!({ ids: [id] }) as { list: Array<Record<string, unknown>> };
|
||||
assert.equal(list[0]!["@type"], "Group");
|
||||
assert.ok(!("memberGroupIds" in list[0]!));
|
||||
});
|
||||
|
||||
/** Mailing lists: their own object, with a set of recipient addresses. */
|
||||
test("a list is created, found by text, and read back with its address", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d1", recipients: { "[email protected]": true }, aliases: {} } } }) as { created: Record<string, { id: string }> };
|
||||
const id = r.created.n!.id;
|
||||
const q = dir.handlers["x:MailingList/query"]!({ filter: { text: "team" }, calculateTotal: true }) as { ids: string[] };
|
||||
assert.deepEqual(q.ids, [id]);
|
||||
const { list } = dir.handlers["x:MailingList/get"]!({ ids: [id] }) as { list: Array<{ emailAddress: string; recipients: Record<string, boolean> }> };
|
||||
assert.match(list[0]!.emailAddress, /^team@/);
|
||||
assert.deepEqual(list[0]!.recipients, { "[email protected]": true });
|
||||
});
|
||||
|
||||
test("a recipient pointer moves one address, and a bad one is refused", () => {
|
||||
const dir = make("admin");
|
||||
dir.handlers["x:MailingList/set"]!({ update: { l2: { "recipients/[email protected]": true, "recipients/[email protected]": null } } });
|
||||
const read = () => (dir.handlers["x:MailingList/get"]!({ ids: ["l2"] }) as { list: Array<{ recipients: Record<string, boolean> }> }).list[0]!.recipients;
|
||||
assert.deepEqual(Object.keys(read()).sort(), ["[email protected]", "[email protected]"]);
|
||||
const bad = dir.handlers["x:MailingList/set"]!({ update: { l2: { "recipients/not-an-address": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(bad.notUpdated?.l2?.type, "invalidPatch");
|
||||
});
|
||||
|
||||
test("a list's address cannot be one an account already has, and a role without the permission is refused", () => {
|
||||
const dir = make("admin");
|
||||
const clash = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "demo", domainId: "d1" } } }) as { notCreated?: Record<string, { type: string }> };
|
||||
assert.equal(clash.notCreated?.n?.type, "primaryKeyViolation");
|
||||
assert.throws(() => make("helpdesk").handlers["x:MailingList/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
/** Roles: Stalwart's grant check, loops, and a role still in use. */
|
||||
test("a role is refused a permission the caller does not hold, directly or through a base", () => {
|
||||
const helpdesk = make("helpdesk");
|
||||
// Helpdesk cannot create roles at all.
|
||||
assert.throws(() => helpdesk.handlers["x:Role/set"]!({ create: { n: { description: "x" } } }), (e: Refused) => e.type === "forbidden");
|
||||
const tenant = make("tenant-admin");
|
||||
const direct = tenant.handlers["x:Role/set"]!({ create: { n: { description: "Too much", enabledPermissions: { sysTenantCreate: true } } } }) as { notCreated?: Record<string, { type: string; description: string }> };
|
||||
assert.equal(direct.notCreated?.n?.type, "forbidden");
|
||||
assert.match(direct.notCreated!.n!.description, /not authorized to grant/);
|
||||
const fine = tenant.handlers["x:Role/set"]!({ create: { n: { description: "Accounts only", enabledPermissions: { sysAccountGet: true }, roleIds: { r1: true } } } }) as { created: Record<string, { id: string }> };
|
||||
assert.ok(fine.created.n!.id);
|
||||
});
|
||||
|
||||
test("a role cannot build on itself through another, and one in use is kept", () => {
|
||||
const dir = make("admin");
|
||||
const loop = dir.handlers["x:Role/set"]!({ update: { r1: { "roleIds/r3": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(loop.notUpdated?.r1?.type, "invalidPatch");
|
||||
const inUse = dir.handlers["x:Role/set"]!({ destroy: ["r1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(inUse.notDestroyed?.r1?.type, "objectIsLinked");
|
||||
assert.deepEqual([...new Set(inUse.notDestroyed!.r1!.linkedObjects.map((l) => l.object))].sort(), ["Authentication", "Role"]);
|
||||
const free = dir.handlers["x:Role/set"]!({ destroy: ["r4"] }) as { destroyed: string[] };
|
||||
assert.deepEqual(free.destroyed, ["r4"]);
|
||||
});
|
||||
|
||||
test("the default roles are read from the authentication settings", () => {
|
||||
const { list } = make("admin").handlers["x:Authentication/get"]!({ ids: ["singleton"] }) as { list: Array<{ defaultUserRoleIds: Record<string, boolean> }> };
|
||||
assert.deepEqual(list[0]!.defaultUserRoleIds, { r1: true });
|
||||
assert.throws(() => make("tenant-admin").handlers["x:Authentication/get"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
test("a permission name Stalwart does not know fails the whole change", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:Role/set"]!({ update: { r4: { "enabledPermissions/notARealPermission": true, description: "Renamed" } } }) as { notUpdated?: Record<string, { type: string; properties: string[] }> };
|
||||
assert.equal(r.notUpdated?.r4?.type, "invalidPatch");
|
||||
assert.deepEqual(r.notUpdated!.r4!.properties, ["enabledPermissions/notARealPermission"]);
|
||||
});
|
||||
|
||||
/** Tenants: what they hold is whatever names them, and only an administrator outside one may move things in. */
|
||||
test("a tenant's members are found by memberTenantId, and it is kept while it has any", () => {
|
||||
const dir = make("admin");
|
||||
const accounts = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberTenantId: "t1" }, calculateTotal: true, limit: 0 }) as { total: number };
|
||||
const domains = dir.handlers["x:Domain/query"]!({ filter: { memberTenantId: "t1" }, calculateTotal: true }) as { ids: string[] };
|
||||
assert.equal(accounts.total, 1);
|
||||
assert.deepEqual(domains.ids, ["d3"]);
|
||||
const refused = dir.handlers["x:Tenant/set"]!({ destroy: ["t1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(refused.notDestroyed?.t1?.type, "objectIsLinked");
|
||||
assert.deepEqual([...new Set(refused.notDestroyed!.t1!.linkedObjects.map((l) => l.object))].sort(), ["Account", "Domain"]);
|
||||
});
|
||||
|
||||
test("a tenant is created with quotas, a domain moves into it, and an empty one is deleted", () => {
|
||||
const dir = make("admin");
|
||||
const c = dir.handlers["x:Tenant/set"]!({ create: { n: { name: "Globex", quotas: { maxAccounts: 5, maxDiskQuota: 1024 } } } }) as { created: Record<string, { id: string }> };
|
||||
const id = c.created.n!.id;
|
||||
const bad = dir.handlers["x:Tenant/set"]!({ update: { [id]: { "quotas/maxWidgets": 3 } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(bad.notUpdated?.[id]?.type, "invalidPatch");
|
||||
dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: id } } });
|
||||
assert.equal((dir.handlers["x:Domain/query"]!({ filter: { memberTenantId: id }, calculateTotal: true }) as { total: number }).total, 1);
|
||||
dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: null } } });
|
||||
assert.deepEqual((dir.handlers["x:Tenant/set"]!({ destroy: [id] }) as { destroyed: string[] }).destroyed, [id]);
|
||||
});
|
||||
|
||||
test("a tenant administrator cannot move anything into a tenant", () => {
|
||||
const dir = make("tenant-admin");
|
||||
const r = dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: "t1" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||
assert.equal(r.notUpdated?.d4?.type, "invalidPatch");
|
||||
assert.match(r.notUpdated!.d4!.description, /memberTenantId/);
|
||||
});
|
||||
|
||||
test("something in a tenant has to be on a domain in it, and something in none may be anywhere", () => {
|
||||
const dir = make("admin");
|
||||
const outside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "stray", domainId: "d1", memberTenantId: "t1" } } }) as { notCreated?: Record<string, { type: string; objectId: { object: string } }> };
|
||||
assert.equal(outside.notCreated?.n?.type, "invalidForeignKey");
|
||||
assert.equal(outside.notCreated!.n!.objectId.object, "Domain");
|
||||
const inside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d3", memberTenantId: "t1" } } }) as { created?: Record<string, { id: string }> };
|
||||
assert.ok(inside.created?.n?.id);
|
||||
const none = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "open", domainId: "d3" } } }) as { created?: Record<string, { id: string }> };
|
||||
assert.ok(none.created?.n?.id);
|
||||
const [someone] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", domainId: "d1" } }) as { ids: string[] }).ids;
|
||||
const move = dir.handlers["x:Account/set"]!({ update: { [someone!]: { memberTenantId: "t1" } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(move.notUpdated?.[someone!]?.type, "invalidForeignKey");
|
||||
});
|
||||
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* Enough of Stalwart 0.16's directory registry to develop administration
|
||||
* against: `x:Account`, `x:Domain` and `x:Role`, gated by permission names the
|
||||
* way the real server gates them.
|
||||
*
|
||||
* Shapes follow the 0.16.22 source rather than the documentation, which has
|
||||
* been wrong about both before:
|
||||
*
|
||||
* - a `List<T>` (credentials, aliases) is an object keyed by index -- `{"0": …}`
|
||||
* -- and a `Set` (memberGroupIds, enabledPermissions) is `{"id": true}`;
|
||||
* - an account's `name` is the local part only, and it lives on a domain by id;
|
||||
* - secrets come back masked, and a new one is written through the password
|
||||
* credential's own pointer, `credentials/<index>/secret`;
|
||||
* - `x:Account/query` understands AND and nothing else.
|
||||
*
|
||||
* It also answers the two feeds Administration's dashboard reads: a short
|
||||
* outbound queue (`x:QueuedMessage`) and a day and a bit of hourly metric
|
||||
* history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off
|
||||
* refuses the history the way a Community server does.
|
||||
*
|
||||
* Tenants are there for a system administrator to manage -- one tenant holding a
|
||||
* domain and an account, `memberTenantId` filters on the queries, and the rule
|
||||
* that only an administrator outside every tenant may move things into one. What
|
||||
* it does not reproduce is a tenant administrator's scoping: every caller sees
|
||||
* every record. The real server scopes those queries, and nothing in the client
|
||||
* relies on seeing more or less than it is given.
|
||||
*
|
||||
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
|
||||
* `helpdesk` (a custom role that may view and edit accounts but not create or
|
||||
* delete them) or `user`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
/** Every permission Stalwart 0.16.22 knows, from the snapshot the translations are checked against. */
|
||||
const KNOWN_PERMISSIONS = new Set(
|
||||
(JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string }> }).permissions.map((p) => p.name),
|
||||
);
|
||||
|
||||
export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
|
||||
|
||||
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||
|
||||
/** What the dashboard reads beyond the directory. */
|
||||
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery", "sysApplicationGet", "sysApplicationQuery"];
|
||||
|
||||
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||
|
||||
export function permissionsFor(role: MockRole): string[] {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "sysAuthenticationGet", "impersonate"];
|
||||
case "tenant-admin":
|
||||
// The queue but not the metric history: Stalwart scopes the one to a
|
||||
// tenant's domains, and the other has no tenant to scope it by.
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer"), "sysQueuedMessageGet", "sysQueuedMessageQuery"];
|
||||
case "helpdesk":
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
|
||||
default:
|
||||
return USER_PERMISSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
export function mockRole(raw: string | undefined): MockRole {
|
||||
return raw === "tenant-admin" || raw === "helpdesk" || raw === "user" ? raw : "admin";
|
||||
}
|
||||
|
||||
const MASKED = "[********]";
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
interface Options {
|
||||
/** The demo user's JMAP account id, which is also its registry id. */
|
||||
accountId: string;
|
||||
/** The demo user's address. */
|
||||
user: string;
|
||||
locale: string;
|
||||
role: MockRole;
|
||||
/** Build the error a method fails with; the mock server owns the type. */
|
||||
fail: (type: string, description?: string) => Error;
|
||||
/** Refuse the metric history, as a Community server does. */
|
||||
metricsOff?: boolean;
|
||||
/** When the history ends; the newest hour is the one this falls in. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function createDirectory(opts: Options) {
|
||||
const permissions = new Set(permissionsFor(opts.role));
|
||||
const [userLocal, userDomain] = splitAddress(opts.user);
|
||||
let counter = 100;
|
||||
|
||||
const managed = (dns: boolean, dkim: boolean, certs: boolean) => ({
|
||||
dnsManagement: dns ? { "@type": "Automatic", dnsServerId: "ns1", origin: null, publishRecords: {} } : { "@type": "Manual" },
|
||||
dkimManagement: dkim ? { "@type": "Automatic", algorithms: { Dkim1Ed25519Sha256: true, Dkim1RsaSha256: true }, selectorTemplate: "v{version}-{algorithm}-{date-%Y%m%d}" } : { "@type": "Manual" },
|
||||
certificateManagement: certs ? { "@type": "Automatic", acmeProviderId: "acme1", subjectAlternativeNames: {} } : { "@type": "Manual" },
|
||||
});
|
||||
const domain = (id: string, name: string, extra: Obj = {}): Obj => ({
|
||||
id, name, aliases: {}, isEnabled: true, createdAt: "2026-06-01T09:00:00Z", description: null, logo: null,
|
||||
...managed(false, true, false), memberTenantId: null, directoryId: null, catchAllAddress: null,
|
||||
subAddressing: { "@type": "Enabled" }, allowRelaying: false, reportAddressUri: "mailto:postmaster", allowScimProvisioning: false, ...extra,
|
||||
});
|
||||
const domains: Obj[] = [
|
||||
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
||||
domain("d2", userDomain === "example.org" ? "example.net" : "example.org", { catchAllAddress: `postmaster@${userDomain}` }),
|
||||
domain("d3", "old-brand.example", { ...managed(false, false, false), description: "No longer used", subAddressing: { "@type": "Custom", customRule: "..." }, memberTenantId: "t1" }),
|
||||
domain("d4", "spare.example", { description: "Waiting for a tenant" }),
|
||||
];
|
||||
const dkimKeys: Obj[] = [
|
||||
{ id: "k1", "@type": "Dkim1Ed25519Sha256", domainId: "d1", selector: "v1-ed25519-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
|
||||
];
|
||||
/** What Stalwart's BIND serializer writes, including a TXT long enough to be split. */
|
||||
const zoneFile = (d: Obj): string => {
|
||||
const n = String(d.name);
|
||||
const lines = [
|
||||
`${n}. IN MX 10 mail.${userDomain}.`,
|
||||
`${n}. IN TXT "v=spf1 mx ra=postmaster -all"`,
|
||||
];
|
||||
for (const k of dkimKeys.filter((k) => k.domainId === d.id && k.stage !== "retired")) {
|
||||
if (String(k["@type"]).includes("Rsa")) {
|
||||
const p = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" + "x".repeat(300) + "IDAQAB";
|
||||
const txt = `v=DKIM1; k=rsa; h=sha256; p=${p}`;
|
||||
lines.push(`${k.selector}._domainkey.${n}. IN TXT (`, ...(txt.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`), ")");
|
||||
} else {
|
||||
lines.push(`${k.selector}._domainkey.${n}. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="`);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
`_dmarc.${n}. IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@${n}; ruf=mailto:postmaster@${n}"`,
|
||||
`_jmap._tcp.${n}. IN SRV 0 1 443 mail.${userDomain}.`,
|
||||
`_submissions._tcp.${n}. IN SRV 0 1 465 mail.${userDomain}.`,
|
||||
`_imaps._tcp.${n}. IN SRV 0 1 993 mail.${userDomain}.`,
|
||||
`mta-sts.${n}. IN CNAME mail.${userDomain}.`,
|
||||
`_mta-sts.${n}. IN TXT "v=STSv1; id=16837364213434767412"`,
|
||||
`_smtp._tls.${n}. IN TXT "v=TLSRPTv1; rua=mailto:postmaster@${n}"`,
|
||||
`autoconfig.${n}. IN CNAME mail.${userDomain}.`,
|
||||
`${n}. IN CAA 0 issue "letsencrypt.org"`,
|
||||
);
|
||||
return lines.join("\n") + "\n";
|
||||
};
|
||||
|
||||
const roles: Obj[] = [
|
||||
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {}, memberTenantId: null },
|
||||
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
{ id: "r4", description: "Read-only auditor", enabledPermissions: flags(["sysAccountGet", "sysAccountQuery", "sysDomainGet", "sysDomainQuery", "sysLogGet"]), disabledPermissions: flags(["jmapEmailUpdate"]), roleIds: { r1: true } },
|
||||
];
|
||||
/** Stalwart's defaults: which roles an account gets when it is given no others. */
|
||||
const authentication: Record<string, Obj> = { defaultUserRoleIds: { r1: true }, defaultGroupRoleIds: {}, defaultTenantRoleIds: {}, defaultAdminRoleIds: {} };
|
||||
|
||||
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
|
||||
|
||||
const accounts: Obj[] = [];
|
||||
const user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean; tenant?: string }) => {
|
||||
const domainId = o.domain === "d2" || o.domain === "d3" ? o.domain : "d1";
|
||||
const row: Obj = {
|
||||
id: o.id ?? `u${counter++}`,
|
||||
"@type": "User",
|
||||
name: o.name,
|
||||
domainId,
|
||||
description: o.description,
|
||||
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
|
||||
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
memberGroupIds: flags(o.groups ?? []),
|
||||
memberTenantId: o.tenant ?? null,
|
||||
roles: o.roles ?? { "@type": "User" },
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
|
||||
usedDiskQuota: Math.round((o.used ?? 0) * GIB),
|
||||
aliases: Object.fromEntries((o.aliases ?? []).map((name, i) => [String(i), { enabled: true, name, domainId, description: null }])),
|
||||
locale: opts.locale,
|
||||
timeZone: null,
|
||||
};
|
||||
accounts.push(row);
|
||||
return row;
|
||||
};
|
||||
// A group's roles are Default or Custom, not a person's User or Admin.
|
||||
const group = (id: string, name: string, description: string) =>
|
||||
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {}, createdAt: "2026-08-01T09:00:00Z" });
|
||||
|
||||
group("g1", "support", "Support");
|
||||
group("g2", "office", "Office");
|
||||
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
|
||||
user({ name: "ada", domain: "d2", description: "Ada Lovelace", used: 3.2, quota: 5, groups: ["g2"] });
|
||||
user({ name: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
|
||||
user({ name: "wile", domain: "d3", description: "Wile E. Coyote", roles: { "@type": "Admin" }, used: 2.1, quota: 5, tenant: "t1" });
|
||||
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
|
||||
user({ name: "margaret", description: "Margaret Hamilton", roles: { "@type": "Admin" }, used: 2.1, quota: 20 });
|
||||
user({ name: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
|
||||
user({ name: "sso.only", description: "Signs in with SSO", password: false, used: 0.1 });
|
||||
const people = ["Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "John Backus", "Radia Perlman", "Ken Thompson", "Hedy Lamarr", "Dennis Ritchie", "Karen Spärck Jones", "Tim Berners-Lee", "Sophie Wilson", "Niklaus Wirth", "Jean Sammet", "Leslie Lamport", "Mary Kenneth Keller", "Tony Hoare", "Evelyn Berezin", "Butler Lampson", "Shafi Goldwasser", "Whitfield Diffie", "Adele Goldberg", "Vint Cerf", "Anita Borg", "Bob Kahn", "Lynn Conway", "Charles Babbage", "Annie Easley"];
|
||||
people.forEach((description, i) => {
|
||||
const name = description.toLowerCase().split(" ")[0]!.normalize("NFD").replace(/[^a-z]/g, "");
|
||||
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
|
||||
});
|
||||
|
||||
// Nine messages waiting, which is what a small live server had queued on the
|
||||
// day this was written: a few retries and the odd report.
|
||||
const queue: Obj[] = Array.from({ length: 9 }, (_, i) => ({ id: `q${i + 1}`, createdAt: new Date(Date.UTC(2026, 8, 15, 6 + i)).toISOString(), size: 2400 + i * 310, priority: 0, flags: {} }));
|
||||
|
||||
/**
|
||||
* Thirty hours of history ending in the current hour: a Counter per hour for
|
||||
* what was queued, and a memory Gauge. Counters that would be zero are left
|
||||
* out, as Stalwart leaves them out.
|
||||
*/
|
||||
const metrics: Obj[] = [];
|
||||
{
|
||||
const hour = 3600_000;
|
||||
const end = Math.floor((opts.now ?? new Date()).getTime() / hour) * hour;
|
||||
for (let h = 29; h >= 0; h--) {
|
||||
const at = end - h * hour;
|
||||
const timestamp = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const seq = (29 - h) * 10;
|
||||
const push = (n: number, type: string, metric: string, count: number) => {
|
||||
if (type === "Counter" && !count) return;
|
||||
metrics.push({ id: `m${String(seq + n).padStart(4, "0")}`, "@type": type, metric, count, timestamp });
|
||||
};
|
||||
push(0, "Gauge", "server.memory", 360_000_000 + ((h * 7_919_000) % 40_000_000));
|
||||
push(1, "Counter", "queue.message-queued", (h * 5 + 3) % 9);
|
||||
push(2, "Counter", "queue.authenticated-message-queued", h % 3);
|
||||
push(3, "Counter", "queue.dsn-queued", h % 11 === 0 ? 1 : 0);
|
||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||
}
|
||||
}
|
||||
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
|
||||
|
||||
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
||||
const tenants: Obj[] = [
|
||||
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
|
||||
];
|
||||
const tenantUsage = (id: string) => accounts.filter((x) => x.memberTenantId === id).reduce((n, x) => n + Number(x.usedDiskQuota ?? 0), 0);
|
||||
/**
|
||||
* Something in a tenant has to be on a domain in that tenant; something in no
|
||||
* tenant may be on anyone's domain. Both as the live server answered
|
||||
* (2026-09-15), including the shape of the refusal.
|
||||
*/
|
||||
const domainTenantRefused = (o: Obj): Obj | null => {
|
||||
const tenant = o.memberTenantId ?? null;
|
||||
const domain = domains.find((d) => d.id === o.domainId);
|
||||
if (!tenant || !domain || (domain.memberTenantId ?? null) === tenant) return null;
|
||||
return { type: "invalidForeignKey", objectId: { object: "Domain", id: domain.id } };
|
||||
};
|
||||
/** Only an administrator outside every tenant may put things in one; Stalwart refuses anyone else. */
|
||||
const tenantRefused = (patch: Obj): Obj | null =>
|
||||
"memberTenantId" in patch && opts.role !== "admin" ? setError("invalidPatch", "Cannot modify memberTenantId property", ["memberTenantId"]) : null;
|
||||
|
||||
const refuseMetrics = () => {
|
||||
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
|
||||
};
|
||||
|
||||
/**
|
||||
* Mailing lists: an address and the addresses it passes mail on to. The
|
||||
* recipient set's shape is the live server's (2026-09-15).
|
||||
*/
|
||||
const lists: Obj[] = [
|
||||
{ id: "l1", name: "announce", domainId: "d1", description: "Announcements", recipients: flags([opts.user, "[email protected]", "[email protected]", "[email protected]"]), aliases: {}, memberTenantId: null },
|
||||
{ id: "l2", name: "board", domainId: "d2", description: "Board", recipients: flags(["[email protected]", "[email protected]"]), aliases: {}, memberTenantId: null },
|
||||
];
|
||||
const addressOk = (a: unknown) => typeof a === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a);
|
||||
|
||||
const demand = (perm: string) => {
|
||||
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
|
||||
};
|
||||
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
|
||||
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
|
||||
/** Every address in use, primary and alias, across accounts and mailing lists. */
|
||||
const addressTaken = (address: string, except?: string) =>
|
||||
[...accounts, ...lists].some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
|
||||
|
||||
const view = (o: Obj, properties: unknown): Obj => {
|
||||
const full: Obj = { ...o };
|
||||
if (accounts.includes(o) || lists.includes(o)) full.emailAddress = addressOf(o);
|
||||
if (domains.includes(o)) full.dnsZoneFile = zoneFile(o);
|
||||
if (full.credentials) {
|
||||
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
|
||||
}
|
||||
if (!Array.isArray(properties)) return full;
|
||||
const out: Obj = { id: o.id };
|
||||
for (const p of properties as string[]) if (p in full) out[p] = full[p];
|
||||
return out;
|
||||
};
|
||||
|
||||
const get = (list: Obj[], perm: string) => (a: Obj) => {
|
||||
demand(perm);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? list.filter((x) => ids.includes(x.id as string)) : list;
|
||||
return { accountId: opts.accountId, state: "1", list: found.map((x) => view(x, a.properties)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* A query, filtered only on what the real server indexes for that object.
|
||||
* Any other name is refused the way Stalwart refuses it -- `unsupportedFilter`
|
||||
* with the name as the whole description -- because a mock that took
|
||||
* `{"type": "User"}` let exactly that ship, and the live server answers it
|
||||
* with "unsupportedFilter - type".
|
||||
*/
|
||||
const query = (list: () => Obj[], perm: string, filterable: string[], match: (o: Obj, filter: Obj) => boolean) => (a: Obj) => {
|
||||
demand(perm);
|
||||
const filter = (a.filter as Obj | undefined) ?? {};
|
||||
if ("operator" in filter) throw opts.fail("unsupportedFilter", "Only AND is supported in filters");
|
||||
const unknown = Object.keys(filter).find((k) => !filterable.includes(k));
|
||||
if (unknown) throw opts.fail("unsupportedFilter", unknown);
|
||||
// Stalwart's default order is newest first, by id.
|
||||
const rows = list().filter((o) => match(o, filter)).sort((x, y) => String(y.id).localeCompare(String(x.id), undefined, { numeric: true }));
|
||||
const position = Math.max(0, Number(a.position ?? 0));
|
||||
const limit = a.limit == null ? rows.length : Number(a.limit);
|
||||
return {
|
||||
accountId: opts.accountId,
|
||||
queryState: "1",
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids: rows.slice(position, position + limit).map((o) => o.id),
|
||||
...(a.calculateTotal ? { total: rows.length } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const matchText = (o: Obj, text: unknown) => {
|
||||
if (typeof text !== "string" || !text.trim()) return true;
|
||||
const needle = text.trim().toLowerCase();
|
||||
return [o.name, o.description, addressOf(o)].some((v) => typeof v === "string" && v.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
const setError = (type: string, description: string, properties?: string[]) => ({ type, description, ...(properties ? { properties } : {}) });
|
||||
|
||||
/** The password checks, roughly as strict as a default Stalwart. */
|
||||
const weakPassword = (secret: unknown) => (typeof secret !== "string" || secret.length < 8 ? "Password must be at least 8 characters long." : null);
|
||||
|
||||
/** Stalwart checks a grant against the caller's own permissions. */
|
||||
const grantRefused = (roles: unknown): string | null => {
|
||||
const r = roles as Obj | undefined;
|
||||
if (!r) return null;
|
||||
if (r["@type"] === "Admin" && opts.role !== "admin" && opts.role !== "tenant-admin") return "You are not authorized to grant permissions: administrator.";
|
||||
if (r["@type"] === "Custom") {
|
||||
for (const id of Object.keys((r.roleIds as Obj) ?? {})) {
|
||||
const role = roles_(id);
|
||||
if (!role) return "Role does not exist.";
|
||||
const missing = Object.keys((role.enabledPermissions as Obj) ?? {}).filter((p) => !permissions.has(p));
|
||||
if (missing.length) return `You are not authorized to grant permissions: ${missing.join(", ")}.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const roles_ = (id: string) => roles.find((r) => r.id === id);
|
||||
|
||||
const handlers: Record<string, (a: Obj) => Obj> = {
|
||||
"x:Account/get": get(accounts, "sysAccountGet"),
|
||||
"x:Account/query": query(() => accounts, "sysAccountQuery", ["text", "@type", "domainId", "externalId", "memberGroupIds", "memberTenantId", "name"], (o, f) =>
|
||||
(f["@type"] === undefined || o["@type"] === f["@type"]) && (f.domainId === undefined || o.domainId === f.domainId) &&
|
||||
(f.memberGroupIds === undefined || Boolean((o.memberGroupIds as Obj | undefined)?.[f.memberGroupIds as string])) &&
|
||||
(f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||
"x:Account/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysAccountCreate");
|
||||
const o = { ...(raw as Obj) };
|
||||
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) { notCreated[cid] = setError("invalidProperties", "Invalid account name.", ["name"]); continue; }
|
||||
if (!domainName(o.domainId)) { notCreated[cid] = setError("invalidForeignKey", "Domain does not exist.", ["domainId"]); continue; }
|
||||
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); continue; }
|
||||
const refused = grantRefused(o.roles);
|
||||
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
|
||||
if (o.memberTenantId) {
|
||||
const refusedTenant = tenantRefused(o) ?? domainTenantRefused(o);
|
||||
if (refusedTenant) { notCreated[cid] = refusedTenant; continue; }
|
||||
}
|
||||
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
|
||||
const weak = password ? weakPassword(password.secret) : null;
|
||||
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
||||
const id = `u${counter++}`;
|
||||
accounts.push({ ...(o["@type"] === "Group" ? {} : { memberGroupIds: {} }), aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
|
||||
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysAccountUpdate");
|
||||
const target = accounts.find((x) => x.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
|
||||
const patch = raw as Obj;
|
||||
const next = structuredClone(target);
|
||||
let failure: Obj | null = tenantRefused(patch);
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (path === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
|
||||
if (path.endsWith("/secret")) {
|
||||
const weak = weakPassword(value);
|
||||
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||
}
|
||||
if (path.startsWith("credentials/") && value && typeof value === "object") {
|
||||
const weak = weakPassword((value as Obj).secret);
|
||||
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||
}
|
||||
setPointer(next, path, value);
|
||||
}
|
||||
// Memberships name groups, and only a person has them: groups do not nest.
|
||||
if (!failure && Object.keys(patch).some((p) => p === "memberGroupIds" || p.startsWith("memberGroupIds/"))) {
|
||||
if (target["@type"] === "Group") failure = setError("invalidProperties", "Groups cannot be members of other groups.", ["memberGroupIds"]);
|
||||
else if (Object.keys((next.memberGroupIds as Obj) ?? {}).some((g) => accounts.find((x) => x.id === g)?.["@type"] !== "Group")) failure = setError("invalidForeignKey", "Group does not exist.", ["memberGroupIds"]);
|
||||
}
|
||||
if (!failure && "memberTenantId" in patch) failure = domainTenantRefused(next);
|
||||
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
||||
const refused = grantRefused(next.roles);
|
||||
if (refused) failure = setError("forbidden", refused);
|
||||
}
|
||||
if (!failure) {
|
||||
for (const al of Object.values((next.aliases as Obj) ?? {})) {
|
||||
const address = `${(al as Obj).name}@${domainName((al as Obj).domainId)}`;
|
||||
if (!domainName((al as Obj).domainId)) { failure = setError("invalidForeignKey", "Domain does not exist.", ["aliases"]); break; }
|
||||
if (addressTaken(address, id)) { failure = setError("primaryKeyViolation", "An account or alias with this email address already exists."); break; }
|
||||
}
|
||||
}
|
||||
if (failure) { notUpdated[id] = failure; continue; }
|
||||
// Secrets are stored hashed; the mock just stops echoing them.
|
||||
for (const c of Object.values((next.credentials as Obj) ?? {})) (c as Obj).secret = MASKED;
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysAccountDestroy");
|
||||
const i = accounts.findIndex((x) => x.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
|
||||
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
|
||||
// Every member's memberGroupIds names the group, which is a link the
|
||||
// registry will not delete through. The shape is the live server's,
|
||||
// from a throwaway group on 2026-09-15.
|
||||
notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Account", id }, linkedObjects: accounts.filter((x) => (x.memberGroupIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })) };
|
||||
continue;
|
||||
}
|
||||
accounts.splice(i, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
"x:Domain/get": get(domains, "sysDomainGet"),
|
||||
"x:Domain/query": query(() => domains, "sysDomainQuery", ["text", "aliases", "memberTenantId", "name"], (o, f) => (f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||
"x:Domain/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
const taken = (name: string, except?: string) => domains.some((d) => d.id !== except && (d.name === name || Object.keys((d.aliases as Obj) ?? {}).includes(name)));
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysDomainCreate");
|
||||
const o = raw as Obj;
|
||||
const name = String(o.name ?? "");
|
||||
// Live on 2026-09-13: a reserved TLD is refused by the registry's
|
||||
// domain validator, as invalidPatch with the validator's own words.
|
||||
if (!/^([a-z0-9-]+\.)+[a-z0-9-]{2,}$/.test(name) || /\.(example|test|invalid|localhost)$/.test(name)) { notCreated[cid] = setError("invalidPatch", "Invalid domain name", ["name"]); continue; }
|
||||
if (taken(name)) { notCreated[cid] = setError("primaryKeyViolation", "A domain with this name already exists.", ["name"]); continue; }
|
||||
const id = `d${counter++}`;
|
||||
domains.push(domain(id, name, { ...o, id, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") }));
|
||||
// Automatic DKIM, the default, makes its keys straight away.
|
||||
dkimKeys.push({ id: `k${counter++}`, "@type": "Dkim1Ed25519Sha256", domainId: id, selector: "v1-ed25519-20260913", stage: "active", createdAt: new Date().toISOString(), nextTransitionAt: null, memberTenantId: null });
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysDomainUpdate");
|
||||
const target = domains.find((d) => d.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Domain not found."); continue; }
|
||||
const refusedTenant = tenantRefused(raw as Obj);
|
||||
if (refusedTenant) { notUpdated[id] = refusedTenant; continue; }
|
||||
if ((raw as Obj).memberTenantId && !tenants.some((x) => x.id === (raw as Obj).memberTenantId)) { notUpdated[id] = setError("invalidForeignKey", "Tenant does not exist.", ["memberTenantId"]); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
// Live on 2026-09-13: a catch-all that is not a whole address.
|
||||
if (typeof next.catchAllAddress === "string" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(next.catchAllAddress)) { notUpdated[id] = setError("invalidPatch", "Invalid email address", ["catchAllAddress"]); continue; }
|
||||
const clash = Object.keys((next.aliases as Obj) ?? {}).find((alias) => alias === next.name || taken(alias, id));
|
||||
if (clash) { notUpdated[id] = setError("primaryKeyViolation", `The name ${clash} is already in use.`, ["aliases"]); continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysDomainDestroy");
|
||||
const i = domains.findIndex((d) => d.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Domain not found."); continue; }
|
||||
const linked = [
|
||||
...accounts.filter((x) => x.domainId === id || Object.values((x.aliases as Obj) ?? {}).some((al) => (al as Obj).domainId === id)).map((x) => ({ object: "Account", id: x.id })),
|
||||
...dkimKeys.filter((k) => k.domainId === id).map((k) => ({ object: "DkimSignature", id: k.id })),
|
||||
];
|
||||
if (linked.length) { notDestroyed[id] = { ...setError("objectIsLinked", "Object is linked to other objects."), linkedObjects: linked }; continue; }
|
||||
domains.splice(i, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
"x:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
|
||||
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) =>
|
||||
(f.domainId === undefined || o.domainId === f.domainId) && (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId)),
|
||||
"x:DkimSignature/set": (a) => {
|
||||
const destroyed: string[] = [];
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysDkimSignatureDestroy");
|
||||
const i = dkimKeys.findIndex((k) => k.id === id);
|
||||
if (i >= 0) { dkimKeys.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
if (a.create) throw opts.fail("forbidden", "The mock does not generate DKIM keys; automatic management does that.");
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created: {}, updated: {}, destroyed };
|
||||
},
|
||||
"x:DnsServer/get": (a) => {
|
||||
demand("sysDnsServerGet");
|
||||
return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] };
|
||||
},
|
||||
"x:QueuedMessage/get": get(queue, "sysQueuedMessageGet"),
|
||||
"x:QueuedMessage/query": query(() => queue, "sysQueuedMessageQuery", [], () => true),
|
||||
"x:Metric/get": (a) => {
|
||||
refuseMetrics();
|
||||
return get(metrics, "sysMetricGet")(a);
|
||||
},
|
||||
// Ids sort the way timestamps do, so the helper's newest-first order is the
|
||||
// `timestamp` descending the dashboard asks for.
|
||||
"x:Metric/query": (a) => {
|
||||
refuseMetrics();
|
||||
return query(() => metrics, "sysMetricQuery", ["timestampIsGreaterThanOrEqual", "timestampIsLessThanOrEqual", "metric"], (o, f) =>
|
||||
(f.timestampIsGreaterThanOrEqual === undefined || String(o.timestamp) >= String(f.timestampIsGreaterThanOrEqual)) &&
|
||||
(f.timestampIsLessThanOrEqual === undefined || String(o.timestamp) <= String(f.timestampIsLessThanOrEqual)) &&
|
||||
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
|
||||
},
|
||||
"x:MailingList/get": get(lists, "sysMailingListGet"),
|
||||
"x:MailingList/query": query(() => lists, "sysMailingListQuery", ["text", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text)),
|
||||
"x:MailingList/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
const check = (o: Obj, id?: string): Obj | null => {
|
||||
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) return setError("invalidProperties", "Invalid email local part", ["name"]);
|
||||
if (!domainName(o.domainId)) return setError("invalidForeignKey", "Domain does not exist.", ["domainId"]);
|
||||
if (addressTaken(`${o.name}@${domainName(o.domainId)}`, id)) return setError("primaryKeyViolation", "An account or alias with this email address already exists.");
|
||||
if (Object.keys((o.recipients as Obj) ?? {}).some((r) => !addressOk(r))) return setError("invalidProperties", "Invalid email address", ["recipients"]);
|
||||
return null;
|
||||
};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysMailingListCreate");
|
||||
const o: Obj = { recipients: {}, aliases: {}, description: null, ...(raw as Obj) };
|
||||
const failure = check(o) ?? (o.memberTenantId ? (tenantRefused(o) ?? domainTenantRefused(o)) : null);
|
||||
if (failure) { notCreated[cid] = failure; continue; }
|
||||
const id = `l${counter++}`;
|
||||
lists.push({ memberTenantId: null, ...o, id });
|
||||
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysMailingListUpdate");
|
||||
const target = lists.find((x) => x.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Mailing list not found."); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
const failure = check(next, id);
|
||||
if (failure) { notUpdated[id] = { ...failure, type: failure.type === "invalidProperties" ? "invalidPatch" : failure.type }; continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysMailingListDestroy");
|
||||
const i = lists.findIndex((x) => x.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Mailing list not found."); continue; }
|
||||
lists.splice(i, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
// Which roles Stalwart hands out by default. Its own settings object; the
|
||||
// Roles screen reads it to warn before a default role is changed.
|
||||
"x:Authentication/get": (a) => {
|
||||
demand("sysAuthenticationGet");
|
||||
const ids = (a.ids as string[] | null | undefined) ?? ["singleton"];
|
||||
return { accountId: opts.accountId, state: "1", list: ids.filter((id) => id === "singleton").map((id) => ({ id, ...authentication })), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
"x:Role/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
/** Stalwart refuses a role whose permissions -- its own or inherited -- the caller does not hold. */
|
||||
const check = (o: Obj, id?: string): Obj | null => {
|
||||
if (typeof o.description !== "string" || !o.description.trim()) return setError("invalidProperties", "String cannot be empty", ["description"]);
|
||||
const seen = new Set<string>();
|
||||
const walk = (rid: string): boolean => {
|
||||
if (rid === id) return false;
|
||||
if (seen.has(rid)) return true;
|
||||
seen.add(rid);
|
||||
const r = roles_(rid);
|
||||
return !!r && Object.keys((r.roleIds as Obj) ?? {}).every(walk);
|
||||
};
|
||||
if (!Object.keys((o.roleIds as Obj) ?? {}).every(walk)) return setError("invalidProperties", "A role cannot inherit from itself or from a role that does not exist.", ["roleIds"]);
|
||||
// A name that is not a permission fails the whole change, as the live server does.
|
||||
for (const set of ["enabledPermissions", "disabledPermissions"]) {
|
||||
const bad = Object.keys((o[set] as Obj) ?? {}).find((p) => !KNOWN_PERMISSIONS.has(p));
|
||||
if (bad) return setError("invalidProperties", "Invalid value for object property", [`${set}/${bad}`]);
|
||||
}
|
||||
const granted = new Set(Object.keys((o.enabledPermissions as Obj) ?? {}));
|
||||
for (const rid of seen) for (const p of Object.keys((roles_(rid)!.enabledPermissions as Obj) ?? {})) granted.add(p);
|
||||
const missing = [...granted].filter((p) => !permissions.has(p));
|
||||
if (missing.length) return setError("forbidden", `You are not authorized to grant permissions: ${missing.slice(0, 5).join(", ")}.`);
|
||||
return null;
|
||||
};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysRoleCreate");
|
||||
const o: Obj = { enabledPermissions: {}, disabledPermissions: {}, roleIds: {}, ...(raw as Obj) };
|
||||
const failure = check(o);
|
||||
if (failure) { notCreated[cid] = failure; continue; }
|
||||
const id = `r${counter++}`;
|
||||
roles.push({ ...o, id, memberTenantId: null });
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysRoleUpdate");
|
||||
const target = roles_(id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Role not found."); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
const failure = check(next, id);
|
||||
if (failure) { notUpdated[id] = failure.type === "invalidProperties" ? { ...failure, type: "invalidPatch" } : failure; continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysRoleDestroy");
|
||||
if (!roles_(id)) { notDestroyed[id] = setError("notFound", "Role not found."); continue; }
|
||||
const linked = [
|
||||
...accounts.filter((x) => ((x.roles as Obj | undefined)?.roleIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })),
|
||||
...roles.filter((x) => (x.roleIds as Obj | undefined)?.[id]).map((x) => ({ object: "Role", id: x.id })),
|
||||
...(Object.values(authentication).some((set) => (set as Obj)[id]) ? [{ object: "Authentication", id: "singleton" }] : []),
|
||||
];
|
||||
if (linked.length) { notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Role", id }, linkedObjects: linked }; continue; }
|
||||
roles.splice(roles.findIndex((x) => x.id === id), 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
"x:Tenant/get": (a) => {
|
||||
demand("sysTenantGet");
|
||||
for (const x of tenants) x.usedDiskQuota = tenantUsage(x.id as string);
|
||||
return get(tenants, "sysTenantGet")(a);
|
||||
},
|
||||
"x:Tenant/query": query(() => tenants, "sysTenantQuery", ["text"], (o, f) => matchText(o, f.text)),
|
||||
"x:Tenant/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
const check = (o: Obj): Obj | null => {
|
||||
if (typeof o.name !== "string" || !o.name.trim()) return setError("invalidProperties", "String cannot be empty", ["name"]);
|
||||
for (const [k, v] of Object.entries((o.quotas as Obj) ?? {})) {
|
||||
if (!["maxAccounts", "maxGroups", "maxDomains", "maxMailingLists", "maxRoles", "maxOauthClients", "maxDkimKeys", "maxDnsServers", "maxDirectories", "maxAcmeProviders", "maxDiskQuota"].includes(k) || typeof v !== "number" || v < 0) {
|
||||
return setError("invalidProperties", "Invalid value for object property", [`quotas/${k}`]);
|
||||
}
|
||||
}
|
||||
return grantRefused(o.roles) ? setError("forbidden", grantRefused(o.roles)!) : null;
|
||||
};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysTenantCreate");
|
||||
const o: Obj = { logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, ...(raw as Obj) };
|
||||
const failure = check(o);
|
||||
if (failure) { notCreated[cid] = failure; continue; }
|
||||
const id = `t${counter++}`;
|
||||
tenants.push({ ...o, id, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") });
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysTenantUpdate");
|
||||
const target = tenants.find((x) => x.id === id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Tenant not found."); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
const failure = check(next);
|
||||
if (failure) { notUpdated[id] = failure.type === "invalidProperties" ? { ...failure, type: "invalidPatch" } : failure; continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysTenantDestroy");
|
||||
if (!tenants.some((x) => x.id === id)) { notDestroyed[id] = setError("notFound", "Tenant not found."); continue; }
|
||||
const linked = [
|
||||
...accounts.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Account", id: x.id })),
|
||||
...domains.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Domain", id: x.id })),
|
||||
...lists.filter((x) => x.memberTenantId === id).map((x) => ({ object: "MailingList", id: x.id })),
|
||||
...roles.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Role", id: x.id })),
|
||||
];
|
||||
if (linked.length) { notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Tenant", id }, linkedObjects: linked }; continue; }
|
||||
tenants.splice(tenants.findIndex((x) => x.id === id), 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
// Stalwart's web interface is an installed application; ihasmail reads its
|
||||
// prefix to link the dashboard to it.
|
||||
"x:Application/query": query(() => applications, "sysApplicationQuery", ["text"], () => true),
|
||||
"x:Application/get": get(applications, "sysApplicationGet"),
|
||||
"x:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId) && matchText(o, f.description)),
|
||||
};
|
||||
|
||||
return { handlers, permissions: [...permissions], accounts };
|
||||
}
|
||||
|
||||
function flags(names: string[]): Obj {
|
||||
return Object.fromEntries(names.map((n) => [n, true]));
|
||||
}
|
||||
|
||||
function splitAddress(address: string): [string, string] {
|
||||
const at = address.lastIndexOf("@");
|
||||
return at < 0 ? [address, "example.com"] : [address.slice(0, at), address.slice(at + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one JMAP patch entry. A path walks into nested objects; `null` at the
|
||||
* end removes the key, which is how an alias or a quota is taken away.
|
||||
*/
|
||||
function setPointer(obj: Obj, path: string, value: unknown): void {
|
||||
const parts = path.split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||
let node = obj;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!node[part] || typeof node[part] !== "object") node[part] = {};
|
||||
node = node[part] as Obj;
|
||||
}
|
||||
const last = parts[parts.length - 1]!;
|
||||
// A top-level property set to null reads back as null -- deleting it here
|
||||
// would leave the old value in place when the change is merged back. A
|
||||
// nested pointer to null takes the entry out of its set or map.
|
||||
if (value === null && parts.length > 1) delete node[last];
|
||||
else node[last] = value;
|
||||
}
|
||||
+45
-19
@@ -6,9 +6,14 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { createDirectory, mockRole } from "./directory.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions;
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
@@ -19,7 +24,7 @@ const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
*/
|
||||
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
/**
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honors it when
|
||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||
* off, in which case the hold is dropped without a word and the message goes
|
||||
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
||||
@@ -40,6 +45,8 @@ const SHARED_CAPS: Obj = {
|
||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
|
||||
const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
|
||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
/**
|
||||
* Credential state, mutable so the self-service flows can be exercised against
|
||||
@@ -193,7 +200,7 @@ function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string,
|
||||
* A marketing template of the shape #290 was reported against.
|
||||
*
|
||||
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
|
||||
* `<style>` block, a coloured call to action, a grey footer — and that is the
|
||||
* `<style>` block, a colored call to action, a gray footer — and that is the
|
||||
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
||||
* mock without one could not show what "apply the theme to messages too" does
|
||||
* to the mail people actually receive: nothing at all.
|
||||
@@ -699,7 +706,7 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
* answer. One event still comes back as a bare object, the shape this returned
|
||||
* when an invitation was all it had to handle.
|
||||
*
|
||||
* The synthetic organiser and attendee only go on events that arrived with a
|
||||
* The synthetic organizer and attendee only go on events that arrived with a
|
||||
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
||||
* are; a plain export is not addressed to anyone, and inventing participants
|
||||
* for it would make imported events look like invitations nobody sent.
|
||||
@@ -885,6 +892,16 @@ function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||
const directory = createDirectory({
|
||||
accountId: ACCOUNT,
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
metricsOff: process.env.MOCK_METRICS === "off",
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||
@@ -893,19 +910,17 @@ const handlers: Record<string, Handler> = {
|
||||
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
// Stalwart's directory extension - the client reads the account locale from here.
|
||||
"x:Account/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||
},
|
||||
// Stalwart's directory registry: accounts, domains and roles, behind the
|
||||
// same permissions as the real thing. The locale fallback reads x:Account
|
||||
// too, and is refused here exactly when a real server would refuse it.
|
||||
...directory.handlers,
|
||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
"Email/query": (a) => {
|
||||
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||
/*
|
||||
* Honour the sort rather than always answering newest-first. This used to
|
||||
* Honor the sort rather than always answering newest-first. This used to
|
||||
* ignore it entirely, which reproduced a server that silently returns a
|
||||
* different order from the one asked for -- the one shape of wrongness a
|
||||
* client cannot detect.
|
||||
@@ -1025,7 +1040,7 @@ const handlers: Record<string, Handler> = {
|
||||
return setResp({ updated: { singleton: null } });
|
||||
},
|
||||
/*
|
||||
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
||||
* Push subscriptions. The JMAP half can be modeled; delivery cannot -- that
|
||||
* runs through the browser vendor's real push service, so nothing local will
|
||||
* ever make a notification appear.
|
||||
*
|
||||
@@ -1210,7 +1225,7 @@ const handlers: Record<string, Handler> = {
|
||||
}
|
||||
const status = undoStatusOf(sub, Date.now());
|
||||
if (status !== "pending") {
|
||||
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
|
||||
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
|
||||
continue;
|
||||
}
|
||||
sub.undoStatus = "canceled";
|
||||
@@ -1255,15 +1270,17 @@ const handlers: Record<string, Handler> = {
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
if (!ids) return genericGet(list)(a);
|
||||
const properties = a.properties as string[] | null | undefined;
|
||||
// With no ids every event comes back under its stored id, none synthetic.
|
||||
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
|
||||
},
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
@@ -1303,6 +1320,8 @@ const handlers: Record<string, Handler> = {
|
||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||
},
|
||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||
// An empty `properties` list returns `id` alone, which `pick` already does.
|
||||
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
|
||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||
"ContactCard/set": genericSet(cards, "cc"),
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
@@ -1351,7 +1370,7 @@ function checkAuth(req: IncomingMessage): boolean {
|
||||
const u = raw.slice(0, sep);
|
||||
const p = raw.slice(sep + 1);
|
||||
if (u !== USER) return false;
|
||||
// App passwords are recognised by shape and skip the second factor, which is
|
||||
// App passwords are recognized by shape and skip the second factor, which is
|
||||
// exactly what lets a webmail session survive 2FA being switched on.
|
||||
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
||||
if (!account.otpUrl) return p === account.password;
|
||||
@@ -1413,7 +1432,14 @@ export const server = createServer(async (req, res) => {
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||
return res.end(JSON.stringify({ permissions: directory.permissions, edition: MOCK_EDITION, locale: MOCK_LOCALE }));
|
||||
}
|
||||
// The registry schema, cut down to the permission list the Roles picker
|
||||
// reads. Gzipped as the real file is, from the 0.16.22 snapshot the
|
||||
// translations are checked against.
|
||||
if (url.pathname === "/api/schema" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json", "content-encoding": "gzip" });
|
||||
return res.end(gzipSync(JSON.stringify({ enums: { Permission: PERMISSION_SNAPSHOT } })));
|
||||
}
|
||||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||
@@ -1475,7 +1501,7 @@ export const server = createServer(async (req, res) => {
|
||||
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
|
||||
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
|
||||
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
|
||||
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
|
||||
* answered 30 and pinged every 30 s) and honors anything above (45 pinged
|
||||
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
|
||||
* altogether; a value that is not a number at all — `abc`, or empty — is a
|
||||
* 400 before the stream opens.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
@@ -38,7 +38,7 @@ describe("expandOccurrences", () => {
|
||||
assert.equal(out[0]!.index, 0);
|
||||
});
|
||||
|
||||
it("honours count", () => {
|
||||
it("honors count", () => {
|
||||
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||
@@ -98,6 +98,54 @@ describe("occurrenceView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventGetView", () => {
|
||||
/*
|
||||
* What 0.16.22 changed in `CalendarEvent/get`, read from its source and the
|
||||
* tests that came with it (`tests/src/jmap/calendar/event.rs` and
|
||||
* `instance.rs`).
|
||||
*/
|
||||
it("reports no base for an event read by its stored id", () => {
|
||||
// 0.16.21 answered with the event's own id here.
|
||||
assert.deepEqual(eventGetView(oneOff(), false, ["id", "baseEventId"]), { id: "ev2", baseEventId: null });
|
||||
assert.equal(eventGetView(series(), false, ["baseEventId"]).baseEventId, null);
|
||||
});
|
||||
|
||||
it("still gives a one-off read through its synthetic id a base", () => {
|
||||
// An expanded query hands a one-off a synthetic id, so this has not
|
||||
// changed: `baseEventId` is still no evidence of a series.
|
||||
const base = oneOff();
|
||||
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!), true, ["baseEventId"]);
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
});
|
||||
|
||||
it("answers null for the rule and overrides named on an occurrence", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!), true,
|
||||
["recurrenceId", "recurrenceRule", "recurrenceOverrides"]);
|
||||
assert.deepEqual(view, { id: "ev1-r20260908T090000", recurrenceId: "2026-09-08T09:00:00", recurrenceRule: null, recurrenceOverrides: null });
|
||||
});
|
||||
|
||||
it("leaves the rule on the series itself alone", () => {
|
||||
assert.deepEqual(eventGetView(series(), false, ["recurrenceRule"]).recurrenceRule, WEEKDAYS);
|
||||
});
|
||||
|
||||
it("reads useDefaultAlerts as false until it is set", () => {
|
||||
// It used to read true until set.
|
||||
assert.equal(eventGetView(series(), false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||
assert.equal(eventGetView({ ...series(), useDefaultAlerts: true }, false, ["useDefaultAlerts"]).useDefaultAlerts, true);
|
||||
assert.equal(eventGetView({ ...series(), useDefaultAlerts: false }, false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||
});
|
||||
|
||||
it("returns only the id for an empty list", () => {
|
||||
// 0.16.21 treated an empty list as asking for everything.
|
||||
assert.deepEqual(eventGetView(series(), false, []), { id: "ev1" });
|
||||
});
|
||||
|
||||
it("returns the object unchanged when no list is given", () => {
|
||||
assert.deepEqual(eventGetView(series(), false, null), series());
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.22.
|
||||
*
|
||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||
* things that only a live server showed were therefore impossible to develop
|
||||
@@ -171,7 +171,7 @@ const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRul
|
||||
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||
*
|
||||
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
||||
* master — so an occurrence is recognizable by its `recurrenceId` and by
|
||||
* nothing else, which is the shape `isRecurring` was written against.
|
||||
*/
|
||||
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
@@ -188,6 +188,43 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Series properties a synthetic id answers `null` for, when they are named. */
|
||||
const NULL_ON_OCCURRENCE = new Set(["recurrenceRule", "recurrenceOverrides"]);
|
||||
|
||||
/**
|
||||
* The object a `CalendarEvent/get` with a `properties` list returns, as 0.16.22
|
||||
* builds it. Omitted or null `properties` returns the stored object unchanged.
|
||||
*
|
||||
* Three of the named properties are no longer read off the object:
|
||||
*
|
||||
* - `baseEventId` is the master's id on a synthetic id and `null` on anything
|
||||
* else. Through 0.16.21 an event read by its stored id reported that id as
|
||||
* its own base. An expanded query still hands a one-off a synthetic id, so
|
||||
* one read that way still carries a base, and `baseEventId` is still no
|
||||
* evidence of a series;
|
||||
* - `recurrenceRule` and `recurrenceOverrides` come back as `null` on a
|
||||
* synthetic id rather than being left out;
|
||||
* - `useDefaultAlerts` is the reader's own preference, and `false` when they
|
||||
* never set one. It used to read `true` until set. The mock has one reader,
|
||||
* so a value stored on the event stands in for that reader's.
|
||||
*
|
||||
* An empty list returns `id` alone, where 0.16.21 treated it as asking for
|
||||
* everything. `ContactCard/get` changed the same way.
|
||||
*
|
||||
* Read from the 0.16.22 source (`calendar_event/get.rs`) and its tests.
|
||||
*/
|
||||
export function eventGetView(event: Obj, synthetic: boolean, properties: string[] | null | undefined): Obj {
|
||||
if (!properties) return event;
|
||||
const out: Obj = { id: event.id };
|
||||
for (const p of properties) {
|
||||
if (p === "baseEventId") out[p] = synthetic ? event.baseEventId : null;
|
||||
else if (p === "useDefaultAlerts") out[p] = event.useDefaultAlerts === true;
|
||||
else if (synthetic && NULL_ON_OCCURRENCE.has(p)) out[p] = null;
|
||||
else if (p in event) out[p] = event[p];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* These are not hand-written. Each was produced by `openssl smime -sign` with a
|
||||
* generated certificate and is stored base64 so no editor, formatter or
|
||||
* checkout setting can touch a byte of it -- a signature is over exact octets,
|
||||
* and a stray line-ending normalisation would turn a working fixture into a
|
||||
* and a stray line-ending normalization would turn a working fixture into a
|
||||
* broken one for reasons invisible in a diff.
|
||||
*
|
||||
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { extractPermissions, parseSchemaBody } from "./permissionSchema.js";
|
||||
|
||||
/** Stalwart's permission list, out of the registry schema it serves at /api/schema. */
|
||||
test("the permission list is enums.Permission, names and labels, once each", () => {
|
||||
const schema = { objects: {}, enums: { Permission: [
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "authenticate", label: "" },
|
||||
{ name: "sysAccountGet", label: "a repeat" },
|
||||
{ label: "no name" },
|
||||
"not an object",
|
||||
] } };
|
||||
assert.deepEqual(extractPermissions(schema), [
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "authenticate", label: "authenticate" },
|
||||
]);
|
||||
assert.deepEqual(extractPermissions({ enums: {} }), []);
|
||||
assert.deepEqual(extractPermissions(null), []);
|
||||
});
|
||||
|
||||
test("the schema reads whether or not the transport already inflated it", () => {
|
||||
const doc = { enums: { Permission: [{ name: "impersonate", label: "Act on behalf of another user" }] } };
|
||||
const plain = new TextEncoder().encode(JSON.stringify(doc));
|
||||
assert.deepEqual(parseSchemaBody(plain), doc);
|
||||
assert.deepEqual(parseSchemaBody(new Uint8Array(gzipSync(plain))), doc);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { config } from "./config.js";
|
||||
|
||||
/**
|
||||
* Stalwart's list of permissions, for the Roles screen's picker.
|
||||
*
|
||||
* Stalwart publishes its whole registry schema at `GET /api/schema` to any
|
||||
* signed-in account -- objects, forms, layouts and `enums.Permission`, a label
|
||||
* for each permission. Its own administration interface is built from it. The
|
||||
* browser cannot fetch it (no credentials there, and another origin), so this
|
||||
* fetches it as the signed-in account and hands back the one part the client
|
||||
* needs: a list of names and English labels, a few dozen kilobytes rather than
|
||||
* the whole document.
|
||||
*
|
||||
* Held in memory for an hour per server, because it changes only when Stalwart
|
||||
* is upgraded. Nothing is written anywhere.
|
||||
*/
|
||||
|
||||
export interface PermissionInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CACHE_MS = 60 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; list: PermissionInfo[] }>();
|
||||
|
||||
/** The permission list out of a schema document, or an empty list if it is not where 0.16 keeps it. */
|
||||
export function extractPermissions(schema: unknown): PermissionInfo[] {
|
||||
const list = (schema as { enums?: { Permission?: unknown } } | null)?.enums?.Permission;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out: PermissionInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of list) {
|
||||
const { name, label } = (item ?? {}) as { name?: unknown; label?: unknown };
|
||||
if (typeof name !== "string" || !name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
out.push({ name, label: typeof label === "string" && label ? label : name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema's bytes as JSON. The file is shipped gzipped; whether the server
|
||||
* says so in Content-Encoding (so fetch has already inflated it) or serves the
|
||||
* .gz as it is, the magic number settles which this is.
|
||||
*/
|
||||
export function parseSchemaBody(bytes: Uint8Array): unknown {
|
||||
const raw = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : Buffer.from(bytes);
|
||||
return JSON.parse(raw.toString("utf8"));
|
||||
}
|
||||
|
||||
export async function fetchPermissions(authorization: string, baseUrl: string): Promise<PermissionInfo[] | null> {
|
||||
const hit = cache.get(baseUrl);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.list;
|
||||
const res = await fetch(`${baseUrl}/api/schema`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const list = extractPermissions(parseSchemaBody(new Uint8Array(await res.arrayBuffer())));
|
||||
if (list.length) cache.set(baseUrl, { at: Date.now(), list });
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { interpretServerAccount, normalizePermission } from "./upstream.js";
|
||||
|
||||
/**
|
||||
* `/api/account` is the only place Stalwart lists what an account may do, and
|
||||
* ihasmail used to read the edition out of it and throw the rest away.
|
||||
*/
|
||||
test("the account's permissions are kept alongside the edition", () => {
|
||||
const info = interpretServerAccount({ edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"], locale: "en_US" });
|
||||
assert.deepEqual(info, { edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"] });
|
||||
});
|
||||
|
||||
test("permission names read the same whichever case the server uses", () => {
|
||||
// The source serializes camelCase; the documentation shows kebab-case.
|
||||
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
|
||||
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
|
||||
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
|
||||
assert.deepEqual(interpretServerAccount({ permissions: ["sys-account-get", "sysAccountGet"] }).permissions, ["sysAccountGet"]);
|
||||
});
|
||||
|
||||
test("a body without a usable list yields no permissions rather than failing", () => {
|
||||
assert.deepEqual(interpretServerAccount({ edition: "oss" }), { edition: "oss", permissions: [] });
|
||||
assert.deepEqual(interpretServerAccount({ permissions: "sysAccountGet" }), { edition: null, permissions: [] });
|
||||
assert.deepEqual(interpretServerAccount({ permissions: [1, null, "sysDomainGet"] }).permissions, ["sysDomainGet"]);
|
||||
assert.deepEqual(interpretServerAccount(null), { edition: null, permissions: [] });
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export interface CreateSessionParams {
|
||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||
* password changes, so it carries the guarantee that changing a credential
|
||||
* invalidates the sessions still holding the old one. A stateless backend
|
||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
||||
* cannot honor that alone; the plan is for OAuth to hand the job to
|
||||
* Stalwart's own token registry, which can already answer both questions.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
|
||||
@@ -72,11 +72,17 @@ test("every entry in the example mapping is a domain and an http(s) URL", () =>
|
||||
if (key.startsWith("_")) continue;
|
||||
const domain = key.trim().toLowerCase().replace(/\.$/, "");
|
||||
assert.ok(domain, "a domain key is empty");
|
||||
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
|
||||
assert.ok(!seen.has(domain), `${domain} appears twice once normalized`);
|
||||
seen.add(domain);
|
||||
assert.equal(typeof value, "string", `${domain} is not a string`);
|
||||
const url = new URL(value as string);
|
||||
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`);
|
||||
// A URL, or an object naming the server's URL and its administration's.
|
||||
const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
|
||||
for (const [field, v] of Object.entries(entry)) {
|
||||
assert.ok(field === "url" || field === "adminUrl", `${domain} has an unknown field ${field}`);
|
||||
assert.equal(typeof v, "string", `${domain} ${field} is not a string`);
|
||||
const url = new URL(v as string);
|
||||
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} ${field} must be http or https`);
|
||||
}
|
||||
assert.equal(typeof entry.url, "string", `${domain} has no url`);
|
||||
}
|
||||
assert.ok(seen.size > 0, "the example should show at least one mapping");
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
* already being read here, so checking what it asks for costs one substring
|
||||
* search per rebuild and turns a mystery into a line in the log.
|
||||
*
|
||||
* A warning rather than a refusal: this reads a built artefact to guess at a
|
||||
* A warning rather than a refusal: this reads a built artifact to guess at a
|
||||
* misconfiguration, and a wrong guess that stops the server from starting is
|
||||
* worse than the problem it is describing.
|
||||
*/
|
||||
@@ -128,7 +128,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
* comes off once, here. Anything outside it is a 404 and not the app
|
||||
* shell: under `/mail` this process shares a hostname with whatever else
|
||||
* the proxy serves, and answering `/` or `/other-app/thing` with our
|
||||
* index would shadow a neighbour rather than let it 404 honestly.
|
||||
* index would shadow a neighbor rather than let it 404 honestly.
|
||||
*/
|
||||
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
const urlPath = stripBasePath(basePath, fullPath);
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
|
||||
* TOTP (RFC 6238) — just enough to enroll a second factor safely.
|
||||
*
|
||||
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
|
||||
* *not* check the new secret when 2FA is switched on: it verifies the
|
||||
* credentials that are already on the account. A user whose authenticator was
|
||||
* mistyped or whose clock has drifted would be locked out of their mailbox at
|
||||
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
|
||||
* the next sign-in. So ihasmail proves the enrollment itself, before asking the
|
||||
* server to store anything.
|
||||
*/
|
||||
|
||||
|
||||
+136
-13
@@ -1,4 +1,5 @@
|
||||
import { config } from "./config.js";
|
||||
import { grantsAdministration } from "./adminGate.js";
|
||||
|
||||
export interface UpstreamSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
@@ -54,6 +55,87 @@ export function upstreamFor(username: string): string {
|
||||
return config.stalwartServers[domain] ?? config.stalwartUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the administrator signed in as `username` opens Stalwart's own
|
||||
* administration.
|
||||
*
|
||||
* What the operator configured wins -- STALWART_ADMIN_URL for the default
|
||||
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
|
||||
* found on the account's own server (`detected`) is used otherwise. Routing is
|
||||
* the same as `upstreamFor`: a routed domain is never pointed at the default
|
||||
* server's administration, and `detected` already came from its own server.
|
||||
*/
|
||||
export function adminUrlFor(username: string, detected: string | null = null): string | null {
|
||||
const at = username.lastIndexOf("@");
|
||||
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? detected;
|
||||
return config.stalwartAdminUrl || detected;
|
||||
}
|
||||
|
||||
/** Stalwart's own default for its web interface, written at first boot (`manager/defaults.rs`). */
|
||||
const DEFAULT_ADMIN_PREFIX = "/admin";
|
||||
|
||||
/**
|
||||
* The prefix Stalwart's administration is served under, from the `x:Application`
|
||||
* answers: "/admin" if an enabled application claims it, null if the server
|
||||
* says there is none (disabled, removed, or moved to another prefix). A refusal
|
||||
* -- the account may not read applications -- is not an answer, and gets
|
||||
* Stalwart's default.
|
||||
*/
|
||||
export function adminPrefixFrom(responses: [string, Record<string, unknown>, string][]): string | null {
|
||||
const get = responses.find(([name]) => name === "x:Application/get" || name === "error");
|
||||
if (!get || get[0] === "error") return DEFAULT_ADMIN_PREFIX;
|
||||
const list = (get[1].list as Array<{ enabled?: unknown; urlPrefix?: unknown }> | undefined) ?? [];
|
||||
const claims = list.some((app) => app.enabled !== false && app.urlPrefix && typeof app.urlPrefix === "object" && DEFAULT_ADMIN_PREFIX in (app.urlPrefix as object));
|
||||
return claims ? DEFAULT_ADMIN_PREFIX : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public origin a Stalwart session belongs to: the host it advertises in
|
||||
* its own URLs, which is the address people reach it at even when this server
|
||||
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative
|
||||
* URL falls back to the configured base.
|
||||
*/
|
||||
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
|
||||
try {
|
||||
return new URL(session.apiUrl, session.baseUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this session's server serves its own administration, found from the
|
||||
* server itself: its advertised origin, and the prefix its web interface
|
||||
* application is installed under. Null when the server says it has none.
|
||||
*/
|
||||
async function detectAdminUrl(authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
const origin = advertisedOrigin(session);
|
||||
const accountId = session.primaryAccounts?.[STALWART_CAP];
|
||||
if (!origin) return null;
|
||||
let prefix: string | null = DEFAULT_ADMIN_PREFIX;
|
||||
if (accountId) {
|
||||
try {
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: [JMAP_CORE, STALWART_CAP],
|
||||
methodCalls: [
|
||||
["x:Application/query", { accountId }, "q"],
|
||||
["x:Application/get", { accountId, "#ids": { resultOf: "q", name: "x:Application/query", path: "/ids" }, properties: ["enabled", "urlPrefix"] }, "g"],
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.ok) prefix = adminPrefixFrom(((await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] }).methodResponses ?? []);
|
||||
} catch {
|
||||
/* unreachable is not "none": keep the default */
|
||||
}
|
||||
}
|
||||
return prefix ? `${origin}${prefix}/` : null;
|
||||
}
|
||||
|
||||
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||
return `${base}/.well-known/jmap`;
|
||||
}
|
||||
@@ -132,11 +214,26 @@ export interface AccountInfo {
|
||||
locale: string | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
/**
|
||||
* The account's effective permissions, as Stalwart reports them for the
|
||||
* credential in use. Empty when the server would not say.
|
||||
*
|
||||
* Carried to the browser so it can offer only what the account may do --
|
||||
* administration above all. It is never a grant: Stalwart checks every call
|
||||
* it is sent, and a list that is stale or wrong costs a refused request, not
|
||||
* access.
|
||||
*/
|
||||
permissions: string[];
|
||||
/**
|
||||
* Where this server's own administration is, found rather than configured:
|
||||
* see `detectAdminUrl`. Only looked for when the account administers.
|
||||
*/
|
||||
adminUrl?: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -153,7 +250,7 @@ const SCRIPT_MODIFIERS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
||||
* Normalize a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
||||
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
|
||||
* anything that does not look like a language tag.
|
||||
*/
|
||||
@@ -198,7 +295,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(session.accounts ?? {})[0];
|
||||
if (!accountId) return EMPTY_INFO;
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
||||
// Against the server that issued this session, not the default: with a
|
||||
// domain mapped elsewhere, the default has never heard of the account.
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -226,7 +325,7 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null, permissions: [] };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
@@ -237,30 +336,54 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
|
||||
}
|
||||
|
||||
/**
|
||||
* Which edition the server is running. Stalwart deliberately does not publish
|
||||
* its version number to clients, but 0.16 does report its edition here.
|
||||
* Permission names in the form the source serializes them.
|
||||
*
|
||||
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
|
||||
* else, which serializes as camelCase (`sysAccountGet`). Its documentation and
|
||||
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
|
||||
* server settles which is true, both are read as the one form, so a check
|
||||
* written against `sysAccountGet` holds either way.
|
||||
*/
|
||||
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
|
||||
export function normalizePermission(name: string): string {
|
||||
return name.includes("-") ? name.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase()) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server says about the signed-in account: its edition and its
|
||||
* effective permissions. Stalwart deliberately does not publish its version
|
||||
* number to clients, but 0.16 reports both of these here.
|
||||
*/
|
||||
async function fetchServerAccount(authorization: string, base: string): Promise<Pick<AccountInfo, "edition" | "permissions">> {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { edition?: unknown };
|
||||
return typeof body.edition === "string" ? body.edition : null;
|
||||
if (!res.ok) return { edition: null, permissions: [] };
|
||||
return interpretServerAccount(await res.json());
|
||||
} catch {
|
||||
return null;
|
||||
return { edition: null, permissions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretServerAccount(body: unknown): Pick<AccountInfo, "edition" | "permissions"> {
|
||||
const b = (body ?? {}) as { edition?: unknown; permissions?: unknown };
|
||||
const permissions = Array.isArray(b.permissions)
|
||||
? [...new Set(b.permissions.filter((p): p is string => typeof p === "string").map(normalizePermission))]
|
||||
: [];
|
||||
return { edition: typeof b.edition === "string" ? b.edition : null, permissions };
|
||||
}
|
||||
|
||||
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
const cached = infoCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
|
||||
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
|
||||
// Only an administrator is shown the link, so only an administrator's
|
||||
// server is asked where it is.
|
||||
if (grantsAdministration(info.permissions)) info = { ...info, adminUrl: await detectAdminUrl(authorization, session) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
@@ -303,7 +426,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
||||
* So by default only the path and query are taken from the advertised URL;
|
||||
* scheme, host and port come from the configured base. That is what a proxy
|
||||
* should have done all along -- the operator named the route on purpose.
|
||||
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
|
||||
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
|
||||
* that genuinely needs to reach Stalwart at a different origin than the one
|
||||
* it was given.
|
||||
*/
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
|
||||
"server at startup rather than failing quietly at somebody's sign-in.",
|
||||
"",
|
||||
"ihasmail's Administration dashboard links to each server's own",
|
||||
"administration, found from the server. A value may instead be an object",
|
||||
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
|
||||
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
|
||||
"never pointed at the default server's administration.",
|
||||
"",
|
||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||
],
|
||||
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": "https://jmap.customer-b.test"
|
||||
"customer-b.test": { "url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test" }
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
||||
not what the user picked here. There used to be two, both with media
|
||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
||||
neither and the colour never moved off whatever the OS implied.
|
||||
neither and the color never moved off whatever the OS implied.
|
||||
|
||||
The initial value is the default theme's background, so the browser chrome
|
||||
is right from the first paint rather than only once JS has run.
|
||||
|
||||
+4
-4
@@ -12,9 +12,9 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"@tanstack/react-virtual": "^3.14.12",
|
||||
"dompurify": "^3.4.15",
|
||||
"lucide-react": "^0.477.0",
|
||||
"lucide-react": "^1.45.0",
|
||||
"marked": "^18.0.11",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -26,9 +26,9 @@
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -31,6 +31,8 @@ const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m)
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
||||
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
||||
// Only ever opened by the few who administer, so nobody else downloads it.
|
||||
const AdminView = lazy(() => import("@/views/admin/AdminView").then((m) => ({ default: m.AdminView })));
|
||||
|
||||
export function App() {
|
||||
const status = useSession((s) => s.status);
|
||||
@@ -42,7 +44,7 @@ export function App() {
|
||||
* knowing its strings just changed. Rather than make every one of the
|
||||
* thousand call sites a subscriber -- which would turn extracting a string
|
||||
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
|
||||
* away and rebuilt when the catalogue changes. Picking a language is a
|
||||
* away and rebuilt when the catalog changes. Picking a language is a
|
||||
* once-in-an-account event; paying for it there is far cheaper than paying
|
||||
* for it on every render everywhere.
|
||||
*/
|
||||
@@ -52,9 +54,9 @@ export function App() {
|
||||
}, [bootstrap]);
|
||||
|
||||
/*
|
||||
* Wait for the catalogue before the first paint.
|
||||
* Wait for the catalog before the first paint.
|
||||
*
|
||||
* The tree is rebuilt when a catalogue lands, so components recover on
|
||||
* The tree is rebuilt when a catalog lands, so components recover on
|
||||
* their own -- but a string computed in an effect does not. A toast fired
|
||||
* in the gap is emitted in English and stays English, in an interface that
|
||||
* is otherwise not. The wait costs nothing visible: the session bootstrap
|
||||
@@ -127,7 +129,7 @@ function AuthedApp() {
|
||||
* or the sign-out that every deploy causes -- the first frame is the
|
||||
* defaults, and the defaults are English. Rendering then means anything
|
||||
* computed before the settings land is computed in the wrong language: not
|
||||
* the interface, which is rebuilt when the catalogue arrives, but a string
|
||||
* the interface, which is rebuilt when the catalog arrives, but a string
|
||||
* emitted once, like a toast. That is why the stale-folder toast came out
|
||||
* in English on an otherwise German screen.
|
||||
*
|
||||
@@ -146,14 +148,14 @@ function AuthedApp() {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let canceled = false;
|
||||
void (async () => {
|
||||
/* Before the account's own settings, so both the seeding below and the
|
||||
enforcement inside `hydrate` have something to apply. */
|
||||
await loadSettingsPolicy();
|
||||
if (cancelled) return;
|
||||
if (canceled) return;
|
||||
const remote = await loadRemoteSettings();
|
||||
if (cancelled) return;
|
||||
if (canceled) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// No settings file: this account has never had settings of its own, so
|
||||
// the installation's defaults are what it starts on rather than
|
||||
@@ -172,10 +174,10 @@ function AuthedApp() {
|
||||
other: "Your administrator changed {n} settings",
|
||||
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
|
||||
}
|
||||
// The catalogue for whatever language that turned out to be. Hydrating
|
||||
// The catalog for whatever language that turned out to be. Hydrating
|
||||
// asks for it; this is waiting for the answer.
|
||||
await whenLanguageReady();
|
||||
if (cancelled) return;
|
||||
if (canceled) return;
|
||||
setReady(true);
|
||||
// Pushes were held back until now so they could not race the load. A
|
||||
// change made while it was in flight was kept, and goes out here.
|
||||
@@ -185,7 +187,7 @@ function AuthedApp() {
|
||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
canceled = true;
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
@@ -305,6 +307,7 @@ function AuthedApp() {
|
||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||
<Route path="/admin/:section?/:id?">{(p) => <AdminView section={p.section} id={p.id} />}</Route>
|
||||
<Route path="/login">
|
||||
<Redirect to="/mail" />
|
||||
</Route>
|
||||
|
||||
@@ -19,6 +19,9 @@ export const CAP = {
|
||||
websocket: "urn:ietf:params:jmap:websocket",
|
||||
} as const;
|
||||
|
||||
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||
|
||||
export class JmapMethodError extends Error {
|
||||
constructor(
|
||||
public readonly method: string,
|
||||
@@ -318,7 +321,7 @@ export class JmapClient {
|
||||
else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed"));
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload"));
|
||||
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled"));
|
||||
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload canceled"));
|
||||
opts.signal?.addEventListener("abort", () => xhr.abort());
|
||||
xhr.send(data);
|
||||
});
|
||||
@@ -349,6 +352,9 @@ export class JmapClient {
|
||||
/** Map method name prefix → required capability URNs. */
|
||||
function usingFor(method: string): string[] {
|
||||
const type = method.split("/")[0] ?? "";
|
||||
// Stalwart's registry: accounts, domains, credentials. Advertised per
|
||||
// account rather than in the session, which supportedUsing() allows for.
|
||||
if (type.startsWith("x:")) return [STALWART_CAP];
|
||||
switch (type) {
|
||||
case "Mailbox":
|
||||
case "Thread":
|
||||
|
||||
@@ -38,7 +38,24 @@ export interface JmapSession {
|
||||
server?: {
|
||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||
edition?: string | null;
|
||||
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
|
||||
adminUrl?: string | null;
|
||||
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
|
||||
enterpriseNotices?: boolean;
|
||||
};
|
||||
/**
|
||||
* False when this session may not administer: the operator turned it off,
|
||||
* or the session was signed in without "This is my own device".
|
||||
*/
|
||||
administration?: boolean;
|
||||
/** An administrator on a device not marked as their own; the menu says so. */
|
||||
administrationNeedsOwnDevice?: boolean;
|
||||
/**
|
||||
* The account's effective permissions on that server, as Stalwart reports
|
||||
* them. What the client offers is shaped by these; what is allowed is
|
||||
* decided by Stalwart on every call.
|
||||
*/
|
||||
permissions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
|
||||
const roles = new Map<string, RoleDef>([
|
||||
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
|
||||
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
|
||||
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
|
||||
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list to list accounts", () => {
|
||||
// Groups are accounts to the server, so they come with the same two permissions.
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts", "groups"]);
|
||||
// A query alone is a count on the dashboard, not a list.
|
||||
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers each section only with both halves of reading it", () => {
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
|
||||
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts", "groups"]);
|
||||
});
|
||||
|
||||
it("gives the dashboard a card for each number the role can read", () => {
|
||||
expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]);
|
||||
expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]);
|
||||
// The history takes its get as well: the query only finds the records.
|
||||
expect(dashboardCards(set("sysMetricQuery"))).toEqual([]);
|
||||
expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]);
|
||||
expect(adminSections(set("jmapEmailGet"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||
expect(can(helpdesk, "Domain", "Get")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart checks a grant, but not a password change or a delete. Without this,
|
||||
* anyone allowed to edit accounts could take over one that can do more.
|
||||
*/
|
||||
describe("an account that outranks the viewer", () => {
|
||||
it("an ordinary user never does", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
|
||||
expect(outranks(helpdesk, {}, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("an administrator does, unless the viewer is one too", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("a custom role does when it carries something the viewer lacks", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("a role that cannot be read counts against the target, not for it", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("extra permissions on the account itself are counted", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
|
||||
// Replace ignores the roles entirely, so only what it lists matters.
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a role that names itself", () => {
|
||||
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("granting a role", () => {
|
||||
it("is offered only for roles whose every permission the viewer holds", () => {
|
||||
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
|
||||
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
|
||||
expect(canGrantRole(everything, "missing", roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated passwords", () => {
|
||||
it("are four groups of five unambiguous characters", () => {
|
||||
const p = generatePassword();
|
||||
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
expect(p).not.toMatch(/[01lIO]/);
|
||||
});
|
||||
|
||||
it("skip bytes that would favor the start of the alphabet", () => {
|
||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||
let call = 0;
|
||||
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
|
||||
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
|
||||
expect(call).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
describe("the dashboard's message numbers", () => {
|
||||
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
||||
const stats = summarizeMetrics([
|
||||
counter("queue.message-queued", 6),
|
||||
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
||||
counter("queue.authenticated-message-queued", 2),
|
||||
counter("queue.dsn-queued", 1),
|
||||
counter("queue.report-queued", 3),
|
||||
// Recorded, but not either number.
|
||||
counter("message-ingest.ham", 50),
|
||||
]);
|
||||
expect(stats.received).toBe(10);
|
||||
expect(stats.sent).toBe(6);
|
||||
});
|
||||
|
||||
it("reads memory from the newest gauge, not the first one listed", () => {
|
||||
const stats = summarizeMetrics([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
||||
]);
|
||||
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
|
||||
});
|
||||
|
||||
it("tells a history that records nothing from a quiet day", () => {
|
||||
expect(summarizeMetrics([]).recorded).toBe(false);
|
||||
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dashboard's queries", () => {
|
||||
it("counts users rather than accounts, and asks for no ids", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
|
||||
expect(await countObjects("Account")).toBe(5);
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
|
||||
await countObjects("QueuedMessage");
|
||||
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
|
||||
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
|
||||
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
|
||||
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
|
||||
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
|
||||
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
|
||||
});
|
||||
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
|
||||
expect(records).toHaveLength(3);
|
||||
expect(call.mock.calls[0]).toEqual([
|
||||
"x:Metric/query",
|
||||
{
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
position: 0,
|
||||
limit: 2,
|
||||
},
|
||||
]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats only a forbidden answer as the server refusing", () => {
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
|
||||
expect(isRefused(new Error("offline"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card grid", () => {
|
||||
it("never leaves a row short when the cards can be divided evenly", () => {
|
||||
for (const n of [1, 2, 3, 4, 6]) {
|
||||
const { wide, mid } = balancedColumns(n);
|
||||
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
|
||||
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
|
||||
expect(wide).toBeLessThanOrEqual(4);
|
||||
}
|
||||
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
|
||||
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
|
||||
|
||||
describe("setting a password", () => {
|
||||
it("writes into the existing password credential, keeping its place", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
|
||||
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
|
||||
});
|
||||
|
||||
it("adds one after the last index when the account has none", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
|
||||
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
|
||||
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
|
||||
expect(hasPassword(account)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists written back", () => {
|
||||
it("re-index aliases the way the server stores a list", () => {
|
||||
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
|
||||
"0": { enabled: true, name: "b", domainId: "d1", description: null },
|
||||
"1": { enabled: false, name: "c", domainId: "d2", description: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("change the disk limit without touching the other quotas", () => {
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
|
||||
expect(quotasWithDisk(undefined, 0)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("explaining a refusal", () => {
|
||||
it("says what a taken address means", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
|
||||
});
|
||||
|
||||
it("keeps the server's own words for a password policy", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
|
||||
});
|
||||
|
||||
it("handles a method-level refusal as well as a set error", () => {
|
||||
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the account query", () => {
|
||||
it("filters on @type, the property's name on the object", async () => {
|
||||
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
|
||||
// and fails the whole list, which is how this was found.
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
|
||||
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart explains a refusal in English, and none of it should reach an
|
||||
* interface in another language as it is. Each case below is a refusal a
|
||||
* live server gave, or one its source says it gives.
|
||||
*/
|
||||
describe("refusals in the reader's language", () => {
|
||||
it("recognizes the registry's validators and says it again, without the server's words", () => {
|
||||
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||
expect(domain).toMatch(/isn't a valid domain name/);
|
||||
expect(domain).not.toContain("Invalid domain name");
|
||||
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
|
||||
});
|
||||
|
||||
it("never echoes a description it does not know", () => {
|
||||
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
|
||||
expect(text).not.toContain("Something only the server would say");
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
|
||||
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
|
||||
});
|
||||
|
||||
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
|
||||
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
|
||||
});
|
||||
|
||||
it("words a clash and a missing object for what it was about", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
|
||||
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
|
||||
});
|
||||
|
||||
it("explains ihasmail's own refusals by their code, not their English message", () => {
|
||||
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
|
||||
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
|
||||
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
|
||||
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
|
||||
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
|
||||
/**
|
||||
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
||||
* parenthesized run of quoted chunks.
|
||||
*/
|
||||
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
||||
const zone = [
|
||||
"example.com. IN MX 10 mail.example.com.",
|
||||
'example.com. IN TXT "v=spf1 mx ra=postmaster -all"',
|
||||
"v1-rsa-20260601._domainkey.example.com. IN TXT (",
|
||||
...(long.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`),
|
||||
")",
|
||||
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:\\"postmaster\\"@example.com"',
|
||||
"_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.",
|
||||
'example.com. IN CAA 0 issue "letsencrypt.org"',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("reading the zone file", () => {
|
||||
const records = parseZoneFile(zone);
|
||||
|
||||
it("gives one row per record, without the root dot", () => {
|
||||
expect(records.map((r) => r.type)).toEqual(["MX", "TXT", "TXT", "TXT", "SRV", "CAA"]);
|
||||
expect(records[0]).toMatchObject({ name: "example.com", value: "10 mail.example.com." });
|
||||
});
|
||||
|
||||
it("joins a split TXT record back into the value a DNS form wants", () => {
|
||||
expect(records[2]!.name).toBe("v1-rsa-20260601._domainkey.example.com");
|
||||
expect(records[2]!.value).toBe(long);
|
||||
expect(records[2]!.line).toContain("(");
|
||||
});
|
||||
|
||||
it("unquotes and unescapes TXT values, and leaves other types as written", () => {
|
||||
expect(records[1]!.value).toBe("v=spf1 mx ra=postmaster -all");
|
||||
expect(records[3]!.value).toBe('v=DMARC1; p=reject; rua=mailto:"postmaster"@example.com');
|
||||
expect(records[5]!.value).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
|
||||
it("keeps a line it cannot read rather than dropping it", () => {
|
||||
expect(parseZoneFile("something unexpected")).toEqual([{ name: "", type: "", value: "something unexpected", line: "something unexpected" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domain names", () => {
|
||||
it("are written back lower-case without the root dot", () => {
|
||||
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("are checked loosely before the server decides", () => {
|
||||
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
|
||||
expect(looksLikeDomain("example")).toBe(false);
|
||||
expect(looksLikeDomain("exa mple.com")).toBe(false);
|
||||
expect(looksLikeDomain("-bad.example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explaining what still uses a domain", () => {
|
||||
it("counts by kind", () => {
|
||||
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
|
||||
});
|
||||
|
||||
it("names a key's algorithm from its type", () => {
|
||||
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
|
||||
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
|
||||
|
||||
describe("group membership", () => {
|
||||
it("is a patch to each member, one pointer each, so no other membership moves", () => {
|
||||
// Stalwart's set patch adds a key on `true` and removes it on `null`, and
|
||||
// leaves every other key in the set as it was.
|
||||
expect(membershipPatch(["u1", "u2"], "g1", true)).toEqual({ u1: { "memberGroupIds/g1": true }, u2: { "memberGroupIds/g1": true } });
|
||||
expect(membershipPatch(["u1"], "g1", false)).toEqual({ u1: { "memberGroupIds/g1": null } });
|
||||
});
|
||||
|
||||
it("counts members as users whose memberships name the group, asking for no ids", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 4 });
|
||||
expect(await countMembers(["g1"])).toEqual(new Map([["g1", 4]]));
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberGroupIds: "g1" }, limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("leaves a count out rather than showing a failed one as none", async () => {
|
||||
const call = vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
|
||||
expect(await countMembers(["g1"])).toEqual(new Map());
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating and deleting a group", () => {
|
||||
it("creates an account of type Group, with nothing a person needs to sign in", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "g9" } } });
|
||||
expect(await createGroup({ name: " sales ", domainId: "d1", description: "", roles: { "@type": "Default" }, diskQuotaBytes: null })).toBe("g9");
|
||||
const create = (call.mock.calls[0]![1] as { create: { n: Record<string, unknown> } }).create.n;
|
||||
expect(create).toMatchObject({ "@type": "Group", name: "sales", domainId: "d1", description: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {} });
|
||||
expect(create).not.toHaveProperty("credentials");
|
||||
expect(create).not.toHaveProperty("encryptionAtRest");
|
||||
expect(create).not.toHaveProperty("memberGroupIds");
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("takes the members out before deleting, and deletes nothing if that fails", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValueOnce({ updated: { u1: null } }).mockResolvedValueOnce({ destroyed: ["g1"] });
|
||||
await destroyGroup("g1", ["u1"]);
|
||||
expect(call.mock.calls.map((c) => [c[0], Object.keys(c[1] as object)])).toEqual([
|
||||
["x:Account/set", ["update"]],
|
||||
["x:Account/set", ["destroy"]],
|
||||
]);
|
||||
call.mockReset();
|
||||
call.mockResolvedValueOnce({ notUpdated: { u1: { type: "forbidden" } } });
|
||||
await expect(destroyGroup("g1", ["u1"])).rejects.toMatchObject({ type: "forbidden" });
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("goes straight to the delete for a group with no members", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ destroyed: ["g1"] });
|
||||
await destroyGroup("g1", []);
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
expect(call).toHaveBeenCalledWith("x:Account/set", { destroy: ["g1"] });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("round-trips a group's roles, which are Default or Custom", () => {
|
||||
for (const roles of [{ "@type": "Default" } as const, { "@type": "Custom", roleIds: { r1: true, r2: true } } as const]) {
|
||||
expect(groupRolesFromKey(groupRoleKey(roles))).toEqual(roles);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
|
||||
|
||||
describe("a mailing list's recipients", () => {
|
||||
it("are saved as what was added and removed, one pointer each", () => {
|
||||
// The live server adds a set key on `true`, removes it on `null`, and
|
||||
// leaves the rest -- so a recipient added elsewhere meanwhile survives.
|
||||
expect(recipientsPatch(["[email protected]", "[email protected]"], ["[email protected]", "[email protected]"])).toEqual({
|
||||
"recipients/[email protected]": null,
|
||||
"recipients/[email protected]": true,
|
||||
});
|
||||
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
|
||||
});
|
||||
|
||||
it("compare without regard to case, and escape what a pointer cannot hold", () => {
|
||||
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
|
||||
expect(recipientsPatch([], ["odd/[email protected]"])).toEqual({ "recipients/[email protected]": true });
|
||||
});
|
||||
|
||||
it("come out of a paste of names, commas and angle brackets, and keep what isn't an address", () => {
|
||||
expect(parseAddresses('Ada Lovelace <[email protected]>, [email protected]; "Alan" [email protected]\[email protected] mailto:[email protected]')).toEqual({
|
||||
addresses: ["[email protected]", "[email protected]", "[email protected]", "[email protected]"],
|
||||
rejected: [],
|
||||
});
|
||||
expect(parseAddresses("ada@, @example.org, someone@nowhere")).toEqual({ addresses: [], rejected: ["ada@", "@example.org", "someone@nowhere"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list calls", () => {
|
||||
it("search on text, and leave the filter out when there is none", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
|
||||
await queryLists({ text: " board ", position: 50, limit: 50 });
|
||||
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { filter: { text: "board" }, position: 50, limit: 50, calculateTotal: true });
|
||||
await queryLists({});
|
||||
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { position: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("create one with its recipients as a set", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "l9" } } });
|
||||
expect(await createList({ name: "team", domainId: "d1", description: " ", recipients: ["[email protected]", "[email protected]"] })).toBe("l9");
|
||||
expect(call).toHaveBeenCalledWith("x:MailingList/set", {
|
||||
create: { n: { name: "team", domainId: "d1", description: null, recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} } },
|
||||
});
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { permissionSet } from "@/lib/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
||||
|
||||
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
||||
const roles = new Map<string, DirectoryRole>([
|
||||
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailUpdate") }],
|
||||
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailUpdate"), roleIds: flags("user") }],
|
||||
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
|
||||
]);
|
||||
|
||||
describe("what a role holds", () => {
|
||||
it("follows every base, and a denial anywhere in the tree wins", () => {
|
||||
// Stalwart unions enabled with enabled and disabled with disabled across
|
||||
// the tree, then takes the disabled away (permissions.rs).
|
||||
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
const { granted, denied } = inherited(["help"], roles, "lead");
|
||||
expect(granted.get("jmapEmailGet")).toBe("help");
|
||||
expect(denied.get("jmapEmailUpdate")).toBe("help");
|
||||
});
|
||||
|
||||
it("changes a set one pointer at a time", () => {
|
||||
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
|
||||
expect(setPatch("roleIds", [], [])).toEqual({});
|
||||
});
|
||||
|
||||
it("will not build on itself, or on a role already built on it", () => {
|
||||
expect(canBuildOn("help", "help", roles)).toBe(false);
|
||||
expect(canBuildOn("help", "lead", roles)).toBe(false);
|
||||
expect(canBuildOn("lead", "user", roles)).toBe(true);
|
||||
expect(canBuildOn(null, "lead", roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
|
||||
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
// jmapEmailUpdate is denied on Helpdesk but enabled on User beneath it: a
|
||||
// grant Stalwart would check, and a delete it would not.
|
||||
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
|
||||
expect(roleOutranks(permissionSet([...viewer, "jmapEmailUpdate"]), roles.get("lead")!, roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
|
||||
|
||||
describe("a tenant's limits", () => {
|
||||
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
||||
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
|
||||
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
|
||||
"quotas/maxAccounts": 30,
|
||||
"quotas/maxDomains": null,
|
||||
"quotas/maxGroups": 5,
|
||||
});
|
||||
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a tenant holds", () => {
|
||||
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
|
||||
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
|
||||
const f = (args as { filter: Record<string, unknown> }).filter;
|
||||
if (method === "x:Role/query") throw new Error("forbidden");
|
||||
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
|
||||
});
|
||||
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
|
||||
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("moves a domain in and out by its memberTenantId", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
|
||||
await setDomainTenant("d4", "t1");
|
||||
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
|
||||
await setDomainTenant("d4", null);
|
||||
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a tenant's logo", () => {
|
||||
it("is drawn only from https or an image data URL", () => {
|
||||
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
|
||||
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
|
||||
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
|
||||
expect(drawableLogo("javascript:alert(1)")).toBeNull();
|
||||
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
|
||||
expect(drawableLogo(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe("the span an availability bar covers", () => {
|
||||
expect(w.span).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks a single day every three hours, labelling every six", () => {
|
||||
it("marks a single day every three hours, labeling every six", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
||||
expect(w.scale).toBe("hours");
|
||||
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("birthdaysInRange", () => {
|
||||
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to a name built from components, then to the organisation", () => {
|
||||
it("falls back to a name built from components, then to the organization", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const parts = {
|
||||
id: "c1",
|
||||
@@ -115,7 +115,7 @@ describe("birthdaysInRange", () => {
|
||||
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
|
||||
});
|
||||
|
||||
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
|
||||
it("gives each occurrence a stable, unique id that marks it as synthesized", () => {
|
||||
const [s, e] = range("2025-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
||||
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
*
|
||||
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
|
||||
* the server rather than have it written in. The sign-in page did not (#236's
|
||||
* neighbour): it fetched `/api/config`, received the name and used only
|
||||
* neighbor): it fetched `/api/config`, received the name and used only
|
||||
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
|
||||
* user meets first. These pin the shape of the answer rather than the name.
|
||||
*/
|
||||
|
||||
@@ -97,14 +97,14 @@ describe("explicit date formats", () => {
|
||||
});
|
||||
|
||||
describe("clock preference", () => {
|
||||
it("honours 24-hour regardless of locale", () => {
|
||||
it("honors 24-hour regardless of locale", () => {
|
||||
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
|
||||
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||
expect(uses24Hour()).toBe(true);
|
||||
expect(formatHourLabel(13)).toBe("13");
|
||||
expect(formatHourLabel(9)).toBe("09");
|
||||
});
|
||||
it("honours 12-hour regardless of locale", () => {
|
||||
it("honors 12-hour regardless of locale", () => {
|
||||
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
|
||||
expect(formatClock(SAMPLE)).toBe("6:23 PM");
|
||||
expect(uses24Hour()).toBe(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The two sentence builders, which had no tests while they were building
|
||||
* English by concatenation -- and no test would have caught the thing wrong
|
||||
* with them, since the English output was correct. These pin the two
|
||||
* properties that matter now: every fragment goes through the catalogue, and
|
||||
* properties that matter now: every fragment goes through the catalog, and
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -12,7 +12,7 @@ import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
describe("sieve describeRule", () => {
|
||||
it("names the header and operator through the catalogue", () => {
|
||||
it("names the header and operator through the catalog", () => {
|
||||
const s = describeSieve({
|
||||
id: "1", name: "r", join: "allof", enabled: true,
|
||||
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
||||
@@ -50,7 +50,7 @@ describe("recurrence describeRule", () => {
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
||||
});
|
||||
|
||||
it("recognises Monday to Friday as every weekday", () => {
|
||||
it("recognizes Monday to Friday as every weekday", () => {
|
||||
const rule = {
|
||||
"@type": "RecurrenceRule", frequency: "weekly",
|
||||
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
||||
@@ -80,12 +80,12 @@ describe("recurrence describeRule", () => {
|
||||
expect(names[0]).toBe("Montag");
|
||||
expect(names).toHaveLength(7);
|
||||
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
||||
// which is why they cannot be catalogue keys and come from Intl instead.
|
||||
// which is why they cannot be catalog keys and come from Intl instead.
|
||||
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
||||
setUiLanguageForFormatting(null);
|
||||
});
|
||||
|
||||
it("renders a translated rule through the catalogue", () => {
|
||||
it("renders a translated rule through the catalog", () => {
|
||||
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
||||
setCatalog("en", { strings: {}, plurals: {} });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFolder, descendantIds, folderColor, movable } from "../folderMove";
|
||||
import { canDropFolder, canMoveFolderTo, descendantIds, folderColor, movable } from "../folderMove";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
|
||||
@@ -68,15 +68,39 @@ describe("canDropFolder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canMoveFolderTo", () => {
|
||||
const rights = (r: Partial<Mailbox["myRights"]>) => ({ mayRename: true, mayCreateChild: true, ...r }) as Mailbox["myRights"];
|
||||
const owned: Record<Id, Mailbox> = Object.fromEntries(Object.values(tree).map((m) => [m.id, { ...m, myRights: rights({}) }]));
|
||||
|
||||
it("agrees with a drop when every right is granted", () => {
|
||||
expect(canMoveFolderTo(owned, "news", "work")).toBe(true);
|
||||
expect(canMoveFolderTo(owned, "eu", null)).toBe(true);
|
||||
expect(canMoveFolderTo(owned, "work", "eu")).toBe(false);
|
||||
expect(canMoveFolderTo(owned, "news", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder the user may not rename, top level included", () => {
|
||||
const locked = { ...owned, eu: { ...owned.eu!, myRights: rights({ mayRename: false }) } };
|
||||
expect(canMoveFolderTo(locked, "eu", "news")).toBe(false);
|
||||
expect(canMoveFolderTo(locked, "eu", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a destination that may not hold new subfolders", () => {
|
||||
const closed = { ...owned, work: { ...owned.work!, myRights: rights({ mayCreateChild: false }) } };
|
||||
expect(canMoveFolderTo(closed, "news", "work")).toBe(false);
|
||||
expect(canMoveFolderTo(closed, "eu", null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderColor", () => {
|
||||
it("returns the colour chosen for that folder, and null for the rest", () => {
|
||||
it("returns the color chosen for that folder, and null for the rest", () => {
|
||||
const colors = { work: "#7c3aed" };
|
||||
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
||||
expect(folderColor(colors, "news")).toBeNull();
|
||||
expect(folderColor({}, "work")).toBeNull();
|
||||
});
|
||||
|
||||
it("is keyed by id, so a renamed folder keeps its colour", () => {
|
||||
it("is keyed by id, so a renamed folder keeps its color", () => {
|
||||
// The id is stable across a rename; the name and path are not.
|
||||
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("sanitizeEmailHtml", () => {
|
||||
});
|
||||
|
||||
describe("htmlDeclaresColors", () => {
|
||||
it("is false for mail that brings no colours", () => {
|
||||
it("is false for mail that brings no colors", () => {
|
||||
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
||||
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
||||
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
||||
@@ -54,9 +54,9 @@ describe("htmlDeclaresColors", () => {
|
||||
/**
|
||||
* Forcing the theme onto mail that styles itself — issue #290.
|
||||
*
|
||||
* The switch above it leaves nearly all HTML mail alone, because one colour
|
||||
* The switch above it leaves nearly all HTML mail alone, because one color
|
||||
* anywhere opts a message out. What this half has to get right is telling a
|
||||
* sheet the design sits on from a surface painted on top of it: neutralise the
|
||||
* sheet the design sits on from a surface painted on top of it: neutralize the
|
||||
* first and the white card goes away, keep the second and a button keeps a
|
||||
* label you can still read.
|
||||
*/
|
||||
@@ -70,15 +70,15 @@ describe("relativeLuminance", () => {
|
||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("has nothing to say about a colour it cannot read", () => {
|
||||
it("has nothing to say about a color it cannot read", () => {
|
||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||
// is the safe way round — an unreadable colour must not keep a white sheet.
|
||||
// is the safe way round — an unreadable color must not keep a white sheet.
|
||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||
expect(relativeLuminance("")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a fully transparent colour as painting nothing", () => {
|
||||
it("treats a fully transparent color as painting nothing", () => {
|
||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||
expect(relativeLuminance("transparent")).toBeNull();
|
||||
});
|
||||
@@ -100,21 +100,21 @@ describe("markKeptSurfaces", () => {
|
||||
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||
* marking. So these assert what the reader actually sees: does the
|
||||
* neutraliser hit this element? The selector is lifted out of the stylesheet
|
||||
* neutralizer hit this element? The selector is lifted out of the stylesheet
|
||||
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||
*/
|
||||
const NEUTRALISER = (() => {
|
||||
const NEUTRALIZER = (() => {
|
||||
const m = EMAIL_BASE_CSS.match(
|
||||
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||
);
|
||||
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
|
||||
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
|
||||
return m[1]!.trim();
|
||||
})();
|
||||
|
||||
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||
const neutralised = (el: Element) => el.matches(NEUTRALISER);
|
||||
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
|
||||
|
||||
it("keeps a coloured button and drops the white sheet around it", () => {
|
||||
it("keeps a colored button and drops the white sheet around it", () => {
|
||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||
@@ -127,7 +127,7 @@ describe("markKeptSurfaces", () => {
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||
});
|
||||
|
||||
it("neutralises a light panel nested inside a dark painted card", () => {
|
||||
it("neutralizes a light panel nested inside a dark painted card", () => {
|
||||
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||
// are dark enough to be marked, with light content tables inside them.
|
||||
// Those tables used to inherit the card's exemption and render as beige
|
||||
@@ -153,11 +153,11 @@ describe("markKeptSurfaces", () => {
|
||||
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||
// being descendants of the card.
|
||||
expect(neutralised(nested)).toBe(true);
|
||||
expect(neutralised(d.querySelector("td")!)).toBe(true);
|
||||
expect(neutralized(nested)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(true);
|
||||
// The card itself is still left alone, and the page surround still goes.
|
||||
expect(neutralised(card)).toBe(false);
|
||||
expect(neutralised(surround)).toBe(true);
|
||||
expect(neutralized(card)).toBe(false);
|
||||
expect(neutralized(surround)).toBe(true);
|
||||
});
|
||||
|
||||
it("still keeps a button that sits inside a nested light panel", () => {
|
||||
@@ -172,11 +172,11 @@ describe("markKeptSurfaces", () => {
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(2);
|
||||
expect(neutralised(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralised(d.querySelector("td")!)).toBe(false);
|
||||
expect(neutralized(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(false);
|
||||
// The label keeps its white, which is the thing #294 bought and this must
|
||||
// not spend.
|
||||
expect(neutralised(d.querySelector("a")!)).toBe(false);
|
||||
expect(neutralized(d.querySelector("a")!)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||
@@ -201,7 +201,7 @@ describe("markKeptSurfaces", () => {
|
||||
expect(panels.length).toBe(21);
|
||||
|
||||
expect(markKeptSurfaces(d)).toBe(7);
|
||||
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
|
||||
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads an inline background as well as the attribute", () => {
|
||||
@@ -233,7 +233,7 @@ describe("markKeptSurfaces", () => {
|
||||
* The control that actually stops it is layout containment on an ancestor of
|
||||
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
||||
* and is asserted at the bottom of this file, because jsdom does no layout and
|
||||
* cannot prove it here. These cover the second line of defence.
|
||||
* cannot prove it here. These cover the second line of defense.
|
||||
*/
|
||||
describe("mail CSS cannot climb out of its card", () => {
|
||||
const render = (html: string) => sanitizeEmailHtml(html).html;
|
||||
@@ -271,7 +271,7 @@ describe("mail CSS cannot climb out of its card", () => {
|
||||
describe("the containment that mail CSS cannot override", () => {
|
||||
it("is still applied to the message body container", async () => {
|
||||
// jsdom does no layout, so this asserts the control is present rather than
|
||||
// that it works; the behaviour was verified in a real browser. Without it,
|
||||
// that it works; the behavior was verified in a real browser. Without it,
|
||||
// a message can cover the viewport regardless of what the sanitizer does.
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("deciding whether a message has an HTML alternative", () => {
|
||||
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the type case-insensitively, since a header may be capitalised", () => {
|
||||
it("matches the type case-insensitively, since a header may be capitalized", () => {
|
||||
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ describe("t", () => {
|
||||
expect(currentLanguage()).toBe("en");
|
||||
});
|
||||
|
||||
it("translates once a catalogue is in force", () => {
|
||||
it("translates once a catalog is in force", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Archive")).toBe("Archivieren");
|
||||
});
|
||||
|
||||
it("falls back per string, not per catalogue", () => {
|
||||
it("falls back per string, not per catalog", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Report spam")).toBe("Report spam");
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe("interpolation", () => {
|
||||
describe("plural", () => {
|
||||
const FORMS = { one: "{n} message", other: "{n} messages" };
|
||||
|
||||
it("picks the English form without a catalogue", () => {
|
||||
it("picks the English form without a catalog", () => {
|
||||
expect(plural(1, FORMS)).toBe("1 message");
|
||||
expect(plural(0, FORMS)).toBe("0 messages");
|
||||
expect(plural(5, FORMS)).toBe("5 messages");
|
||||
@@ -73,7 +73,7 @@ describe("plural", () => {
|
||||
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
||||
});
|
||||
|
||||
it("falls back to `other` when the catalogue lacks the category", () => {
|
||||
it("falls back to `other` when the catalog lacks the category", () => {
|
||||
setCatalog("de", de);
|
||||
// German has no "few"; asking for 3 must not render undefined.
|
||||
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
||||
@@ -95,7 +95,7 @@ describe("tNode", () => {
|
||||
|
||||
it("lets a translator move the element", () => {
|
||||
// Splitting the sentence into two t() calls could not do this: the
|
||||
// fragments would render in the English order whatever the catalogue said.
|
||||
// fragments would render in the English order whatever the catalog said.
|
||||
setCatalog("de", de);
|
||||
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
||||
.toBe("<code>mailto:</code>-Links hier öffnen");
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("parseIcsDuration", () => {
|
||||
});
|
||||
|
||||
describe("looksLikeCalendar", () => {
|
||||
it("recognises a calendar and rejects an error page", () => {
|
||||
it("recognizes a calendar and rejects an error page", () => {
|
||||
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
|
||||
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l)
|
||||
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||
|
||||
describe("the document around the events", () => {
|
||||
it("is a calendar a reader will recognise", () => {
|
||||
it("is a calendar a reader will recognize", () => {
|
||||
const l = lines([base]);
|
||||
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
||||
expect(l).toContain("VERSION:2.0");
|
||||
@@ -105,7 +105,7 @@ describe("recurrence", () => {
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
||||
});
|
||||
|
||||
it("turns a cancelled occurrence into an EXDATE", () => {
|
||||
it("turns a canceled occurrence into an EXDATE", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
@@ -166,7 +166,7 @@ describe("the rest of an event", () => {
|
||||
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
||||
});
|
||||
|
||||
it("writes the organiser and the guests, with what each answered", () => {
|
||||
it("writes the organizer and the guests, with what each answered", () => {
|
||||
const e = {
|
||||
...base,
|
||||
organizerCalendarAddress: "mailto:[email protected]",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comboOf, keyboard } from "@/lib/keyboard";
|
||||
|
||||
/*
|
||||
* A "keydown" that carries no key. Chrome's password autofill dispatches one
|
||||
* as a plain Event when a saved login is picked, and comboOf read `key.length`
|
||||
* off it -- an uncaught TypeError in the console on every sign-in.
|
||||
*/
|
||||
|
||||
let pop: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
pop?.();
|
||||
pop = null;
|
||||
});
|
||||
|
||||
describe("a keydown with no key", () => {
|
||||
it("has no combo", () => {
|
||||
expect(comboOf(new Event("keydown") as KeyboardEvent)).toBeNull();
|
||||
});
|
||||
|
||||
it("reaches no binding and throws nothing", () => {
|
||||
const handler = vi.fn();
|
||||
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
|
||||
const errors: unknown[] = [];
|
||||
const onError = (ev: ErrorEvent) => errors.push(ev.error);
|
||||
window.addEventListener("error", onError);
|
||||
try {
|
||||
window.dispatchEvent(new Event("keydown", { bubbles: true }));
|
||||
} finally {
|
||||
window.removeEventListener("error", onError);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves real keys alone", () => {
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("E");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toMatch(/enter$/);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe("resolveUiLanguage", () => {
|
||||
});
|
||||
|
||||
it("refuses a language whose strings are not shipped", () => {
|
||||
// The account travels between machines and can outlive a catalogue. A
|
||||
// The account travels between machines and can outlive a catalog. A
|
||||
// page that says lang="fr" while rendering English is worse than one that
|
||||
// admits to English: it stops the reader translating it themselves.
|
||||
// Derived rather than named, so shipping another language does not turn
|
||||
@@ -30,7 +30,7 @@ describe("resolveUiLanguage", () => {
|
||||
});
|
||||
|
||||
it("carries the Beta flag until a person has signed the language off", () => {
|
||||
// Not a completeness measure. A catalogue can be word-for-word finished
|
||||
// Not a completeness measure. A catalog can be word-for-word finished
|
||||
// and still read like a machine wrote it, which is what this marks.
|
||||
// Every shipped language except English is unreviewed, and stays marked
|
||||
// until a person says otherwise.
|
||||
@@ -40,12 +40,12 @@ describe("resolveUiLanguage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("honours one that is", () => {
|
||||
it("honors one that is", () => {
|
||||
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
});
|
||||
|
||||
it("only offers languages that resolve to themselves", () => {
|
||||
// Guards the ordering mistake: adding a picker entry before its catalogue.
|
||||
// Guards the ordering mistake: adding a picker entry before its catalog.
|
||||
for (const l of UI_LANGUAGES) {
|
||||
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
expect(l.name.trim()).not.toBe("");
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("comparatorsFor, custom levels", () => {
|
||||
});
|
||||
|
||||
describe("optional sorts, which a server is allowed to refuse", () => {
|
||||
it("recognises the keyword properties", () => {
|
||||
it("recognizes the keyword properties", () => {
|
||||
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
@@ -19,7 +19,7 @@ const mb = (id: string, name: string, role: string | null = null, parentId: stri
|
||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||
|
||||
describe("mailboxDisplayName", () => {
|
||||
it("is the server's name until a catalogue says otherwise", () => {
|
||||
it("is the server's name until a catalog says otherwise", () => {
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||
});
|
||||
|
||||
@@ -43,18 +43,18 @@ describe("mailboxDisplayName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalisedName", () => {
|
||||
describe("isLocalizedName", () => {
|
||||
it("tells an editor when the name on screen is not the server's", () => {
|
||||
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||
// the moment somebody pressed Save — a real change made by accident.
|
||||
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailboxDisplayPath", () => {
|
||||
it("localises each part that has a role and leaves the rest", () => {
|
||||
it("localizes each part that has a role and leaves the rest", () => {
|
||||
setCatalog("de", de);
|
||||
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("renderMarkdown", () => {
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitiser.
|
||||
* these renders as a script tag without a sanitizer.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("the rest of the schema", () => {
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
||||
});
|
||||
|
||||
it("reads the organisation, its units and the job title", () => {
|
||||
it("reads the organization, its units and the job title", () => {
|
||||
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
||||
expect(values(c.organizations)[0]).toMatchObject({
|
||||
name: "Example Corp",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowIsOpen, visibleMessages } from "../openMessage";
|
||||
|
||||
/*
|
||||
* Reported from the inbox: with conversation view off, the list showed the two
|
||||
* messages of a thread as separate rows -- correctly -- but clicking either one
|
||||
* highlighted *both* and filled the reading pane with all five messages of the
|
||||
* conversation.
|
||||
*
|
||||
* The setting reached only as far as `collapseThreads` on the query. These are
|
||||
* the two rules that were missing downstream.
|
||||
*/
|
||||
|
||||
const msg = (id: string) => ({ id });
|
||||
|
||||
describe("which row is drawn as open", () => {
|
||||
it("marks only the opened message, not its siblings", () => {
|
||||
// The reported case: two rows, one thread, one of them opened.
|
||||
expect(rowIsOpen("m1", "t1", "m1", "t1")).toBe(true);
|
||||
expect(rowIsOpen("m2", "t1", "m1", "t1")).toBe(false);
|
||||
});
|
||||
|
||||
it("still marks the whole thread when conversation view is on", () => {
|
||||
// No message singled out: every row of the open thread is part of what the
|
||||
// reading pane is showing, so every one of them is open.
|
||||
expect(rowIsOpen("m1", "t1", null, "t1")).toBe(true);
|
||||
expect(rowIsOpen("m2", "t1", null, "t1")).toBe(true);
|
||||
expect(rowIsOpen("m3", "t2", null, "t1")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks nothing when nothing is open", () => {
|
||||
expect(rowIsOpen("m1", "t1", null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark a row whose thread is unknown", () => {
|
||||
// A row whose email has not loaded yet has no thread id; `undefined` must
|
||||
// not match a null openThreadId and light the row up.
|
||||
expect(rowIsOpen("m1", undefined, null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which messages the reading pane shows", () => {
|
||||
const thread = [msg("a"), msg("b"), msg("c")];
|
||||
|
||||
it("shows just the opened message", () => {
|
||||
expect(visibleMessages(thread, "b")).toEqual([msg("b")]);
|
||||
});
|
||||
|
||||
it("shows the whole thread when none is singled out", () => {
|
||||
expect(visibleMessages(thread, null)).toEqual(thread);
|
||||
});
|
||||
|
||||
it("falls back to the thread when the id names nothing in it", () => {
|
||||
/*
|
||||
* Two ways to arrive here: a link shared by somebody whose conversation
|
||||
* view is on, and an `m` parameter left in the URL when the setting is
|
||||
* switched back. A conversation is a better answer to both than an empty
|
||||
* pane, which is what filtering to nothing would produce.
|
||||
*/
|
||||
expect(visibleMessages(thread, "zzz")).toEqual(thread);
|
||||
});
|
||||
|
||||
it("leaves an empty thread empty rather than inventing a message", () => {
|
||||
expect(visibleMessages([], "b")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ describe("the palettes themselves", () => {
|
||||
it("has a light and a dark half for every one of them", () => {
|
||||
// The reason there is no "this palette is dark only" machinery: there is
|
||||
// no such palette. ihasmail's own gained a light half, and the override,
|
||||
// the toggle's memory and a greyed-out control all went with it.
|
||||
// the toggle's memory and a grayed-out control all went with it.
|
||||
expect(PALETTES.map((p) => p.id)).toEqual([
|
||||
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
|
||||
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
|
||||
@@ -75,7 +75,7 @@ describe("legacyTheme, read by a device still on an older build", () => {
|
||||
});
|
||||
|
||||
describe("toggleTarget", () => {
|
||||
it("flips the mode and keeps the colours, whatever the palette", () => {
|
||||
it("flips the mode and keeps the colors, whatever the palette", () => {
|
||||
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
|
||||
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
|
||||
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "../../locales/permissions/source.json";
|
||||
import { describePermissions, splitLabel, type PermissionCatalog } from "@/lib/permissionLabels";
|
||||
import { UI_LANGUAGES } from "@/lib/languages";
|
||||
|
||||
|
||||
describe("permission labels", () => {
|
||||
it("split Stalwart's label into its heading and action", () => {
|
||||
expect(splitLabel("Accounts Management: Create accounts")).toEqual({ categoryKey: "Accounts Management", action: "Create accounts" });
|
||||
expect(splitLabel("Action: Reload: TLS certificates")).toEqual({ categoryKey: "Action", action: "Reload: TLS certificates" });
|
||||
expect(splitLabel("Act on behalf of another user")).toEqual({ categoryKey: "General", action: "Act on behalf of another user" });
|
||||
});
|
||||
|
||||
it("fall back to Stalwart's English for a permission a language does not have yet", () => {
|
||||
const catalog: PermissionCatalog = { categories: { "Accounts Management": "Kontenverwaltung" }, labels: { sysAccountGet: "Konten abrufen" } };
|
||||
expect(describePermissions([
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "sysBrandNewThing", label: "Novelties: Do something new" },
|
||||
{ name: "impersonate", label: "Act on behalf of another user" },
|
||||
], catalog, "Allgemein")).toEqual([
|
||||
{ name: "sysAccountGet", categoryKey: "Accounts Management", category: "Kontenverwaltung", action: "Konten abrufen" },
|
||||
{ name: "sysBrandNewThing", categoryKey: "Novelties", category: "Novelties", action: "Do something new" },
|
||||
{ name: "impersonate", categoryKey: "General", category: "Allgemein", action: "Act on behalf of another user" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every language covers every permission the snapshot has, and nothing it
|
||||
* does not: a missing one would show English in the middle of a translated
|
||||
* picker, and a stale one would never be looked up.
|
||||
*/
|
||||
describe("the permission catalogs", () => {
|
||||
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
||||
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
||||
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
||||
const names = new Set(source.permissions.map((p) => p.name));
|
||||
const categories = new Set(source.permissions.map((p) => splitLabel(p.label).categoryKey).filter((c) => c !== "General"));
|
||||
|
||||
it("exist for every language the interface ships", () => {
|
||||
expect(Object.keys(modules).map(tagOf).sort()).toEqual([...languages].sort());
|
||||
});
|
||||
|
||||
for (const tag of languages) {
|
||||
it(`${tag} names every permission and heading, and nothing else`, async () => {
|
||||
const load = Object.entries(modules).find(([path]) => tagOf(path) === tag)?.[1];
|
||||
expect(load, `locales/permissions/${tag}.ts`).toBeTruthy();
|
||||
const { permissionCatalog } = await load!();
|
||||
expect(Object.keys(permissionCatalog.labels).filter((n) => !names.has(n))).toEqual([]);
|
||||
expect([...names].filter((n) => !permissionCatalog.labels[n]?.trim())).toEqual([]);
|
||||
expect(Object.keys(permissionCatalog.categories).sort()).toEqual([...categories].sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
||||
* Settings used to live only in localStorage, so nothing followed the user
|
||||
* between devices — issue #54, whose sharpest case is the default identity:
|
||||
* with none set, the address that sorts first wins, so mail goes out from an
|
||||
* address the recipient may not recognise.
|
||||
* address the recipient may not recognize.
|
||||
*
|
||||
* The split is written as a list of exceptions, which means the interesting
|
||||
* test is not "does this key sync" but "does a key added later sync without
|
||||
@@ -25,7 +25,7 @@ describe("which settings follow the account", () => {
|
||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
||||
// A pane width picked on a monitor is wrong on a laptop, and the
|
||||
// notification toggles track a per-browser permission grant.
|
||||
for (const key of ["listPaneWidth", "listPaneHeight", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
|
||||
for (const key of ["listPaneWidth", "listPaneHeight", "contactsListWidth", "sidebarWidth", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
|
||||
expect(synced, key).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("sharing a file", () => {
|
||||
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
||||
});
|
||||
|
||||
it("raises anything it does not recognise, so a real fault is still reported", async () => {
|
||||
it("raises anything it does not recognize, so a real fault is still reported", async () => {
|
||||
stubNavigator({
|
||||
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
|
||||
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
|
||||
|
||||
describe("signature compaction", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colors and links", () => {
|
||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const out = compactHtml(src);
|
||||
expect(out).not.toContain("mso-");
|
||||
|
||||
@@ -6,8 +6,8 @@ import { catalog as de } from "@/locales/de";
|
||||
|
||||
/**
|
||||
* The briefing is the only thing standing between a notification action and a
|
||||
* button labelled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalogue.
|
||||
* button labeled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalog.
|
||||
*
|
||||
* It is also the only place the archive mailbox is named, and getting that
|
||||
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||
@@ -45,7 +45,7 @@ describe("the worker's briefing", () => {
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalogue. Everything it will say has to be said here
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
setCatalog("de", de);
|
||||
const { store } = fakeCaches();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||
|
||||
/**
|
||||
* A swipe names what it is about to do on a coloured strip the reader sees for
|
||||
* A swipe names what it is about to do on a colored strip the reader sees for
|
||||
* about a third of a second before letting go. These check that the name is
|
||||
* true in the folder it is being read in — which is the whole reason the
|
||||
* descriptor exists rather than a fixed label per setting.
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("remembering the palette you were on", () => {
|
||||
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||
});
|
||||
|
||||
it("keeps the colours when the palette has both sides", () => {
|
||||
it("keeps the colors when the palette has both sides", () => {
|
||||
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
|
||||
expect(away.palette).toBe("gruvbox");
|
||||
expect(away.mode).toBe("light");
|
||||
|
||||
@@ -65,7 +65,7 @@ const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||
|
||||
describe("isTnef", () => {
|
||||
it("recognises the types and the filename", () => {
|
||||
it("recognizes the types and the filename", () => {
|
||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("lockAxis", () => {
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("counts distance on either axis towards committing", () => {
|
||||
it("counts distance on either axis toward committing", () => {
|
||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
/*
|
||||
* The guard's answers, and which of them the dialog leans on.
|
||||
*
|
||||
* It shipped with "Discard changes" as the only choice carrying a colour, which
|
||||
* It shipped with "Discard changes" as the only choice carrying a color, which
|
||||
* made losing the work the easy thing to click on a dialog whose entire purpose
|
||||
* is to stop that (#175). The emphasis belongs on the safe answer; the
|
||||
* destructive one stays legible as destructive without being the loudest thing
|
||||
|
||||
@@ -42,7 +42,7 @@ const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
||||
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
||||
|
||||
/**
|
||||
* The account to read and write for this capability, honouring the switcher.
|
||||
* The account to read and write for this capability, honoring the switcher.
|
||||
*
|
||||
* Use for anything the reader is looking at: their mail, a shared calendar,
|
||||
* somebody's files. Not for anything of the reader's own — see below.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* What the signed-in account may administer, read from the permissions Stalwart
|
||||
* reported for it at sign-in.
|
||||
*
|
||||
* None of this is a security boundary, and nothing here should read as one.
|
||||
* Every administrative call is a JMAP `x:` method sent through the ordinary
|
||||
* proxy, and Stalwart checks each of them against the credential making it --
|
||||
* scoping a tenant administrator's queries to their own tenant, and refusing a
|
||||
* write the account may not make. What this decides is only what the client
|
||||
* *offers*: a menu that appears for the people it can do something for, and
|
||||
* buttons that are there when pressing them would work.
|
||||
*
|
||||
* The one place it is more than presentation is `outranks`, which stands in
|
||||
* for a check Stalwart does not make. See there.
|
||||
*/
|
||||
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric";
|
||||
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||
|
||||
export type Permissions = ReadonlySet<string>;
|
||||
|
||||
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
|
||||
return new Set(list ?? []);
|
||||
}
|
||||
|
||||
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "tenants" | "roles" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
/**
|
||||
* The dashboard's cards an account may see.
|
||||
*
|
||||
* A count is a query with `calculateTotal`, so a query alone earns one. The
|
||||
* three read from the metric history need the get as well, since the query
|
||||
* only finds the records. Stalwart scopes the first three to a tenant
|
||||
* administrator's own tenancy; the metric history has no tenant in it at all,
|
||||
* and the Tenant Administrator role Stalwart creates does not hold it -- which
|
||||
* is how a tenant's dashboard comes to show only what is theirs.
|
||||
*/
|
||||
export function dashboardCards(perms: Permissions): DashboardCard[] {
|
||||
const out: DashboardCard[] = [];
|
||||
if (can(perms, "Account", "Query")) out.push("users");
|
||||
if (can(perms, "Domain", "Query")) out.push("domains");
|
||||
if (can(perms, "QueuedMessage", "Query")) out.push("pending");
|
||||
if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections an account may open, in the order they are listed.
|
||||
*
|
||||
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||
* of reading one: the query that finds the objects and the get that shows them.
|
||||
* The dashboard comes first, and is there whenever it has a card to show.
|
||||
*/
|
||||
export function adminSections(perms: Permissions): AdminSection[] {
|
||||
const out: AdminSection[] = [];
|
||||
if (dashboardCards(perms).length) out.push("dashboard");
|
||||
// Groups are accounts to the server, behind the same two permissions.
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
|
||||
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
|
||||
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) out.push("tenants");
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether to offer Administration at all: when there is a section to open. */
|
||||
export function hasAdministration(perms: Permissions): boolean {
|
||||
return adminSections(perms).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an administrator holds, at the least: Stalwart's built-in Tenant
|
||||
* Administrator role, for the parts of it that manage people and domains.
|
||||
* Anyone who has all of this can already do anything to the accounts an
|
||||
* "Administrator" account could.
|
||||
*/
|
||||
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
|
||||
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
|
||||
);
|
||||
|
||||
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export type PermissionsMode =
|
||||
| { "@type": "Inherit" }
|
||||
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
|
||||
|
||||
export interface RoleDef {
|
||||
id: string;
|
||||
description?: string | null;
|
||||
enabledPermissions?: Record<string, boolean>;
|
||||
roleIds?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account can do something the viewer cannot.
|
||||
*
|
||||
* Stalwart checks that a caller holds every permission they grant -- when
|
||||
* roles or permissions change, and when an account is created. It does not
|
||||
* check when only a password changes, and it does not check a delete. So an
|
||||
* account allowed to edit accounts could reset the password of one with far
|
||||
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||
* and treats such an account as read-only.
|
||||
*
|
||||
* It errs toward refusing. A role that cannot be read -- the viewer lacks
|
||||
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||
* inside a tenant and a server administrator outside one, and a tenant-scoped
|
||||
* viewer is not told which it is looking at. It never sees the second kind,
|
||||
* which is why comparing against the administrator baseline is enough there.
|
||||
*/
|
||||
export function outranks(
|
||||
viewer: Permissions,
|
||||
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
|
||||
roles: ReadonlyMap<string, RoleDef> | null,
|
||||
): boolean {
|
||||
let granted = new Set<string>();
|
||||
const kind = target.roles?.["@type"] ?? "User";
|
||||
if (kind === "Admin") {
|
||||
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
|
||||
} else if (kind === "Custom") {
|
||||
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
|
||||
const resolved = resolveRoles(ids, roles);
|
||||
if (!resolved) return true;
|
||||
granted = resolved;
|
||||
}
|
||||
const mode = target.permissions;
|
||||
if (mode && mode["@type"] !== "Inherit") {
|
||||
const enabled = Object.keys(mode.enabledPermissions ?? {});
|
||||
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
|
||||
}
|
||||
for (const p of granted) if (!viewer.has(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
|
||||
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
|
||||
if (!ids.length) return new Set();
|
||||
if (!roles) return null;
|
||||
const out = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (seen.has(id)) return true;
|
||||
seen.add(id);
|
||||
const role = roles.get(id);
|
||||
if (!role) return false;
|
||||
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
|
||||
return Object.keys(role.roleIds ?? {}).every(walk);
|
||||
};
|
||||
return ids.every(walk) ? out : null;
|
||||
}
|
||||
|
||||
/** Whether the viewer could grant a role: they hold everything it carries. */
|
||||
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
|
||||
const granted = resolveRoles([roleId], roles);
|
||||
return granted !== null && [...granted].every((p) => viewer.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* A password to hand to somebody who will change it.
|
||||
*
|
||||
* Twenty characters from an alphabet without the ones people misread aloud
|
||||
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
|
||||
* equally likely rather than the first few of the alphabet slightly more.
|
||||
*/
|
||||
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
|
||||
const out: string[] = [];
|
||||
const limit = 256 - (256 % ALPHABET.length);
|
||||
while (out.length < 20) {
|
||||
for (const byte of random(32)) {
|
||||
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
|
||||
}
|
||||
}
|
||||
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
|
||||
/**
|
||||
* The numbers on Administration's dashboard, over the ordinary JMAP proxy.
|
||||
*
|
||||
* Counts are queries with `calculateTotal` and `limit: 0`: Stalwart lifts its
|
||||
* own limit when asked for a total, so the number is the whole count, and no
|
||||
* ids come back to be thrown away. For a tenant administrator the server scopes
|
||||
* all three to the tenancy -- accounts and domains to its members, the queue to
|
||||
* messages touching its domains.
|
||||
*
|
||||
* The rest is read from `x:Metric`, the history Stalwart records once per
|
||||
* collection interval (hourly by default): a Counter holds what happened in
|
||||
* that interval, a Gauge the reading at its end. Received and sent are the sums
|
||||
* Stalwart's own dashboard shows, over the same metric names. The history is
|
||||
* Enterprise-only and has to be switched on (`x:MetricsStore`); a Community
|
||||
* server refuses the query as `forbidden`, and one that records nothing
|
||||
* answers with nothing -- the two cases the dashboard tells apart.
|
||||
*/
|
||||
|
||||
export const RECEIVED_METRICS = ["queue.message-queued"] as const;
|
||||
export const SENT_METRICS = ["queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued"] as const;
|
||||
export const MEMORY_METRIC = "server.memory";
|
||||
|
||||
/** The window received and sent cover. */
|
||||
export const DASHBOARD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface MetricRecord {
|
||||
"@type": "Counter" | "Gauge" | "Histogram";
|
||||
metric: string;
|
||||
count: number;
|
||||
timestamp: string;
|
||||
sum?: number;
|
||||
}
|
||||
|
||||
export interface MessageStats {
|
||||
received: number;
|
||||
sent: number;
|
||||
/** The latest memory reading, or null when none was recorded in the window. */
|
||||
memory: { bytes: number; at: string } | null;
|
||||
/**
|
||||
* Whether the server recorded anything in the window. Memory is written every
|
||||
* interval, so a window with no records at all is a history that is switched
|
||||
* off -- not a quiet day, which would still say zero.
|
||||
*/
|
||||
recorded: boolean;
|
||||
}
|
||||
|
||||
export function summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let memory: MessageStats["memory"] = null;
|
||||
const receivedNames = new Set<string>(RECEIVED_METRICS);
|
||||
const sentNames = new Set<string>(SENT_METRICS);
|
||||
for (const r of records) {
|
||||
if (r["@type"] === "Counter") {
|
||||
if (receivedNames.has(r.metric)) received += r.count;
|
||||
else if (sentNames.has(r.metric)) sent += r.count;
|
||||
} else if (r["@type"] === "Gauge" && r.metric === MEMORY_METRIC) {
|
||||
if (!memory || r.timestamp > memory.at) memory = { bytes: r.count, at: r.timestamp };
|
||||
}
|
||||
}
|
||||
return { received, sent, memory, recorded: records.length > 0 };
|
||||
}
|
||||
|
||||
type CountedObject = "Account" | "Domain" | "QueuedMessage";
|
||||
|
||||
/** How many there are. Accounts are counted as users: groups are accounts too. */
|
||||
export async function countObjects(object: CountedObject): Promise<number> {
|
||||
const res = await client.call<{ total?: number; ids?: string[] }>(`x:${object}/query`, {
|
||||
...(object === "Account" ? { filter: { "@type": "User" } } : {}),
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
});
|
||||
return res.total ?? res.ids?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every record of the dashboard's metrics since `since`, newest first.
|
||||
*
|
||||
* The filter keys are Stalwart's comparison names for the property -- a bare
|
||||
* `timestamp` is `unsupportedFilter`. A day at the default hourly interval is
|
||||
* well under one page; the paging is for a server that collects far more often.
|
||||
*/
|
||||
export async function loadMetrics(since: Date): Promise<MetricRecord[]> {
|
||||
const filter = {
|
||||
timestampIsGreaterThanOrEqual: since.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
metric: [...RECEIVED_METRICS, ...SENT_METRICS, MEMORY_METRIC],
|
||||
};
|
||||
const step = client.maxObjectsInGet;
|
||||
const out: MetricRecord[] = [];
|
||||
for (let position = 0; ; position += step) {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Metric/query", { filter, sort: [{ property: "timestamp", isAscending: false }], position, limit: step });
|
||||
const ids = q.ids ?? [];
|
||||
if (ids.length) out.push(...(await client.call<{ list: MetricRecord[] }>("x:Metric/get", { ids })).list);
|
||||
if (ids.length < step) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns the cards take, so no row is left short.
|
||||
*
|
||||
* `wide` is the most that divides the cards evenly without going past four;
|
||||
* `mid` is what they drop to when that no longer fits, again only a count the
|
||||
* cards divide into -- three cards go to one column rather than two and one.
|
||||
* Five is the one count nothing divides, and takes three over two. A phone
|
||||
* always gets one column, which the stylesheet decides.
|
||||
*/
|
||||
export function balancedColumns(cards: number): { wide: number; mid: number } {
|
||||
switch (cards) {
|
||||
case 0:
|
||||
case 1:
|
||||
return { wide: 1, mid: 1 };
|
||||
case 2:
|
||||
return { wide: 2, mid: 2 };
|
||||
case 3:
|
||||
return { wide: 3, mid: 1 };
|
||||
case 4:
|
||||
return { wide: 4, mid: 2 };
|
||||
default:
|
||||
return { wide: 3, mid: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A refusal from the server itself, as opposed to a failure to reach it. */
|
||||
export function isRefused(err: unknown): boolean {
|
||||
return err instanceof JmapMethodError && err.error.type === "forbidden";
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||
*
|
||||
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
|
||||
* domains and roles are registry objects now, read and written with `x:Account`,
|
||||
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
|
||||
* authenticated as the signed-in account, so ihasmail holds nothing new: no
|
||||
* route of its own, no store, no cache beyond the component showing the list.
|
||||
*
|
||||
* Shapes, from the 0.16.22 source:
|
||||
*
|
||||
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
|
||||
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
|
||||
* - An account's `name` is its local part, and its domain is a `domainId`.
|
||||
* `emailAddress` and `usedDiskQuota` are computed by the server.
|
||||
* - Secrets read back masked. A new password is written to the existing
|
||||
* password credential, so its id -- which OAuth tokens are tied to -- stays.
|
||||
* - Filters are AND only, keyed by property name as it appears on the object
|
||||
* (`@type`, not `type`), and the default order is newest first.
|
||||
*
|
||||
* Query and get are two requests rather than one with a result reference.
|
||||
* Whether the registry methods resolve back-references has not been checked on
|
||||
* a live server, and a list that loads a moment slower is a better failure than
|
||||
* one that never loads.
|
||||
*/
|
||||
|
||||
export interface EmailAlias {
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
domainId: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
"@type": "Password" | "AppPassword" | "ApiKey";
|
||||
secret?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryAccount {
|
||||
id: string;
|
||||
"@type": "User" | "Group";
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: UserRoles;
|
||||
permissions?: PermissionsMode;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
memberGroupIds?: Record<string, boolean>;
|
||||
/** The tenant the account belongs to; only ever read back to an administrator outside every tenant. */
|
||||
memberTenantId?: string | null;
|
||||
credentials?: Record<string, Credential>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryDomain {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The tenant the domain is in: an account can be in a tenant only on one of its domains. */
|
||||
memberTenantId?: string | null;
|
||||
}
|
||||
|
||||
const ACCOUNT_PROPERTIES = [
|
||||
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
||||
"usedDiskQuota", "aliases", "memberGroupIds", "memberTenantId", "credentials", "createdAt",
|
||||
];
|
||||
|
||||
/** The one quota ihasmail edits; the others keep whatever they had. */
|
||||
export const DISK_QUOTA = "maxDiskQuota";
|
||||
|
||||
/** An error with a SetError behind it, kept so the caller can explain it. */
|
||||
export class DirectoryError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly description: string | undefined,
|
||||
readonly properties: string[] = [],
|
||||
) {
|
||||
super(description ?? type);
|
||||
this.name = "DirectoryError";
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
ids: string[];
|
||||
total?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
// The registry names the discriminator `@type`, as it is on the object. A
|
||||
// plain `type` is not a property it knows and fails the whole query.
|
||||
const filter: Record<string, unknown> = { "@type": opts.type };
|
||||
if (opts.text?.trim()) filter.text = opts.text.trim();
|
||||
const res = await client.call<QueryResult>("x:Account/query", {
|
||||
filter,
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
|
||||
// In the order the query gave, which is the order the list is shown in.
|
||||
const byId = new Map(res.list.map((a) => [a.id, a]));
|
||||
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
|
||||
}
|
||||
|
||||
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
|
||||
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
|
||||
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export const listDomains = () => all<DirectoryDomain>("Domain", ["name", "memberTenantId"]);
|
||||
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
||||
|
||||
export async function listGroups(): Promise<DirectoryAccount[]> {
|
||||
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
|
||||
if (!q.ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
|
||||
|
||||
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const failure = Object.values(res[kind] ?? {})[0];
|
||||
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
|
||||
}
|
||||
|
||||
export interface NewAccount {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
password: string;
|
||||
roles: UserRoles;
|
||||
diskQuotaBytes: number | null;
|
||||
/** Put the account in a tenant; only an administrator outside every tenant may. */
|
||||
memberTenantId?: string | null;
|
||||
}
|
||||
|
||||
export async function createAccount(input: NewAccount): Promise<string> {
|
||||
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
|
||||
create: {
|
||||
n: {
|
||||
"@type": "User",
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
credentials: { "0": { "@type": "Password", secret: input.password } },
|
||||
roles: input.roles,
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||
aliases: {},
|
||||
memberGroupIds: {},
|
||||
...(input.memberTenantId ? { memberTenantId: input.memberTenantId } : {}),
|
||||
// Required on create. Turning it on is one-way and not offered here.
|
||||
encryptionAtRest: { "@type": "Disabled" },
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyAccount(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch that sets a new password.
|
||||
*
|
||||
* Into the existing password credential when there is one, which keeps its
|
||||
* credential id; as a new credential after the last index when there is not --
|
||||
* an account that has only ever signed in through a directory, say. An account
|
||||
* holds one password at most, so adding a second is never the answer.
|
||||
*/
|
||||
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
|
||||
const entries = Object.entries(account.credentials ?? {});
|
||||
const existing = entries.find(([, c]) => c["@type"] === "Password");
|
||||
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
|
||||
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
|
||||
return { [`credentials/${next}`]: { "@type": "Password", secret } };
|
||||
}
|
||||
|
||||
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
|
||||
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
|
||||
}
|
||||
|
||||
/** Re-index a list of aliases the way the server stores them. */
|
||||
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
|
||||
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
|
||||
}
|
||||
|
||||
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
|
||||
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
|
||||
const next = { ...(quotas ?? {}) };
|
||||
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
|
||||
else delete next[DISK_QUOTA];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own wording for a value one of its validators refused, and
|
||||
* what to say instead. These come from the registry's string validators
|
||||
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
|
||||
* else Stalwart says about a value is picked up by the fallback below.
|
||||
*/
|
||||
const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
|
||||
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
|
||||
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
|
||||
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
|
||||
"String cannot be empty": () => t("A required value was left empty."),
|
||||
};
|
||||
|
||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" | "tenant";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
*
|
||||
* Stalwart explains a refusal in English, and its words are never shown as
|
||||
* they are: an interface in German that answers in English reads as broken
|
||||
* even when the English is exact. Every type the registry returns has its
|
||||
* own message, and a value a validator refused is recognized by the
|
||||
* validator's wording and said again here.
|
||||
*
|
||||
* One exception, on purpose. A password policy is the server's to set -- a
|
||||
* length, a strength -- and there is no way to know its rule in advance to
|
||||
* translate it, so its reason is kept after a translated sentence. Dropping it
|
||||
* would leave "not accepted" with no way to find out why.
|
||||
*/
|
||||
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
|
||||
if (!(err instanceof DirectoryError)) {
|
||||
const e = err as { type?: string; code?: string; status?: number };
|
||||
// ihasmail's own proxy, refusing for this session or this installation.
|
||||
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
|
||||
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
|
||||
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
|
||||
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
// A method-level JMAP error: the whole call was refused.
|
||||
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
|
||||
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
|
||||
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
|
||||
}
|
||||
const description = err.description ?? "";
|
||||
switch (err.type) {
|
||||
case "forbidden":
|
||||
if (/not authorized to grant/i.test(description)) {
|
||||
return object === "role" ? t("You can't give a role permissions your own role doesn't have.") : t("You can't give an account permissions your own role doesn't have.");
|
||||
}
|
||||
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
|
||||
if (/licen[cs]ed account limit/i.test(description)) return t("The server's license allows no more accounts.");
|
||||
return t("The mail server refused this. Your role may not allow it.");
|
||||
case "primaryKeyViolation":
|
||||
return object === "domain"
|
||||
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
|
||||
: t("That address is already in use on this server, as an account, a list or an alias.");
|
||||
case "invalidForeignKey":
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return object === "domain"
|
||||
? t("Your organization has reached the number of domains it is allowed.")
|
||||
: object === "group"
|
||||
? t("Your organization has reached the number of groups it is allowed.")
|
||||
: object === "list"
|
||||
? t("Your organization has reached the number of mailing lists it is allowed.")
|
||||
: object === "role"
|
||||
? t("Your organization has reached the number of roles it is allowed.")
|
||||
: object === "tenant"
|
||||
? t("The server allows no more tenants.")
|
||||
: t("Your organization has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
return object === "domain"
|
||||
? t("This domain no longer exists. Someone may have removed it.")
|
||||
: object === "group"
|
||||
? t("This group no longer exists. Someone may have deleted it.")
|
||||
: object === "list"
|
||||
? t("This mailing list no longer exists. Someone may have deleted it.")
|
||||
: object === "role"
|
||||
? t("This role no longer exists. Someone may have deleted it.")
|
||||
: object === "tenant"
|
||||
? t("This tenant no longer exists. Someone may have deleted it.")
|
||||
: t("This account no longer exists. Someone may have deleted it.");
|
||||
case "rateLimit":
|
||||
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||
case "tooLarge":
|
||||
return t("That is more than the mail server accepts in one change.");
|
||||
case "invalidPatch":
|
||||
case "invalidProperties":
|
||||
case "validationFailed": {
|
||||
if (err.properties.includes("secret")) {
|
||||
return description ? t("The password was not accepted: {reason}", { reason: description }) : t("The password was not accepted.");
|
||||
}
|
||||
const known = VALIDATOR_MESSAGES[description];
|
||||
if (known) return known();
|
||||
return t("The mail server rejected one of the values. Check what you entered and try again.");
|
||||
}
|
||||
default:
|
||||
return t("The mail server refused the change ({code}).", { code: err.type });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||
*
|
||||
* From the 0.16.22 source (`Domain`, `DkimSignature`, and the registry's get):
|
||||
*
|
||||
* - `aliases` are other names for the domain, a set: `{"example.net": true}`.
|
||||
* - `dkimManagement`, `dnsManagement` and `certificateManagement` are each
|
||||
* `{"@type": "Manual"}` or `{"@type": "Automatic", …}`. A new domain gets
|
||||
* automatic DKIM and manual DNS and certificates unless told otherwise.
|
||||
* - `dnsZoneFile` is computed on read: every record the server wants published
|
||||
* for the domain, as BIND lines.
|
||||
* - A DKIM key is created with its private key, which the server validates;
|
||||
* with automatic management it makes and rotates them itself.
|
||||
* - Deleting a domain anything still points at is refused with
|
||||
* `objectIsLinked` and the list of what does -- including the domain's own
|
||||
* DKIM keys, which is why removing one means removing those first.
|
||||
*/
|
||||
|
||||
export interface Managed {
|
||||
"@type": "Manual" | "Automatic";
|
||||
dnsServerId?: string;
|
||||
acmeProviderId?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryDomainFull {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases?: Record<string, boolean>;
|
||||
isEnabled?: boolean;
|
||||
createdAt?: string;
|
||||
description?: string | null;
|
||||
catchAllAddress?: string | null;
|
||||
subAddressing?: { "@type": "Enabled" | "Disabled" | "Custom" };
|
||||
dkimManagement?: Managed;
|
||||
dnsManagement?: Managed;
|
||||
certificateManagement?: Managed;
|
||||
memberTenantId?: string | null;
|
||||
directoryId?: string | null;
|
||||
dnsZoneFile?: string;
|
||||
}
|
||||
|
||||
export interface DkimKey {
|
||||
id: string;
|
||||
"@type": string;
|
||||
selector: string;
|
||||
stage?: "active" | "pending" | "retiring" | "retired";
|
||||
createdAt?: string;
|
||||
nextTransitionAt?: string | null;
|
||||
}
|
||||
|
||||
const DOMAIN_PROPERTIES = [
|
||||
"name", "aliases", "isEnabled", "createdAt", "description", "catchAllAddress", "subAddressing",
|
||||
"dkimManagement", "dnsManagement", "certificateManagement", "memberTenantId", "directoryId",
|
||||
];
|
||||
|
||||
type SetFailure = { type: string; description?: string; properties?: string[]; linkedObjects?: { object?: string; id?: string }[] };
|
||||
type SetResponse = Record<string, Record<string, SetFailure | null | { id: string }> | undefined>;
|
||||
|
||||
/** A refusal, with what the server said still depends on the object. */
|
||||
export class DomainError extends DirectoryError {
|
||||
constructor(failure: SetFailure) {
|
||||
super(failure.type, failure.description, failure.properties);
|
||||
this.linked = (failure.linkedObjects ?? []).map((o) => String(o.object ?? ""));
|
||||
}
|
||||
readonly linked: string[];
|
||||
}
|
||||
|
||||
function refused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const failure = Object.values(res[kind] ?? {})[0] as SetFailure | undefined;
|
||||
if (failure) throw new DomainError(failure);
|
||||
}
|
||||
|
||||
export async function queryDomains(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (opts.text?.trim()) filter.text = opts.text.trim().toLowerCase();
|
||||
const res = await client.call<{ ids: string[]; total?: number }>("x:Domain/query", {
|
||||
filter,
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getDomains(ids: string[], opts: { zoneFile?: boolean } = {}): Promise<DirectoryDomainFull[]> {
|
||||
if (!ids.length) return [];
|
||||
const properties = opts.zoneFile ? [...DOMAIN_PROPERTIES, "dnsZoneFile"] : DOMAIN_PROPERTIES;
|
||||
const res = await client.call<{ list: DirectoryDomainFull[] }>("x:Domain/get", { ids, properties });
|
||||
const byId = new Map(res.list.map((d) => [d.id, d]));
|
||||
return ids.map((id) => byId.get(id)).filter((d): d is DirectoryDomainFull => Boolean(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts live on each domain. One query per domain, batched into as
|
||||
* few requests as the server allows; a count that fails is left out rather
|
||||
* than shown as zero, which would read as "safe to delete".
|
||||
*/
|
||||
export async function countAccounts(domainIds: string[]): Promise<Map<string, number>> {
|
||||
const counts = new Map<string, number>();
|
||||
await Promise.all(
|
||||
domainIds.map((domainId) =>
|
||||
client
|
||||
.call<{ total?: number; ids?: string[] }>("x:Account/query", { filter: { domainId }, limit: 1, calculateTotal: true })
|
||||
.then((r) => { if (typeof r.total === "number") counts.set(domainId, r.total); })
|
||||
.catch(() => {}),
|
||||
),
|
||||
);
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function listDkimKeys(domainId: string): Promise<DkimKey[]> {
|
||||
const q = await client.call<{ ids: string[] }>("x:DkimSignature/query", { filter: { domainId } });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: DkimKey[] }>("x:DkimSignature/get", { ids: q.ids, properties: ["@type", "selector", "stage", "createdAt", "nextTransitionAt"] });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Promise<Map<string, string>> {
|
||||
if (!ids.length) return new Map();
|
||||
const property = object === "Tenant" ? "name" : "description";
|
||||
const res = await client.call<{ list: Array<{ id: string } & Record<string, unknown>> }>(`x:${object}/get`, { ids, properties: [property] });
|
||||
return new Map(res.list.map((o) => [o.id, String(o[property] ?? o.id)]));
|
||||
}
|
||||
|
||||
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
||||
export function normalizeDomain(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
/** Enough of a check to catch a typo before the server does; the server decides. */
|
||||
export function looksLikeDomain(name: string): boolean {
|
||||
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normalizeDomain(name));
|
||||
}
|
||||
|
||||
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Domain/set", {
|
||||
create: { n: { name: normalizeDomain(input.name), description: input.description.trim() || null } },
|
||||
});
|
||||
refused(res, "notCreated");
|
||||
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the domain was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateDomain(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Domain/set", { update: { [id]: patch } });
|
||||
refused(res, "notUpdated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain, and its DKIM keys with it.
|
||||
*
|
||||
* The keys go first, in the same request, because the server will not remove a
|
||||
* domain its keys still name. Keys that belong to a domain being removed sign
|
||||
* nothing afterwards, so there is no case for keeping them.
|
||||
*/
|
||||
export async function destroyDomain(id: string, dkimKeyIds: string[]): Promise<void> {
|
||||
if (dkimKeyIds.length) {
|
||||
const keys = await client.call<SetResponse>("x:DkimSignature/set", { destroy: dkimKeyIds });
|
||||
refused(keys, "notDestroyed");
|
||||
}
|
||||
const res = await client.call<SetResponse>("x:Domain/set", { destroy: [id] });
|
||||
refused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
/** The line as the zone file had it, for copying into a BIND zone. */
|
||||
line: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the zone file Stalwart computes for a domain.
|
||||
*
|
||||
* Its serializer writes one record per line as `name IN TYPE value`, and a TXT
|
||||
* record longer than 255 bytes as a parenthesized run of quoted strings, one
|
||||
* per line. A DNS provider's form wants the whole value, so the strings are
|
||||
* joined and unescaped; the original lines are kept for anyone pasting into a
|
||||
* zone. Anything that does not parse is kept too, as its own row, rather than
|
||||
* silently dropped from a list somebody is copying from.
|
||||
*/
|
||||
export function parseZoneFile(text: string): DnsRecord[] {
|
||||
const out: DnsRecord[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]!;
|
||||
if (!line.trim() || line.trim().startsWith(";")) continue;
|
||||
if (line.includes("(") && !line.includes(")")) {
|
||||
while (i + 1 < lines.length && !lines[i]!.includes(")")) line += `\n${lines[++i]}`;
|
||||
}
|
||||
const m = /^(\S+)\s+(?:\d+\s+)?(?:IN\s+)?([A-Z]+)\s+([\s\S]*)$/.exec(line.trim());
|
||||
if (!m) {
|
||||
out.push({ name: "", type: "", value: line.trim(), line: line.trim() });
|
||||
continue;
|
||||
}
|
||||
const [, name, type, rest] = m;
|
||||
let value = rest!.trim();
|
||||
if (type === "TXT") {
|
||||
const parts = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((p) => p[1]!.replace(/\\(.)/g, "$1"));
|
||||
if (parts.length) value = parts.join("");
|
||||
}
|
||||
out.push({ name: name!.replace(/\.$/, ""), type: type!, value, line: line.trim() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A readable name for a DKIM key's algorithm, from its `@type`. */
|
||||
export function dkimAlgorithm(type: string): string {
|
||||
const version = /^Dkim2/.test(type) ? "DKIM2" : "DKIM1";
|
||||
const algo = /Ed25519/i.test(type) ? "Ed25519" : /Rsa/i.test(type) ? "RSA" : type;
|
||||
return `${algo} · ${version}`;
|
||||
}
|
||||
|
||||
/** What still points at an object, counted by kind, for a refusal message. */
|
||||
export function describeLinked(linked: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const kind of linked) counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
const parts: string[] = [];
|
||||
for (const [kind, n] of counts) {
|
||||
if (kind === "Account") parts.push(plural(n, { one: "{n} account", other: "{n} accounts" }));
|
||||
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
||||
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
||||
else if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
|
||||
else if (kind === "Domain") parts.push(plural(n, { one: "{n} domain", other: "{n} domains" }));
|
||||
else if (kind === "Authentication") parts.push(t("the default roles"));
|
||||
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Groups, from Stalwart 0.16's directory.
|
||||
*
|
||||
* A group is not an object of its own: it is an `x:Account` whose `@type` is
|
||||
* `Group`, read and written with the same methods and the same `sysAccount*`
|
||||
* permissions as a person. What differs, from the 0.16.22 source:
|
||||
*
|
||||
* - **Membership lives on the member.** A group has no list of members; each
|
||||
* user carries `memberGroupIds`, and a group's members are the users whose set
|
||||
* names it. Adding or removing one is a patch to that user --
|
||||
* `memberGroupIds/<group>: true`, or `null` to take it out -- which touches
|
||||
* nothing else in the set. Groups do not nest: a group has no memberships.
|
||||
* - **Membership is access, not permission.** A user's permissions come from
|
||||
* their own roles only. What a group gives its members is whatever has been
|
||||
* shared with the group -- a mailbox, a calendar.
|
||||
* - **Roles are `Default` or `Custom`,** not a person's `User`/`Admin`/`Custom`.
|
||||
* A group has no credentials and cannot sign in.
|
||||
*/
|
||||
|
||||
export type GroupRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export interface DirectoryGroup {
|
||||
id: string;
|
||||
"@type": "Group";
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: GroupRoles;
|
||||
permissions?: PermissionsMode;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/** A member as the group's panel shows them, with what `outranks` and `isSelf` need. */
|
||||
export interface GroupMember {
|
||||
id: string;
|
||||
name: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: UserRoles;
|
||||
permissions?: PermissionsMode;
|
||||
}
|
||||
|
||||
const GROUP_PROPERTIES = ["@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas", "usedDiskQuota", "aliases", "createdAt"];
|
||||
const MEMBER_PROPERTIES = ["name", "emailAddress", "description", "roles", "permissions"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0];
|
||||
if (first) throw new DirectoryError(first.type, first.description, first.properties);
|
||||
}
|
||||
|
||||
export const queryGroups = (opts: { text?: string; position?: number; limit?: number }) => queryAccounts({ type: "Group", ...opts });
|
||||
|
||||
export async function getGroups(ids: string[]): Promise<DirectoryGroup[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryGroup[] }>("x:Account/get", { ids, properties: GROUP_PROPERTIES });
|
||||
const byId = new Map(res.list.map((g) => [g.id, g]));
|
||||
return ids.map((id) => byId.get(id)).filter((g): g is DirectoryGroup => Boolean(g));
|
||||
}
|
||||
|
||||
/** The filter that finds a group's members: users whose memberships name it. */
|
||||
export const memberFilter = (groupId: string) => ({ "@type": "User", memberGroupIds: groupId });
|
||||
|
||||
/** How many members each group has. A count that fails is left out rather than shown as none. */
|
||||
export async function countMembers(groupIds: string[]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
await Promise.all(
|
||||
groupIds.map(async (id) => {
|
||||
try {
|
||||
const res = await client.call<{ total?: number }>("x:Account/query", { filter: memberFilter(id), limit: 0, calculateTotal: true });
|
||||
if (typeof res.total === "number") out.set(id, res.total);
|
||||
} catch {
|
||||
/* the column shows a dash */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A group's members, as many as one get allows, newest first as the server orders them. */
|
||||
export async function listMembers(groupId: string): Promise<{ members: GroupMember[]; total: number }> {
|
||||
const q = await client.call<{ ids?: string[]; total?: number }>("x:Account/query", { filter: memberFilter(groupId), limit: client.maxObjectsInGet, calculateTotal: true });
|
||||
const ids = q.ids ?? [];
|
||||
if (!ids.length) return { members: [], total: q.total ?? 0 };
|
||||
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids, properties: MEMBER_PROPERTIES });
|
||||
return { members: res.list, total: q.total ?? res.list.length };
|
||||
}
|
||||
|
||||
/** People to offer when adding a member, by name or address. */
|
||||
export async function searchUsers(text: string, limit = 8): Promise<GroupMember[]> {
|
||||
const q = await queryAccounts({ type: "User", text, limit });
|
||||
if (!q.ids.length) return [];
|
||||
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids: q.ids, properties: MEMBER_PROPERTIES });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export interface NewGroup {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
roles: GroupRoles;
|
||||
diskQuotaBytes: number | null;
|
||||
}
|
||||
|
||||
export async function createGroup(input: NewGroup): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Account/set", {
|
||||
create: {
|
||||
n: {
|
||||
"@type": "Group",
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
roles: input.roles,
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||
aliases: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the group was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The patch that puts users into a group or takes them out, one pointer each so no other membership moves. */
|
||||
export function membershipPatch(userIds: readonly string[], groupId: string, member: boolean): Record<string, Record<string, true | null>> {
|
||||
return Object.fromEntries(userIds.map((id) => [id, { [`memberGroupIds/${groupId}`]: member ? true : null }]));
|
||||
}
|
||||
|
||||
export async function setMembership(userIds: readonly string[], groupId: string, member: boolean): Promise<void> {
|
||||
if (!userIds.length) return;
|
||||
const res = await client.call<SetResponse>("x:Account/set", { update: membershipPatch(userIds, groupId, member) });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group, taking its members out of it first.
|
||||
*
|
||||
* Stalwart keeps an object that others still name, and every member's
|
||||
* `memberGroupIds` names the group -- the same reason a domain's keys go
|
||||
* before the domain. The two are separate calls: if the memberships cannot be
|
||||
* changed, nothing has been deleted.
|
||||
*/
|
||||
export async function destroyGroup(groupId: string, memberIds: readonly string[]): Promise<void> {
|
||||
await setMembership(memberIds, groupId, false);
|
||||
const res = await client.call<SetResponse>("x:Account/set", { destroy: [groupId] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** A group's roles as one select value: "Default", or "custom:<ids>". */
|
||||
export function groupRoleKey(roles: GroupRoles | undefined): string {
|
||||
if (!roles || roles["@type"] === "Default") return "Default";
|
||||
return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`;
|
||||
}
|
||||
|
||||
export function groupRolesFromKey(key: string): GroupRoles {
|
||||
if (key.startsWith("custom:")) return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) };
|
||||
return { "@type": "Default" };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Mailing lists, from Stalwart 0.16's directory.
|
||||
*
|
||||
* A list is its own registry object, `x:MailingList`, behind `sysMailingList*`.
|
||||
* It is an address and the addresses it passes mail on to, and nothing more:
|
||||
* there are no owners, no moderation and no posting policy to set. Shapes, as
|
||||
* the live server answered them (2026-09-15):
|
||||
*
|
||||
* - `recipients` is a set of addresses, `{"[email protected]": true}`, on this
|
||||
* server or anywhere else. One is added with `recipients/<address>: true` and
|
||||
* taken out with `null`, which leaves the rest of the set alone.
|
||||
* - `emailAddress` is computed from `name` and `domainId`, as an account's is.
|
||||
* - The query filters on `text`; the default order is newest first.
|
||||
*/
|
||||
|
||||
export interface DirectoryList {
|
||||
id: string;
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
recipients?: Record<string, boolean>;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
}
|
||||
|
||||
const LIST_PROPERTIES = ["name", "domainId", "emailAddress", "description", "recipients", "aliases"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0];
|
||||
if (first) throw new DirectoryError(first.type, first.description, first.properties);
|
||||
}
|
||||
|
||||
export async function queryLists(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const res = await client.call<{ ids?: string[]; total?: number }>("x:MailingList/query", {
|
||||
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getLists(ids: string[]): Promise<DirectoryList[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryList[] }>("x:MailingList/get", { ids, properties: LIST_PROPERTIES });
|
||||
const byId = new Map(res.list.map((l) => [l.id, l]));
|
||||
return ids.map((id) => byId.get(id)).filter((l): l is DirectoryList => Boolean(l));
|
||||
}
|
||||
|
||||
export interface NewList {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
recipients: string[];
|
||||
}
|
||||
|
||||
export async function createList(input: NewList): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", {
|
||||
create: {
|
||||
n: {
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
recipients: Object.fromEntries(input.recipients.map((r) => [r, true])),
|
||||
aliases: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the list was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateList(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyList(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:MailingList/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** An address as one step of a JSON pointer: `~` and `/` escaped, as RFC 6901 has it. */
|
||||
const pointerKey = (address: string) => address.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
|
||||
/**
|
||||
* The recipient changes between two lists of addresses, one pointer each.
|
||||
*
|
||||
* Only what changed is sent, so a recipient someone else added while this panel
|
||||
* was open is not taken out by saving it. Addresses compare without regard to
|
||||
* case, the way mail is delivered to them.
|
||||
*/
|
||||
export function recipientsPatch(before: readonly string[], after: readonly string[]): Record<string, true | null> {
|
||||
const lower = (list: readonly string[]) => new Map(list.map((a) => [a.toLowerCase(), a]));
|
||||
const was = lower(before);
|
||||
const now = lower(after);
|
||||
const patch: Record<string, true | null> = {};
|
||||
for (const [key, address] of was) if (!now.has(key)) patch[`recipients/${pointerKey(address)}`] = null;
|
||||
for (const [key, address] of now) if (!was.has(key)) patch[`recipients/${pointerKey(address)}`] = true;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** A plausible address: one @, something either side, a dot in the domain. Stalwart has the last word. */
|
||||
export function looksLikeAddress(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Addresses out of whatever was typed or pasted: a line from a spreadsheet, a
|
||||
* list separated by commas, `Name <address>`. Words with no @ in them are the
|
||||
* names around the addresses and are passed over; something with an @ that is
|
||||
* not an address is returned, so it can be shown rather than dropped.
|
||||
*/
|
||||
export function parseAddresses(text: string): { addresses: string[]; rejected: string[] } {
|
||||
const addresses: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of text.split(/[\s,;]+/)) {
|
||||
const token = raw.replace(/^["'(<]+|[>"')]+$/g, "").replace(/^mailto:/i, "");
|
||||
if (!token.includes("@")) continue;
|
||||
if (!looksLikeAddress(token)) {
|
||||
rejected.push(token);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(token.toLowerCase())) continue;
|
||||
seen.add(token.toLowerCase());
|
||||
addresses.push(token);
|
||||
}
|
||||
return { addresses, rejected };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { apiFetch, client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { Permissions, RoleDef } from "@/lib/adminAccess";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import type { PermissionInfo } from "@/lib/permissionLabels";
|
||||
|
||||
/**
|
||||
* Roles, from Stalwart 0.16's directory.
|
||||
*
|
||||
* `x:Role` behind `sysRole*`. A role has a `description` -- which is its name;
|
||||
* there is no other -- the roles it builds on (`roleIds`, followed all the way
|
||||
* down), and two sets of permissions: `enabledPermissions` it adds and
|
||||
* `disabledPermissions` it takes away, which wins over anything enabled or
|
||||
* inherited. The four a new server starts with (User, Group, Tenant
|
||||
* Administrator, System Administrator) are ordinary rows, editable like any
|
||||
* other, written once at first boot.
|
||||
*
|
||||
* Stalwart refuses to create or change a role that would carry a permission
|
||||
* the caller does not hold, which is what the picker's locked rows show ahead
|
||||
* of time. It does not check a delete.
|
||||
*/
|
||||
|
||||
export interface DirectoryRole extends RoleDef {
|
||||
description?: string | null;
|
||||
enabledPermissions?: Record<string, boolean>;
|
||||
disabledPermissions?: Record<string, boolean>;
|
||||
roleIds?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Which roles Stalwart gives an account that has been given none of its own. */
|
||||
export interface RoleDefaults {
|
||||
user: string[];
|
||||
group: string[];
|
||||
tenant: string[];
|
||||
admin: string[];
|
||||
}
|
||||
|
||||
const ROLE_PROPERTIES = ["description", "enabledPermissions", "disabledPermissions", "roleIds"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
/** A refusal, carrying what the server says still uses the role -- a delete's usual answer. */
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0] as ({ type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null | undefined);
|
||||
if (first) throw new DomainError(first);
|
||||
}
|
||||
|
||||
/** Every role, sorted by name. There are few enough to hold at once; the server caps a get anyway. */
|
||||
export async function listAllRoles(): Promise<DirectoryRole[]> {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Role/query", { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: DirectoryRole[] }>("x:Role/get", { ids: q.ids, properties: ROLE_PROPERTIES });
|
||||
return res.list.sort((a, b) => (a.description ?? a.id).localeCompare(b.description ?? b.id));
|
||||
}
|
||||
|
||||
/** The default roles, or null when the viewer may not read the authentication settings. */
|
||||
export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
|
||||
try {
|
||||
const res = await client.call<{ list: Array<Record<string, Record<string, boolean> | undefined>> }>("x:Authentication/get", {
|
||||
ids: ["singleton"],
|
||||
properties: ["defaultUserRoleIds", "defaultGroupRoleIds", "defaultTenantRoleIds", "defaultAdminRoleIds"],
|
||||
});
|
||||
const s = res.list[0];
|
||||
if (!s) return null;
|
||||
const ids = (k: string) => Object.keys(s[k] ?? {});
|
||||
return { user: ids("defaultUserRoleIds"), group: ids("defaultGroupRoleIds"), tenant: ids("defaultTenantRoleIds"), admin: ids("defaultAdminRoleIds") };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stalwart's labeled permission list, through ihasmail's server. */
|
||||
export async function loadPermissionList(): Promise<PermissionInfo[]> {
|
||||
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
|
||||
return res.permissions;
|
||||
}
|
||||
|
||||
export interface NewRole {
|
||||
description: string;
|
||||
roleIds: string[];
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
}
|
||||
|
||||
const set = (names: readonly string[]) => Object.fromEntries(names.map((n) => [n, true]));
|
||||
|
||||
export async function createRole(input: NewRole): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Role/set", {
|
||||
create: { n: { description: input.description.trim(), roleIds: set(input.roleIds), enabledPermissions: set(input.enabled), disabledPermissions: set(input.disabled) } },
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the role was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Role/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyRole(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Role/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** A set property's changes as one pointer per name, so nothing else in the set is touched. */
|
||||
export function setPatch(property: string, before: Iterable<string>, after: Iterable<string>): Record<string, true | null> {
|
||||
const was = new Set(before);
|
||||
const now = new Set(after);
|
||||
const patch: Record<string, true | null> = {};
|
||||
for (const n of was) if (!now.has(n)) patch[`${property}/${n}`] = null;
|
||||
for (const n of now) if (!was.has(n)) patch[`${property}/${n}`] = true;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** What a permission is, on the role being edited. */
|
||||
export type PermissionState = "allow" | "deny" | "none";
|
||||
|
||||
/**
|
||||
* What a role's bases grant and take away, and which base each came through.
|
||||
*
|
||||
* Stalwart unions every role in the tree -- enabled with enabled, disabled with
|
||||
* disabled -- and then takes the disabled set away (`permissions.rs`), so a
|
||||
* denial on a base role holds on every role built on it.
|
||||
*/
|
||||
export function inherited(roleIds: readonly string[], roles: ReadonlyMap<string, DirectoryRole>, exclude?: string): { granted: Map<string, string>; denied: Map<string, string> } {
|
||||
const granted = new Map<string, string>();
|
||||
const denied = new Map<string, string>();
|
||||
const seen = new Set<string>(exclude ? [exclude] : []);
|
||||
const walk = (id: string, via: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const role = roles.get(id);
|
||||
if (!role) return;
|
||||
for (const p of Object.keys(role.enabledPermissions ?? {})) if (!granted.has(p)) granted.set(p, via);
|
||||
for (const p of Object.keys(role.disabledPermissions ?? {})) if (!denied.has(p)) denied.set(p, via);
|
||||
for (const child of Object.keys(role.roleIds ?? {})) walk(child, via);
|
||||
};
|
||||
for (const id of roleIds) walk(id, id);
|
||||
return { granted, denied };
|
||||
}
|
||||
|
||||
/** The roles a role may build on: not itself, and none that already builds on it. */
|
||||
export function canBuildOn(roleId: string | null, candidate: string, roles: ReadonlyMap<string, DirectoryRole>): boolean {
|
||||
if (!roleId) return roles.has(candidate);
|
||||
if (candidate === roleId) return false;
|
||||
const seen = new Set<string>();
|
||||
const reaches = (id: string): boolean => {
|
||||
if (id === roleId) return true;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return Object.keys(roles.get(id)?.roleIds ?? {}).some(reaches);
|
||||
};
|
||||
return !reaches(candidate);
|
||||
}
|
||||
|
||||
/** Everything a role grants once its bases are followed and every denial in the tree taken away. */
|
||||
export function effectivePermissions(role: Pick<DirectoryRole, "enabledPermissions" | "disabledPermissions" | "roleIds">, roles: ReadonlyMap<string, DirectoryRole>, self?: string): Set<string> {
|
||||
const base = inherited(Object.keys(role.roleIds ?? {}), roles, self);
|
||||
const out = new Set<string>([...base.granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
|
||||
for (const p of [...base.denied.keys(), ...Object.keys(role.disabledPermissions ?? {})]) out.delete(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a role carries a permission the viewer does not hold, which makes it
|
||||
* read-only to them. Everything enabled anywhere in its tree counts, denied or
|
||||
* not: that is what Stalwart checks a grant against, and what a delete -- which
|
||||
* it does not check -- would otherwise let someone take away.
|
||||
*/
|
||||
export function roleOutranks(viewer: Permissions, role: DirectoryRole, roles: ReadonlyMap<string, DirectoryRole>): boolean {
|
||||
const granted = new Set<string>([...inherited(Object.keys(role.roleIds ?? {}), roles, role.id).granted.keys(), ...Object.keys(role.enabledPermissions ?? {})]);
|
||||
for (const p of granted) if (!viewer.has(p)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
|
||||
/**
|
||||
* Tenants, from Stalwart 0.16's directory.
|
||||
*
|
||||
* `x:Tenant` behind `sysTenant*`, an Enterprise feature: on a Community server
|
||||
* the objects exist, but anyone inside a tenant is held to a plain user's
|
||||
* permissions. A tenant is a name, an optional logo, the roles its members may
|
||||
* at most have, and quotas. It holds no list of what is in it -- membership
|
||||
* runs the other way, as `memberTenantId` on accounts, groups, domains,
|
||||
* mailing lists, roles and DKIM keys.
|
||||
*
|
||||
* Only an account outside every tenant may set `memberTenantId` (Stalwart
|
||||
* refuses "Cannot modify memberTenantId property" to anyone else), and inside a
|
||||
* tenant the server scopes every query to it and fills it in on create. Shapes
|
||||
* from the 0.16.22 schema:
|
||||
*
|
||||
* - `quotas` is a map from a `TenantStorageQuota` name to a number: counts for
|
||||
* accounts, groups, domains and the rest, bytes for `maxDiskQuota`. A quota
|
||||
* that is absent is no limit.
|
||||
* - `logo` is a URL or a data URL, or null.
|
||||
*/
|
||||
|
||||
export type TenantRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export interface DirectoryTenant {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
roles?: TenantRoles;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/** The quotas ihasmail offers, in the order they are shown. Disk space is bytes; the rest are counts. */
|
||||
export const TENANT_QUOTAS = ["maxAccounts", "maxGroups", "maxMailingLists", "maxDomains", "maxRoles", "maxDkimKeys", "maxDiskQuota"] as const;
|
||||
export type TenantQuota = (typeof TENANT_QUOTAS)[number];
|
||||
|
||||
/** What belongs to a tenant, and how each is counted. */
|
||||
export const TENANT_MEMBERS = [
|
||||
{ key: "accounts", method: "x:Account/query", filter: { "@type": "User" }, quota: "maxAccounts" },
|
||||
{ key: "groups", method: "x:Account/query", filter: { "@type": "Group" }, quota: "maxGroups" },
|
||||
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
|
||||
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
|
||||
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
|
||||
// A domain's keys join the tenant it was created in, and keep it there.
|
||||
{ key: "dkimKeys", method: "x:DkimSignature/query", filter: {}, quota: "maxDkimKeys" },
|
||||
] as const;
|
||||
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
|
||||
|
||||
const TENANT_PROPERTIES = ["name", "logo", "roles", "quotas", "usedDiskQuota", "createdAt"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null> | undefined> & {
|
||||
created?: Record<string, { id: string }>;
|
||||
};
|
||||
|
||||
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const first = Object.values(res[key] ?? {})[0];
|
||||
if (first) throw new DomainError(first);
|
||||
}
|
||||
|
||||
export async function queryTenants(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const res = await client.call<{ ids?: string[]; total?: number }>("x:Tenant/query", {
|
||||
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getTenants(ids: string[]): Promise<DirectoryTenant[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryTenant[] }>("x:Tenant/get", { ids, properties: TENANT_PROPERTIES });
|
||||
const byId = new Map(res.list.map((x) => [x.id, x]));
|
||||
return ids.map((id) => byId.get(id)).filter((x): x is DirectoryTenant => Boolean(x));
|
||||
}
|
||||
|
||||
/** Every tenant's id and name, for pickers. */
|
||||
export async function listTenantNames(): Promise<Array<{ id: string; name: string }>> {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Tenant/query", { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: Array<{ id: string; name: string }> }>("x:Tenant/get", { ids: q.ids, properties: ["name"] });
|
||||
return res.list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of each kind of thing a tenant holds. A count that fails -- the
|
||||
* viewer may not read that kind at all -- is left out rather than shown as
|
||||
* none, which would read as "safe to delete".
|
||||
*/
|
||||
export async function countTenantMembers(tenantId: string): Promise<Partial<Record<TenantMemberKind, number>>> {
|
||||
const out: Partial<Record<TenantMemberKind, number>> = {};
|
||||
await Promise.all(
|
||||
TENANT_MEMBERS.map(async (m) => {
|
||||
try {
|
||||
const res = await client.call<{ total?: number }>(m.method, { filter: { ...m.filter, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
|
||||
if (typeof res.total === "number") out[m.key] = res.total;
|
||||
} catch {
|
||||
/* left out */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The domains in a tenant, and those in none, which are the ones that can be added. */
|
||||
export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> }> {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Domain/query", { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return { inTenant: [], unassigned: [] };
|
||||
const res = await client.call<{ list: Array<{ id: string; name: string; memberTenantId?: string | null }> }>("x:Domain/get", { ids: q.ids, properties: ["name", "memberTenantId"] });
|
||||
const sorted = res.list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
inTenant: sorted.filter((d) => d.memberTenantId === tenantId).map(({ id, name }) => ({ id, name })),
|
||||
unassigned: sorted.filter((d) => !d.memberTenantId).map(({ id, name }) => ({ id, name })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of a tenant's accounts and groups are on a domain.
|
||||
*
|
||||
* Stalwart lets a domain leave a tenant while the tenant still has accounts on
|
||||
* it (live, 2026-09-15), leaving them in a tenant on a domain outside it --
|
||||
* which it refuses to create. The panel asks this before it offers the move.
|
||||
*/
|
||||
export async function tenantAccountsOnDomain(tenantId: string, domainId: string): Promise<number> {
|
||||
const res = await client.call<{ total?: number }>("x:Account/query", { filter: { domainId, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
|
||||
return res.total ?? 0;
|
||||
}
|
||||
|
||||
/** Put a domain in a tenant, or take it out with null. */
|
||||
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export interface NewTenant {
|
||||
name: string;
|
||||
logo: string | null;
|
||||
roles: TenantRoles;
|
||||
quotas: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function createTenant(input: NewTenant): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Tenant/set", {
|
||||
create: { n: { name: input.name.trim(), logo: input.logo, roles: input.roles, permissions: { "@type": "Inherit" }, quotas: input.quotas } },
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the tenant was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateTenant(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Tenant/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyTenant(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Tenant/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The quota changes as one pointer each, so a quota ihasmail does not offer
|
||||
* (OAuth clients, DNS servers, directories, ACME providers) keeps its value.
|
||||
*/
|
||||
export function quotasPatch(before: Record<string, number> | undefined, after: Partial<Record<TenantQuota, number | null>>): Record<string, number | null> {
|
||||
const patch: Record<string, number | null> = {};
|
||||
for (const key of TENANT_QUOTAS) {
|
||||
if (!(key in after)) continue;
|
||||
const next = after[key] ?? null;
|
||||
const was = before?.[key] ?? null;
|
||||
if (next !== was) patch[`quotas/${key}`] = next;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** A logo worth showing: an https or data image URL. Anything else is kept but not drawn. */
|
||||
export function drawableLogo(logo: string | null | undefined): string | null {
|
||||
if (!logo) return null;
|
||||
return /^https:\/\//i.test(logo) || /^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(logo) ? logo : null;
|
||||
}
|
||||
@@ -53,8 +53,8 @@ function bodyText(email: Email): string {
|
||||
* Everyone the message was between, as guests: the sender and the people it
|
||||
* was addressed to.
|
||||
*
|
||||
* The reader's own addresses come out -- they are the organiser, and an
|
||||
* organiser listed among their own guests is an event that invites you to your
|
||||
* The reader's own addresses come out -- they are the organizer, and an
|
||||
* organizer listed among their own guests is an event that invites you to your
|
||||
* own appointment. Bcc stays out too, on a message the reader sent themselves:
|
||||
* a blind recipient added to a guest list is visible to every other guest, and
|
||||
* turning a hidden copy into a public one is not something a menu item should
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user