Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cf45f502 | ||
|
|
7469598178 | ||
|
|
f0a92deb08 | ||
|
|
8abf3a96aa | ||
|
|
acb93a90a6 | ||
|
|
4cac9088dd | ||
|
|
1cde6f3032 | ||
|
|
e5f590978e | ||
|
|
cf93a1697f | ||
|
|
17f0552453 | ||
|
|
461129c5d6 | ||
|
|
95f7f8008e | ||
|
|
097d08900c | ||
|
|
de120ba7ca | ||
|
|
25763832f3 | ||
|
|
a2ba7f5acd | ||
|
|
fdcf27f3ea | ||
|
|
9e3844e94a | ||
|
|
f8119fafbf | ||
|
|
8a7ff5d42f | ||
|
|
c201d34377 | ||
|
|
05d1645ab7 | ||
|
|
cfa661de20 | ||
|
|
cee4f74257 | ||
|
|
bb25355c23 | ||
|
|
9d2de725c9 | ||
|
|
091782ae3a | ||
|
|
2e668edb51 | ||
|
|
cdd8fabff9 | ||
|
|
8857bdac30 | ||
|
|
c118184975 | ||
|
|
2740129c6a | ||
|
|
82dc877fe1 | ||
|
|
4c67460450 | ||
|
|
e158ebac5a | ||
|
|
786976312f | ||
|
|
5fe89d6e15 | ||
|
|
f79915aa89 | ||
|
|
191c4e7e68 | ||
|
|
4c1ceca8e9 | ||
|
|
8a08c3d6db | ||
|
|
ebf678be73 | ||
|
|
37eb145652 | ||
|
|
3d7602ce74 | ||
|
|
4054f82c37 | ||
|
|
d38dee7eb9 | ||
|
|
aa9bf1b9b2 | ||
|
|
c63fd0dfe0 | ||
|
|
f123467897 | ||
|
|
71d211a13f | ||
|
|
56bd48e891 | ||
|
|
da87925b9c | ||
|
|
360420402d | ||
|
|
8bd7904a21 | ||
|
|
6090442058 | ||
|
|
4c7b2ec370 | ||
|
|
9560ad06f4 | ||
|
|
6139031689 | ||
|
|
e3cd56314b | ||
|
|
c6dbcaef63 | ||
|
|
460760ba12 | ||
|
|
a607450aaa | ||
|
|
a9302075e7 | ||
|
|
dfe885a921 | ||
|
|
9691a7bbf5 | ||
|
|
e2b4cc18db | ||
|
|
47a2477d9f | ||
|
|
98e105efd6 | ||
|
|
b2e7db938c | ||
|
|
55fcbf72f5 | ||
|
|
d0b13272f3 | ||
|
|
5b85c254e7 | ||
|
|
bd6a605d61 | ||
|
|
5cc31037c1 | ||
|
|
e42c81ab09 | ||
|
|
4517d154a2 | ||
|
|
f7712b1c1e | ||
|
|
0bde2df69d | ||
|
|
df06b8ea04 | ||
|
|
441fb07cc9 | ||
|
|
f3ee4ff65d | ||
|
|
1ec9579db2 | ||
|
|
4cb1945eb5 | ||
|
|
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 | ||
|
|
9b497af756 | ||
|
|
73a1bad29f | ||
|
|
a9923c48d9 | ||
|
|
5b21720312 | ||
|
|
f2aaa9cea4 | ||
|
|
6632231815 | ||
|
|
7d9d5b005c | ||
|
|
23738501c7 | ||
|
|
91dda348bc | ||
|
|
3240c56e84 | ||
|
|
136c754bcd | ||
|
|
aa0d594666 | ||
|
|
53ccaad468 | ||
|
|
d51d523ce1 | ||
|
|
d90a1cef93 | ||
|
|
829c5ab14d | ||
|
|
1b9058fccb | ||
|
|
a2ae868f28 | ||
|
|
520de85d12 | ||
|
|
36ad85feef | ||
|
|
9418d3f935 | ||
|
|
3e8b1ebb38 | ||
|
|
38fb78a095 | ||
|
|
6f3aba06a6 | ||
|
|
a389b9e8c5 | ||
|
|
0ad19802f8 | ||
|
|
1592f38515 | ||
|
|
acc50f009c | ||
|
|
f42fb014c8 | ||
|
|
a73525f425 | ||
|
|
82470e8db0 | ||
|
|
f39d6ac30c | ||
|
|
4e61adfe80 | ||
|
|
b65732ea04 | ||
|
|
166a04f578 | ||
|
|
79d891c623 | ||
|
|
d13cf6ed6b | ||
|
|
b56ffdf268 | ||
|
|
0ebdb38a98 | ||
|
|
35935f2d3c | ||
|
|
8173e22ccb | ||
|
|
7825097333 | ||
|
|
cd6dff5346 | ||
|
|
0e05bee69a | ||
|
|
dc676acf52 | ||
|
|
1a955d64df | ||
|
|
575f634f9c | ||
|
|
fdfb83b254 | ||
|
|
17a24fe880 | ||
|
|
9e7723ca66 | ||
|
|
befe1dbf53 | ||
|
|
a875274a8e | ||
|
|
276ecfccff | ||
|
|
a35f360952 | ||
|
|
2464c9655f | ||
|
|
c66308e4bb | ||
|
|
6432e11beb | ||
|
|
db7b103a08 | ||
|
|
2c47c0851c | ||
|
|
f569f2cc7a | ||
|
|
3fd0d0cfa6 | ||
|
|
ed93fefb9b | ||
|
|
6098ffb8e5 | ||
|
|
01f721d8d1 | ||
|
|
5356e603fe | ||
|
|
fafeee481e | ||
|
|
a618f3fca6 | ||
|
|
7d6dfe4581 | ||
|
|
c84f190f76 | ||
|
|
7aa2e374d4 | ||
|
|
45c8929697 | ||
|
|
6a467d9bc4 | ||
|
|
e93d42d27e | ||
|
|
e9349863e8 | ||
|
|
3a74f0a715 | ||
|
|
1f9c17ad18 | ||
|
|
0df62e6b2f | ||
|
|
eca8468d84 | ||
|
|
429c232e0c | ||
|
|
53a44d7d18 | ||
|
|
fa22d30347 | ||
|
|
fcbd8f6449 | ||
|
|
310dc85b62 | ||
|
|
0c9a15a691 | ||
|
|
cee107d948 | ||
|
|
f201b09e90 | ||
|
|
029f079094 | ||
|
|
4d23cef511 | ||
|
|
b4248a6661 | ||
|
|
1f8c12e29e | ||
|
|
17d98748c4 | ||
|
|
1070ee13bc | ||
|
|
71827a2d04 | ||
|
|
5dd0a56732 | ||
|
|
b55c8b13bc | ||
|
|
0cf9b81444 | ||
|
|
8386444ac7 | ||
|
|
e1ae97139c | ||
|
|
503eaf17ec | ||
|
|
1e02d9ebba | ||
|
|
d40dbf04b8 | ||
|
|
2a9e18f04c | ||
|
|
4d89f5e672 | ||
|
|
95e5c69e8f | ||
|
|
50d08a18e4 | ||
|
|
9a634311b2 | ||
|
|
b811c84b12 | ||
|
|
b9b01ce02c | ||
|
|
104e3c7ba0 | ||
|
|
0a03c64ff3 | ||
|
|
4c430ea995 | ||
|
|
112b3ea52f | ||
|
|
31edf33839 | ||
|
|
1a842d8d14 | ||
|
|
22f39a4507 | ||
|
|
bf60fe6157 | ||
|
|
8d4063e921 | ||
|
|
ea6c098057 | ||
|
|
f7cbdb2e7a | ||
|
|
379623614a | ||
|
|
662385c6da | ||
|
|
0fb4d1e964 | ||
|
|
898815eaca | ||
|
|
6ac5ee45dd | ||
|
|
f500261697 | ||
|
|
7a67a2c1c3 | ||
|
|
9245fc5b1e | ||
|
|
9ed64bea88 | ||
|
|
21d0320765 | ||
|
|
2a5c6a5c18 | ||
|
|
e5c8594901 | ||
|
|
d844786e14 | ||
|
|
1b75580da2 | ||
|
|
c0faed2c2a | ||
|
|
bb5a26c343 | ||
|
|
be088ed78d | ||
|
|
20b6475f18 | ||
|
|
31feb4114a | ||
|
|
2280df1ea9 | ||
|
|
083039b27e | ||
|
|
d525b18f6d | ||
|
|
f5ba09ef77 | ||
|
|
3b77fb85fd | ||
|
|
b05cd178e6 | ||
|
|
f2437b6904 | ||
|
|
ba16067529 | ||
|
|
b10149fd54 | ||
|
|
3ffee1224f | ||
|
|
4b2c97df4e | ||
|
|
60a4647a43 | ||
|
|
5cb0b2f3ea |
@@ -3,4 +3,7 @@ node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
server/data
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ---- ihasmail server configuration ----
|
||||
|
||||
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
|
||||
# the JMAP session at <STALWART_URL>/.well-known/jmap.
|
||||
STALWART_URL=https://mail.example.com
|
||||
# Base URL of your mail server (scheme + host, no path). ihasmail discovers
|
||||
# the JMAP session at <MAIL_SERVER_URL>/.well-known/jmap.
|
||||
MAIL_SERVER_URL=https://mail.example.com
|
||||
|
||||
# Random secret used to derive encryption keys for persisted sessions.
|
||||
# Generate with: openssl rand -base64 48
|
||||
@@ -61,14 +61,21 @@ 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 role on the mail server manages accounts and
|
||||
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
||||
# the mail server's registry methods beyond an account's own password, app passwords
|
||||
# and settings. INBUXA Admin is not affected.
|
||||
ADMINISTRATION=1
|
||||
|
||||
# Branding
|
||||
APP_NAME=ihasmail
|
||||
|
||||
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
|
||||
# asks whoever runs a modified version to offer *that* version's source -- so if
|
||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||
# and in Settings > About.
|
||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
|
||||
# the default is this fork.
|
||||
SOURCE_URL=https://github.com/inbuxa/ihasmail-inbuxa
|
||||
|
||||
# ---- Settings this installation decides (all optional) ----
|
||||
#
|
||||
@@ -94,16 +101,16 @@ SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
# Read once at startup: editing a policy means restarting the container.
|
||||
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
|
||||
|
||||
# ---- Several Stalwart servers (optional) ----
|
||||
# ---- Several mail servers (optional) ----
|
||||
#
|
||||
# Choose the upstream by the domain someone signs in with. STALWART_URL above
|
||||
# Choose the upstream by the domain someone signs in with. MAIL_SERVER_URL above
|
||||
# stays required and stays the default; this only adds domains that go
|
||||
# elsewhere. See the shipped stalwart-servers.example.json, and mount it
|
||||
# elsewhere. See the shipped mail-servers.example.json, and mount it
|
||||
# read-only:
|
||||
#
|
||||
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||
#
|
||||
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
|
||||
# MAIL_SERVERS_FILE=/etc/ihasmail/servers.json
|
||||
#
|
||||
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
|
||||
# An unlisted domain, or a username with no domain, goes to MAIL_SERVER_URL. A
|
||||
# listed domain never falls back. Read once at startup: editing means a restart.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
#
|
||||
# There is deliberately no publish job, although publish.yml is in the tree.
|
||||
# Every tag in this repository is one of ihasmail's own upstream tags, the
|
||||
# same commits, and at those tags publish.yml pushed to ihasmail's image, not
|
||||
# an INBUXA one. A tag-driven publish here would ship plain ihasmail under the
|
||||
# INBUXA name the moment upstream tags reached this project -- which happened
|
||||
# once, by hand, and was deleted. Add one back only with a release scheme that
|
||||
# produces tags this repository alone has.
|
||||
#
|
||||
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# unreviewed can be pulled in. Read the comment for the version; the digest is
|
||||
# what runs. Do not "simplify" one back to a bare tag.
|
||||
#
|
||||
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
|
||||
# address, never through the Cloudflare-proxied public name, which caps
|
||||
# request bodies at 100 MB.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['**']
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------- test ------
|
||||
node:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
env:
|
||||
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
|
||||
steps:
|
||||
# version.test.ts shells out to git to resolve a build version, and the
|
||||
# slim image ships without it; the checkout action installs it when it
|
||||
# is missing, so it is there for the tests too. Full history, because
|
||||
# the version is computed from it.
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||
# refused. Root ignores the permission bits, so as root that assertion
|
||||
# can never hold. The tests run as the image's unprivileged `node` user
|
||||
# for that reason; -p keeps the environment.
|
||||
#
|
||||
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves
|
||||
# to IPv4 only, and the test's control case connects to a port nothing
|
||||
# is listening on. That is a runner property, so it cannot be fixed from
|
||||
# this file -- if these tests ever fail again with ECONNREFUSED on
|
||||
# 127.0.0.1, check that the runner still puts jobs on an IPv6-enabled
|
||||
# network.
|
||||
- run: chown -R node:node "$GITHUB_WORKSPACE"
|
||||
- run: su node -p -c "npm ci --ignore-scripts"
|
||||
- run: su node -p -c "npm run typecheck"
|
||||
- run: su node -p -c "npm test"
|
||||
- run: su node -p -c "npm run build"
|
||||
|
||||
# ------------------------------------------------------------- build ------
|
||||
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
|
||||
# Dockerfile builds everything itself; `needs` only keeps the order.
|
||||
docker-build:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [node]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
|
||||
docker build -t "$tag" .
|
||||
docker image rm "$tag"
|
||||
@@ -0,0 +1,4 @@
|
||||
# Funding platforms shown behind the repository's Sponsor button.
|
||||
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
github: jcoffey-dev
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Thanks for contributing to ihasmail. CONTRIBUTING.md has the full guide;
|
||||
this is the short version. Delete any section that does not apply.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changes, and why. -->
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- e.g. Closes #123. Leave blank if there are none. -->
|
||||
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
- Adds or alters user-visible strings: how many keys, and the fallback
|
||||
count before and after.
|
||||
- Adds none.
|
||||
|
||||
"Adds none" is an answer. Saying nothing is not -- it leaves it to be
|
||||
inferred. See CONTRIBUTING.md -> Translations.
|
||||
-->
|
||||
|
||||
## Testing
|
||||
|
||||
<!--
|
||||
What you ran, and what you saw. `npm run typecheck`, `npm test` and
|
||||
`npm run build` all run in CI, so the useful thing here is what CI cannot
|
||||
do: which flows you exercised by hand, and against what -- a real Stalwart
|
||||
instance, or `npm run dev:mock`.
|
||||
|
||||
If the change is visible on screen, drive the built app, not just the
|
||||
store. See CONTRIBUTING.md -> Verifying UI work.
|
||||
-->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For UI changes. Before/after, or a GIF for anything with motion. -->
|
||||
@@ -0,0 +1,44 @@
|
||||
version: 2
|
||||
updates:
|
||||
# The npm entry sits at the root because that is where the single lockfile
|
||||
# is: root, server and web are one npm workspace, so one entry covers all
|
||||
# three. Pointing entries at server/ or web/ would find package.json files
|
||||
# with no lockfile beside them and update nothing.
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
# Everything routine arrives as one PR a week, so the dashboard is not
|
||||
# the only place these get noticed. Majors are deliberately left out of
|
||||
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
|
||||
# and each deserves its own PR and its own CI run.
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
# The runtime and build stages both pin node:22-alpine, so this is what
|
||||
# keeps the published container images off a stale base between the weekly
|
||||
# releases.
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
@@ -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:
|
||||
@@ -15,10 +15,21 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||
# the release it belongs to in the trailing comment, and the repository
|
||||
# requires it -- an unpinned ref fails the run rather than quietly
|
||||
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
|
||||
# last moved it to, so trusting one is trusting every future version of
|
||||
# that action, including the one pushed by whoever compromises the
|
||||
# account. Read the comment for the version; the SHA is what runs.
|
||||
#
|
||||
# Dependabot updates both halves together on its weekly github-actions
|
||||
# run, so this costs nothing to keep current -- do not "simplify" a pin
|
||||
# back to a tag.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm run typecheck
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Prune old image versions from GHCR.
|
||||
#
|
||||
# Releases are kept forever -- they carry no assets and their generated notes
|
||||
# are this project's only changelog, so deleting one destroys history that
|
||||
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
||||
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
||||
# untagged per-architecture manifests behind each time on top of the tagged
|
||||
# index. Those accumulate and nobody wants fifty of them.
|
||||
#
|
||||
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
||||
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
||||
# manifests that a multi-arch tag points *at*, because they are untagged by
|
||||
# design. Nothing appears to break: the tag still exists, and pulls simply
|
||||
# start failing for one architecture. This action understands manifest lists
|
||||
# and will not orphan a retained index, and `validate` re-checks every
|
||||
# multi-arch manifest against the registry afterwards.
|
||||
#
|
||||
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
||||
# exactly what would be deleted without rebuilding and re-pushing an image to
|
||||
# find out.
|
||||
name: Prune images
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "List what would be deleted, delete nothing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
# The only third-party action here that is not published by GitHub or
|
||||
# Docker, and the one with the most to lose: it is handed
|
||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||
# day. It was pinned to a commit long before the rest of them were.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||
with:
|
||||
owner: Coffey-Labs
|
||||
package: ihasmail
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ten weekly releases is roughly a quarter of history, which is more
|
||||
# than enough to roll back to and far less than the year's worth that
|
||||
# would otherwise pile up. Older *releases* stay either way; this
|
||||
# only removes the images.
|
||||
keep-n-tagged: 10
|
||||
# Belt and braces on top of the action's own manifest awareness:
|
||||
# `latest` is never a candidate for deletion under any counting.
|
||||
exclude-tags: latest
|
||||
delete-untagged: true
|
||||
# Sweeps the wreckage of a half-failed run: an index whose platform
|
||||
# images did not all land, and referrers whose parent is gone.
|
||||
delete-partial-images: true
|
||||
delete-orphaned-images: true
|
||||
# Checks every remaining multi-architecture manifest still resolves
|
||||
# in the registry. This is the step that would catch the footgun
|
||||
# above rather than leaving a reader to discover it on `docker pull`.
|
||||
validate: true
|
||||
dry-run: ${{ inputs.dry_run }}
|
||||
@@ -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
|
||||
@@ -26,8 +26,25 @@ name: Publish image
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
# Callable, so release.yml can build the release it just cut. This is not a
|
||||
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
||||
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
||||
# to stop a workflow looping on its own output. A scheduled job that cut a
|
||||
# release and expected this file to notice would silently never publish. The
|
||||
# alternatives are a personal access token kept as a secret, or calling the
|
||||
# workflow directly. This is the one that needs no credential.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
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.
|
||||
@@ -46,7 +63,11 @@ env:
|
||||
# Hardcoded rather than derived from github.repository: a registry path must
|
||||
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
|
||||
# remembering to lowercase it. This is the string the docs already name.
|
||||
IMAGE: ghcr.io/coffey-labs/ihasmail
|
||||
# inbuxa: this fork publishes to INBUXA's own path. Inherited from public
|
||||
# ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that
|
||||
# here would push INBUXA's webmail over the image every public ihasmail
|
||||
# install pulls, which SPEC.md 5 exists to prevent.
|
||||
IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa
|
||||
|
||||
jobs:
|
||||
# The version is worked out once and handed to both builds, so the two
|
||||
@@ -59,13 +80,13 @@ jobs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
- id: v
|
||||
run: |
|
||||
V="$(node scripts/version.mjs)"
|
||||
@@ -91,18 +112,18 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -123,7 +144,7 @@ jobs:
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
@@ -140,13 +161,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -175,3 +196,11 @@ jobs:
|
||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||
- name: Show what landed
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
|
||||
|
||||
# Runs only after a successful publish, because that is the only moment the
|
||||
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
||||
prune:
|
||||
needs: publish
|
||||
permissions:
|
||||
packages: write
|
||||
uses: ./.github/workflows/cleanup.yml
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Cut a release once a week, but only if there is something in it.
|
||||
#
|
||||
# Releases had drifted 184 commits behind main, which made `:latest` describe
|
||||
# a build nobody was running -- the demo, prod and anyone building from source
|
||||
# were all ahead of it. Publishing on release is the right trigger only if
|
||||
# releases actually happen, so this is the part that makes that true without
|
||||
# anyone having to remember.
|
||||
#
|
||||
# It does nothing on a quiet week. A release with no commits in it is worse
|
||||
# than no release: it moves `:latest` to an identical build, spends a version
|
||||
# number, and mails everybody watching the repository about nothing.
|
||||
name: Weekly release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 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. 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:
|
||||
description: "Work out what would be released, then stop"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One at a time. Two overlapping runs would race to create the same tag, and
|
||||
# the loser fails noisily for a reason that has nothing to do with the code.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_release: ${{ steps.decide.outputs.should_release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
title: ${{ steps.decide.outputs.title }}
|
||||
sha: ${{ steps.decide.outputs.sha }}
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The newest published release, or empty on a repository that has
|
||||
# never had one -- in which case everything counts as new. Drafts are
|
||||
# excluded: an unpublished draft is not a release anybody has, so
|
||||
# counting from it would hide commits that have never shipped.
|
||||
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
||||
# A tag named by a release is normally present after a full checkout,
|
||||
# but a release can outlive its tag. Falling back to the whole
|
||||
# history is the safe direction to be wrong in: it over-counts, which
|
||||
# cuts a release that was due anyway, where under-counting would skip
|
||||
# one that was.
|
||||
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"
|
||||
fi
|
||||
|
||||
version="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain '+', and neither should the git tag,
|
||||
# so the two always agree about what to call a build.
|
||||
tag="v${version/+/-}"
|
||||
title="v${version%%+*}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
|
||||
should_release=true
|
||||
reason=""
|
||||
if [ "$count" -eq 0 ]; then
|
||||
should_release=false
|
||||
reason="no commits since ${previous}"
|
||||
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
# Same commit, different week: the version is derived from the
|
||||
# commit, so nothing new means the tag already exists.
|
||||
should_release=false
|
||||
reason="tag ${tag} already exists"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "should_release=$should_release"
|
||||
echo "tag=$tag"
|
||||
echo "title=$title"
|
||||
echo "sha=$sha"
|
||||
echo "previous=$previous"
|
||||
echo "count=$count"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Written to the run summary so a skipped week reads as a decision
|
||||
# rather than as a workflow that quietly did nothing.
|
||||
{
|
||||
echo "### Weekly release"
|
||||
echo
|
||||
if [ "$should_release" = "true" ]; then
|
||||
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
|
||||
else
|
||||
echo "Nothing to release: ${reason}."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cut:
|
||||
needs: check
|
||||
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--target "${{ needs.check.outputs.sha }}"
|
||||
--title "${{ needs.check.outputs.title }}"
|
||||
--generate-notes)
|
||||
# Bound the notes to what is actually new. Without a start tag the
|
||||
# generator reaches back to whatever it decides is previous, which on
|
||||
# a repository with older tag shapes is not always the last release.
|
||||
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
||||
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
||||
fi
|
||||
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
||||
|
||||
# Called rather than left to the `release` trigger on purpose: see the note
|
||||
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
||||
# event, so without this the tag would exist and no image would follow it.
|
||||
publish:
|
||||
needs: [check, cut]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/publish.yml
|
||||
with:
|
||||
ref: ${{ needs.check.outputs.sha }}
|
||||
tag_latest: true
|
||||
@@ -1,11 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
server/data/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Worktrees used by parallel agents; never part of a commit.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# CI for the self-hosted GitLab that replaced GitHub Actions when the account
|
||||
# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml,
|
||||
# kept in the tree for reference and for the day the appeal succeeds.
|
||||
#
|
||||
# There is deliberately no publish job, although publish.yml is in the tree.
|
||||
# Every tag in this repository is one of ihasmail's own upstream tags, the same
|
||||
# commits, and at those tags publish.yml pushed to ihasmail's image, not an
|
||||
# INBUXA one. A tag-driven publish here would ship plain ihasmail under the
|
||||
# INBUXA name the moment upstream tags reached this project -- which happened
|
||||
# once, by hand, and was deleted. Add one back only with a release scheme that
|
||||
# produces tags this repository alone has.
|
||||
#
|
||||
# Every `image:` here is pinned to a digest, with the tag it belonged to in the
|
||||
# trailing comment. That is the direct replacement for the SHA-pinned `uses:`
|
||||
# in the Actions workflows: GitLab has no equivalent of an action allowlist, so
|
||||
# the only thing standing between this pipeline and whatever the publisher
|
||||
# pushes to a tag next is the digest. Read the comment for the version; the
|
||||
# digest is what runs. Do not "simplify" one back to a bare tag.
|
||||
#
|
||||
# The runner is a group runner on Web_Host with the host docker socket bound
|
||||
# in, reached over the internal container network rather than
|
||||
# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free
|
||||
# plan, which caps request bodies at 100 MB and would break artifact uploads.
|
||||
|
||||
stages: [test, build]
|
||||
|
||||
variables:
|
||||
GIT_DEPTH: "0"
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
||||
# ---------------------------------------------------------------- test ------
|
||||
node:
|
||||
stage: test
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
variables:
|
||||
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
|
||||
cache:
|
||||
key:
|
||||
files: [package-lock.json]
|
||||
paths: [.npm/]
|
||||
before_script:
|
||||
# version.test.ts shells out to git to resolve a build version, and the
|
||||
# slim image ships without it. The clone is done by the runner's helper
|
||||
# image, so nothing else here needs git and its absence is easy to miss.
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
|
||||
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||
# refused. Root ignores the permission bits, so as root that assertion can
|
||||
# never hold. The tests run as the image's unprivileged `node` user for
|
||||
# that reason; -p keeps the environment.
|
||||
#
|
||||
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to
|
||||
# IPv4 only, and the test's control case connects to a port nothing is
|
||||
# listening on. That is a runner property, so it cannot be fixed from this
|
||||
# file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1,
|
||||
# check that the runner still puts jobs on an IPv6-enabled network.
|
||||
- chown -R node:node "$CI_PROJECT_DIR"
|
||||
script:
|
||||
- su node -p -c "npm ci --ignore-scripts"
|
||||
- su node -p -c "npm run typecheck"
|
||||
- su node -p -c "npm test"
|
||||
- su node -p -c "npm run build"
|
||||
artifacts:
|
||||
paths: [dist/]
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
# --------------------------------------------------------------- build ------
|
||||
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step.
|
||||
#
|
||||
# Not called `image`: that is a reserved keyword, and a job by that name is
|
||||
# silently read as the global image: setting instead ("image name should be a
|
||||
# string"). Same trap for `stages`, `cache`, `services` and `variables`.
|
||||
docker-build:
|
||||
stage: build
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
needs: [node]
|
||||
script:
|
||||
- docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA .
|
||||
- docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
@@ -47,3 +47,104 @@ bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c
|
||||
link #2959aa
|
||||
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
|
||||
yellow #8f5e15 · teal #33635c · green #385f0d
|
||||
|
||||
---
|
||||
|
||||
Fetched 2026-09-06 from the projects' own repositories, same rule as above.
|
||||
Where a project publishes fewer background tiers than ihasmail needs, the
|
||||
missing one is derived and marked **derived** here rather than passed off as
|
||||
upstream. Body text is lifted to 7:1 by the build script for most of these —
|
||||
they target their own ~4.5:1 — and every shift is printed in the generated CSS.
|
||||
|
||||
## Catppuccin — catppuccin/palette, MIT (palette.json)
|
||||
Cited from the palette repo rather than the hub README; it is the normative
|
||||
machine-readable source.
|
||||
|
||||
### Mocha (dark)
|
||||
base #1e1e2e · mantle #181825 · crust #11111b · surface0 #313244 · surface1 #45475a
|
||||
text #cdd6f4 · subtext0 #a6adc8 · overlay1 #7f849c
|
||||
mauve #cba6f7 · blue #89b4fa · red #f38ba8 · peach #fab387 · green #a6e3a1
|
||||
yellow #f9e2af · pink #f5c2e7
|
||||
|
||||
### Latte (light)
|
||||
base #eff1f5 · mantle #e6e9ef · crust #dce0e8 · surface0 #ccd0da · surface1 #bcc0cc
|
||||
text #4c4f69 · subtext0 #6c6f85
|
||||
mauve #8839ef · blue #1e66f5 · red #d20f39 · peach #fe640b · green #40a02b
|
||||
yellow #df8e1d · pink #ea76cb
|
||||
|
||||
Latte publishes no tier lighter than `base`, so `base` is used as the elevated
|
||||
surface and `mantle` as the page behind it.
|
||||
|
||||
## Solarized — altercation/solarized, MIT (README "The Values")
|
||||
base03 #002b36 · base02 #073642 · base01 #586e75 · base00 #657b83
|
||||
base0 #839496 · base1 #93a1a1 · base2 #eee8d5 · base3 #fdf6e3
|
||||
yellow #b58900 · orange #cb4b16 · red #dc322f · magenta #d33682
|
||||
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 colors #0d4552 and #e6dfc8.
|
||||
|
||||
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
|
||||
### Dark
|
||||
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
|
||||
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 · 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 color Everforest calls its background.
|
||||
|
||||
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
|
||||
### Wave (dark)
|
||||
sumiInk0 #16161D · sumiInk3 #1F1F28 · sumiInk4 #2A2A37 · sumiInk5 #363646
|
||||
fujiWhite #DCD7BA · fujiGray #727169
|
||||
crystalBlue #7E9CD8 · springBlue #7FB4CA · samuraiRed #E82424 · roninYellow #FF9E3B
|
||||
springGreen #98BB6C · carpYellow #E6C384 · sakuraPink #D27E99
|
||||
|
||||
### Lotus (light)
|
||||
lotusWhite0 #d5cea3 · lotusWhite1 #dcd5ac · lotusWhite2 #e5ddb0 · lotusWhite3 #f2ecbc
|
||||
lotusInk1 #545464 · lotusGray2 #716e61
|
||||
lotusViolet4 #624c83 · lotusBlue4 #4d699b · lotusRed #c84053 · lotusOrange #cc6d00
|
||||
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 organization's
|
||||
MIT-licensed ayu-theme/vscode-ayu build.
|
||||
|
||||
### Dark
|
||||
surface base #0D1017 · lift #10141C (sunk is `base -L0.1`, **derived** here as #070a0f)
|
||||
ui line #1B1F29 · ui fg #5A6378 · editor fg #BFBDB6
|
||||
red #F07178 · orange #FF8F40 · yellow #FFB454 · green #AAD94C · teal #95E6CB
|
||||
indigo #39BAE6 · blue #59C2FF · purple #D2A6FF · accent #E6B450 (vscode-ayu)
|
||||
|
||||
### Light
|
||||
surface sunk #EBEEF0 · base #F8F9FA · lift #FCFCFC
|
||||
ui fg #828E9F · editor fg #5C6166 · rule #dfe2e5 (**derived**)
|
||||
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 color values are MIT; "GitHub"
|
||||
and the Invertocat are trademarks, and nothing here is endorsed by them.
|
||||
|
||||
### Dark
|
||||
neutral #0D1117 #151B23 #212830 #262C36 #2A313C #2F3742 #3D444D #656C76
|
||||
#9198A1 #B7BDC8 #D1D7E0 #F0F6FC · black #010409
|
||||
blue #79c0ff #58a6ff · green #56d364 #3fb950 · yellow #e3b341 #d29922
|
||||
red #ff7b72 · purple #d2a8ff
|
||||
|
||||
### Light
|
||||
neutral #F6F8FA #EFF2F5 #E6EAEF #E0E6EB #DAE0E7 #D1D9E0 #C8D1DA #818B98
|
||||
#59636E #454C54 #393F46 #25292E
|
||||
blue #0969da #0550ae · green #1a7f37 #116329 · yellow #bf8700 #9a6700
|
||||
red #cf222e · purple #8250df
|
||||
|
||||
@@ -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 the **INBUXA webmail**, an immutable, JMAP-only webmail client for the INBUXA mail server, built on ihasmail. Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -9,7 +9,7 @@ By participating in this project, you agree to treat other contributors with res
|
||||
## Before You Start
|
||||
|
||||
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
|
||||
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first.
|
||||
- ihasmail has **no database of its own** — all state lives on the mail server, over JMAP. Contributions should not introduce a separate persistence layer without discussion first.
|
||||
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
|
||||
|
||||
## How to Contribute
|
||||
@@ -21,16 +21,16 @@ Before opening a new issue, please search [existing issues](https://github.com/C
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
- Expected behavior vs. actual behavior
|
||||
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.)
|
||||
- Your environment: browser/OS, mail server version, and how ihasmail is deployed (Docker, bare metal, etc.)
|
||||
- Relevant logs, console errors, or screenshots
|
||||
- Whether the issue is reproducible against a fresh Stalwart instance
|
||||
- Whether the issue is reproducible against a fresh mail server
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
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.
|
||||
@@ -41,13 +41,28 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
||||
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
|
||||
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
|
||||
4. **Write clear commit messages** describing what changed and why.
|
||||
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle.
|
||||
5. **Test your changes** against a real (or local) mail server where possible, since JMAP behavior can be subtle.
|
||||
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
|
||||
7. **Open the pull request** against `main`, filling out the PR template with:
|
||||
- A summary of the change
|
||||
- Related issue number(s), if any
|
||||
- Screenshots/GIFs for UI changes
|
||||
- Any manual testing you performed
|
||||
8. **Add translations** for any new user-visible string — see
|
||||
[Translations](#translations) below — and **drive the built app** for any
|
||||
change that is visible on screen, as described in
|
||||
[Verifying UI work](#verifying-ui-work).
|
||||
|
||||
`main` is protected. A change reaches it through a pull request whose **build**
|
||||
check has passed — not afterwards — and the branch cannot be force-pushed or
|
||||
deleted. No approving review is required, so a PR of your own is not blocked
|
||||
waiting for one.
|
||||
|
||||
**CI on a PR from a fork waits to be approved.** Every workflow run on an
|
||||
outside contributor's branch sits at *awaiting approval* until a maintainer
|
||||
starts it by hand, so the **build** check will not appear the moment you open
|
||||
the PR — that is the gate working, not a broken run. Pushing again will not
|
||||
start it, and neither will closing and reopening.
|
||||
|
||||
### Code Style
|
||||
|
||||
@@ -56,6 +71,56 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
||||
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
|
||||
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
|
||||
|
||||
### Translations
|
||||
|
||||
Nine languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
|
||||
`web/src/locales/`. A missing key renders its English source rather than
|
||||
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
|
||||
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 catalog key for a plural is the `other` form
|
||||
|
||||
`plural()` looks the entry up by `forms.other`, so a call site written as
|
||||
|
||||
```ts
|
||||
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||
```
|
||||
|
||||
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 catalog health; exits 1 on a finding
|
||||
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
|
||||
```
|
||||
|
||||
Compare the "falling back to English" number against `main` before and after.
|
||||
It should not rise. Do not read the percentage instead — adding keys moves the
|
||||
denominator, so it can hold steady while new strings go untranslated.
|
||||
|
||||
Plural forms are per language, from `Intl.PluralRules`: `one`/`other` for most,
|
||||
`one`/`few`/`many`/`other` for Russian and Ukrainian, `other` alone for Japanese
|
||||
and Chinese. Supplying a form a language does not draw is inventing a
|
||||
distinction, not being thorough.
|
||||
|
||||
### Verifying UI work
|
||||
|
||||
Store tests do not exercise the component. At least one bug in this repo's
|
||||
history — a shift-click range measured inside a `setState` updater, which React
|
||||
runs after the anchor ref has already moved — passed every store assertion and
|
||||
failed the moment the built app was driven. If a change is visible on screen,
|
||||
run it: `npm run dev:mock` (the mock mail server, credentials printed on start), then
|
||||
drive the real thing. Add a component test for what you find; there are
|
||||
examples in `web/src/views/*/__tests__/`.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Clone your fork:
|
||||
@@ -63,10 +128,82 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
||||
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 INBUXA 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 # a real mail server (MAIL_SERVER_URL in .env) — server :8080, Vite :5173
|
||||
npm run dev:mock # built-in mock mail server ([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)──► mail server
|
||||
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 the mail server'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 mail server for development and demos.
|
||||
|
||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||
`quota`, `blob`, `filenode`, EventSource push, plus the mail server's own
|
||||
registry capability. Features degrade gracefully when one is missing.
|
||||
|
||||
#### The mock
|
||||
|
||||
An in-memory fake mail server — 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: the registry capability advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking the server'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 registry 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 a server without metrics history does |
|
||||
| `MOCK_EDITION=enterprise` | Reports the `enterprise` edition, for code that still reads it |
|
||||
|
||||
It tracks the current mail server release, 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 the mail server on
|
||||
purpose: the server's own version is its own business. 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:26-alpine AS build
|
||||
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
||||
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
||||
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
||||
@@ -25,7 +25,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- runtime stage ----
|
||||
FROM node:22-alpine AS runtime
|
||||
FROM node:26-alpine AS runtime
|
||||
# Re-declared: an ARG does not cross stages.
|
||||
ARG IHASMAIL_VERSION=""
|
||||
ARG BASE_PATH=""
|
||||
@@ -37,16 +37,28 @@ ENV NODE_ENV=production \
|
||||
IHASMAIL_VERSION=$IHASMAIL_VERSION \
|
||||
BASE_PATH=$BASE_PATH
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY server/package.json server/
|
||||
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
||||
# set it never looks further; without it, it falls back to package.json rather
|
||||
# than failing, since there is no git in here to ask.
|
||||
COPY scripts/ ./scripts/
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
# Only what the server loads at runtime: hono and its Node adapter, about 4 MB.
|
||||
# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that
|
||||
# never executes here but shipped anyway -- and showed up in every CVE scan.
|
||||
RUN npm ci --ignore-scripts --omit=dev --workspace server \
|
||||
&& rm -rf /root/.npm /tmp/*
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
RUN mkdir -p /data && chown -R node:node /data /app
|
||||
# /data is the only path the process may write. /app stays root-owned and
|
||||
# read-only to the runtime user on purpose; the previous `chown -R /app`
|
||||
# re-wrote every file and, on overlayfs, duplicated the whole tree into a
|
||||
# second 173 MB layer.
|
||||
RUN mkdir -p /data && chown node:node /data \
|
||||
# The base image ships a package manager the server never calls. Anyone who
|
||||
# gets code execution should not find one waiting for them.
|
||||
&& rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
|
||||
/usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
USER node
|
||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Known issues and pending QA
|
||||
|
||||
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.20**, upgraded from 0.16.19 on 2026-08-31 with
|
||||
eight seconds of downtime, and as of **2026-08-26 there is nothing left
|
||||
pending**. Every entry below was exercised against 0.16.19 on the date it
|
||||
names, and the dates still say so: the upgrade was read against the
|
||||
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
|
||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
||||
The calendar entries below carrying a 2026-08-31 date are the exception: those
|
||||
were exercised against the live 0.16.20 directly.
|
||||
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.
|
||||
|
||||
Entries keep saying what was checked and when, because this section has been
|
||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
||||
capability looked for in the wrong place meant it had never run at all.
|
||||
|
||||
Some entries record what a live **0.15.5** proved before that server was
|
||||
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
||||
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.
|
||||
|
||||
- **`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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **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 points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||
@@ -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
|
||||
|
||||
Four 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
|
||||
Ten of the palettes offered in Settings › Appearance are the work of their own
|
||||
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,28 +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 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 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 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 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 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 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 four 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"),
|
||||
|
||||
@@ -1,422 +1,104 @@
|
||||
<p align="center">
|
||||
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
|
||||
<img src="web/public/img/inbuxa-mark.png" alt="" width="110">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong><a href="https://demo.ihasmail.com">Try the demo</a></strong><br>
|
||||
<sub>A working copy with an invented mailbox behind it — no sign-up, nothing real, nothing kept.</sub>
|
||||
</p>
|
||||
<h1 align="center">INBUXA webmail</h1>
|
||||
|
||||
<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.20" src="https://img.shields.io/badge/Stalwart-0.16.20-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>
|
||||
<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>
|
||||
</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.**
|
||||
|
||||
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.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
|
||||
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
|
||||
| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs |
|
||||
| 🧪 **[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 |
|
||||
|
||||
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).
|
||||
The webmail of the INBUXA suite: mail, calendars, contacts, files and filters
|
||||
in one app that works as well on a phone as on a desktop. It talks only JMAP to
|
||||
the INBUXA mail server, and keeps nothing of its own: everything durable,
|
||||
settings included, lives on the server, so the container is disposable.
|
||||
|
||||
## 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
|
||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
||||
- **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
|
||||
- **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**, stored on the mail server.
|
||||
- **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 to an account whose role allows it.
|
||||
Everything else is in INBUXA Admin.
|
||||
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
||||
never handles a password to sign someone in, and holds only sealed tokens.
|
||||
- **Ten interface languages and twelve themes.**
|
||||
|
||||
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/).
|
||||
## Configuration
|
||||
|
||||
## Requires Stalwart 0.16 or newer
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MAIL_SERVER_URL` | How this webmail reaches the mail server. |
|
||||
| `APP_SECRET` | A long random secret for sealing sessions. Required in production. |
|
||||
| `OAUTH_CLIENT_SECRET` | Turns on sign-in through the server's page. The secret of the confidential client the server registers for this webmail: on the server, the same value as `INBUXA_WEBMAIL_CLIENT_SECRET`. |
|
||||
| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what the server registers. |
|
||||
| `PUBLIC_URL` | Where browsers reach the webmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI, `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
|
||||
| `MAIL_SERVERS_FILE` | Optional: several mail servers, picked by the account's domain. See `mail-servers.example.json`. |
|
||||
| `ADMIN_URL` | Optional: where INBUXA Admin is, for the dashboard's link. |
|
||||
| `APP_NAME` | What the webmail calls itself. Default `INBUXA`, shown as the INBUXA wordmark; any other name shows as text. |
|
||||
|
||||
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.
|
||||
`.env.example` lists the rest.
|
||||
|
||||
- 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.
|
||||
On the mail server, set `INBUXA_WEBMAIL_URL` to the webmail's address (with
|
||||
`BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
|
||||
The server registers the client on start and allows the webmail's origin for
|
||||
cross-origin requests.
|
||||
|
||||
With one mail server, the sign-in page asks for no address, only whether this
|
||||
is the person's own device. The server's page asks for the rest. With several,
|
||||
the address comes first, since its domain picks the server.
|
||||
|
||||
A password change revokes the server's tokens, so it signs the person out
|
||||
everywhere, this session included.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||
# edit: MAIL_SERVER_URL, APP_SECRET, and for server sign-in OAUTH_CLIENT_SECRET and PUBLIC_URL
|
||||
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
|
||||
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.
|
||||
## Source code
|
||||
|
||||
Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
|
||||
<https://github.com/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings ›
|
||||
About link there, beside the version, which names the commit the running build
|
||||
came from.
|
||||
|
||||
### Container images
|
||||
|
||||
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`:
|
||||
|
||||
```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.
|
||||
Run your own patched build and that offer becomes yours, not ours: point
|
||||
`SOURCE_URL` at your tree and both links follow it.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node ≥ 20.10 (22 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
|
||||
npm run dev:mock # the built-in mock mail server ([email protected] / demo)
|
||||
npm test
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||
build. Running it for real is covered in
|
||||
[Installing](https://docs.ihasmail.org/install/) and
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
The mock also answers OAuth. Start it and the webmail with
|
||||
`OAUTH_CLIENT_SECRET=mock-oauth-secret` and a `PUBLIC_URL`, and its sign-in
|
||||
page approves the demo user at once.
|
||||
|
||||
### The mock
|
||||
Architecture, the mock's switches and how versions are numbered are in
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
|
||||
|
||||
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. Two 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.
|
||||
## Built on ihasmail
|
||||
|
||||
### 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.
|
||||
|
||||
```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 .
|
||||
```
|
||||
|
||||
`.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.
|
||||
|
||||
## Contributing
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
||||
The INBUXA webmail is built on [ihasmail](https://github.com/Coffey-Labs/ihasmail),
|
||||
Coffey Labs' own webmail, which stays an independent product. The public
|
||||
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
|
||||
keep up. Nothing here is pushed there.
|
||||
|
||||
## 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
|
||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
||||
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Roadmap / not yet
|
||||
|
||||
Things ihasmail does not do, and why. An issue number here says where the entry
|
||||
came from, not that it is tracked elsewhere — a report can be closed because the
|
||||
bug in it was fixed while the larger thing it asked for stays on this page. What
|
||||
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
|
||||
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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -22,13 +22,13 @@ Please include as much of the following as you can:
|
||||
- A description of the vulnerability and its potential impact
|
||||
- Steps to reproduce, or a proof-of-concept
|
||||
- The version/commit of ihasmail affected
|
||||
- The version of Stalwart Mail Server you were testing against, if relevant
|
||||
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency
|
||||
- The version of the mail server you were testing against, if relevant
|
||||
- Whether the issue is in the webmail itself, in how it talks to the mail server over JMAP, or in a dependency
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
|
||||
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code.
|
||||
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on the mail server's store over JMAP, some reports may need to be routed to or coordinated with the mail server's own project if the root cause lives there rather than in ihasmail's client code.
|
||||
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
|
||||
|
||||
### Scope
|
||||
@@ -42,9 +42,9 @@ In scope:
|
||||
|
||||
Out of scope (please report upstream instead):
|
||||
|
||||
- Vulnerabilities in Stalwart Mail Server itself — report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server)
|
||||
- Vulnerabilities in the INBUXA mail server itself: report those to the mail server's own project
|
||||
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
|
||||
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance
|
||||
- Issues requiring physical access to a user's device or an already-compromised mail server
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,14 +9,27 @@ services:
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
# Loopback only: ihasmail expects a TLS reverse proxy in front of it. On
|
||||
# every interface the app is reachable over plain HTTP, passwords and all,
|
||||
# and with TRUST_PROXY any machine on a private network can set its own
|
||||
# X-Forwarded-For. A proxy running in Docker can reach the service by name
|
||||
# on the compose network and needs no published port at all.
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# The app needs no privileges and writes only to /data and /tmp.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
MAIL_SERVER_URL: ${MAIL_SERVER_URL:?set MAIL_SERVER_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/inbuxa/ihasmail-inbuxa}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Optional: which mail server a domain signs in to.",
|
||||
"",
|
||||
"MAIL_SERVER_URL stays required and stays the default. This file only adds",
|
||||
"domains that go somewhere else -- delete it and nothing changes.",
|
||||
"",
|
||||
"Point at it with MAIL_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
||||
"it read-only. Read once at startup, so editing it means restarting.",
|
||||
"",
|
||||
"A domain that is not listed here, and a bare username with no domain at",
|
||||
"all, go to MAIL_SERVER_URL. A domain that IS listed never falls back: if its",
|
||||
"server is unreachable that sign-in fails, because falling back would",
|
||||
"authenticate somebody against a server their domain was routed away from.",
|
||||
"",
|
||||
"Keys are lower-cased and stripped of a trailing dot when read. Malformed",
|
||||
"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\": ...}.",
|
||||
"ADMIN_URL is the same for the default server. A listed domain is",
|
||||
"never pointed at the default server's administration.",
|
||||
"",
|
||||
"Each listed domain signs in to its own server; everything else goes to MAIL_SERVER_URL."
|
||||
],
|
||||
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": { "url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test" }
|
||||
}
|
||||
@@ -6,6 +6,31 @@ server {
|
||||
|
||||
client_max_body_size 60m;
|
||||
|
||||
# Compression. The bundle is the bulk of first load -- about 933 KB
|
||||
# uncompressed against 311 KB gzipped -- and nginx passes through anything
|
||||
# the upstream already encoded rather than re-encoding it, so this is
|
||||
# correct whether or not ihasmail compresses on its own.
|
||||
#
|
||||
# text/event-stream is deliberately absent from gzip_types: the push stream
|
||||
# must not be compressed or buffered, which is also why proxy_buffering is
|
||||
# off below.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
# text/javascript is listed explicitly: ihasmail serves scripts with that
|
||||
# type rather than application/javascript, so a conventional gzip_types
|
||||
# list compresses the stylesheet and leaves the largest asset alone.
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"web"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.10"
|
||||
"node": ">=20.19"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||
@@ -20,14 +20,15 @@
|
||||
"test": "npm run test -w web && npm run test -w server",
|
||||
"lint": "npm run typecheck",
|
||||
"mock": "npm run mock -w server",
|
||||
"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\"",
|
||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"MAIL_SERVER_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\" \"MAIL_SERVER_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",
|
||||
"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\""
|
||||
"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\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.7.3"
|
||||
"concurrently": "^10.0.5",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-ast": "npm:typescript@^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -163,6 +163,90 @@ SOURCES = {
|
||||
q1="#006c86", q2="#385f0d", q3="#65359d",
|
||||
),
|
||||
},
|
||||
"catppuccin": {
|
||||
"dark": dict( # Mocha
|
||||
bg="#1e1e2e", elev="#313244", sunken="#181825", line="#45475a",
|
||||
fg="#cdd6f4", muted="#a6adc8", accent="#cba6f7", link="#89b4fa",
|
||||
danger="#f38ba8", warn="#fab387", success="#a6e3a1", star="#f9e2af",
|
||||
q1="#89b4fa", q2="#a6e3a1", q3="#f5c2e7",
|
||||
),
|
||||
"light": dict( # Latte
|
||||
bg="#e6e9ef", elev="#eff1f5", sunken="#dce0e8", line="#ccd0da",
|
||||
fg="#4c4f69", muted="#6c6f85", accent="#8839ef", link="#1e66f5",
|
||||
danger="#d20f39", warn="#fe640b", success="#40a02b", star="#df8e1d",
|
||||
q1="#1e66f5", q2="#40a02b", q3="#ea76cb",
|
||||
),
|
||||
},
|
||||
"solarized": {
|
||||
"dark": dict(
|
||||
bg="#002b36", elev="#073642", sunken="#001f28", line="#0d4552",
|
||||
fg="#839496", muted="#586e75", accent="#268bd2", link="#2aa198",
|
||||
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
||||
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
||||
),
|
||||
"light": dict(
|
||||
bg="#fdf6e3", elev="#fffdf6", sunken="#eee8d5", line="#e6dfc8",
|
||||
fg="#657b83", muted="#93a1a1", accent="#268bd2", link="#2aa198",
|
||||
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
||||
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
||||
),
|
||||
},
|
||||
"ayu": {
|
||||
"dark": dict(
|
||||
bg="#0d1017", elev="#10141c", sunken="#070a0f", line="#1b1f29",
|
||||
fg="#bfbdb6", muted="#5a6378", accent="#e6b450", link="#59c2ff",
|
||||
danger="#f07178", warn="#ff8f40", success="#aad94c", star="#ffb454",
|
||||
q1="#39bae6", q2="#aad94c", q3="#d2a6ff",
|
||||
),
|
||||
"light": dict(
|
||||
bg="#f8f9fa", elev="#fcfcfc", sunken="#ebeef0", line="#dfe2e5",
|
||||
fg="#5c6166", muted="#828e9f", accent="#f29718", link="#22a4e6",
|
||||
danger="#f07171", warn="#fa8532", success="#86b300", star="#eba400",
|
||||
q1="#55b4d4", q2="#86b300", q3="#a37acc",
|
||||
),
|
||||
},
|
||||
"kanagawa": {
|
||||
"dark": dict( # Wave
|
||||
bg="#1f1f28", elev="#2a2a37", sunken="#16161d", line="#363646",
|
||||
fg="#dcd7ba", muted="#727169", accent="#7e9cd8", link="#7fb4ca",
|
||||
danger="#e82424", warn="#ff9e3b", success="#98bb6c", star="#e6c384",
|
||||
q1="#7fb4ca", q2="#98bb6c", q3="#d27e99",
|
||||
),
|
||||
"light": dict( # Lotus
|
||||
bg="#e5ddb0", elev="#f2ecbc", sunken="#dcd5ac", line="#d5cea3",
|
||||
fg="#545464", muted="#716e61", accent="#624c83", link="#4d699b",
|
||||
danger="#c84053", warn="#cc6d00", success="#6f894e", star="#77713f",
|
||||
q1="#4d699b", q2="#6f894e", q3="#b35b79",
|
||||
),
|
||||
},
|
||||
"everforest": {
|
||||
"dark": dict( # medium
|
||||
bg="#2d353b", elev="#343f44", sunken="#232a2e", line="#475258",
|
||||
fg="#d3c6aa", muted="#859289", accent="#a7c080", link="#7fbbb3",
|
||||
danger="#e67e80", warn="#e69875", success="#a7c080", star="#dbbc7f",
|
||||
q1="#7fbbb3", q2="#a7c080", q3="#d699b6",
|
||||
),
|
||||
"light": dict( # medium
|
||||
bg="#efebd4", elev="#fdf6e3", sunken="#e6e2cc", line="#bdc3af",
|
||||
fg="#5c6a72", muted="#939f91", accent="#8da101", link="#3a94c5",
|
||||
danger="#f85552", warn="#f57d26", success="#8da101", star="#dfa000",
|
||||
q1="#3a94c5", q2="#8da101", q3="#df69ba",
|
||||
),
|
||||
},
|
||||
"primer": {
|
||||
"dark": dict(
|
||||
bg="#0d1117", elev="#151b23", sunken="#010409", line="#3d444d",
|
||||
fg="#f0f6fc", muted="#9198a1", accent="#58a6ff", link="#79c0ff",
|
||||
danger="#ff7b72", warn="#e3b341", success="#3fb950", star="#d29922",
|
||||
q1="#79c0ff", q2="#56d364", q3="#d2a8ff",
|
||||
),
|
||||
"light": dict(
|
||||
bg="#f6f8fa", elev="#ffffff", sunken="#eff2f5", line="#d1d9e0",
|
||||
fg="#25292e", muted="#59636e", accent="#0969da", link="#0550ae",
|
||||
danger="#cf222e", warn="#9a6700", success="#1a7f37", star="#bf8700",
|
||||
q1="#0550ae", q2="#116329", q3="#8250df",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# What each token has to clear, and against which surface. Normal text is 4.5;
|
||||
@@ -177,12 +261,21 @@ 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 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 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"])
|
||||
|
||||
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
|
||||
# Between muted and the background, but still readable: this is timestamps
|
||||
# and counts, which are small and still prose.
|
||||
@@ -272,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"]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Write a Brotli and a gzip copy beside every compressible file in a web build.
|
||||
*
|
||||
* The server used to gzip the bundle again on every request that asked for it,
|
||||
* at a level chosen for speed. These are made once, at the level chosen for
|
||||
* size, and `server/src/static.ts` hands one out when the browser accepts it.
|
||||
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
|
||||
* do per request, which is why it was never offered.
|
||||
*
|
||||
* node scripts/precompress.mjs web/dist
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
|
||||
|
||||
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
|
||||
// Below this, the encoding costs more than it saves.
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
function* files(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* files(p);
|
||||
else yield p;
|
||||
}
|
||||
}
|
||||
|
||||
const root = process.argv[2];
|
||||
if (!root) {
|
||||
console.error("usage: precompress.mjs <dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
let count = 0;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const p of files(root)) {
|
||||
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
|
||||
const data = readFileSync(p);
|
||||
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
|
||||
writeFileSync(`${p}.br`, br);
|
||||
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
|
||||
count++;
|
||||
before += data.length;
|
||||
after += br.length;
|
||||
}
|
||||
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
|
||||
@@ -5,8 +5,8 @@
|
||||
* images already use rather than whatever a window happens to be.
|
||||
*
|
||||
* npm run dev:mock # in another terminal
|
||||
* node docs/screenshots.mjs docs/screenshots
|
||||
* node docs/screenshots-light.mjs docs/screenshots
|
||||
* node scripts/screenshots.mjs screenshots
|
||||
* node scripts/screenshots-light.mjs screenshots
|
||||
*
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
@@ -30,7 +30,7 @@
|
||||
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
||||
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
||||
* at --window-size and never calling setDeviceMetricsOverride renders it
|
||||
* correctly, which is what docs/screenshots-light.mjs does.
|
||||
* correctly, which is what scripts/screenshots-light.mjs does.
|
||||
*
|
||||
* assertTheme() stays either way: without it this script wrote a dark
|
||||
* screenshot under a light caption and reported success, and that is how the
|
||||
@@ -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"
|
||||
@@ -226,7 +226,7 @@ try {
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
// (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
|
||||
// (inbox-light is captured by scripts/screenshots-light.mjs -- see the header)
|
||||
|
||||
|
||||
// --- calendar ---
|
||||
@@ -16,12 +16,12 @@
|
||||
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"hono": "^4.7.4"
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"hono": "^4.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/node": "^26.5.1",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const PORT = 18797;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-account-flows";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||
assert.ok(created.body.id);
|
||||
@@ -84,8 +84,86 @@ test("app passwords are created, listed once with their secret, and revoked", as
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("an app password needs the account password", async () => {
|
||||
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.error, "missing_fields");
|
||||
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.equal(wrong.body.error, "invalid_credentials");
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
|
||||
});
|
||||
|
||||
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
|
||||
// Signed in without "my own device", so the proxy reads every request.
|
||||
const res = await call("/api/jmap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
|
||||
assert.equal(up.status, 200);
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
|
||||
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store");
|
||||
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
test("a download passes a byte range through, for viewers that read in pieces", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
|
||||
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
|
||||
assert.equal(part.status, 206);
|
||||
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
|
||||
assert.equal(part.headers.get("accept-ranges"), "bytes");
|
||||
assert.equal(await part.text(), "hello");
|
||||
const whole = await app.request(url, { headers: { cookie } });
|
||||
assert.equal(whole.status, 200);
|
||||
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
|
||||
assert.equal(await whole.text(), "hello world");
|
||||
// Past the end, Stalwart sends the whole file rather than a 416.
|
||||
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
|
||||
assert.equal(beyond.status, 200);
|
||||
assert.equal(await beyond.text(), "hello world");
|
||||
// Anything that is not a plain byte range is not passed on.
|
||||
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
|
||||
assert.equal(odd.status, 200);
|
||||
await odd.arrayBuffer();
|
||||
});
|
||||
|
||||
test("upstream caches let go of sessions that have aged out", async () => {
|
||||
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
|
||||
// Signed in above, so this session has an entry.
|
||||
assert.ok(upstreamCacheSizes().sessions >= 1);
|
||||
sweepUpstreamCaches(Date.now() + 60 * 60_000);
|
||||
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
||||
});
|
||||
|
||||
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
|
||||
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
|
||||
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
|
||||
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
|
||||
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
|
||||
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
|
||||
} }, "s"]]);
|
||||
assert.equal(res.status, 200);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
|
||||
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
|
||||
assert.ok(set.created.inline.id, "a data URI is accepted");
|
||||
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
|
||||
});
|
||||
|
||||
test("an app password needs a name", async () => {
|
||||
const res = await post("/api/account/app-passwords", { description: " " });
|
||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error, "missing_fields");
|
||||
});
|
||||
@@ -156,7 +234,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!);
|
||||
|
||||
@@ -72,7 +72,7 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
|
||||
if (!res.ok) throw new UpstreamError(`The mail server rejected the request (${res.status})`, 502);
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
@@ -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,86 @@
|
||||
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 can be read", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
|
||||
});
|
||||
|
||||
test("but not written: a credential minted here would outlive a borrowed session", () => {
|
||||
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
|
||||
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
|
||||
}
|
||||
});
|
||||
|
||||
test("API keys are not the account's to reach from here at all", () => {
|
||||
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
|
||||
});
|
||||
|
||||
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,97 @@
|
||||
/**
|
||||
* 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", "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 = "", op = ""] = name.slice(2).split("/");
|
||||
/*
|
||||
* Read, never write. The browser sends none of these itself -- password,
|
||||
* app-password and 2FA changes go through /api/account, which checks the
|
||||
* account password first -- so a write here could only come from
|
||||
* somebody working the console of a session on a borrowed machine, and
|
||||
* `x:AppPassword/set` would hand them a credential that outlives it.
|
||||
*/
|
||||
if (!SELF_SERVICE.has(object) || op === "set") 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.MAIL_SERVER_URL = "https://default.example";
|
||||
process.env.ADMIN_URL = "https://admin.default.example/";
|
||||
process.env.MAIL_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. MAIL_SERVER_URL is how this
|
||||
* server reaches 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("../../mail-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);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
test("CSRF guard rejects API POSTs without the custom header", async () => {
|
||||
@@ -111,7 +111,7 @@ test("only a PDF blob may be framed, and only by us", async () => {
|
||||
/*
|
||||
* #239: retrying through an outage must not lock somebody out of the recovery.
|
||||
*
|
||||
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
|
||||
* MAIL_SERVER_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
|
||||
* so every sign-in here is the outage case. Before the fix, the eleventh of
|
||||
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
|
||||
* had actually been wrong.
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { compress } from "hono/compress";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
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 { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { fetchPermissions } from "./permissionSchema.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||
import { safeEqual } from "./crypto.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
@@ -17,12 +26,13 @@ import {
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
upstreamFor,
|
||||
adminUrlFor,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
AccountError,
|
||||
assertEnrolmentCode,
|
||||
beginOtpEnrolment,
|
||||
assertEnrollmentCode,
|
||||
beginOtpEnrollment,
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
@@ -31,6 +41,7 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
@@ -59,6 +70,19 @@ const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_00
|
||||
* cannot get the whole deployment banned.
|
||||
*/
|
||||
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
||||
const apiLimiter = new RateLimiter(config.apiRateLimit, 60_000);
|
||||
|
||||
/** Per-session budget on the data path. See config.apiRateLimit. */
|
||||
const apiRateLimited: MiddlewareHandler<Env> = async (c, next) => {
|
||||
if (config.apiRateLimit > 0) {
|
||||
const session = c.get("session");
|
||||
if (session && !apiLimiter.check(session.id)) {
|
||||
c.header("Retry-After", String(apiLimiter.retryAfterSeconds(session.id)));
|
||||
return c.json({ error: "rate_limited" }, 429);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
@@ -109,7 +133,76 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
|
||||
};
|
||||
|
||||
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
||||
/**
|
||||
* Routes that forward somebody else's bytes rather than producing our own.
|
||||
*
|
||||
* Compression is right for the app shell, the bundle and our JSON; it is not
|
||||
* worth the risk on the proxy paths. Those carry a content-length copied from
|
||||
* upstream under the rules in `forwardedContentLength`, and issue #76 was a
|
||||
* silent truncation caused by exactly that header disagreeing with the body.
|
||||
* Re-encoding them would be safe in principle -- the length is dropped and the
|
||||
* response goes out chunked -- but the payloads are attachments, images and
|
||||
* calendar data that are already compressed or too small to matter, so there
|
||||
* is nothing to win and a scar to respect.
|
||||
*
|
||||
* `/api/events` needs no entry here: Hono skips `text/event-stream` by content
|
||||
* type. It is listed anyway, because a future change to that route's type
|
||||
* should not quietly start buffering the push stream.
|
||||
*/
|
||||
const UNCOMPRESSED_ROUTES = [
|
||||
"/api/blob/",
|
||||
"/api/image",
|
||||
"/api/ics",
|
||||
"/api/upload/",
|
||||
"/api/events",
|
||||
/*
|
||||
* The liveness probe, which is small enough that gzip makes it bigger: 53
|
||||
* bytes becomes 73. Hono's size threshold cannot catch this on its own,
|
||||
* because it only applies when the response carries a content-length and
|
||||
* `c.json()` does not set one. Every other JSON route is left compressed --
|
||||
* a JMAP response can run to hundreds of kilobytes and its length is just as
|
||||
* unknown -- so this is the one place worth naming.
|
||||
*/
|
||||
"/api/health",
|
||||
];
|
||||
|
||||
/**
|
||||
* gzip for what we generate.
|
||||
*
|
||||
* The bundle ships uncompressed otherwise: 915 KB on the wire where 307 KB
|
||||
* would do, on every first load. `Caddyfile.example` and
|
||||
* `nginx.example.conf` both compress at the proxy, but that only helps the
|
||||
* deployments that use them, and the default should not depend on reading the
|
||||
* examples.
|
||||
*
|
||||
* Hono's middleware declines anything already carrying `Content-Encoding` or
|
||||
* `Transfer-Encoding`, so a proxy compressing in front of us wins and we do
|
||||
* not double-encode.
|
||||
*/
|
||||
function compressResponses(basePath: string): MiddlewareHandler {
|
||||
const inner = compress({ threshold: 1024 });
|
||||
const skip = UNCOMPRESSED_ROUTES.map((r) => `${basePath}${r}`);
|
||||
if (!config.compressJmap) skip.push(`${basePath}/api/jmap`);
|
||||
const offersEncoding = /\b(gzip|deflate)\b/i;
|
||||
return async (c, next) => {
|
||||
/*
|
||||
* A client that did not ask for an encoding must not pay for one. Hono's
|
||||
* middleware still inspects and re-labels every compressible response it
|
||||
* declines -- setting Vary forces a streamed passthrough to be rebuilt off
|
||||
* its fast path -- and that was measured at 1.2 ms per JMAP call, on a
|
||||
* 1.9 ms operation, for a request that never sent Accept-Encoding.
|
||||
*/
|
||||
if (!offersEncoding.test(c.req.header("accept-encoding") ?? "")) return next();
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (skip.some((prefix) => path.startsWith(prefix))) return next();
|
||||
return inner(c, next);
|
||||
};
|
||||
}
|
||||
|
||||
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
// The mail server's sign-in page sends the browser back here, so this one
|
||||
// arrives cross-site by design. Its state, bound to a cookie, stands in.
|
||||
if (c.req.method === "GET" && c.req.path.endsWith("/api/auth/callback")) return next();
|
||||
const site = c.req.header("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") {
|
||||
return c.json({ error: "cross_site_request" }, 403);
|
||||
@@ -122,9 +215,38 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* The largest body an API route that reads JSON will take.
|
||||
*
|
||||
* Hono reads a JSON body whole, and before this nothing bounded it: a few
|
||||
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
|
||||
* run the process out of memory, and a restart signs everybody out. What
|
||||
* these routes actually receive is a username and password, or a code.
|
||||
*
|
||||
* JMAP and uploads carry real payloads and bound themselves as they stream;
|
||||
* the push callback has its own limit ahead of this one.
|
||||
*/
|
||||
const MAX_SMALL_BODY = 64 * 1024;
|
||||
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
|
||||
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
|
||||
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
|
||||
|
||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
let session = sessions.resolve(cookie);
|
||||
if (session?.tokens && needsRefresh(session.tokens)) {
|
||||
try {
|
||||
session = await refreshSession(cookie!, session);
|
||||
} catch (err) {
|
||||
// Couldn't ask the server. The token may still have a few minutes; if
|
||||
// not, the call itself will say so.
|
||||
console.warn("[ihasmail] token refresh failed:", (err as Error).message);
|
||||
}
|
||||
if (!session) {
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
@@ -132,6 +254,55 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/*
|
||||
* One refresh per session at a time: a page opening does several requests at
|
||||
* once, and each would otherwise renew the same token.
|
||||
*/
|
||||
const refreshing = new Map<string, Promise<LiveSession | null>>();
|
||||
|
||||
/**
|
||||
* Renew an OAuth session's access token and keep the new one. Null when the
|
||||
* server refused the refresh token (a password change revokes it), which
|
||||
* ends the session.
|
||||
*/
|
||||
function refreshSession(cookie: string, session: LiveSession): Promise<LiveSession | null> {
|
||||
let inFlight = refreshing.get(session.id);
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
const renewed = await refreshTokens(upstreamFor(session.username), session.tokens!);
|
||||
if (!renewed) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
return null;
|
||||
}
|
||||
sessions.updateTokens(cookie, renewed);
|
||||
return sessions.resolve(cookie);
|
||||
})().finally(() => refreshing.delete(session.id));
|
||||
refreshing.set(session.id, inFlight);
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* What push keeps to renew an account's subscription long after the session
|
||||
* that started it. A password is good until it changes; OAuth tokens get a
|
||||
* copy that renews itself, since push outlives any one access token.
|
||||
*/
|
||||
export function pushCredential(session: LiveSession): { get(): Promise<string> } {
|
||||
if (!session.tokens) {
|
||||
const authorization = session.authorization;
|
||||
return { get: async () => authorization };
|
||||
}
|
||||
let tokens: TokenSet = session.tokens;
|
||||
const base = upstreamFor(session.username);
|
||||
return {
|
||||
async get() {
|
||||
if (needsRefresh(tokens)) tokens = (await refreshTokens(base, tokens)) ?? tokens;
|
||||
return `Bearer ${tokens.access}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the session cookie to the mount, not the whole host.
|
||||
*
|
||||
@@ -178,11 +349,29 @@ function upstreamFailure(c: Context, err: unknown) {
|
||||
export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
app.use("*", compressResponses(basePath));
|
||||
|
||||
const api = new Hono<Env>();
|
||||
api.use("*", csrfGuard);
|
||||
api.use("*", smallBodies);
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
||||
|
||||
/*
|
||||
* Stalwart's push delivery. Authenticated by the token in the path -- 32
|
||||
* random bytes, one per account, known only to us and to Stalwart -- and by
|
||||
* nothing else, since Stalwart carries no credential when it POSTs. An
|
||||
* unknown token is a 404 that looks like any other. See push.ts.
|
||||
*/
|
||||
app.post(`${basePath}/api/push/:token`, async (c) => {
|
||||
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.body(null, 415);
|
||||
const len = Number(c.req.header("content-length") ?? "0");
|
||||
if (!len || len > 64 * 1024) return c.body(null, 413);
|
||||
let body: unknown;
|
||||
try { body = await c.req.json(); } catch { return c.body(null, 400); }
|
||||
return c.body(null, (await pushReceive(c.req.param("token"), body)) as 200 | 400 | 404 | 500);
|
||||
});
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
|
||||
|
||||
api.get("/config", (c) =>
|
||||
c.json({
|
||||
@@ -193,12 +382,96 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
/* Sent before sign-in like the rest of this: it says what the
|
||||
installation has decided, not anything about who is asking. */
|
||||
settingsPolicy: config.settingsPolicy,
|
||||
/* "oauth": sign in on the mail server's own page (see oauth.ts). */
|
||||
signIn: oauthEnabled() ? "oauth" : "password",
|
||||
/* With "oauth": true when the server's page can take it from here, so
|
||||
the sign-in form doesn't ask for an address first. */
|
||||
signInDirect: oauthEnabled() && singleServer(),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------- Auth ----------
|
||||
/*
|
||||
* Sign-in through the mail server's page. `start` sends the browser there;
|
||||
* `callback` is where the server sends it back. See oauth.ts.
|
||||
*/
|
||||
const OAUTH_STATE_COOKIE = `${config.cookieName}_signin`;
|
||||
|
||||
api.get("/auth/oauth/start", async (c) => {
|
||||
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||
const rateIp = rateLimitKey(clientIp(c));
|
||||
if (!loginFloodLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
console.warn("[ihasmail] could not start sign-in:", (err as Error).message);
|
||||
return c.redirect(`${basePath}/?signin_error=unavailable`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
api.get("/auth/callback", async (c) => {
|
||||
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||
const boundState = getCookie(c, OAUTH_STATE_COOKIE);
|
||||
deleteCookie(c, OAUTH_STATE_COOKIE, { path: `${basePath}/api/auth` });
|
||||
const fail = (code: string) => c.redirect(`${basePath}/?signin_error=${code}`, 302);
|
||||
const rateIp = rateLimitKey(clientIp(c));
|
||||
if (!loginFloodLimiter.check(rateIp)) return fail("rate_limited");
|
||||
const state = c.req.query("state") ?? "";
|
||||
const code = c.req.query("code") ?? "";
|
||||
// The server's page sends `error` when the person cancels or is refused.
|
||||
if (!code || c.req.query("error")) return fail("cancelled");
|
||||
let result;
|
||||
try {
|
||||
result = await finishSignIn({ state, boundState, code });
|
||||
} catch (err) {
|
||||
if (err instanceof SignInError) return fail(err.code);
|
||||
console.warn("[ihasmail] sign-in exchange failed:", (err as Error).message);
|
||||
return fail("unavailable");
|
||||
}
|
||||
const authorization = `Bearer ${result.tokens.access}`;
|
||||
try {
|
||||
const upstream = await fetchUpstreamSession(authorization, result.base);
|
||||
if (!hasStalwartRegistry(upstream)) return fail("unsupported_server");
|
||||
const username = upstream.username || result.username;
|
||||
// Every later call finds the account's server from its name. If the
|
||||
// server signed in an account that routes elsewhere, calls would go to
|
||||
// the wrong server, so refuse it.
|
||||
if (upstreamFor(username) !== result.base) return fail("wrong_account");
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(result.base, username),
|
||||
tokens: result.tokens,
|
||||
remember: result.remember,
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
} catch (err) {
|
||||
console.warn("[ihasmail] sign-in failed after the exchange:", (err as Error).message);
|
||||
return fail("unavailable");
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/auth/login", async (c) => {
|
||||
// With sign-in on the mail server's page, this form never sees a password.
|
||||
if (oauthEnabled()) return c.json({ error: "oauth_required", message: "Sign in on the mail server's page." }, 403);
|
||||
const ip = clientIp(c);
|
||||
// What the limits count under: the address, or its /64 for IPv6.
|
||||
const rateIp = rateLimitKey(ip);
|
||||
// The flood ceiling needs nothing from the body, so it goes before reading one.
|
||||
if (!loginFloodLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
@@ -214,7 +487,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
/*
|
||||
* Three checks, answering different questions.
|
||||
*
|
||||
* `limitKey` is this username from this address, and `ip` is any username
|
||||
* `limitKey` is this username from this address, and `rateIp` is any username
|
||||
* from it -- both guard guessing, and both are given back when the upstream
|
||||
* never got as far as judging the password. Refunding only the first would
|
||||
* not fix #239: ten retries through an outage would still spend the address
|
||||
@@ -224,12 +497,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
* The flood ceiling is the one that is never refunded, and it is the reason
|
||||
* the other two safely can be.
|
||||
*/
|
||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
||||
if (!loginFloodLimiter.check(ip)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
||||
const limitKey = `${rateIp}|${username.toLowerCase()}`;
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
@@ -247,12 +516,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// The credentials were accepted; only the server is too old. Not an
|
||||
// attempt worth counting against them.
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
message:
|
||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
||||
"Your credentials are fine, but this mail server isn't one this webmail supports.",
|
||||
},
|
||||
501,
|
||||
);
|
||||
@@ -260,12 +529,17 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||
password: effectivePassword,
|
||||
remember: Boolean(body.remember),
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
// Start the account's push subscription now, so it is usually verified
|
||||
// by the time the browser opens its stream. See push.ts.
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
@@ -290,20 +564,20 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
{
|
||||
error: "totp_unsupported",
|
||||
message:
|
||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
||||
"This mail server does not accept two-factor codes from this form. Sign in with an app password instead. Your password and code are probably fine.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
/*
|
||||
* 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).
|
||||
*/
|
||||
if (!(err instanceof UpstreamError && err.status === 401)) {
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -337,12 +611,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
api.get("/auth/sessions", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
|
||||
});
|
||||
|
||||
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
const n = sessions.destroyAllForUser(session.username, session.id);
|
||||
const n = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ revoked: n });
|
||||
});
|
||||
|
||||
@@ -354,7 +628,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 MAIL_SERVER_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 };
|
||||
};
|
||||
|
||||
@@ -366,8 +644,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||
@@ -400,12 +678,20 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
if (session.tokens) {
|
||||
// The server revokes every token when the password changes, this
|
||||
// session's included, so there is nothing to keep: sign in again.
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account);
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ ok: true, revokedSessions: revoked - 1, signedOut: true });
|
||||
}
|
||||
// The old password is now dead: re-seal this session with the new one and
|
||||
// drop the others, whose sealed copies would fail on their next call.
|
||||
const otpCode = body.otpCode?.trim();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -419,12 +705,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* An app password is a credential that outlives this session, a password
|
||||
* change and a sign-out -- so minting one asks for the account password, as
|
||||
* changing the password does. Otherwise a session left open on somebody
|
||||
* else's machine is enough to take a permanent key away from it.
|
||||
*/
|
||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
|
||||
const limited = guarded(c, "app-password");
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ description?: string }>(c);
|
||||
const body = await readJson<{ description?: string; current?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
const current = body.current ?? "";
|
||||
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
|
||||
if (!(await confirmsPassword(session, current))) {
|
||||
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
|
||||
}
|
||||
try {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
@@ -447,7 +747,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);
|
||||
}
|
||||
@@ -472,10 +772,20 @@ 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);
|
||||
}
|
||||
if (session.tokens) {
|
||||
// Signed in on the server's page, where two-factor is asked for, so
|
||||
// nothing here needs moving onto an app password.
|
||||
try {
|
||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||
@@ -499,7 +809,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// Other sessions still hold the bare password and will be refused.
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -514,6 +824,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
if (session.tokens) return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||
// This session may be running on the app password minted when 2FA went on;
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
@@ -522,12 +833,51 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- JMAP API proxy ----------
|
||||
api.post("/jmap", requireSession, async (c) => {
|
||||
api.post("/jmap", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
const ct = c.req.header("content-type") ?? "";
|
||||
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)) {
|
||||
const held = gatedReads.get(session.id) ?? 0;
|
||||
if (held >= MAX_GATED_PER_SESSION) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "rate_limited" }, 429);
|
||||
}
|
||||
gatedReads.set(session.id, held + 1);
|
||||
let raw: string;
|
||||
try {
|
||||
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
|
||||
} catch (err) {
|
||||
if (err instanceof GatedBudgetError) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "busy" }, 503);
|
||||
}
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
} finally {
|
||||
const left = (gatedReads.get(session.id) ?? 1) - 1;
|
||||
if (left > 0) gatedReads.set(session.id, left);
|
||||
else gatedReads.delete(session.id);
|
||||
}
|
||||
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), {
|
||||
@@ -537,7 +887,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),
|
||||
});
|
||||
@@ -553,6 +903,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");
|
||||
@@ -583,7 +957,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- Blob download ----------
|
||||
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
||||
api.get("/blob/:accountId/:blobId/:name", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
const { accountId, blobId, name } = c.req.param();
|
||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||
@@ -591,23 +965,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||
// A PDF viewer or a video element asks for pieces; pass that on. A server
|
||||
// that ignores it answers with the whole file, as it did before.
|
||||
const range = c.req.header("range");
|
||||
const res = await fetch(url, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (res.status === 416) return c.body(null, 416);
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const partial = res.status === 206 && res.headers.get("content-range");
|
||||
if (partial) headers.set("Content-Range", partial);
|
||||
/*
|
||||
* Said here because Stalwart does not say it. It honors a single byte
|
||||
* range but sends no `Accept-Ranges` (0.16.22, checked live on
|
||||
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
|
||||
* the first response advertises it. A server that ignores a range sends
|
||||
* the whole file, which the browser takes just as well.
|
||||
*/
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||
@@ -626,8 +1014,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} else {
|
||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||
}
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
// Kept out of the browser's disk cache on a device that is not the
|
||||
// person's own: signing out wipes what the app stores, not that.
|
||||
/*
|
||||
* A blob id names its content -- the same id is the same bytes for good
|
||||
* -- so on the reader's own device there is nothing to revalidate. On
|
||||
* anyone else's, nothing is left in the disk cache at all.
|
||||
*/
|
||||
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
|
||||
return new Response(res.body, { status: partial ? 206 : 200, headers });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -642,6 +1037,18 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||
// Subscribe mode: if this account's subscription is verified, the tab is
|
||||
// served by fan-out and holds nothing upstream. Otherwise it gets its own
|
||||
// relay, and is moved to fan-out the moment the account verifies.
|
||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||
if (accountId && pushAttach(session.username, accountId, pushCredential(session), out)) {
|
||||
out.writeHead(200, SSE_HEADERS);
|
||||
out.flushHeaders();
|
||||
out.write(": subscribed\n\n");
|
||||
return RESPONSE_ALREADY_SENT;
|
||||
}
|
||||
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization, session.username);
|
||||
const controller = new AbortController();
|
||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||
const res = await fetch(url, {
|
||||
@@ -662,10 +1069,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
api.get("/image", requireSession, imageProxyHandler);
|
||||
api.get("/image", requireSession, apiRateLimited, imageProxyHandler);
|
||||
// Behind the session for the same reason the image proxy is: an open fetcher
|
||||
// on someone else's server is a gift to whoever finds it.
|
||||
api.get("/ics", requireSession, icsProxyHandler);
|
||||
api.get("/ics", requireSession, apiRateLimited, icsProxyHandler);
|
||||
|
||||
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||
api.onError((err, c) => {
|
||||
@@ -700,6 +1107,71 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `candidate` the password of the account this session is signed in to?
|
||||
*
|
||||
* Compared with the credential the session holds first, which costs nothing
|
||||
* and tells Stalwart nothing -- its auto-ban counts failures against the
|
||||
* proxy's address, which every user shares. That credential is the password,
|
||||
* with a TOTP code after a `$` when one was given at sign-in. A session that
|
||||
* turning on 2FA moved onto an app password (Stalwart's secrets start
|
||||
* `$app$`) holds something else, and only then is the candidate put to the
|
||||
* server.
|
||||
*/
|
||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||
if (session.tokens) {
|
||||
// Holding no password, the only judge is the server.
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
||||
const held = decoded.slice(decoded.indexOf(":") + 1);
|
||||
if (safeEqual(held, candidate)) return true;
|
||||
const withoutCode = held.replace(/\$\d{6,8}$/, "");
|
||||
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
|
||||
// Holding the password, the comparison above is the answer, and a wrong
|
||||
// guess never reaches the server's auto-ban.
|
||||
if (!held.startsWith("$app$")) return false;
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a two-factor change on a token session: whether the server still
|
||||
* honors this session's token. If it revoked it, end the session here too,
|
||||
* so the web app can send the person to sign in again.
|
||||
*/
|
||||
async function afterCredentialChange(c: Context<Env>, session: LiveSession): Promise<{ signedOut: boolean }> {
|
||||
forgetUpstreamSession(session.id);
|
||||
try {
|
||||
await fetchUpstreamSession(session.authorization, upstreamFor(session.username));
|
||||
return { signedOut: false };
|
||||
} catch (err) {
|
||||
if (!(err instanceof UpstreamError && err.status === 401)) return { signedOut: false };
|
||||
sessions.destroyAllForUser(session.account);
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return { signedOut: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
||||
* read as a PDF in the downloads list. A filename has no use for them.
|
||||
*/
|
||||
function withoutBidiControls(name: string): string {
|
||||
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
|
||||
}
|
||||
|
||||
/** Name the app password after the browser it will live in. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
@@ -707,7 +1179,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,
|
||||
@@ -717,10 +1189,39 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
sessionId: session.id,
|
||||
loginName: session.username,
|
||||
remember: session.remember,
|
||||
/** "oauth": signed in on the mail server's page, holding tokens, not a password. */
|
||||
signIn: session.tokens ? "oauth" : "password",
|
||||
/** 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 : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -730,8 +1231,132 @@ 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.
|
||||
*
|
||||
* Only sessions that may not administer come this way, and what the client
|
||||
* sends is small: attachments and pasted images go through `/upload`, and the
|
||||
* composer turns inline images into uploads before a draft is saved. Stalwart
|
||||
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
|
||||
* held here as a string, parsed and serialized again, so each one costs
|
||||
* several times its size; 4 MB is far past anything the client sends.
|
||||
*/
|
||||
const MAX_GATED_REQUEST = 4 * 1024 * 1024;
|
||||
/**
|
||||
* How many checked requests one session may have in flight at once. Matches
|
||||
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
|
||||
* already stays within.
|
||||
*/
|
||||
const MAX_GATED_PER_SESSION = 4;
|
||||
/**
|
||||
* The bytes all checked requests together may hold at once. Counted as they
|
||||
* arrive rather than reserved up front, so a slow body that has sent little
|
||||
* holds little, and a burst of large ones is turned away with a 503 instead of
|
||||
* taking the process down.
|
||||
*/
|
||||
const GATED_BUDGET = 32 * 1024 * 1024;
|
||||
const gatedReads = new Map<string, number>();
|
||||
let gatedBytes = 0;
|
||||
|
||||
class GatedBudgetError extends Error {}
|
||||
|
||||
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
let mine = 0;
|
||||
const counted = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
mine += chunk.byteLength;
|
||||
gatedBytes += chunk.byteLength;
|
||||
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
|
||||
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await new Response(stream.pipeThrough(counted)).text();
|
||||
} finally {
|
||||
gatedBytes -= mine;
|
||||
}
|
||||
}
|
||||
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
/**
|
||||
* Hold a push stream open with the least machinery that will do it.
|
||||
*
|
||||
* The fetch() version above builds an undici Response, a web ReadableStream,
|
||||
* a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of
|
||||
* it alive for as long as the tab is open. Measured against a real Stalwart
|
||||
* that is about 44 KiB of JavaScript heap per tab -- twelve times what the
|
||||
* session itself costs -- and a signed-in tab is otherwise nothing but this
|
||||
* one held connection. Here the upstream socket is piped straight into the
|
||||
* Node response, so what stays resident per tab is two sockets and their
|
||||
* 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 serialized.
|
||||
*/
|
||||
const SSE_HEADERS = {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
} as const;
|
||||
|
||||
function relayPushRaw(c: Context<Env>, url: string, authorization: string, username?: string): Response {
|
||||
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||
const target = new URL(url);
|
||||
const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, {
|
||||
method: "GET",
|
||||
headers: { authorization, accept: "text/event-stream" },
|
||||
});
|
||||
const signal = c.req.raw.signal;
|
||||
const abort = () => req.destroy();
|
||||
signal.addEventListener("abort", abort);
|
||||
out.on("close", abort);
|
||||
const fail = () => {
|
||||
if (!out.headersSent) {
|
||||
out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
out.end(JSON.stringify({ error: "upstream_error" }));
|
||||
} else {
|
||||
out.end();
|
||||
}
|
||||
};
|
||||
/*
|
||||
* Once this account's subscription verifies, the upstream request goes and
|
||||
* the browser stream below is served by fan-out instead. Three things have
|
||||
* to be true for that to be seamless: the browser must already have its
|
||||
* headers (verification can beat the upstream response); nothing may treat
|
||||
* the torn-down upstream as an error; and nothing may keep a reference to
|
||||
* it -- the request, its response and this handler's context are exactly
|
||||
* the per-tab weight the subscription exists to shed.
|
||||
*/
|
||||
let migrated = false;
|
||||
const migrate = () => {
|
||||
migrated = true;
|
||||
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
|
||||
signal.removeEventListener("abort", abort);
|
||||
out.removeListener("close", abort);
|
||||
req.removeAllListeners();
|
||||
req.on("error", () => {});
|
||||
req.destroy();
|
||||
};
|
||||
if (username) pushAttachRelay(username, out, migrate);
|
||||
req.on("response", (res) => {
|
||||
if (migrated) { res.destroy(); return; }
|
||||
if (res.statusCode !== 200) { res.resume(); fail(); return; }
|
||||
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
|
||||
// end: false -- the browser stream outlives the upstream if we migrate.
|
||||
res.pipe(out, { end: false });
|
||||
res.on("end", () => { if (!migrated) out.end(); });
|
||||
res.on("error", () => { if (!migrated) out.end(); });
|
||||
});
|
||||
req.on("error", () => { if (!migrated) fail(); });
|
||||
req.end();
|
||||
// Tells @hono/node-server the raw ServerResponse has been written to and
|
||||
// must be left alone.
|
||||
return RESPONSE_ALREADY_SENT;
|
||||
}
|
||||
|
||||
function passthrough(res: Response): Response {
|
||||
const headers = new Headers();
|
||||
res.headers.forEach((v, k) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -97,3 +97,21 @@ export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: Trus
|
||||
const real = headers.realIp?.trim();
|
||||
return real && isIP(real) !== 0 ? real : peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a rate limit counts an address under.
|
||||
*
|
||||
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
|
||||
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
|
||||
* address holds 2^64 of them, and a limit keyed on the full address is no
|
||||
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
|
||||
* IPv4 NAT already makes.
|
||||
*/
|
||||
export function rateLimitKey(ip: string): string {
|
||||
if (isIP(ip) !== 6) return ip;
|
||||
const bits = toBits(ip);
|
||||
if (!bits) return ip;
|
||||
const prefix = bits.value >> 64n;
|
||||
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
|
||||
return `${groups.join(":")}::/64`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/*
|
||||
* A static root of our own, built before the app is imported.
|
||||
*
|
||||
* CI runs `npm test` before `npm run build`, so `web/dist` does not exist when
|
||||
* these run: pointing at it would serve the "web build not found" fallback,
|
||||
* which is short, plain text and rightly uncompressed. That failure looked
|
||||
* exactly like compression being broken.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-compress-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
const script = `/* ${"x".repeat(40_000)} */\n`;
|
||||
writeFileSync(join(root, "assets", "app.js"), script);
|
||||
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
test("an asset is gzipped when the client asks for it", async () => {
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.match(res.headers.get("vary") ?? "", /accept-encoding/i);
|
||||
});
|
||||
|
||||
test("a client that does not ask for gzip does not get it", async () => {
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
|
||||
test("gzip actually makes the asset smaller", async () => {
|
||||
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||
const gz = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } })).arrayBuffer();
|
||||
assert.ok(gz.byteLength < plain.byteLength / 2, `${gz.byteLength} should be well under ${plain.byteLength}`);
|
||||
});
|
||||
|
||||
test("a gzipped response decodes to the bytes we would have sent plain", async () => {
|
||||
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||
const decoded = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
|
||||
assert.deepEqual(Buffer.from(decoded), Buffer.from(plain));
|
||||
});
|
||||
|
||||
test("the app shell is gzipped", async () => {
|
||||
const res = await createApp().request("/", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
});
|
||||
|
||||
test("proxy routes that forward upstream bytes are never compressed", async () => {
|
||||
// Unauthenticated, so these stop at 401 -- enough to prove the middleware
|
||||
// declines the path, which is what issue #76 was about.
|
||||
const app = createApp();
|
||||
for (const path of ["/api/blob/a/b/c.pdf", "/api/image?url=https://example.com/x.png", "/api/ics?url=https://example.com/x.ics"]) {
|
||||
const res = await app.request(path, { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.headers.get("content-encoding"), null, `${path} must not be compressed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the push stream is never compressed", async () => {
|
||||
const res = await createApp().request("/api/events", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
|
||||
test("the liveness probe is not compressed, since gzip would make it bigger", async () => {
|
||||
const res = await createApp().request("/api/health", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
|
||||
test("advertised upstream URLs are pinned to the configured origin", async () => {
|
||||
const { absoluteUpstream } = await import("./upstream.js");
|
||||
const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080");
|
||||
assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*");
|
||||
// A relative URL still resolves against the base, as before.
|
||||
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
|
||||
});
|
||||
|
||||
test("the data path is rate limited per session, and login stays on its own budget", async () => {
|
||||
// No session: every call is refused before the limiter, so it must never 429.
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await app.request("/api/jmap", { method: "POST",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
|
||||
assert.equal(res.status, 401);
|
||||
}
|
||||
// The limiter itself: a fresh key gets its budget and nothing more.
|
||||
const { RateLimiter } = await import("./ratelimit.js");
|
||||
const l = new RateLimiter(3, 60_000);
|
||||
assert.deepEqual([l.check("s1"), l.check("s1"), l.check("s1"), l.check("s1")], [true, true, true, false]);
|
||||
assert.ok(l.retryAfterSeconds("s1") >= 1);
|
||||
assert.equal(l.check("s2"), true, "another session is not affected");
|
||||
});
|
||||
|
||||
test("a response to a client that offered no encoding is not touched by the compressor", async () => {
|
||||
const res = await createApp().request("/assets/app.js"); // no Accept-Encoding at all
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
assert.equal(res.headers.get("vary"), null, "no Vary: the middleware never ran");
|
||||
});
|
||||
@@ -57,7 +57,7 @@ if (!appSecret || appSecret === "change-me") {
|
||||
);
|
||||
}
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
const stalwartUrl = env("MAIL_SERVER_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Declares that this instance is running as an immutable container: read-only
|
||||
@@ -190,7 +190,7 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
||||
/**
|
||||
* Which Stalwart a domain signs in to.
|
||||
*
|
||||
* `STALWART_URL` stays required and stays the default; this only adds domains
|
||||
* `MAIL_SERVER_URL` stays required and stays the default; this only adds domains
|
||||
* that go somewhere else (#238). An installation that sets nothing behaves
|
||||
* exactly as it always has.
|
||||
*
|
||||
@@ -202,47 +202,83 @@ 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> {
|
||||
const file = process.env.STALWART_SERVERS_FILE;
|
||||
if (!file) return {};
|
||||
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
|
||||
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||
const file = process.env.MAIL_SERVERS_FILE;
|
||||
if (!file) return { urls: {}, adminUrls: {} };
|
||||
if (!existsSync(file)) throw new Error(`MAIL_SERVERS_FILE does not exist: ${file}`);
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||
throw new Error(`Invalid MAIL_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`);
|
||||
throw new Error(`Invalid MAIL_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`);
|
||||
if (!domain) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): a domain key is empty`);
|
||||
if (domain in out) throw new Error(`Invalid MAIL_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 MAIL_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
out[domain] = httpUrl(value.url, `MAIL_SERVERS_FILE (${file}): "${domain}"`);
|
||||
if (value.adminUrl !== undefined) {
|
||||
if (typeof value.adminUrl !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
|
||||
adminUrls[domain] = httpUrl(value.adminUrl, `MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl`);
|
||||
}
|
||||
}
|
||||
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(rawUrl);
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
|
||||
throw new Error(`Invalid ${where}: not an absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
out[domain] = rawUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return out;
|
||||
|
||||
const stalwartServers = readStalwartServers();
|
||||
|
||||
/*
|
||||
* Signing in through the mail server's own page. On when OAUTH_CLIENT_SECRET
|
||||
* is set: the secret of the confidential client the server registers for this
|
||||
* webmail (INBUXA registers `ihasmail-inbuxa` from INBUXA_WEBMAIL_URL and
|
||||
* INBUXA_WEBMAIL_CLIENT_SECRET). PUBLIC_URL is where browsers reach ihasmail,
|
||||
* without BASE_PATH; the redirect URI is built from it and must match the one
|
||||
* registered exactly.
|
||||
*/
|
||||
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
|
||||
const publicUrl = process.env.PUBLIC_URL ? httpUrl(process.env.PUBLIC_URL, "PUBLIC_URL") : "";
|
||||
if (oauthClientSecret && !publicUrl) {
|
||||
throw new Error("OAUTH_CLIENT_SECRET is set but PUBLIC_URL is not: the sign-in redirect needs ihasmail's public address");
|
||||
}
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
appName: env("APP_NAME", "INBUXA"),
|
||||
settingsPolicy: readSettingsPolicy(),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
@@ -256,9 +292,10 @@ export const config = {
|
||||
*
|
||||
* The AGPL asks whoever *runs* a modified version to offer that version's
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree.
|
||||
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself
|
||||
* such a tree, so the default is INBUXA's fork.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/inbuxa/ihasmail-inbuxa"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
/**
|
||||
@@ -275,7 +312,27 @@ 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 MAIL_SERVER_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.ADMIN_URL ? httpUrl(process.env.ADMIN_URL, "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),
|
||||
/** See the note above `config`. Empty keeps the password form. */
|
||||
oauthClientSecret,
|
||||
oauthClientId: env("OAUTH_CLIENT_ID", "ihasmail-inbuxa"),
|
||||
publicUrl,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
@@ -295,9 +352,41 @@ 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),
|
||||
/*
|
||||
* Requests per minute one session may make on the data path -- JMAP, blobs,
|
||||
* the image and calendar proxies. The proxy is one Node process and saturates
|
||||
* a core at roughly 2,000 operations a second, so without this a single
|
||||
* signed-in user can deny service to everyone else. 1,200 a minute is twenty
|
||||
* a second sustained: well above what a busy tab does, and an order of
|
||||
* magnitude below where one tab starts to hurt the rest. 0 disables it.
|
||||
*/
|
||||
apiRateLimit: int("API_RATE_LIMIT", 1200),
|
||||
/* Whether JMAP responses are gzipped. Measured: see the bake-off rerun. */
|
||||
compressJmap: process.env.COMPRESS_JMAP !== "0",
|
||||
/*
|
||||
* How push reaches the browser. "relay" holds one upstream stream per tab
|
||||
* (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.
|
||||
* An account that cannot be verified stays on the relay.
|
||||
*/
|
||||
pushMode: (process.env.PUSH_MODE === "relay" ? "relay" : "subscribe") as "relay" | "subscribe",
|
||||
pushUrl: process.env.PUSH_URL || "",
|
||||
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
|
||||
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
|
||||
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
|
||||
followAdvertisedUrls: process.env.MAIL_SERVER_FOLLOW_ADVERTISED_URLS === "1",
|
||||
};
|
||||
|
||||
export type Config = typeof config;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.STALWART_URL = "https://default.example";
|
||||
process.env.MAIL_SERVER_URL = "https://default.example";
|
||||
|
||||
const { upstreamFor } = await import("./upstream.js");
|
||||
const { config } = await import("./config.js");
|
||||
@@ -9,7 +9,7 @@ const { config } = await import("./config.js");
|
||||
/**
|
||||
* Which Stalwart a username goes to (#238).
|
||||
*
|
||||
* `STALWART_URL` is required and is the default. The mapping only adds domains
|
||||
* `MAIL_SERVER_URL` is required and is the default. The mapping only adds domains
|
||||
* that go elsewhere, so an installation with no mapping behaves exactly as it
|
||||
* always has -- which is what these first cases pin.
|
||||
*/
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
process.env.APP_SECRET = "test-secret-for-ics-proxy";
|
||||
|
||||
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
process.env.APP_SECRET = "test-secret-for-image-proxy";
|
||||
|
||||
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
|
||||
@@ -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
|
||||
|
||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
// As for attachments: nothing left in the disk cache of a device that is not the person's own.
|
||||
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
|
||||
@@ -7,7 +7,7 @@ async function main() {
|
||||
const app = createApp();
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
|
||||
console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`);
|
||||
console.log(`[ihasmail] mail server: ${config.stalwartUrl}`);
|
||||
console.log(`[ihasmail] static dir: ${config.staticDir}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
@@ -48,13 +48,12 @@ test("a server without the registry is refused, with good credentials", async ()
|
||||
assert.equal(res.body.error, "unsupported_server");
|
||||
});
|
||||
|
||||
test("the message says the credentials were fine, and names the way out", async () => {
|
||||
test("the message says the credentials were fine, and that the server isn't supported", async () => {
|
||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
||||
// Someone hitting this has typed a correct password. Saying so is the
|
||||
// difference between "upgrade your server" and "try your password again".
|
||||
// difference between "wrong server" and "try your password again".
|
||||
assert.match(body.message, /credentials are fine/i);
|
||||
assert.match(body.message, /0\.16/);
|
||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
||||
assert.match(body.message, /isn't one this webmail supports/);
|
||||
});
|
||||
|
||||
test("no session is minted for a server we cannot talk to", async () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { account } from "./config.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
|
||||
/* Shared by the HTTP layer and by the handlers that re-check a code. */
|
||||
export function checkOtp(code: string | undefined): boolean {
|
||||
if (!account.otpUrl) return true;
|
||||
const params = parseOtpauthUrl(account.otpUrl);
|
||||
return Boolean(code && params && verifyTotp(params, code));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
|
||||
export 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;
|
||||
|
||||
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
*/
|
||||
export const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||
export const MAX_DELAYED_SEND = 86400 * 30;
|
||||
export const ACCOUNT = "a1";
|
||||
/** How long a push subscription lives before the server drops it. */
|
||||
export const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** An account somebody has shared with the demo user. See the session below. */
|
||||
export const SHARED_ACCOUNT = "a2";
|
||||
export const SHARED_CAPS: Obj = {
|
||||
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||
};
|
||||
export const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
export 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. */
|
||||
export const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
|
||||
export const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
/**
|
||||
* Credential state, mutable so the self-service flows can be exercised against
|
||||
* the mock the way they run against a real 0.16 server: the password changes,
|
||||
* 2FA starts demanding a code on every request, and app passwords keep working
|
||||
* without one.
|
||||
*/
|
||||
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
|
||||
export const MASKED = "[********]";
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
export const state = { n: 1 };
|
||||
export const nextState = () => String(state.n++);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||
import { Obj, SHARED_ACCOUNT, USER, account } from "./config.js";
|
||||
|
||||
/* ---------- data ---------- */
|
||||
/*
|
||||
* The names are Stalwart's own defaults, which follow the Exchange convention:
|
||||
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
|
||||
* short forms, so anything built from a folder's name read differently here
|
||||
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
|
||||
* against a real server -- and every screenshot in the README showed a folder
|
||||
* list no user has. The role is what the client branches on; the name is only
|
||||
* ever displayed, which is exactly why it has to look right.
|
||||
*/
|
||||
/** Push subscriptions, as a fresh account has none. */
|
||||
export const pushSubscriptions: Obj[] = [];
|
||||
|
||||
export const mailboxes: Obj[] = [
|
||||
mb("inbox", "Inbox", "inbox"),
|
||||
mb("drafts", "Drafts", "drafts"),
|
||||
mb("sent", "Sent Items", "sent"),
|
||||
mb("junk", "Junk Mail", "junk"),
|
||||
mb("trash", "Deleted Items", "trash"),
|
||||
mb("archive", "Archive", "archive"),
|
||||
mb("work", "Work", null),
|
||||
mb("work-inv", "Invoices", null, "work"),
|
||||
mb("news", "Newsletters", null),
|
||||
];
|
||||
export function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj {
|
||||
return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } };
|
||||
}
|
||||
|
||||
export const blobs = new Map<string, { type: string; data: Buffer }>();
|
||||
export function putBlob(data: Buffer | string, type: string): string {
|
||||
const id = `b${randomUUID().slice(0, 8)}`;
|
||||
blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const people = [
|
||||
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
];
|
||||
export const subjects = [
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
|
||||
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
|
||||
];
|
||||
export const emails: Obj[] = [];
|
||||
export const seq = { counter: 1 };
|
||||
/**
|
||||
* A real TNEF blob, built to the format description, so the winmail.dat
|
||||
* decoder has something to open that is not a hand-made fixture in its own
|
||||
* test file. Two files inside, one of them carrying a long name in the MAPI
|
||||
* stream behind an 8.3 title -- which is the case the decoder exists for.
|
||||
*/
|
||||
export function winmailDat(): Buffer {
|
||||
const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]);
|
||||
const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]);
|
||||
const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; };
|
||||
const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]);
|
||||
const asciiProp = (id: number, value: string) => {
|
||||
const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]);
|
||||
const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4);
|
||||
return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]);
|
||||
};
|
||||
const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]);
|
||||
|
||||
const renddata = Buffer.alloc(14);
|
||||
const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]);
|
||||
const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1");
|
||||
const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1");
|
||||
|
||||
return Buffer.concat([
|
||||
u32(0x223e9f78), u16(0x1234),
|
||||
attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("QUARTE~1.CSV")),
|
||||
attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])),
|
||||
attr(2, 0x0006800f, csv),
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("notes.txt")),
|
||||
attr(2, 0x0006800f, notes),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A really signed message, served as the raw blob a client verifies against.
|
||||
*
|
||||
* The signature is over exact bytes, so this deliberately does not go through
|
||||
* addEmail: that builds a message out of parts and would hand back a body it
|
||||
* had assembled rather than the one that was signed. Here the blob *is* the
|
||||
* fixture, byte for byte, and the JMAP metadata is arranged around it.
|
||||
*
|
||||
* `bodyStructure` says multipart/signed because that is what the client checks
|
||||
* before deciding to download anything -- a mock that omitted it would leave
|
||||
* the whole path unreachable while every stored byte was still correct.
|
||||
*/
|
||||
export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const raw = signedMessage(o.which);
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
|
||||
const textBlob = putBlob(body, "text/plain");
|
||||
const e: Obj = {
|
||||
id,
|
||||
blobId: putBlob(raw, "message/rfc822"),
|
||||
threadId: `t${id}`,
|
||||
mailboxIds: { [o.mailbox]: true },
|
||||
keywords: o.unread ? {} : { $seen: true },
|
||||
size: raw.length,
|
||||
receivedAt: received,
|
||||
sentAt: received,
|
||||
messageId: [`${id}@mock`],
|
||||
inReplyTo: null,
|
||||
references: null,
|
||||
from: [{ name: o.from[0], email: o.from[1] }],
|
||||
to: [{ name: "Demo User", email: USER }],
|
||||
cc: null, bcc: null, replyTo: null, sender: null,
|
||||
subject: o.subject,
|
||||
hasAttachment: false,
|
||||
preview: body.slice(0, 120),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
|
||||
bodyStructure: {
|
||||
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
|
||||
subParts: [
|
||||
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
|
||||
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
emails.push(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 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.
|
||||
*/
|
||||
export const STYLED_MARKETING_HTML = `<html><head><style>
|
||||
a { color:#1155CC; text-decoration:underline }
|
||||
.h { font-size:20px; color:#111111 }
|
||||
</style></head><body style="margin:0;background-color:#f4f4f4">
|
||||
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
|
||||
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
|
||||
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
|
||||
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
|
||||
<table cellpadding="0" cellspacing="0"><tr>
|
||||
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
|
||||
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
|
||||
</td></tr></table>
|
||||
<p style="color:#666666;font-size:12px">Order #4471 · placed 2 September</p>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
|
||||
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table></body></html>`;
|
||||
|
||||
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const textBlob = putBlob(text, "text/plain");
|
||||
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
|
||||
const attachments: Obj[] = [];
|
||||
if (o.attach) {
|
||||
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
||||
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.winmail) {
|
||||
const dat = winmailDat();
|
||||
attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
|
||||
const e: Obj = {
|
||||
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
|
||||
threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true },
|
||||
keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) },
|
||||
size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received,
|
||||
messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null,
|
||||
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
||||
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
|
||||
attachments,
|
||||
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
||||
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
||||
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
||||
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
|
||||
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
|
||||
// delivered mail carries it and mail this account wrote does not.
|
||||
"header:X-Spam-Status:asText":
|
||||
o.mailbox === "junk"
|
||||
? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no"
|
||||
: o.mailbox === "inbox"
|
||||
? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]"
|
||||
: null,
|
||||
};
|
||||
emails.push(e);
|
||||
return e;
|
||||
}
|
||||
// Seed
|
||||
for (let i = 0; i < 45; i++) {
|
||||
const p = people[i % people.length]!;
|
||||
const subj = subjects[i % subjects.length]!;
|
||||
const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 });
|
||||
if (i % 4 === 0) {
|
||||
// thread replies
|
||||
addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true });
|
||||
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
|
||||
}
|
||||
}
|
||||
addEmail({ from: ["Shop Updates", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
|
||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||
|
||||
/*
|
||||
* Three signed messages, so every branch of the signature banner can be seen
|
||||
* without staging a certificate authority. Read "A note" first: that pins Ada's
|
||||
* certificate, after which the other two have something to disagree with.
|
||||
*/
|
||||
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||
// A thread whose unread message is not the last one: someone's server queued
|
||||
// their reply for hours, so it landed after messages that answer it and sits in
|
||||
// the middle of the conversation. Opening this thread at the newest message
|
||||
// left that reply above the fold until the mark-read timer swept it (#87).
|
||||
{
|
||||
const subj = "Compiler timings for the release";
|
||||
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
|
||||
const tid = t.threadId as string;
|
||||
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
|
||||
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
|
||||
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
|
||||
// Long enough after the unread one that the thread scrolls: opening at the
|
||||
// bottom put four messages between the reader and the mail they had not read.
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
|
||||
}
|
||||
// Invitation email
|
||||
{
|
||||
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
const e = addEmail({ from: ["Ada Lovelace", "[email protected]"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||
const b = putBlob(ics, "text/calendar");
|
||||
(e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }];
|
||||
(e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null });
|
||||
e.hasAttachment = true;
|
||||
}
|
||||
|
||||
export const identities: Obj[] = [
|
||||
{ id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
|
||||
{ id: "i2", name: "Demo (alias)", email: "[email protected]", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true },
|
||||
];
|
||||
export const vacationBox: { current: Obj } = { current: { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null } };
|
||||
export const sieveScripts: Obj[] = [];
|
||||
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||
export const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||
export const sharedEvents: Obj[] = [];
|
||||
export const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||
export const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||
export const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||
export function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||
export const events: Obj[] = [];
|
||||
{
|
||||
const now = new Date();
|
||||
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
|
||||
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
|
||||
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
|
||||
/*
|
||||
* One event in a zone that is not the reader's, because every other fixture
|
||||
* here uses the machine's own and so cannot tell a correct conversion from
|
||||
* no conversion at all. Dragging this one is what proves a move keeps the
|
||||
* time the event says it happens at.
|
||||
*/
|
||||
events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" });
|
||||
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
|
||||
// Two in the shared account, so a colleague's calendar has something in it.
|
||||
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||
}
|
||||
export const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
export const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||
export const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
|
||||
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||
what a share usually is. */
|
||||
export const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||
export const sharedCards: Obj[] = [
|
||||
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
];
|
||||
/**
|
||||
* One sort property, as Email/query defines them. `hasKeyword` sorts a
|
||||
* boolean, and false comes before true -- which is what makes "unread first"
|
||||
* an *ascending* sort on $seen.
|
||||
*/
|
||||
export function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
|
||||
const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? "");
|
||||
switch (property) {
|
||||
case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt));
|
||||
case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt));
|
||||
case "size": return Number(x.size ?? 0) - Number(y.size ?? 0);
|
||||
case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? ""));
|
||||
case "from": return addr(x.from).localeCompare(addr(y.from));
|
||||
case "to": return addr(x.to).localeCompare(addr(y.to));
|
||||
case "hasKeyword": {
|
||||
const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0);
|
||||
return has(x) - has(y);
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
||||
export const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||
|
||||
/** The floor Stalwart puts under a requested EventSource ping interval. */
|
||||
export const PING_FLOOR_SECONDS = 30;
|
||||
|
||||
/*
|
||||
* An account that may not send calendar invitations.
|
||||
*
|
||||
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
|
||||
* the account lacks the `calendarSchedulingSend` permission, rather than
|
||||
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
|
||||
* (2026-09-06)** against an account holding a role with that permission
|
||||
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
|
||||
* `forbidden` and the text below, while the identical request with the flag
|
||||
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
|
||||
* that account.
|
||||
*/
|
||||
export const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
|
||||
export const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
|
||||
|
||||
export const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||
/** One per contact, by index; a gap means that card has no birthday. */
|
||||
export const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
||||
{ year: 1815, month: 12, day: 10 },
|
||||
{ month: 6, day: 9 }, // no year: the common case
|
||||
{ year: 1912, month: 6, day: 23 },
|
||||
null,
|
||||
{ year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year
|
||||
{ year: 1918, month: 8, day: 26 },
|
||||
];
|
||||
|
||||
export const cards: Obj[] = people.slice(0, 6).map((p, i) => {
|
||||
const [given, surname] = p[0]!.split(" ");
|
||||
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined,
|
||||
/*
|
||||
* Birthdays on most but not all of them, and one with no year, because a
|
||||
* card that records only a day and month is the common case rather than
|
||||
* the exceptional one.
|
||||
*/
|
||||
anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined };
|
||||
});
|
||||
export const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
|
||||
export const fileNodes: Obj[] = [
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
|
||||
/* What the shared account holds. Its own nodes, so opening the share in Files
|
||||
shows something different from the reader's own folders rather than the same
|
||||
list under another name. */
|
||||
export const sharedFileNodes: Obj[] = [
|
||||
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
/** The node list an account owns. */
|
||||
export const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
|
||||
|
||||
export function fr() {
|
||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
}
|
||||
|
||||
export function recount() {
|
||||
for (const m of mailboxes) {
|
||||
const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]);
|
||||
m.totalEmails = inBox.length;
|
||||
m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length;
|
||||
const threads = new Set(inBox.map((e) => e.threadId));
|
||||
m.totalThreads = threads.size;
|
||||
m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size;
|
||||
}
|
||||
}
|
||||
recount();
|
||||
|
||||
@@ -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: "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;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { createDirectory, mockRole } from "./directory.js";
|
||||
import { ACCOUNT, MOCK_LOCALE, Obj, USER, account, nextState, state } from "./config.js";
|
||||
import { NO_SCHEDULING_SEND, SCHEDULING_FORBIDDEN, blobs, events, mailboxes } from "./data.js";
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
export function pick(o: Obj, props?: string[] | null): Obj {
|
||||
if (!props) return o;
|
||||
const out: Obj = { id: o.id };
|
||||
for (const p of props) if (p in o) out[p] = o[p];
|
||||
else if (p.startsWith("header:")) out[p] = null;
|
||||
return out;
|
||||
}
|
||||
export function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (k.startsWith("#")) {
|
||||
const r = v as { resultOf: string; name: string; path: string };
|
||||
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
||||
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
||||
} else out[k] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
||||
* go, pointing at something created earlier in the same request. Sending a
|
||||
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
||||
* without this the mock quietly declines to create any submission at all.
|
||||
*
|
||||
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
||||
* the method that receives them resolves them itself.
|
||||
*/
|
||||
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
||||
if (key === "onSuccessUpdateEmail") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(value as Obj)) {
|
||||
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
||||
out[nk] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export function jsonPointer(obj: unknown, path: string): unknown {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let cur: unknown = obj;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]!;
|
||||
if (p === "*") {
|
||||
const rest = parts.slice(i + 1).join("/");
|
||||
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
|
||||
return arr;
|
||||
}
|
||||
cur = (cur as Obj)?.[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.operator) {
|
||||
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
|
||||
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
|
||||
}
|
||||
const kw = e.keywords as Obj;
|
||||
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
|
||||
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
|
||||
if (f.notKeyword && kw[f.notKeyword as string]) return false;
|
||||
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
|
||||
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
|
||||
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
|
||||
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
|
||||
if (f.after && String(e.receivedAt) < String(f.after)) return false;
|
||||
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
|
||||
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
|
||||
return true;
|
||||
}
|
||||
export function applyPatch(obj: Obj, patch: Obj) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k.includes("/")) {
|
||||
const [root, ...rest] = k.split("/");
|
||||
const key = rest.join("/");
|
||||
const target = (obj[root!] as Obj) ?? {};
|
||||
if (v === null) delete target[key];
|
||||
else target[key] = v;
|
||||
obj[root!] = target;
|
||||
} else obj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- method handlers ---------- */
|
||||
export type Handler = (args: Obj) => Obj | [string, Obj][];
|
||||
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
|
||||
export class MethodError extends Error {
|
||||
constructor(
|
||||
public readonly type: string,
|
||||
description?: string,
|
||||
) {
|
||||
super(description ?? type);
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_OBJECTS = 500;
|
||||
|
||||
/**
|
||||
* Stalwart refuses a whole method call that carries more objects than it will
|
||||
* process at once - it does not quietly handle the first 500. Enforce the same
|
||||
* ceiling the session advertises, so an unbatched client fails here too.
|
||||
*/
|
||||
export function enforceLimits(name: string, args: Obj): void {
|
||||
const tooLarge = () => {
|
||||
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
|
||||
};
|
||||
if (name.endsWith("/get")) {
|
||||
const ids = args.ids as unknown[] | null | undefined;
|
||||
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
if (name.endsWith("/set")) {
|
||||
const n =
|
||||
Object.keys((args.create as Obj) ?? {}).length +
|
||||
Object.keys((args.update as Obj) ?? {}).length +
|
||||
((args.destroy as unknown[] | undefined)?.length ?? 0);
|
||||
if (n > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
}
|
||||
|
||||
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||
|
||||
/*
|
||||
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
||||
* name: a `/get` with no `properties` comes back without the field at all.
|
||||
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
||||
* The mock handing it over unasked meant a client that never asked still saw
|
||||
* every share, and the one place that did not -- the real server -- showed
|
||||
* nothing shared at all.
|
||||
*
|
||||
* Calendars and address books used to behave the same way and no longer do.
|
||||
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
||||
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
||||
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
||||
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
||||
* stays applied to mailboxes alone.
|
||||
*/
|
||||
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||
if (a.properties) return res;
|
||||
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||
}
|
||||
|
||||
export function genericGet(list: Obj[]) {
|
||||
return (a: Obj) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An id, as either a stored event or one occurrence of one.
|
||||
*
|
||||
* A synthetic id whose base is gone, or whose date the rule no longer
|
||||
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
||||
* the way the server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||
const direct = list.find((x) => x.id === id);
|
||||
if (direct) return { base: direct };
|
||||
const parsed = parseSyntheticId(id);
|
||||
if (!parsed) return null;
|
||||
const base = list.find((x) => x.id === parsed.baseId);
|
||||
if (!base) return null;
|
||||
const occ = occurrenceAt(base, parsed.recurrenceId);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
export class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
|
||||
}
|
||||
|
||||
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
return (a: Obj) => {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `${prefix}${randomUUID().slice(0, 6)}`;
|
||||
const o = { ...(obj as Obj), id };
|
||||
try {
|
||||
onCreate?.(o);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetError)) throw err;
|
||||
notCreated[cid] = err.toJSON();
|
||||
continue;
|
||||
}
|
||||
list.push(o);
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const o = list.find((x) => x.id === id);
|
||||
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = list.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- calendar events ---------- */
|
||||
|
||||
/**
|
||||
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||
*
|
||||
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||
*
|
||||
* The refusals are the point of reproducing this at all:
|
||||
*
|
||||
* - a base event and one of its instances in the same request is refused, both
|
||||
* ids at once, because the server cannot apply them in a defined order;
|
||||
* - the same id twice is "Duplicate event id.";
|
||||
* - the ten event-level properties are refused with `invalidProperties`;
|
||||
* - and the twelve inherited ones are dropped in silence, with the response
|
||||
* still saying the update succeeded. A mock that applied them would let a
|
||||
* client that sends them look correct everywhere except a real server.
|
||||
*/
|
||||
/**
|
||||
* Enough of an iCalendar reader to stand in for Stalwart's.
|
||||
*
|
||||
* It reads per VEVENT rather than across the whole file, because a file is the
|
||||
* case an emailed invitation never was: an export carries a year of them, and a
|
||||
* regex over the whole text would find the first DTSTART and call that the
|
||||
* 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 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.
|
||||
*/
|
||||
export function calendarEventParse(a: Obj) {
|
||||
const parsed: Obj = {};
|
||||
const notParsable: string[] = [];
|
||||
for (const b of a.blobIds as string[]) {
|
||||
const blob = blobs.get(b);
|
||||
if (!blob) { notParsable.push(b); continue; }
|
||||
const text = blob.data.toString();
|
||||
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
|
||||
const method = field(text, "METHOD");
|
||||
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
|
||||
const events = bodies.map((body) => {
|
||||
const g = (k: string) => field(body, k);
|
||||
const ds = g("DTSTART") ?? "20260101T000000Z";
|
||||
const de = g("DTEND") ?? ds;
|
||||
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
|
||||
const start = new Date(`${toLocal(ds)}Z`);
|
||||
const end = new Date(`${toLocal(de)}Z`);
|
||||
return {
|
||||
"@type": "Event",
|
||||
uid: g("UID"),
|
||||
title: g("SUMMARY"),
|
||||
start: toLocal(ds),
|
||||
timeZone: "Etc/UTC",
|
||||
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
|
||||
method,
|
||||
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
|
||||
participants: method
|
||||
? {
|
||||
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
|
||||
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
if (!events.length) { notParsable.push(b); continue; }
|
||||
parsed[b] = events.length === 1 ? events[0] : events;
|
||||
}
|
||||
return { accountId: ACCOUNT, parsed, notParsable };
|
||||
}
|
||||
|
||||
export function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
/*
|
||||
* An account that may not send invitations refuses the whole request the
|
||||
* moment it asks for them, and refuses it per object rather than as a method
|
||||
* error. Confirmed live on 0.16.21 for all three of create, update and
|
||||
* destroy; the same requests with the flag absent or false went through.
|
||||
* The flag alone decides it — the server does not first check whether the
|
||||
* event has anyone to notify.
|
||||
*/
|
||||
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
||||
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
||||
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
||||
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
||||
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||
// 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 #26 and #30 reached a live server unnoticed — so it does both.
|
||||
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
o.uid = o.uid ?? randomUUID();
|
||||
events.push(o);
|
||||
created[cid] = { id: o.id };
|
||||
}
|
||||
|
||||
const updates = Object.entries((a.update as Obj) ?? {});
|
||||
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||
const seen = new Set<string>();
|
||||
|
||||
/* A base and one of its instances cannot be settled in the same request. */
|
||||
const baseOf = (id: string): string | null => {
|
||||
const r = resolveEvent(events, id);
|
||||
return r ? (r.base.id as string) : null;
|
||||
};
|
||||
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||
const b = baseOf(id);
|
||||
if (!b) continue;
|
||||
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||
touched.set(b, entry);
|
||||
}
|
||||
const conflicted = new Set<string>();
|
||||
for (const [, e] of touched) {
|
||||
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||
}
|
||||
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||
|
||||
for (const [id, patch] of updates) {
|
||||
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||
seen.add(id);
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||
writeOverride(resolved.base, resolved.occ, applied);
|
||||
updated[id] = null;
|
||||
}
|
||||
|
||||
for (const id of destroys) {
|
||||
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||
if (resolved.occ) {
|
||||
// One date off a series, which is an override rather than a deletion.
|
||||
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||
destroyed.push(id);
|
||||
continue;
|
||||
}
|
||||
const i = events.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into the override for one date.
|
||||
*
|
||||
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||
* an override always carries its own timing; the mock does the same, or a
|
||||
* client could depend on inheriting them and be right only here.
|
||||
*/
|
||||
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||
const next: Obj = { ...existing };
|
||||
if (!replace) {
|
||||
if (!("start" in next)) next.start = occ.start;
|
||||
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||
}
|
||||
applyPatch(next, patch);
|
||||
overrides[occ.recurrenceId] = next;
|
||||
base.recurrenceOverrides = overrides;
|
||||
}
|
||||
|
||||
/* ---------- submissions ---------- */
|
||||
/**
|
||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||
* envelope's FUTURERELEASE parameter rather than set by the client, and
|
||||
* `undoStatus` reports whether the message is still in the queue.
|
||||
*/
|
||||
export const submissions: Obj[] = [];
|
||||
|
||||
export function submissionView(sub: Obj): Obj {
|
||||
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
|
||||
}
|
||||
|
||||
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
|
||||
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
|
||||
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||
export 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),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { ACCOUNT, state } from "./config.js";
|
||||
|
||||
/*
|
||||
* The server-sent-events fan-out and the Email/changes ring buffer.
|
||||
*
|
||||
* Separate from index.ts because the JMAP handlers raise these events and
|
||||
* index.ts imports the handlers -- leaving them in index.ts makes that a
|
||||
* cycle. Separate from data.ts because a live HTTP response is not fixture
|
||||
* data.
|
||||
*/
|
||||
export const sseClients = new Set<ServerResponse>();
|
||||
/** What changed and when, so `Email/changes` can answer honestly. */
|
||||
export const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
export function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
// A window is plenty; the client refetches from scratch if it falls behind.
|
||||
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
||||
}
|
||||
|
||||
/** The same for contact cards, so `ContactCard/changes` can answer too. */
|
||||
export const cardChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
/** Changes at or below this state have been dropped from the log, so a client that far behind cannot be answered. */
|
||||
export const cardLog = { floor: 0 };
|
||||
export function recordCardChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
cardChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
if (cardChanges.length > 200) {
|
||||
const dropped = cardChanges.splice(0, cardChanges.length - 200);
|
||||
cardLog.floor = dropped[dropped.length - 1]!.state;
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcast(types: string[]) {
|
||||
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
|
||||
for (const c of sseClients) c.write(payload);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { checkOtp } from "./auth.js";
|
||||
import { cardChanges, cardLog, emailChanges, recordCardChange, recordEmailChange, broadcast } from "./events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj, PUSH_TTL_MS, SHARED_ACCOUNT, account, nextState, state } from "./config.js";
|
||||
import { NO_KEYWORD_SORT, abRights, blobs, booksFor, calendarsFor, cards, compareBy, emails, eventsFor, fileNodes, fr, identities, mailboxes, mb, nodesFor, participantIdentities, principals, pushSubscriptions, putBlob, recount, rightsCal, seq, sharedCards, sieveScripts, vacationBox } from "./data.js";
|
||||
import { Handler, MethodError, applyPatch, calendarEventParse, calendarEventSet, directory, genericGet, genericSet, hideShareWithUnlessAsked, matchFilter, matchSubmissionFilter, pick, resolveEvent, setResp, submissionView, submissions } from "./engine.js";
|
||||
|
||||
/** Stalwart's limit per account (0.16.22). */
|
||||
const MAX_PUSH_SUBSCRIPTIONS = 15;
|
||||
/** What an empty or missing `types` list is taken to mean: everything. */
|
||||
const ALL_PUSH_TYPES = ["Email", "EmailDelivery", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse", "CalendarEvent", "Calendar", "ContactCard", "AddressBook", "FileNode", "Quota", "SieveScript", "PushSubscription"];
|
||||
|
||||
export 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).
|
||||
"x:AccountSettings/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? ["singleton"];
|
||||
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 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));
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }];
|
||||
if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) {
|
||||
// A method-level failure, the way a real server refuses an optional sort:
|
||||
// the whole call fails rather than the sort being quietly dropped.
|
||||
throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported.");
|
||||
}
|
||||
list.sort((x, y) => {
|
||||
for (const c of sort) {
|
||||
const asc = c.isAscending !== false;
|
||||
const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined);
|
||||
if (cmp !== 0) return asc ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
if (a.collapseThreads) {
|
||||
const seen = new Set<string>();
|
||||
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
}
|
||||
const pos = Number(a.position ?? 0);
|
||||
const limit = Number(a.limit ?? 50);
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
|
||||
},
|
||||
"Email/get": (a) => genericGet(emails)(a),
|
||||
/*
|
||||
* Real changes, not an empty answer.
|
||||
*
|
||||
* This used to return three empty arrays whatever had happened, so the
|
||||
* client's whole reconciliation path -- `Email/changes`, then deciding what
|
||||
* to do with what came back -- never ran against the mock. A bug living in
|
||||
* that path could not be reproduced here at all, which is how one reached
|
||||
* production and survived being "fixed" once (#100). The log below is what
|
||||
* the real server can answer from.
|
||||
*/
|
||||
"Email/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
const relevant = emailChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"Email/set": (a) => {
|
||||
const r = genericSet(emails, "e", (o) => {
|
||||
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
|
||||
const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); };
|
||||
const parts: Obj[] = [];
|
||||
walk(o.bodyStructure as Obj, parts);
|
||||
o.textBody = parts.filter((p) => p.type === "text/plain");
|
||||
o.htmlBody = parts.filter((p) => p.type === "text/html");
|
||||
o.attachments = [];
|
||||
const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); };
|
||||
collect(o.bodyStructure as Obj);
|
||||
o.hasAttachment = (o.attachments as Obj[]).length > 0;
|
||||
o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`;
|
||||
o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
o.size = 2000;
|
||||
o.preview = (bv.text?.value ?? "").slice(0, 100);
|
||||
o.messageId = [`${o.id}@mock`];
|
||||
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
|
||||
})(a);
|
||||
recount();
|
||||
nextState();
|
||||
recordEmailChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((a.update as Obj) ?? {}),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
/* A real server pushes a state change after a set, and the client acts on
|
||||
it -- `Email/changes` runs and the store reconciles what came back. The
|
||||
mock stayed silent, so that whole path never ran here and a bug living
|
||||
in it could not be reproduced: marking a message read went round the
|
||||
server and back on the live instance, and did nothing at all on the mock
|
||||
(#100). Announced now, the way Stalwart does. */
|
||||
broadcast(["Email", "Mailbox", "Thread"]);
|
||||
return r;
|
||||
},
|
||||
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${seq.counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
|
||||
// Stalwart 0.16 registry objects backing self-service credentials.
|
||||
"x:AccountPassword/get": () => ({
|
||||
accountId: ACCOUNT,
|
||||
state: String(state.n),
|
||||
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
|
||||
notFound: [],
|
||||
}),
|
||||
"x:AccountPassword/set": (a) => {
|
||||
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
|
||||
if (!patch) return setResp({ updated: {} });
|
||||
const current = patch.currentSecret as string | undefined;
|
||||
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
|
||||
if (!current) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
|
||||
}
|
||||
if (current !== account.password) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
if (account.otpUrl && !code) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
|
||||
}
|
||||
if (account.otpUrl && !checkOtp(code!)) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
const secret = patch.secret as string | undefined;
|
||||
if (secret !== undefined && secret !== MASKED) {
|
||||
if (secret.length < 8) {
|
||||
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
|
||||
}
|
||||
account.password = secret;
|
||||
}
|
||||
if ("otpAuth/otpUrl" in patch) {
|
||||
const url = patch["otpAuth/otpUrl"] as string | null;
|
||||
if (url !== MASKED) account.otpUrl = url;
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ updated: { singleton: null } });
|
||||
},
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* What is worth reproducing is the handshake, because it is the part that
|
||||
* fails quietly: a subscription is created unverified and stays silent until
|
||||
* the client echoes back a code the server pushed. A mock that marked one
|
||||
* verified on creation would let a client ship without ever implementing
|
||||
* that, and the symptom in production is "registered, and no notifications".
|
||||
*/
|
||||
"PushSubscription/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
||||
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
||||
// `keys` is write-only in JMAP: the server never hands it back.
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
||||
},
|
||||
"PushSubscription/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o = obj as Obj;
|
||||
const keys = (o.keys ?? {}) as Obj;
|
||||
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
||||
// API produces; padding it would be the client inventing a shape.
|
||||
for (const k of ["p256dh", "auth"]) {
|
||||
const v = String(keys[k] ?? "");
|
||||
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
||||
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (notCreated[cid]) continue;
|
||||
if (!String(o.url ?? "").startsWith("https://")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
||||
continue;
|
||||
}
|
||||
// A filter condition with a null value is not a filter -- the real server
|
||||
// answers "Invalid filter" and refuses the whole subscription. ihasmail
|
||||
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
|
||||
// all here, and the mock accepted it happily. It does not any more.
|
||||
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
|
||||
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
|
||||
return Object.values(f).some((v) => v === null || v === undefined);
|
||||
});
|
||||
if (badFilter) {
|
||||
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* As Stalwart does (checked live on 0.16.22, 2026-09-16): a repeated
|
||||
* deviceClientId is a second subscription, not a replacement -- this mock
|
||||
* used to replace, which is how the client's pile-up never showed here
|
||||
* (#375) -- and an account holds at most fifteen.
|
||||
*/
|
||||
const deviceId = String(o.deviceClientId ?? "");
|
||||
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
|
||||
notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." };
|
||||
continue;
|
||||
}
|
||||
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||
/*
|
||||
* A subscription expires, and this used to hand back `expires: null`.
|
||||
* That is the one shape that makes the client's real problem invisible in
|
||||
* development: JMAP puts a ceiling of seven days on a push subscription
|
||||
* and expects the client to re-register before it lapses, so a client
|
||||
* that never renews works perfectly against a mock that never expires
|
||||
* anything and goes silent a week after being deployed. Seven days here,
|
||||
* so "does this client renew?" is a question the mock can answer.
|
||||
*/
|
||||
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
|
||||
// An empty or missing list means every type, not none.
|
||||
const types = Array.isArray(o.types) && o.types.length ? o.types : ALL_PUSH_TYPES;
|
||||
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
||||
created[cid] = { id, expires };
|
||||
state.n++;
|
||||
}
|
||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const s = pushSubscriptions.find((x) => x.id === id);
|
||||
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
const code = (patch as Obj).verificationCode;
|
||||
if (code !== undefined) {
|
||||
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||
s.verified = true;
|
||||
}
|
||||
// An expiry can be extended, up to the same seven days a new one gets.
|
||||
const wanted = (patch as Obj).expires;
|
||||
if (typeof wanted === "string") {
|
||||
const at = Math.min(Date.parse(wanted), Date.now() + PUSH_TTL_MS);
|
||||
if (Number.isNaN(at)) { notUpdated[id] = { type: "invalidProperties", properties: ["expires"] }; continue; }
|
||||
s.expires = new Date(at).toISOString();
|
||||
}
|
||||
updated[id] = null;
|
||||
state.n++;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
||||
}
|
||||
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
||||
},
|
||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||
"x:AppPassword/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `ap${randomUUID().slice(0, 6)}`;
|
||||
// Real app passwords carry their credential id, so the server can spot
|
||||
// one by its shape alone. Mirror that.
|
||||
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
||||
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
|
||||
account.appPasswords.push(row);
|
||||
created[cid] = { id, secret, createdAt: row.createdAt };
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = account.appPasswords.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ created, destroyed });
|
||||
},
|
||||
"Identity/get": genericGet(identities),
|
||||
"Identity/set": (a) => {
|
||||
// Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of
|
||||
// UTF-8, not characters. Anything longer is refused by name.
|
||||
for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) {
|
||||
for (const [key, obj] of Object.entries(entries)) {
|
||||
const over = ["htmlSignature", "textSignature"].find((prop) => {
|
||||
const v = (obj as Obj)[prop];
|
||||
return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047;
|
||||
});
|
||||
if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } });
|
||||
}
|
||||
}
|
||||
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
|
||||
},
|
||||
"EmailSubmission/get": (a) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
|
||||
},
|
||||
"EmailSubmission/query": (a) => {
|
||||
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
|
||||
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
|
||||
const pos = Number(a.position ?? 0);
|
||||
const limit = Number(a.limit ?? 50);
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
|
||||
},
|
||||
"EmailSubmission/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const sub = raw as Obj;
|
||||
const emailId = sub.emailId as string;
|
||||
const e = emails.find((x) => x.id === emailId);
|
||||
if (!e) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
|
||||
continue;
|
||||
}
|
||||
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
|
||||
if (Number.isNaN(hold)) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
|
||||
continue;
|
||||
}
|
||||
// Stalwart rejects MAIL FROM outright past its own limit.
|
||||
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
|
||||
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
|
||||
continue;
|
||||
}
|
||||
// With the MTA extension off, the hold is dropped in silence.
|
||||
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
|
||||
const rec: Obj = {
|
||||
id: `s${randomUUID().slice(0, 6)}`,
|
||||
identityId: sub.identityId ?? null,
|
||||
emailId,
|
||||
threadId: e.threadId ?? null,
|
||||
envelope: sub.envelope ?? null,
|
||||
sendAt: new Date(sendAt).toISOString(),
|
||||
undoStatus: null,
|
||||
deliveryStatus: null,
|
||||
};
|
||||
submissions.push(rec);
|
||||
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
|
||||
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
|
||||
if (patch) applyPatch(e, patch);
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const patch = raw as Obj;
|
||||
const sub = submissions.find((x) => x.id === id);
|
||||
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (patch.undoStatus !== "canceled") {
|
||||
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
|
||||
continue;
|
||||
}
|
||||
const status = undoStatusOf(sub, Date.now());
|
||||
if (status !== "pending") {
|
||||
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
|
||||
continue;
|
||||
}
|
||||
sub.undoStatus = "canceled";
|
||||
updated[id] = null;
|
||||
}
|
||||
recount();
|
||||
return setResp({
|
||||
created,
|
||||
updated,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
});
|
||||
},
|
||||
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacationBox.current], notFound: [] }),
|
||||
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacationBox.current = { ...vacationBox.current, ...p }; return setResp({ updated: { singleton: null } }); },
|
||||
"Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }),
|
||||
"SieveScript/get": genericGet(sieveScripts),
|
||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||
/*
|
||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||
* included, which is what a live 0.16.19 does and what makes `baseEventId`
|
||||
* useless as a test for a series. Without it (the `findByUid` path) the
|
||||
* stored ids come back untouched, because callers hand those straight to a
|
||||
* destroy and mean the whole event.
|
||||
*/
|
||||
"CalendarEvent/query": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const filter = (a.filter as Obj) ?? {};
|
||||
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||
if (!a.expandRecurrences) {
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||
}
|
||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||
const ids: string[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||
},
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
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 ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||
}
|
||||
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
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||
"CalendarEvent/parse": (a) => calendarEventParse(a),
|
||||
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||
"Principal/get": genericGet(principals),
|
||||
// One busy block a day across whatever range was asked for. It used to answer
|
||||
// with a single block on the first day whatever the range, which was all an
|
||||
// availability bar a day wide could show -- and left a bar covering several
|
||||
// days looking as though everyone were free for all but the first of them.
|
||||
"Principal/getAvailability": (a) => {
|
||||
const from = new Date(String(a.utcStart));
|
||||
const to = new Date(String(a.utcEnd));
|
||||
const list: Obj[] = [];
|
||||
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
|
||||
const date = day.toISOString().slice(0, 11);
|
||||
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
|
||||
}
|
||||
return { accountId: ACCOUNT, list };
|
||||
},
|
||||
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
||||
"AddressBook/set": (a) => {
|
||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||
included -- "You are not allowed to modify this address book", confirmed
|
||||
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
|
||||
that accepted it would have agreed that subscribing works, which is
|
||||
exactly the belief that shipped. Calendars accept the same write; the
|
||||
difference is the server's, not ours. */
|
||||
if (a.accountId === SHARED_ACCOUNT && a.update) {
|
||||
const notUpdated: Obj = {};
|
||||
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
|
||||
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
|
||||
}
|
||||
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),
|
||||
/*
|
||||
* Recorded and announced like Email/set, so the client's incremental sync
|
||||
* (`ContactCard/changes`, then fetching what it names) runs here too. A
|
||||
* state older than the log's window cannot be answered, as on a real server.
|
||||
*/
|
||||
"ContactCard/set": (a) => {
|
||||
/*
|
||||
* Stalwart refuses a `blobId` inside `media` (0.16.22, checked live on
|
||||
* 2026-09-16), and takes the whole call down for it. The mock took
|
||||
* anything, which is how ihasmail shipped a photo upload that never
|
||||
* worked against the real server (#376).
|
||||
*/
|
||||
const withBlobMedia = (o: unknown) => Object.values(((o as Obj)?.media as Record<string, Obj> | null) ?? {}).some((m) => m && "blobId" in m);
|
||||
const refuse = { type: "invalidProperties", description: "blobIds in media is not supported.", properties: ["media"] };
|
||||
const create = { ...((a.create as Obj) ?? {}) };
|
||||
const update = { ...((a.update as Obj) ?? {}) };
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
for (const [k, v] of Object.entries(create)) if (withBlobMedia(v)) { notCreated[k] = refuse; delete create[k]; }
|
||||
for (const [k, v] of Object.entries(update)) if (withBlobMedia(v)) { notUpdated[k] = refuse; delete update[k]; }
|
||||
const r = genericSet(cards, "cc")({ ...a, create, update });
|
||||
if (Object.keys(notCreated).length) r.notCreated = { ...((r.notCreated as Obj) ?? {}), ...notCreated };
|
||||
if (Object.keys(notUpdated).length) r.notUpdated = notUpdated;
|
||||
nextState();
|
||||
recordCardChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((r.updated ?? {}) as Obj),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
broadcast(["ContactCard"]);
|
||||
return r;
|
||||
},
|
||||
"ContactCard/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
if (since < cardLog.floor) throw new MethodError("cannotCalculateChanges", "That state is too old to answer from.");
|
||||
const relevant = cardChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: a.accountId ?? ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"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: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const fileNodes = nodesFor(a.accountId);
|
||||
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||
// directories and was handed files, which it then drew as folders.
|
||||
const list = fileNodes.filter((n) => {
|
||||
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||
return true;
|
||||
});
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
|
||||
"FileNode/set": (a) => {
|
||||
return genericSet(nodesFor(a.accountId), "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
// file properties. Keep it internally so query and get stay consistent.
|
||||
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
|
||||
})(a);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The mock's OAuth side, enough to sign in the way INBUXA's server does:
|
||||
* metadata, a sign-in page, and a token endpoint for one confidential client.
|
||||
*
|
||||
* The sign-in page approves the demo user at once: there is no form, since
|
||||
* what's being exercised is ihasmail's side of the flow. Tokens are tied to
|
||||
* the password they were issued under, so a password change revokes them,
|
||||
* as it does on the real server.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { PORT, USER, account } from "./config.js";
|
||||
|
||||
export const OAUTH_CLIENT_ID = process.env.MOCK_OAUTH_CLIENT_ID ?? "ihasmail-inbuxa";
|
||||
export const OAUTH_CLIENT_SECRET = process.env.MOCK_OAUTH_CLIENT_SECRET ?? "mock-oauth-secret";
|
||||
/** Seconds an access token lasts. */
|
||||
export let accessTokenTtl = Number(process.env.MOCK_OAUTH_TOKEN_TTL ?? 3600);
|
||||
|
||||
interface Grant { password: string }
|
||||
const codes = new Map<string, { challenge: string; redirectUri: string; issuedAt: number }>();
|
||||
const accessTokens = new Map<string, Grant & { expiresAt: number }>();
|
||||
const refreshTokens = new Map<string, Grant>();
|
||||
|
||||
const base = () => `http://127.0.0.1:${PORT}`;
|
||||
|
||||
/** For tests: how long new access tokens last, and a way to end every token. */
|
||||
export const oauthMock = {
|
||||
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
|
||||
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
|
||||
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; },
|
||||
};
|
||||
|
||||
/** A bearer token the mock issued, still valid under the current password. */
|
||||
export function checkBearer(header: string): boolean {
|
||||
if (!header.startsWith("Bearer ")) return false;
|
||||
const t = accessTokens.get(header.slice(7));
|
||||
return Boolean(t && t.expiresAt > Date.now() && t.password === account.password);
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString())));
|
||||
});
|
||||
}
|
||||
|
||||
function issue(res: ServerResponse, refresh: string | null) {
|
||||
const access = `mock-at-${randomBytes(16).toString("hex")}`;
|
||||
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
|
||||
const body: Record<string, unknown> = { access_token: access, token_type: "bearer", expires_in: accessTokenTtl };
|
||||
if (!refresh) {
|
||||
const fresh = `mock-rt-${randomBytes(16).toString("hex")}`;
|
||||
refreshTokens.set(fresh, { password: account.password });
|
||||
body.refresh_token = fresh;
|
||||
}
|
||||
return json(res, 200, body);
|
||||
}
|
||||
|
||||
/** Handles the OAuth routes; false for anything else. */
|
||||
export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
json(res, 200, {
|
||||
issuer: base(),
|
||||
authorization_endpoint: `${base()}/login`,
|
||||
token_endpoint: `${base()}/auth/token`,
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["openid", "offline_access"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/login" && req.method === "GET") {
|
||||
const q = url.searchParams;
|
||||
const redirectUri = q.get("redirect_uri") ?? "";
|
||||
if (q.get("client_id") !== OAUTH_CLIENT_ID || q.get("response_type") !== "code" || !redirectUri || q.get("code_challenge_method") !== "S256") {
|
||||
json(res, 400, { error: "invalid_request" });
|
||||
return true;
|
||||
}
|
||||
const code = randomBytes(16).toString("hex");
|
||||
codes.set(code, { challenge: q.get("code_challenge") ?? "", redirectUri, issuedAt: Date.now() });
|
||||
const back = new URL(redirectUri);
|
||||
back.searchParams.set("code", code);
|
||||
back.searchParams.set("state", q.get("state") ?? "");
|
||||
res.writeHead(302, { location: back.toString() });
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/token" && req.method === "POST") {
|
||||
const form = await readForm(req);
|
||||
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
|
||||
json(res, 400, { error: "invalid_client" });
|
||||
return true;
|
||||
}
|
||||
if (form.get("grant_type") === "authorization_code") {
|
||||
const code = codes.get(form.get("code") ?? "");
|
||||
codes.delete(form.get("code") ?? "");
|
||||
const verifier = form.get("code_verifier") ?? "";
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
if (!code || code.challenge !== challenge || code.redirectUri !== form.get("redirect_uri") || Date.now() - code.issuedAt > 600_000) {
|
||||
json(res, 400, { error: "invalid_grant" });
|
||||
return true;
|
||||
}
|
||||
issue(res, null);
|
||||
return true;
|
||||
}
|
||||
if (form.get("grant_type") === "refresh_token") {
|
||||
const refresh = form.get("refresh_token") ?? "";
|
||||
const grant = refreshTokens.get(refresh);
|
||||
if (!grant || grant.password !== account.password) {
|
||||
json(res, 400, { error: "invalid_grant" });
|
||||
return true;
|
||||
}
|
||||
issue(res, refresh);
|
||||
return true;
|
||||
}
|
||||
json(res, 400, { error: "unsupported_grant_type" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The username the mock signs in, for tests. */
|
||||
export const OAUTH_USER = USER;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, 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);
|
||||
@@ -68,9 +68,9 @@ describe("expandOccurrences", () => {
|
||||
describe("occurrenceView", () => {
|
||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||
const base = series();
|
||||
const occ = occurrenceAt(base, 1)!;
|
||||
const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.id, syntheticId("ev1", 1));
|
||||
assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
|
||||
assert.equal(view.baseEventId, "ev1");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
assert.equal(view.recurrenceRule, undefined);
|
||||
@@ -81,8 +81,8 @@ describe("occurrenceView", () => {
|
||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||
const base = oneOff();
|
||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
||||
assert.equal(view.id, "ev2-o0");
|
||||
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
|
||||
assert.equal(view.id, "ev2-r20260908T120000");
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
assert.notEqual(view.id, view.baseEventId);
|
||||
assert.equal(view.recurrenceId, undefined);
|
||||
@@ -90,21 +90,73 @@ describe("occurrenceView", () => {
|
||||
|
||||
it("lets an override win over the series", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
||||
// for the id this occurrence had *before* the write is the bug below.
|
||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
||||
// The same recurrence id as before the override was written, because that
|
||||
// is now the whole point: the write does not move any other occurrence.
|
||||
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
|
||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||
assert.equal(view.title, "Moved");
|
||||
});
|
||||
});
|
||||
|
||||
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", 12)), { baseId: "ev1", slot: 12 });
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
||||
{ baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" });
|
||||
});
|
||||
it("does not claim a stored id", () => {
|
||||
assert.equal(parseSyntheticId("ev1"), null);
|
||||
});
|
||||
it("does not claim an id that merely ends in digits", () => {
|
||||
assert.equal(parseSyntheticId("ev1-r2026"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOccurrencePatch", () => {
|
||||
@@ -135,35 +187,47 @@ describe("splitOccurrencePatch", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("synthetic ids are only true until the next write", () => {
|
||||
describe("synthetic ids survive a write", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
||||
* entry renumbered a five-week series so that the *same* ids addressed
|
||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
||||
* that rather than the exact permutation, because the property that bites is
|
||||
* not which date an id moves to but that it moves at all, silently.
|
||||
* This used to assert the opposite, and the reversal is the point.
|
||||
*
|
||||
* Up to 0.16.20 a synthetic id encoded a position, so writing one override
|
||||
* renumbered the series and a held id silently began naming a different
|
||||
* date — confirmed live on 2026-08-31, and reproduced here on purpose so a
|
||||
* client could not be written against a comfort the server did not offer.
|
||||
*
|
||||
* 0.16.21 identifies an occurrence by its recurrence id instead.
|
||||
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was
|
||||
* expanded, its third occurrence retitled through the synthetic id, and all
|
||||
* five original ids re-read. Every one resolved, and every one still named
|
||||
* its own date. So the hazard is gone, and the mock stops teaching it.
|
||||
*/
|
||||
it("makes a cached id address a different date after an override is written", () => {
|
||||
it("keeps a cached id on the same date after an override is written", () => {
|
||||
const before = series();
|
||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
||||
const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
|
||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||
|
||||
assert.notEqual(dateAfter, dateBefore);
|
||||
// And crucially it still resolves — a stale id is wrong, not invalid, so a
|
||||
// client that trusts it gets a confident answer about the wrong day.
|
||||
assert.ok(dateAfter);
|
||||
assert.equal(dateAfter, dateBefore);
|
||||
});
|
||||
|
||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
||||
it("resolves every id of a series after one of them is overridden", () => {
|
||||
const before = series();
|
||||
const occ = occurrenceAt(before, 3)!;
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
||||
assert.equal(same!.start, occ.start);
|
||||
const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
|
||||
.map((o) => syntheticId("ev1", o.recurrenceId));
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
|
||||
for (const id of held) {
|
||||
const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId);
|
||||
assert.ok(occ, `${id} should still resolve`);
|
||||
assert.equal(syntheticId("ev1", occ.recurrenceId), id);
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses an id whose date the rule no longer generates", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
|
||||
assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||
* 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
|
||||
@@ -8,8 +8,8 @@
|
||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
||||
* `recurrenceOverrides` entry rather than touching the series.
|
||||
* - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
|
||||
* rather than touching the series.
|
||||
*
|
||||
* A mock that agrees with the client rather than with the server is how #26 and
|
||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||
@@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750;
|
||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||
|
||||
/**
|
||||
* The id an occurrence is addressed by, which is only true until the next write.
|
||||
* The id an occurrence is addressed by: its `recurrenceId`, not its position.
|
||||
*
|
||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||
* them, and nothing in ihasmail may read either.
|
||||
*
|
||||
* They are also deliberately **unstable**, because the real ones are.
|
||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
||||
* component that renumbers it. A five-week series held `e i m q u` over
|
||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
||||
* different dates.
|
||||
* **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
|
||||
* encoded a *position* in the expanded series, so writing one override
|
||||
* renumbered the rest and a held id silently began addressing a different
|
||||
* date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
|
||||
* an occurrence is now identified by its recurrence id.
|
||||
*
|
||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
||||
* would hand back a loud `notFound` and let a client that caches ids look
|
||||
* careful. So the numbering is shifted by the number of overrides — an
|
||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
||||
* matters: hold an id across a write and it silently addresses another date.
|
||||
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
|
||||
* expanded, the third occurrence retitled through its synthetic id, and all
|
||||
* five original ids re-read afterwards. Every one still resolved, and every
|
||||
* one still named its own date; nothing was renumbered and nothing was
|
||||
* `notFound`. Only the *order* of the ids from an expanded query changed —
|
||||
* the overridden occurrence moved to the end of the list — which is why a
|
||||
* client sorts by `start` rather than trusting query order.
|
||||
*
|
||||
* The real ids look nothing like these (`h1fo9uaaaaab` for the first of that
|
||||
* series); what has to match is that holding one across a write stays correct.
|
||||
*/
|
||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
||||
const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
|
||||
|
||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
||||
}
|
||||
export const syntheticId = (baseId: string, recurrenceId: string): string =>
|
||||
`${baseId}-r${compact(recurrenceId)}`;
|
||||
|
||||
/** How far the id numbering has been rotated away from the series order. */
|
||||
function rotation(base: Obj): number {
|
||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
||||
}
|
||||
|
||||
/** The id slot this occurrence currently answers to. */
|
||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
||||
return occ.index + rotation(base);
|
||||
export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
|
||||
const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
|
||||
if (!m) return null;
|
||||
const c = m[2]!;
|
||||
const recurrenceId =
|
||||
`${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
|
||||
`T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
|
||||
return { baseId: m[1]!, recurrenceId };
|
||||
}
|
||||
|
||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||
@@ -104,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
||||
const emit = (index: number, at: Date): boolean => {
|
||||
const recurrenceId = localDateTime(at);
|
||||
const override = overrides[recurrenceId];
|
||||
// An excluded date is simply gone from the expansion. Its slot is not
|
||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
||||
// An excluded date is simply gone from the expansion. Nothing is
|
||||
// reserved in its place, and no other occurrence's id moves because of it.
|
||||
if (override?.excluded === true) return true;
|
||||
/*
|
||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||
@@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||
* `2027-06-14T09:00:00`.
|
||||
*
|
||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
||||
* the one name for this instance that neither a renumbering nor a move
|
||||
* changes.
|
||||
* Which is exactly why `recurrenceId` is what a client holds on to, and
|
||||
* since 0.16.21 what the id is built from: the one name for this instance
|
||||
* that a move does not change.
|
||||
*/
|
||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||
const shown = parseLocal(start);
|
||||
@@ -171,14 +171,14 @@ 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 {
|
||||
const view: Obj = { ...base };
|
||||
for (const k of SERIES_ONLY) delete view[k];
|
||||
Object.assign(view, occ.override ?? {});
|
||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
||||
view.id = syntheticId(base.id as string, occ.recurrenceId);
|
||||
view.baseEventId = base.id;
|
||||
view.start = occ.start;
|
||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||
@@ -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`. */
|
||||
@@ -229,10 +266,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
|
||||
return { applied };
|
||||
}
|
||||
|
||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
||||
const index = slot - rotation(base);
|
||||
if (index < 0) return null;
|
||||
/**
|
||||
* The occurrence a recurrence id addresses, which no later write moves.
|
||||
*
|
||||
* An id whose date the rule no longer generates — excluded, or past a `count`
|
||||
* — resolves to nothing, and the caller turns that into `notFound`.
|
||||
*/
|
||||
export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null {
|
||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||
return all.find((o) => o.index === index) ?? null;
|
||||
return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Real signed messages, for driving signature checking against the mock.
|
||||
*
|
||||
* 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 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.
|
||||
*
|
||||
* good Ada Lovelace <[email protected]>, RSA/SHA-256, intact
|
||||
* tampered the same message with one word of the body changed and the
|
||||
* signature untouched -- what the feature exists to catch
|
||||
* imposter signed with a certificate for [email protected] while claiming
|
||||
* to be from Ada, which is a valid signature by the wrong person
|
||||
*/
|
||||
export const SIGNED_MESSAGES = {
|
||||
good: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0ZXZlciB0byBvcmlnaW5hdGUgYW55dGhpbmcuDQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTIKQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXBrY3M3LXNpZ25hdHVyZTsgbmFtZT0ic21pbWUucDdzIgpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQKQ29udGVudC1EaXNwb3NpdGlvbjogYXR0YWNobWVudDsgZmlsZW5hbWU9InNtaW1lLnA3cyIKCk1JSUdKd1lKS29aSWh2Y05BUWNDb0lJR0dEQ0NCaFFDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURBTEJna3EKaGtpRzl3MEJCd0dnZ2dPTk1JSURpVENDQW5HZ0F3SUJBZ0lVUGc0OW12c1VhQ0ZvSUdYV1ZyRTlyNWFGVm1NdwpEUVlKS29aSWh2Y05BUUVMQlFBd05ERVZNQk1HQTFVRUF3d01RV1JoSUV4dmRtVnNZV05sTVJzd0dRWURWUVFLCkRCSkJibUZzZVhScFkyRnNJRVZ1WjJsdVpYTXdIaGNOTWpZd09UQTFNRGd5TnpRNFdoY05Nell3T1RBeU1EZ3kKTnpRNFdqQTBNUlV3RXdZRFZRUUREQXhCWkdFZ1RHOTJaV3hoWTJVeEd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsagpZV3dnUlc1bmFXNWxjekNDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFKU0JGYnJCCmtTTFRySG91Zlc1V05Zb0hmUFFZZCtrZWVTc1puaGw4TkdjVFVpb1hMRlBnWCt1ZW9sZWJNQlJ2U1ErZUZuWFYKY1lnRHR1NHllNXFmeVlMM1d2Q1dRb2l3Z3UyblA4ejZrRlRpUUtsdTJaUkNZc20vMCtEU0QyOHdIUUZ4KzlOcwpsTFlDZGsyMmZsVWhNbmtDa1d2ZFJiMDQ4K0o3NjJCY3h4bkRDRXphK0RQZ3ROcy9rSTJVcWNoaStWUVpaV1F1Ck1mRTU4ZzJVTTJaM3NlNTVRZlMydll0NGo3cFFYanRjVHNqT3hUUlVmenNzbGFoR0xjTklTR2w1a2RqTDV3cngKMUx3dzNZRWwxbnVjUzFRWkR0N3BjU0dOVVFsZE83ZTFyaDBReFFabG5SekZ5a09FSEpSakRvMDdZOWJjdmhuYQp2NWVPUHlPRDBweFdTMWNDQXdFQUFhT0JrakNCanpBZEJnTlZIUTRFRmdRVVFPamRqbHJwVkY0TXBsdDNISmNQCkhFVG9tN1F3SHdZRFZSMGpCQmd3Rm9BVVFPamRqbHJwVkY0TXBsdDNISmNQSEVUb203UXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFhQmdOVkhSRUVFekFSZ1E5aFpHRkFaWGhoYlhCc1pTNWpiMjB3Q3dZRFZSMFBCQVFEQWdlQQpNQk1HQTFVZEpRUU1NQW9HQ0NzR0FRVUZCd01FTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCSXFHRjRoQmwyClRBTUIxeU9MK3gySiswQVNWYXJyemZ5eVZST2JZK0JaL0dwTG04RGozYkU5a243cVBldjc5dzVqWGlqdkUzOWEKaFpqRG9KWmxsd1ZxbEdNSjZBbWRDR0VkMHcxQStpZnB4SUo2SUs2cTk4SE9vTUVOR0tRZ0RrdTFoUURISVZrLwpsYWVRTEx4Wk12KzlZbHpRTEltR0kyOUl0R2ZFTks2YnZqSzlVaXJyWmNBaGVpSkhCN2ZBOVoyOFRmRkgrTXNPCkpuQlRhbkdrc3d4WUkyZzJKblZiZnNLU3pHVXppUzhQYTVMSTR3UUJqTnZ2OUtMV0tvMk9SbEdlUXltdlRVK2sKL0o5Sk83QngzakphZUpJS0t1K25SVEdjZVFNOE9qandxVzlFQXJYVmZhOTcySWg5bitYditXZitoekVocDZGYQpjT20rNXMweUlVQ0tNWUlDWGpDQ0Fsb0NBUUV3VERBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4Ckd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsallXd2dSVzVuYVc1bGN3SVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUYKVm1Nd0RRWUpZSVpJQVdVREJBSUJCUUNnZ2VRd0dBWUpLb1pJaHZjTkFRa0RNUXNHQ1NxR1NJYjNEUUVIQVRBYwpCZ2txaGtpRzl3MEJDUVV4RHhjTk1qWXdPVEExTURneU56UTRXakF2QmdrcWhraUc5dzBCQ1FReElnUWdENnROCkV1RVc1VWxZdmFuODhqZGJSMEh3RkpuSnhoMFl0SFVHQTlOSXlmOHdlUVlKS29aSWh2Y05BUWtQTVd3d2FqQUwKQmdsZ2hrZ0JaUU1FQVNvd0N3WUpZSVpJQVdVREJBRVdNQXNHQ1dDR1NBRmxBd1FCQWpBS0JnZ3Foa2lHOXcwRApCekFPQmdncWhraUc5dzBEQWdJQ0FJQXdEUVlJS29aSWh2Y05Bd0lDQVVBd0J3WUZLdzREQWdjd0RRWUlLb1pJCmh2Y05Bd0lDQVNnd0RRWUpLb1pJaHZjTkFRRUJCUUFFZ2dFQWppV1VvSmtGbUN4ZGN3cFNRVFdqUmlseTY4NU0KNEpRNTgzMlZSbFdBM0toQ2tuMC9yc3ptR0NzQ1R0MERBQkVWWU1XMU42ck4wbjBpTEt5ZkNlVVNkL1BQVUFWLwp2UEI3b20veWhCWnBTS1NDWWtBajVMOHFzc3M4cEZRVUczUjVtOFBwcjFkN0Vvcm5ydkVxWnJLc2s3S3grMk81CmxGUExSRUpHWUtnSDVoOHI4NGRIek9Hek9sUjZKVVdqbVFUSEJVQ0dkZUhKdmJOaHp1TFoyQnFvU3VVYzJXcEYKWXNnVGJiSWZQSXdaZFRNZVBtUHIrYzBMYkloRE05S0JhL1J6OWVZRjlOUitEL2ZvRnZVQ2dML0tXcEtnZ2FtUQprVjVndUt2T3FzYUtmNC9kYjE4OEl1UkVibkdVd3NjeVR1TlV1OXUrc0toaVlnZDAydFZyS2RZUGhBPT0KCi0tLS0tLTEyRDAwRUJDMEI1RDMzNTJGMEUyRjI1QjFBNUQzNTUyLS0KCg==",
|
||||
tampered: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0c29ldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJR0p3WUpLb1pJaHZjTkFRY0NvSUlHR0RDQ0JoUUNBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ09OTUlJRGlUQ0NBbkdnQXdJQkFnSVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUZWbU13CkRRWUpLb1pJaHZjTkFRRUxCUUF3TkRFVk1CTUdBMVVFQXd3TVFXUmhJRXh2ZG1Wc1lXTmxNUnN3R1FZRFZRUUsKREJKQmJtRnNlWFJwWTJGc0lFVnVaMmx1WlhNd0hoY05Nall3T1RBMU1EZ3lOelE0V2hjTk16WXdPVEF5TURneQpOelE0V2pBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4R3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqCllXd2dSVzVuYVc1bGN6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUpTQkZickIKa1NMVHJIb3VmVzVXTllvSGZQUVlkK2tlZVNzWm5obDhOR2NUVWlvWExGUGdYK3Vlb2xlYk1CUnZTUStlRm5YVgpjWWdEdHU0eWU1cWZ5WUwzV3ZDV1FvaXdndTJuUDh6NmtGVGlRS2x1MlpSQ1lzbS8wK0RTRDI4d0hRRngrOU5zCmxMWUNkazIyZmxVaE1ua0NrV3ZkUmIwNDgrSjc2MkJjeHhuRENFemErRFBndE5zL2tJMlVxY2hpK1ZRWlpXUXUKTWZFNThnMlVNMlozc2U1NVFmUzJ2WXQ0ajdwUVhqdGNUc2pPeFRSVWZ6c3NsYWhHTGNOSVNHbDVrZGpMNXdyeAoxTHd3M1lFbDFudWNTMVFaRHQ3cGNTR05VUWxkTzdlMXJoMFF4UVpsblJ6RnlrT0VISlJqRG8wN1k5YmN2aG5hCnY1ZU9QeU9EMHB4V1MxY0NBd0VBQWFPQmtqQ0JqekFkQmdOVkhRNEVGZ1FVUU9qZGpscnBWRjRNcGx0M0hKY1AKSEVUb203UXdId1lEVlIwakJCZ3dGb0FVUU9qZGpscnBWRjRNcGx0M0hKY1BIRVRvbTdRd0R3WURWUjBUQVFILwpCQVV3QXdFQi96QWFCZ05WSFJFRUV6QVJnUTloWkdGQVpYaGhiWEJzWlM1amIyMHdDd1lEVlIwUEJBUURBZ2VBCk1CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUVNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJJcUdGNGhCbDIKVEFNQjF5T0wreDJKKzBBU1ZhcnJ6Znl5VlJPYlkrQlovR3BMbThEajNiRTlrbjdxUGV2Nzl3NWpYaWp2RTM5YQpoWmpEb0pabGx3VnFsR01KNkFtZENHRWQwdzFBK2lmcHhJSjZJSzZxOThIT29NRU5HS1FnRGt1MWhRREhJVmsvCmxhZVFMTHhaTXYrOVlselFMSW1HSTI5SXRHZkVOSzZidmpLOVVpcnJaY0FoZWlKSEI3ZkE5WjI4VGZGSCtNc08KSm5CVGFuR2tzd3hZSTJnMkpuVmJmc0tTekdVemlTOFBhNUxJNHdRQmpOdnY5S0xXS28yT1JsR2VReW12VFUrawovSjlKTzdCeDNqSmFlSklLS3UrblJUR2NlUU04T2pqd3FXOUVBclhWZmE5NzJJaDluK1h2K1dmK2h6RWhwNkZhCmNPbSs1czB5SVVDS01ZSUNYakNDQWxvQ0FRRXdUREEwTVJVd0V3WURWUVFEREF4QlpHRWdURzkyWld4aFkyVXgKR3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqWVd3Z1JXNW5hVzVsY3dJVVBnNDltdnNVYUNGb0lHWFdWckU5cjVhRgpWbU13RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUhBVEFjCkJna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZ0Q2dE4KRXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3YWpBTApCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUc5dzBECkJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSUtvWkkKaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBamlXVW9Ka0ZtQ3hkY3dwU1FUV2pSaWx5Njg1TQo0SlE1ODMyVlJsV0EzS2hDa24wL3Jzem1HQ3NDVHQwREFCRVZZTVcxTjZyTjBuMGlMS3lmQ2VVU2QvUFBVQVYvCnZQQjdvbS95aEJacFNLU0NZa0FqNUw4cXNzczhwRlFVRzNSNW04UHByMWQ3RW9ybnJ2RXFacktzazdLeCsyTzUKbEZQTFJFSkdZS2dINWg4cjg0ZEh6T0d6T2xSNkpVV2ptUVRIQlVDR2RlSEp2Yk5oenVMWjJCcW9TdVVjMldwRgpZc2dUYmJJZlBJd1pkVE1lUG1QcitjMExiSWhETTlLQmEvUno5ZVlGOU5SK0QvZm9GdlVDZ0wvS1dwS2dnYW1RCmtWNWd1S3ZPcXNhS2Y0L2RiMTg4SXVSRWJuR1V3c2N5VHVOVXU5dStzS2hpWWdkMDJ0VnJLZFlQaEE9PQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTItLQoK",
|
||||
imposter: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogTm90IHJlYWxseSBBZGEKTUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvc2lnbmVkOyBwcm90b2NvbD0iYXBwbGljYXRpb24veC1wa2NzNy1zaWduYXR1cmUiOyBtaWNhbGc9InNoYS0yNTYiOyBib3VuZGFyeT0iLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3IgoKVGhpcyBpcyBhbiBTL01JTUUgc2lnbmVkIG1lc3NhZ2UKCi0tLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3CkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsgY2hhcnNldD11dGYtOA0KDQpUaGUgQW5hbHl0aWNhbCBFbmdpbmUgaGFzIG5vIHByZXRlbnNpb25zIHdoYXRldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS1BMzc2RDc2M0M3RjQ3NTA5NzFGN0MwNEIyN0EzOENBNwpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJRnlnWUpLb1pJaHZjTkFRY0NvSUlGdXpDQ0JiY0NBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ05NTUlJRFNEQ0NBakNnQXdJQkFnSVVGZEtOZmhPdkJDaloxMUk3UGpYM1NtNlBTaFF3CkRRWUpLb1pJaHZjTkFRRUxCUUF3R0RFV01CUUdBMVVFQXd3TlUyOXRaV0p2WkhrZ1JXeHpaVEFlRncweU5qQTUKTURVd09ESTNORGhhRncwek5qQTVNREl3T0RJM05EaGFNQmd4RmpBVUJnTlZCQU1NRFZOdmJXVmliMlI1SUVWcwpjMlV3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQ2xkZ3RlR0lwSTdiemlpazJQCmxvc3JkWVdKS1pTZy9FSjQ0YW05QmFiemUrTkNKVHhNdkUvZnpZRFVuVWdVeUw3WEtVNmRhaGtPQlJyS0VqTEgKRW5SblRjcjhrNlpxc2tGSnd3V2FTQUhqdklUZ0hPUTd3R01Jd2NKbGROdy9ZKzRaUUhlSFVuY1RiYUc4YnlONwpkVnRsNE1HNFBvZFdaTVlYRlVLSDJiRW1QUW5yVG1LZm9oL2l4T2xWbk54dTQrUy9HOXFIK0VJOHNaeXJCeUlXClBZNkNoV1hEbzNDNTdpZkpDdHNxdlNEaUhZeEVOOWROL0RIZ2xLMzhielFPdzRRNzRYVG93aUw0NytwbVUwYkYKRFNnMjdxMmUvaC9ESEFIczE4Vy9YRGNEa3hwRU9IL0IwZS9HdEFLL1I1cUFnZm1uVnZJM2Y3d1JpZlc0bWovUAplRXFmQWdNQkFBR2pnWWt3Z1lZd0hRWURWUjBPQkJZRUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQjhHCkExVWRJd1FZTUJhQUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHcKSGdZRFZSMFJCQmN3RllFVGJXRnNiRzl5ZVVCbGVHRnRjR3hsTG01bGREQVRCZ05WSFNVRUREQUtCZ2dyQmdFRgpCUWNEQkRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUxrUkxrdXNmdHFyUkZOa2hiWmZiT3d0NEtPdGZGa1FuClluNEp6T1lOZnVlU2lkcldLWTNGMnMzWGZCaWNoQmVQVjZ0MXRvT2owK2VhZ1lOK3hpSTlLZnR5eWtWVlliNS8KZFBabEhMUmdSRmF2eGxxTExnMjViQlVFenB3M0xwYU1NYTYyWmhjMUNwME44aUFUVms5dnBNeE4vREZOMnc2SApxZSswQ29RWVJNOGFXL0QzYW9zK0VZS2JOc0IxWlYwQVp6dC9NSlllSnZSaHA3b0gyUUE0c1hwODJmMEYwUkFWCnVTWkNYMzhXemJwZnZsRE9vYXNVVWxPZFBFdnhCQmFRSXB0S0cxcTJER2pxTFpVaUh5eW9udGRqelI2K1ZhaVYKeXBCOGdzNy9vRlNLTm9RRVc4d3pvRW51YlhoNzBBMzZQcXIxVkZGWnRMa05KRFVmYkJZelBqR0NBa0l3Z2dJKwpBZ0VCTURBd0dERVdNQlFHQTFVRUF3d05VMjl0WldKdlpIa2dSV3h6WlFJVUZkS05maE92QkNqWjExSTdQalgzClNtNlBTaFF3RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUgKQVRBY0Jna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZwpENnRORXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3CmFqQUxCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUcKOXcwREJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSQpLb1pJaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBSEtKbXQ0SmYrZ1kvTmtIS0xueTc3VC9KCkQxc3lBM2xGWjAwOGlUR3htQU5mQVV3VlFXeTdmSEd6UG1mMkZCdjN5ais3bGJTQUo0YjBKSVRDbFowMUVlalcKUUdkaE1ybVZBZ2QwTTU1ckNFZGNMcms3aFBzWlE3VU9kamMyTzIyY1MyWkJ3WkdrUzRyZThhMHF5NUQydEhBaAowZm5tdTB6RG9Wd1p1bzc4UGFuYzk2dGgzU2pTcExsSlU4andNeWl3bFJIWHpQMG5ITzhDUFdTRE1Lemk1KzhICkVIRHZjaml2ekdudFZPSHZhZmVva0UyTm1ySXZKcENFdk1FTVZ5Vm15c2dVRU5UUUpZT0loRWJYdTJrdEs3OWYKangrdzZpWVVaam5wRUhKNzlPTEFyWnNWNitlN3g5bnE2bGhBM3pTdDJDd3BPR3ZmUk0xN3ROMGMrT0FMcGc9PQoKLS0tLS0tQTM3NkQ3NjNDN0Y0NzUwOTcxRjdDMDRCMjdBMzhDQTctLQoK",
|
||||
} as const;
|
||||
|
||||
/** The message as bytes, ready to be served as a blob. */
|
||||
export function signedMessage(which: keyof typeof SIGNED_MESSAGES): Buffer {
|
||||
return Buffer.from(SIGNED_MESSAGES[which], "base64");
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { test, before, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Signing in on the mail server's own page, end to end against the mock's
|
||||
* OAuth side: the redirect out, the callback, the session holding tokens
|
||||
* instead of a password, token renewal, and what ends a session.
|
||||
*/
|
||||
|
||||
const PORT = 18811;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-oauth";
|
||||
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
|
||||
process.env.PUBLIC_URL = "https://webmail.example.test";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { oauthMock } = await import("./mock/oauth.js");
|
||||
const { createApp, pushCredential, sessions } = await import("./app.js");
|
||||
const { resetOAuthState } = await import("./oauth.js");
|
||||
|
||||
const app = createApp();
|
||||
const CALLBACK = "https://webmail.example.test/api/auth/callback";
|
||||
|
||||
/** A cookie jar, since sign-in sets two cookies on different paths. */
|
||||
let jar = new Map<string, string>();
|
||||
|
||||
function keepCookies(res: Response) {
|
||||
for (const header of res.headers.getSetCookie()) {
|
||||
const [pair, ...attrs] = header.split(";");
|
||||
const [name, value] = [pair!.slice(0, pair!.indexOf("=")), pair!.slice(pair!.indexOf("=") + 1)];
|
||||
const expired = attrs.some((a) => /max-age=0\b/i.test(a.trim()) || /expires=thu, 01 jan 1970/i.test(a.trim()));
|
||||
if (expired || value === "") jar.delete(name);
|
||||
else jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path: string, init: RequestInit = {}) {
|
||||
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
const res = await app.request(path, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
|
||||
});
|
||||
keepCookies(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function jsonOf(res: Response) {
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
/** Leave for the server's page and come back: returns the callback URL. */
|
||||
async function goToServerAndBack(username = "[email protected]"): Promise<URL> {
|
||||
const start = await call(`/api/auth/oauth/start?username=${encodeURIComponent(username)}&remember=1`);
|
||||
assert.equal(start.status, 302);
|
||||
const signInPage = new URL(start.headers.get("location")!);
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
assert.equal(approved.status, 302, "the mock's page approves the demo user");
|
||||
return new URL(approved.headers.get("location")!);
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
const back = await goToServerAndBack();
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.status, 302);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
assert.ok(jar.get("ihm_session"), "a session cookie was set");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jar = new Map();
|
||||
oauthMock.reset();
|
||||
resetOAuthState();
|
||||
});
|
||||
|
||||
before(() => {});
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("the configuration tells the web app to use the server's page", async () => {
|
||||
const body = await jsonOf(await call("/api/config"));
|
||||
assert.equal(body.signIn, "oauth");
|
||||
assert.equal(body.signInDirect, true, "one mail server: its page asks for the username, not ihasmail");
|
||||
});
|
||||
|
||||
test("with one mail server, sign-in starts without an address", async () => {
|
||||
const res = await call("/api/auth/oauth/start");
|
||||
assert.equal(res.status, 302);
|
||||
const to = new URL(res.headers.get("location")!);
|
||||
assert.equal(to.searchParams.has("login_hint"), false);
|
||||
const approved = await fetch(to, { redirect: "manual" });
|
||||
const back = new URL(approved.headers.get("location")!);
|
||||
assert.equal((await call(`/api/auth/callback${back.search}`)).headers.get("location"), "/");
|
||||
assert.equal((await call("/api/auth/session")).status, 200);
|
||||
});
|
||||
|
||||
test("the password form is refused: ihasmail never sees a password", async () => {
|
||||
const res = await call("/api/auth/login", { method: "POST", body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal((await jsonOf(res)).error, "oauth_required");
|
||||
});
|
||||
|
||||
test("start sends the browser to the server's page with PKCE and a bound state", async () => {
|
||||
const res = await call("/api/auth/oauth/[email protected]");
|
||||
assert.equal(res.status, 302);
|
||||
const to = new URL(res.headers.get("location")!);
|
||||
assert.equal(`${to.origin}${to.pathname}`, `http://127.0.0.1:${PORT}/login`);
|
||||
assert.equal(to.searchParams.get("client_id"), "ihasmail-inbuxa");
|
||||
assert.equal(to.searchParams.get("redirect_uri"), CALLBACK);
|
||||
assert.equal(to.searchParams.get("response_type"), "code");
|
||||
assert.equal(to.searchParams.get("code_challenge_method"), "S256");
|
||||
assert.match(to.searchParams.get("code_challenge") ?? "", /^[\w-]{43}$/);
|
||||
assert.equal(to.searchParams.get("login_hint"), "[email protected]");
|
||||
assert.equal(to.searchParams.get("scope"), "openid offline_access");
|
||||
assert.equal(jar.get("ihm_session_signin"), to.searchParams.get("state"), "the state is bound to this browser");
|
||||
});
|
||||
|
||||
test("a full sign-in holds tokens, and the session works", async () => {
|
||||
await signIn();
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonOf(res);
|
||||
assert.equal(body.ihasmail.loginName, "[email protected]");
|
||||
assert.equal(jar.get("ihm_session_signin"), undefined, "the state cookie is cleared");
|
||||
});
|
||||
|
||||
test("the callback comes back cross-site, and is still accepted", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
const res = await call(`/api/auth/callback${back.search}`, { headers: { "sec-fetch-site": "cross-site" } });
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
});
|
||||
|
||||
test("a callback from a sign-in this browser didn't start is refused", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
jar.delete("ihm_session_signin");
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=state_mismatch");
|
||||
assert.equal(jar.get("ihm_session"), undefined);
|
||||
});
|
||||
|
||||
test("a state is good for one attempt", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
const state = jar.get("ihm_session_signin")!;
|
||||
await call(`/api/auth/callback${back.search}`);
|
||||
jar = new Map([["ihm_session_signin", state]]);
|
||||
const again = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(again.headers.get("location"), "/?signin_error=state_mismatch");
|
||||
});
|
||||
|
||||
test("cancelling on the server's page comes back as an error, not a session", async () => {
|
||||
await call("/api/auth/oauth/[email protected]");
|
||||
const state = jar.get("ihm_session_signin")!;
|
||||
const res = await call(`/api/auth/callback?error=access_denied&state=${state}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=cancelled");
|
||||
assert.equal(jar.get("ihm_session"), undefined);
|
||||
});
|
||||
|
||||
test("a code the server won't exchange is refused", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
back.searchParams.set("code", "not-a-code");
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=exchange_failed");
|
||||
});
|
||||
|
||||
test("an access token about to expire is renewed without the person noticing", async () => {
|
||||
oauthMock.setAccessTokenTtl(60); // inside the renewal margin from the start
|
||||
await signIn();
|
||||
oauthMock.expireAccessTokens(); // the one the session holds is now dead upstream
|
||||
const res = await call("/api/auth/session?refresh=1");
|
||||
assert.equal(res.status, 200, "renewed before the call went upstream");
|
||||
});
|
||||
|
||||
test("renewal refused by the server ends the session", async () => {
|
||||
await signIn();
|
||||
const cookie = jar.get("ihm_session")!;
|
||||
const session = sessions.resolve(cookie)!;
|
||||
// Pretend the token is about to expire, then make the server refuse to renew it.
|
||||
sessions.updateTokens(cookie, { ...session.tokens!, expiresAt: Date.now() + 1000, refresh: "revoked" });
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(jar.get("ihm_session"), undefined, "and the cookie is cleared");
|
||||
});
|
||||
|
||||
test("a password change signs the session out, since the server revokes its tokens", async () => {
|
||||
await signIn();
|
||||
const res = await call("/api/account/password", { method: "POST", body: JSON.stringify({ current: "demo-password", next: "new-password-123" }) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await jsonOf(res)).signedOut, true);
|
||||
assert.equal((await call("/api/auth/session")).status, 401);
|
||||
// Put it back for the tests after this one.
|
||||
const { account } = await import("./mock/config.js");
|
||||
account.password = "demo-password";
|
||||
});
|
||||
|
||||
test("creating an app password checks the typed password with the server", async () => {
|
||||
await signIn();
|
||||
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
|
||||
assert.equal(wrong.status, 403);
|
||||
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
|
||||
assert.equal(right.status, 200);
|
||||
});
|
||||
|
||||
test("push keeps a credential that renews itself", async () => {
|
||||
oauthMock.setAccessTokenTtl(60);
|
||||
await signIn();
|
||||
const session = sessions.resolve(jar.get("ihm_session"))!;
|
||||
const credential = pushCredential(session);
|
||||
const first = await credential.get();
|
||||
oauthMock.expireAccessTokens();
|
||||
const second = await credential.get();
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Signing in through the mail server's own page (OAuth 2.0 authorization code
|
||||
* with PKCE), so ihasmail never handles a password to sign someone in.
|
||||
*
|
||||
* The flow, with ihasmail as a confidential client registered on the server:
|
||||
*
|
||||
* 1. `start()` picks the account's server from the username, reads the
|
||||
* server's OAuth metadata, and sends the browser to its sign-in page with
|
||||
* a PKCE challenge and a one-time `state`. The state is bound to the
|
||||
* browser by a short-lived cookie, so a callback carrying somebody else's
|
||||
* code can't sign this browser into their account.
|
||||
* 2. The person signs in there, two-factor included, and the server sends the
|
||||
* browser back to `/api/auth/callback` with a code.
|
||||
* 3. `finish()` checks the state, exchanges the code (with the PKCE verifier
|
||||
* and this client's secret) for an access and a refresh token, and the
|
||||
* session keeps those, sealed, instead of a password.
|
||||
*
|
||||
* Access tokens last an hour; `refreshTokens()` renews them before they run
|
||||
* out. A password change on the server revokes both tokens, which ends every
|
||||
* session holding them -- the safe result, and the one the web app is told
|
||||
* about.
|
||||
*
|
||||
* Nothing here is taken from another client's implementation; the shapes are
|
||||
* RFC 6749, RFC 7636 and RFC 8414.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { randomToken } from "./crypto.js";
|
||||
import { UpstreamError, absoluteUpstream } from "./upstream.js";
|
||||
|
||||
export interface TokenSet {
|
||||
access: string;
|
||||
refresh: string | null;
|
||||
/** When the access token expires, in ms since the epoch. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/** Renew an access token this long before it expires. */
|
||||
export const REFRESH_MARGIN_MS = 5 * 60_000;
|
||||
/** How long a sign-in may take between leaving and coming back. */
|
||||
const PENDING_TTL_MS = 10 * 60_000;
|
||||
const METADATA_TTL_MS = 60 * 60_000;
|
||||
const MAX_PENDING = 10_000;
|
||||
|
||||
export function oauthEnabled(): boolean {
|
||||
return Boolean(config.oauthClientSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every account is on the same server. Then sign-in needs no address
|
||||
* first: the server's page asks for the username itself. With several servers
|
||||
* (MAIL_SERVERS_FILE), the domain picks the server, so the address comes
|
||||
* first.
|
||||
*/
|
||||
export function singleServer(): boolean {
|
||||
return Object.values(config.stalwartServers).every((url) => url === config.stalwartUrl);
|
||||
}
|
||||
|
||||
/** The one redirect URI registered for this client on the server. */
|
||||
export function redirectUri(): string {
|
||||
return `${config.publicUrl}${config.basePath}/api/auth/callback`;
|
||||
}
|
||||
|
||||
const metadataCache = new Map<string, { metadata: Metadata; fetchedAt: number }>();
|
||||
|
||||
async function metadataFor(base: string): Promise<Metadata> {
|
||||
const cached = metadataCache.get(base);
|
||||
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata;
|
||||
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`OAuth metadata request failed (${res.status})`, 502);
|
||||
const doc = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string; scopes_supported?: string[] };
|
||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||
throw new UpstreamError("The mail server's OAuth metadata has no authorization or token endpoint", 502);
|
||||
}
|
||||
const metadata = {
|
||||
// Where the *browser* goes, so the server's public address, as advertised.
|
||||
authorizationEndpoint: new URL(doc.authorization_endpoint, base).toString(),
|
||||
// Where this process goes, so the configured route, like every other call.
|
||||
tokenEndpoint: absoluteUpstream(doc.token_endpoint, base),
|
||||
scopes: doc.scopes_supported ?? [],
|
||||
};
|
||||
metadataCache.set(base, { metadata, fetchedAt: Date.now() });
|
||||
return metadata;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
verifier: string;
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
function sweepPending(now = Date.now()) {
|
||||
for (const [state, p] of pending) if (now - p.createdAt > PENDING_TTL_MS) pending.delete(state);
|
||||
}
|
||||
|
||||
function challengeOf(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", config.oauthClientId);
|
||||
url.searchParams.set("redirect_uri", redirectUri());
|
||||
if (scope) url.searchParams.set("scope", scope);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
export class SignInError extends Error {
|
||||
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
}
|
||||
pending.delete(params.state);
|
||||
if (Date.now() - p.createdAt > PENDING_TTL_MS) throw new SignInError("expired", "The sign-in took too long. Try again.");
|
||||
const metadata = await metadataFor(p.base);
|
||||
const tokens = await tokenRequest(metadata.tokenEndpoint, {
|
||||
grant_type: "authorization_code",
|
||||
code: params.code,
|
||||
code_verifier: p.verifier,
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew an access token. Null when the server refuses the refresh token --
|
||||
* revoked by a password change, expired, or the client's secret changed --
|
||||
* which ends the session. Throws when the server couldn't be asked.
|
||||
*/
|
||||
export async function refreshTokens(base: string, tokens: TokenSet): Promise<TokenSet | null> {
|
||||
if (!tokens.refresh) return null;
|
||||
const metadata = await metadataFor(base);
|
||||
const renewed = await tokenRequest(metadata.tokenEndpoint, { grant_type: "refresh_token", refresh_token: tokens.refresh });
|
||||
// The server hands out a new refresh token only when the old one is close
|
||||
// to expiring; otherwise the old one stays good.
|
||||
return renewed && { ...renewed, refresh: renewed.refresh ?? tokens.refresh };
|
||||
}
|
||||
|
||||
async function tokenRequest(endpoint: string, fields: Record<string, string>): Promise<TokenSet | null> {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
||||
body: new URLSearchParams({ ...fields, client_id: config.oauthClientId, client_secret: config.oauthClientSecret }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 400 || res.status === 401) return null;
|
||||
if (!res.ok) throw new UpstreamError(`Token request failed (${res.status})`, 502);
|
||||
const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string };
|
||||
if (!body.access_token || (body.token_type && body.token_type.toLowerCase() !== "bearer")) {
|
||||
throw new UpstreamError("The mail server returned no usable access token", 502);
|
||||
}
|
||||
return {
|
||||
access: body.access_token,
|
||||
refresh: body.refresh_token ?? null,
|
||||
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function needsRefresh(tokens: TokenSet, now = Date.now()): boolean {
|
||||
return tokens.expiresAt - now < REFRESH_MARGIN_MS;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetOAuthState(): void {
|
||||
pending.clear();
|
||||
metadataCache.clear();
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
process.env.PUSH_URL = "https://ihasmail.example";
|
||||
const push = await import("./push.js");
|
||||
|
||||
/** A fixed credential, as a password session hands push. */
|
||||
const cred = (authorization: string) => ({ get: async () => authorization });
|
||||
|
||||
// Nothing in this file may reach the network. Background subscribe() calls
|
||||
// outlive the test that started them, so the stub stays in place for the
|
||||
// whole file rather than per test; the per-test stubs below layer on top.
|
||||
const NO_NETWORK = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response("{}", { status: 599 })) as typeof fetch;
|
||||
process.on("exit", () => { globalThis.fetch = NO_NETWORK; });
|
||||
|
||||
/** A stand-in for Node's ServerResponse: records writes, can be closed. */
|
||||
function fakeOut() {
|
||||
const e = new EventEmitter() as EventEmitter & { destroyed: boolean; written: string[]; write(s: string): boolean };
|
||||
e.destroyed = false; e.written = [];
|
||||
e.write = (s: string) => { e.written.push(s); return true; };
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Answer any upstream call as Stalwart would for a successful PushSubscription/set. */
|
||||
function stubUpstream(created = true) {
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const body = { methodResponses: [["PushSubscription/set", created
|
||||
? { created: { s: { id: "sub1", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } }, updated: { sub1: null } }
|
||||
: { notCreated: { s: { type: "forbidden" } } }, "0"]] };
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
return () => { globalThis.fetch = real; };
|
||||
}
|
||||
|
||||
test("an unknown token is a 404", async () => {
|
||||
assert.equal(await push.receive("nope", { "@type": "StateChange" }), 404);
|
||||
});
|
||||
|
||||
test("a tab opened before verification gets no fan-out, and a subscription is started", async () => {
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
const out = fakeOut();
|
||||
const entry = push.attach("[email protected]", "a", cred("Basic x"), out as never);
|
||||
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const st = push.pushStatus();
|
||||
assert.equal(st.accounts.pending + st.accounts.verified, 1);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("verification then fan-out: one POST reaches every open tab for the account", async () => {
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
// First contact starts the subscription; wait for the stubbed create to land.
|
||||
const first = fakeOut();
|
||||
push.attach("[email protected]", "a", cred("Basic y"), first as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
|
||||
// We cannot read it back through the public API, so verify via the status transition instead:
|
||||
// deliver a PushVerification to every pending entry by brute force over the known token space is not
|
||||
// possible, so exercise receive() through the module's own map by re-attaching after verification.
|
||||
const status = push.pushStatus();
|
||||
assert.ok(status.accounts.pending >= 1 || status.accounts.verified >= 1);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("a StateChange is written to attached tabs as an SSE frame, and closed tabs are dropped", async () => {
|
||||
// Drive the fan-out directly through an entry made verified by the verification path.
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
const out1 = fakeOut(), out2 = fakeOut();
|
||||
push.attach("[email protected]", "a", cred("Basic z"), out1 as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
|
||||
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
|
||||
let token: string | null = null;
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const b = typeof init?.body === "string" ? init.body : "";
|
||||
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(b);
|
||||
if (m) token = m[1];
|
||||
return real(input, init);
|
||||
}) as typeof fetch;
|
||||
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
|
||||
push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
globalThis.fetch = real;
|
||||
assert.ok(token, "the subscribe call carries the push URL with the token");
|
||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||
const entry = push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||
assert.ok(entry, "verified: the tab is served by fan-out");
|
||||
push.attach("[email protected]", "a", cred("Basic w"), out2 as never);
|
||||
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
|
||||
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
|
||||
assert.equal(out2.written.length, 1);
|
||||
out2.destroyed = true; out2.emit("close");
|
||||
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s2" } } });
|
||||
assert.equal(out1.written.length, 2); assert.equal(out2.written.length, 1, "a closed tab receives nothing more");
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("a malformed body is a 400, not a crash", async () => {
|
||||
assert.equal(await push.receive("nope", "not an object"), 404);
|
||||
});
|
||||
|
||||
test("a tab on the relay is moved to fan-out when its account verifies, and its upstream is dropped", async () => {
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
let token: string | null = null;
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(typeof init?.body === "string" ? init.body : "");
|
||||
if (m) token = m[1];
|
||||
return real(input, init);
|
||||
}) as typeof fetch;
|
||||
push.prepare("[email protected]", "a", cred("Basic m")); // sign-in starts the subscription
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
globalThis.fetch = real;
|
||||
assert.ok(token);
|
||||
const out = fakeOut(); let dropped = 0;
|
||||
assert.equal(push.attach("[email protected]", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
|
||||
push.attachRelay("[email protected]", out as never, () => { dropped++; });
|
||||
assert.equal(push.pushStatus().tabs.relay >= 1, true);
|
||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||
assert.equal(dropped, 1, "the relay's upstream request was ended on verification");
|
||||
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s9" } } });
|
||||
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("a new subscription clears what this installation left behind, and only that", async () => {
|
||||
// What a restart finds: its own subscription from the last process, another
|
||||
// installation's on the same server, a browser's, and the old id format.
|
||||
const calls: Array<[string, Record<string, unknown>]> = [];
|
||||
let ownPrefix = "";
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const [name, args, id] = methodCalls[0]!;
|
||||
calls.push([name, args]);
|
||||
let result: Record<string, unknown> = {};
|
||||
if (name === "PushSubscription/get") {
|
||||
result = { list: [
|
||||
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
|
||||
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
|
||||
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
|
||||
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
|
||||
] };
|
||||
} else if (name === "PushSubscription/set" && args.create) {
|
||||
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
|
||||
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
|
||||
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
|
||||
} else {
|
||||
result = { destroyed: args.destroy };
|
||||
}
|
||||
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||
push.prepare("[email protected]", "a", cred("Basic p"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
|
||||
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||
|
||||
calls.length = 0;
|
||||
push.prepare("[email protected]", "a", cred("Basic r"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Push by subscription: hold no upstream connection per tab.
|
||||
*
|
||||
* Today every signed-in tab holds a Server-Sent Events stream to ihasmail,
|
||||
* and ihasmail holds a matching stream to Stalwart behind it. The upstream
|
||||
* one is most of what a tab costs -- measured, 81 KiB of TLS state plus the
|
||||
* request objects -- and it is also the only reason Stalwart's connection
|
||||
* limit applies to ihasmail at all.
|
||||
*
|
||||
* RFC 8620 §7.2 defines the other transport: a PushSubscription, where the
|
||||
* server POSTs StateChange objects to a URL the client registers. Stalwart
|
||||
* implements it. So ihasmail registers one subscription per *account*, and
|
||||
* when Stalwart POSTs a change, fans it out to that account's open tabs over
|
||||
* the browser-facing streams it already holds. Nothing is held upstream.
|
||||
*
|
||||
* Nothing here is taken from any other client's implementation; the shapes
|
||||
* are the RFC's.
|
||||
*
|
||||
* The subscription URL must be https and Stalwart must trust its
|
||||
* certificate -- the RFC requires the scheme and Stalwart enforces it. Where
|
||||
* that is not the case the subscription never verifies, and the account
|
||||
* stays on the per-tab relay it uses today. Both paths coexist; the
|
||||
* transition loses no events, because a tab opened before verification keeps
|
||||
* its own relay for its whole life.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
||||
|
||||
const USING = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
|
||||
const RENEW_BEFORE_MS = 60 * 60_000; // renew an hour before Stalwart expires it
|
||||
const VERIFY_TIMEOUT_MS = 3 * 60_000; // Stalwart's first attempt waits 60 s; allow retries
|
||||
const SWEEP_MS = 30_000;
|
||||
|
||||
interface AccountPush {
|
||||
key: string; // upstream base + username
|
||||
username: string;
|
||||
accountId: string;
|
||||
base: string;
|
||||
token: string; // what Stalwart puts in the URL
|
||||
credential: PushCredential; // one live session's credential, for set/verify/renew
|
||||
subscriptionId: string | null;
|
||||
state: "pending" | "verified" | "failed";
|
||||
since: number;
|
||||
expires: number;
|
||||
tabs: Set<ServerResponse>;
|
||||
/** Tabs still on the per-tab relay, with the hook that ends their upstream request. */
|
||||
relays: Map<ServerResponse, () => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* How push authenticates its own calls. A password session's is fixed; an
|
||||
* OAuth session's renews its access token itself, since a subscription lives
|
||||
* for days and an access token for an hour. See pushCredential() in app.ts.
|
||||
*/
|
||||
export interface PushCredential {
|
||||
get(): Promise<string>;
|
||||
}
|
||||
|
||||
const byKey = new Map<string, AccountPush>();
|
||||
const byToken = new Map<string, AccountPush>();
|
||||
let sweeper: NodeJS.Timeout | null = null;
|
||||
|
||||
export function pushEnabled(): boolean {
|
||||
return config.pushMode === "subscribe" && !!config.pushUrl;
|
||||
}
|
||||
|
||||
function keyFor(base: string, username: string) { return `${base} ${username}`; }
|
||||
|
||||
async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||
const authorization = await entry.credential.get();
|
||||
const upstream = await getUpstreamSession(entry.key, authorization, entry.base);
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: USING, methodCalls: calls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) throw new Error(`upstream ${res.status}`);
|
||||
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
||||
}
|
||||
|
||||
/*
|
||||
* Whose subscriptions are whose.
|
||||
*
|
||||
* Each process used to register a subscription per account and forget it when
|
||||
* it stopped -- state here is in memory, and an immutable deployment restarts
|
||||
* on every deploy -- so each restart left one more behind, receiving 404s until
|
||||
* it expired. Stalwart keeps them all and allows fifteen per account (checked
|
||||
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
|
||||
* too (#375).
|
||||
*
|
||||
* So the device id names the installation -- a hash of the address Stalwart
|
||||
* posts to, stable across restarts and different for another installation on
|
||||
* the same server -- and a new subscription first removes the ones this
|
||||
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
|
||||
* the browsers' own, which the web client may clear to make room.
|
||||
*/
|
||||
function installationId(): string {
|
||||
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
|
||||
}
|
||||
|
||||
function deviceIdFor(entry: AccountPush): string {
|
||||
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
async function removeLeftovers(entry: AccountPush) {
|
||||
const mine = `ihasmail-proxy-${installationId()}-`;
|
||||
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
|
||||
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
|
||||
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
|
||||
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
|
||||
}
|
||||
|
||||
/** Give the live subscription another week, rather than registering a second one. */
|
||||
async function renew(entry: AccountPush) {
|
||||
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
|
||||
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
|
||||
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
|
||||
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
|
||||
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
|
||||
entry.expires = after ? Date.parse(after) : Date.parse(expires);
|
||||
}
|
||||
|
||||
async function subscribe(entry: AccountPush) {
|
||||
try {
|
||||
await removeLeftovers(entry);
|
||||
} catch (err) {
|
||||
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
|
||||
}
|
||||
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||
const r = await jmap(entry, [["PushSubscription/set", {
|
||||
create: { s: { deviceClientId: deviceIdFor(entry), url,
|
||||
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||
}, "0"]]);
|
||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
||||
if (!created) throw new Error("subscription not created");
|
||||
entry.subscriptionId = created.id;
|
||||
entry.expires = created.expires ? Date.parse(created.expires) : Date.now() + 7 * 86_400_000;
|
||||
}
|
||||
|
||||
async function verify(entry: AccountPush, code: string) {
|
||||
await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { verificationCode: code } } }, "0"]]);
|
||||
entry.state = "verified";
|
||||
// Every tab of this account that has been holding its own upstream stream
|
||||
// can now let go of it: the subscription is live, so Stalwart will POST the
|
||||
// same changes here. The browser-facing stream is untouched. Done in this
|
||||
// order there is no gap -- at worst a change lands twice, which is harmless.
|
||||
let moved = 0;
|
||||
for (const [out, dropUpstream] of entry.relays) {
|
||||
entry.relays.delete(out);
|
||||
if (out.destroyed) continue;
|
||||
dropUpstream(); entry.tabs.add(out); moved++;
|
||||
}
|
||||
console.log(`[ihasmail] push: subscription verified for ${entry.username}` + (moved ? `, ${moved} tab(s) moved off the relay` : ""));
|
||||
}
|
||||
|
||||
async function unsubscribe(entry: AccountPush) {
|
||||
if (entry.subscriptionId) {
|
||||
try { await jmap(entry, [["PushSubscription/set", { destroy: [entry.subscriptionId] }, "0"]]); } catch { /* best effort */ }
|
||||
}
|
||||
byKey.delete(entry.key); byToken.delete(entry.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or refresh) the account's subscription. Called at sign-in, so that
|
||||
* by the time the browser opens its stream the verification is usually
|
||||
* already in flight, and called again by attach() as a safety net.
|
||||
*/
|
||||
export function prepare(username: string, accountId: string, credential: PushCredential): AccountPush | null {
|
||||
if (!pushEnabled()) return null;
|
||||
const base = upstreamFor(username);
|
||||
const key = keyFor(base, username);
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
|
||||
credential, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
|
||||
byKey.set(key, entry); byToken.set(entry.token, entry);
|
||||
subscribe(entry).catch((err) => {
|
||||
entry!.state = "failed";
|
||||
console.warn(`[ihasmail] push: subscribe failed for ${username}: ${(err as Error).message}; relay in use`);
|
||||
});
|
||||
startSweeper();
|
||||
} else {
|
||||
entry.credential = credential; // keep a live credential for renewals
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a tab opens. Returns the account's push entry if the tab can
|
||||
* be served by fan-out right now, or null if it must hold its own relay.
|
||||
*/
|
||||
export function attach(username: string, accountId: string, credential: PushCredential, out: ServerResponse): AccountPush | null {
|
||||
const entry = prepare(username, accountId, credential);
|
||||
if (!entry || entry.state !== "verified") return null;
|
||||
entry.tabs.add(out);
|
||||
out.on("close", () => { entry.tabs.delete(out); });
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab that had to start on the relay registers here with the hook that
|
||||
* ends its upstream request, so verify() can move it to fan-out later.
|
||||
*/
|
||||
export function attachRelay(username: string, out: ServerResponse, dropUpstream: () => void): void {
|
||||
if (!pushEnabled()) return;
|
||||
const entry = byKey.get(keyFor(upstreamFor(username), username));
|
||||
if (!entry) return;
|
||||
entry.relays.set(out, dropUpstream);
|
||||
out.on("close", () => { entry.relays.delete(out); });
|
||||
}
|
||||
|
||||
/** Stalwart's POST. Returns an HTTP status. */
|
||||
export async function receive(token: string, body: unknown): Promise<number> {
|
||||
const entry = byToken.get(token);
|
||||
if (!entry) return 404;
|
||||
const msg = body as { "@type"?: string; verificationCode?: string; changed?: unknown };
|
||||
if (msg["@type"] === "PushVerification" && typeof msg.verificationCode === "string") {
|
||||
try { await verify(entry, msg.verificationCode); return 200; }
|
||||
catch (err) { console.warn(`[ihasmail] push: verify failed: ${(err as Error).message}`); return 500; }
|
||||
}
|
||||
if (msg["@type"] === "StateChange") {
|
||||
const frame = `event: state\ndata: ${JSON.stringify(msg)}\n\n`;
|
||||
for (const out of entry.tabs) { if (!out.destroyed) out.write(frame); }
|
||||
return 200;
|
||||
}
|
||||
return 400;
|
||||
}
|
||||
|
||||
/** One shared timer for every tab: keep-alives, renewals, and cleanup. */
|
||||
function startSweeper() {
|
||||
if (sweeper) return;
|
||||
sweeper = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const entry of [...byKey.values()]) {
|
||||
for (const out of entry.tabs) { if (out.destroyed) entry.tabs.delete(out); else out.write(": ping\n\n"); }
|
||||
if (entry.state === "pending" && now - entry.since > VERIFY_TIMEOUT_MS) {
|
||||
entry.state = "failed";
|
||||
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
||||
}
|
||||
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||
// Extended in place, which keeps it verified. Only if the server will
|
||||
// not is a new one registered, and that one has to verify again.
|
||||
entry.expires = now + RENEW_BEFORE_MS;
|
||||
renew(entry).catch(() => {
|
||||
entry.state = "pending"; entry.since = Date.now();
|
||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||
});
|
||||
}
|
||||
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||
void unsubscribe(entry);
|
||||
}
|
||||
}
|
||||
if (byKey.size === 0 && sweeper) { clearInterval(sweeper); sweeper = null; }
|
||||
}, SWEEP_MS);
|
||||
sweeper.unref();
|
||||
}
|
||||
|
||||
/** For /api/health: how many accounts are on each path. */
|
||||
export function pushStatus() {
|
||||
let verified = 0, pending = 0, failed = 0, tabs = 0, relays = 0;
|
||||
for (const e of byKey.values()) { tabs += e.tabs.size; relays += e.relays.size; if (e.state === "verified") verified++; else if (e.state === "pending") pending++; else failed++; }
|
||||
return { mode: pushEnabled() ? "subscribe" : "relay", accounts: { verified, pending, failed }, tabs: { fanout: tabs, relay: relays } };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* How much a request may make the proxy hold in memory.
|
||||
*
|
||||
* Routes that read JSON take a small body and no more, whether or not anyone
|
||||
* is signed in. The JMAP route streams straight through for a session that may
|
||||
* administer; for one that may not, it reads the body to check it, and that
|
||||
* read is capped in size, in how many one session runs at once, and in bytes
|
||||
* across everyone.
|
||||
*/
|
||||
|
||||
const PORT = 18813;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-request-limits";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { rateLimitKey } = await import("./clientip.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
let cookie = "";
|
||||
|
||||
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
|
||||
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent >= size) return controller.close();
|
||||
const n = Math.min(chunk, size - sent);
|
||||
controller.enqueue(new Uint8Array(n).fill(0x20));
|
||||
sent += n;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const jmap = (body: BodyInit) =>
|
||||
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
|
||||
|
||||
before(async () => {
|
||||
// Not remembered: a device that is not the person's own, so JMAP is checked.
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||
assert.equal(res.status, 200, "login should succeed against the mock");
|
||||
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("sign-in refuses a large body by its length, before reading it", async () => {
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("sign-in refuses a large chunked body without holding all of it", async () => {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("other JSON routes are limited too", async () => {
|
||||
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("an ordinary checked JMAP request still goes through", async () => {
|
||||
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than the check allows is refused", async () => {
|
||||
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
|
||||
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
|
||||
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
|
||||
assert.equal((await jmap(body)).status, 200);
|
||||
});
|
||||
|
||||
test("one session cannot hold more than a few checked reads at once", async () => {
|
||||
// Bodies that never finish: each holds its slot until its stream fails.
|
||||
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
||||
const pending: Promise<Response>[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
|
||||
pending.push(jmap(s));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const fifth = await jmap("{}");
|
||||
assert.equal(fifth.status, 429);
|
||||
assert.ok(fifth.headers.get("retry-after"));
|
||||
for (const c of controllers) c.error(new Error("client went away"));
|
||||
await Promise.allSettled(pending);
|
||||
// The slots are given back once those requests end.
|
||||
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
|
||||
assert.equal(again.status, 200);
|
||||
});
|
||||
|
||||
test("IPv6 addresses share a rate-limit key across their /64", () => {
|
||||
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
|
||||
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
|
||||
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
|
||||
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
|
||||
assert.equal(rateLimitKey("unknown"), "unknown");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { SessionStore, accountKey } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
|
||||
assert.equal(store.resolve(cookie), null);
|
||||
});
|
||||
|
||||
test("sessions group by the account, however its name was typed", () => {
|
||||
const store = new SessionStore("");
|
||||
const key = accountKey("https://mail.example.com", "[email protected]");
|
||||
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
// The same name on another configured server is another account.
|
||||
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
assert.equal(a.session.account, b.session.account);
|
||||
assert.equal(store.listForUser(a.session.account).length, 2);
|
||||
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
|
||||
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
|
||||
assert.ok(store.resolve(a.cookie), "this session was kept");
|
||||
});
|
||||
|
||||
test("persisted session data does not contain the password", () => {
|
||||
const store = new SessionStore("");
|
||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
||||
import type { TokenSet } from "./oauth.js";
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
@@ -10,9 +11,11 @@ export interface StoredSession {
|
||||
secretHash: string;
|
||||
/** base64 random salt for key derivation */
|
||||
salt: string;
|
||||
/** sealed JSON {username, password} */
|
||||
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
account?: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
@@ -24,8 +27,12 @@ export interface StoredSession {
|
||||
export interface LiveSession {
|
||||
id: string;
|
||||
username: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
|
||||
authorization: string;
|
||||
/** The OAuth tokens behind `authorization`, or null for a password session. */
|
||||
tokens: TokenSet | null;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
@@ -46,9 +53,30 @@ export interface SessionSummary {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key sessions are grouped by for "sign out everywhere else".
|
||||
*
|
||||
* Not the username as typed: Stalwart takes `[email protected]` and a bare
|
||||
* `alice` as the same account, and a session opened either way was missing
|
||||
* from the list and survived the sign-out. The server's own name for the
|
||||
* account, lower-cased, and the server it lives on -- the same name on two
|
||||
* configured servers is two accounts.
|
||||
*/
|
||||
export function accountKey(upstream: string, canonicalUsername: string): string {
|
||||
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function accountOf(s: StoredSession): string {
|
||||
return s.account ?? s.username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
password: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
/** Exactly one of `password` and `tokens`. */
|
||||
password?: string;
|
||||
tokens?: TokenSet;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
@@ -75,7 +103,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 {
|
||||
@@ -84,13 +112,25 @@ export interface SessionBackend {
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
/** Store renewed OAuth tokens in place of the ones the session holds. */
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
listForUser(account: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
/** What a session seals: a password, or OAuth tokens. */
|
||||
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
|
||||
|
||||
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
|
||||
if (params.tokens) return { u: username, t: params.tokens };
|
||||
if (params.password !== undefined) return { u: username, p: params.password };
|
||||
throw new Error("a session needs a password or tokens");
|
||||
}
|
||||
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
@@ -170,8 +210,9 @@ export class SessionStore implements SessionBackend {
|
||||
id,
|
||||
secretHash: sha256(secret),
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: now + ttl,
|
||||
@@ -182,7 +223,7 @@ export class SessionStore implements SessionBackend {
|
||||
this.sessions.set(id, stored);
|
||||
this.scheduleSave();
|
||||
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
||||
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
||||
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
|
||||
}
|
||||
|
||||
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
||||
@@ -204,9 +245,9 @@ export class SessionStore implements SessionBackend {
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
const json = open(stored.sealedCredentials, key);
|
||||
if (!json) return null;
|
||||
let creds: { u: string; p: string };
|
||||
let creds: Sealed;
|
||||
try {
|
||||
creds = JSON.parse(json) as { u: string; p: string };
|
||||
creds = JSON.parse(json) as Sealed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -217,7 +258,7 @@ export class SessionStore implements SessionBackend {
|
||||
stored.expiresAt = now + ttl;
|
||||
this.scheduleSave();
|
||||
}
|
||||
return this.toLive(stored, creds.u, creds.p);
|
||||
return this.toLive(stored, creds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +271,14 @@ export class SessionStore implements SessionBackend {
|
||||
* secret half of it, which the server never keeps.
|
||||
*/
|
||||
reseal(cookie: string | undefined, password: string): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
|
||||
}
|
||||
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
|
||||
}
|
||||
|
||||
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
|
||||
if (!cookie) return false;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return false;
|
||||
@@ -239,7 +288,7 @@ export class SessionStore implements SessionBackend {
|
||||
if (!stored) return false;
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
||||
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
@@ -248,10 +297,10 @@ export class SessionStore implements SessionBackend {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
destroyAllForUser(username: string, exceptId?: string): number {
|
||||
destroyAllForUser(account: string, exceptId?: string): number {
|
||||
let n = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.username === username && id !== exceptId) {
|
||||
if (accountOf(s) === account && id !== exceptId) {
|
||||
this.sessions.delete(id);
|
||||
n++;
|
||||
}
|
||||
@@ -260,21 +309,26 @@ export class SessionStore implements SessionBackend {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
listForUser(account: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
||||
if (accountOf(s) !== account) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
||||
private toLive(s: StoredSession, creds: Sealed): LiveSession {
|
||||
const username = creds.u;
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
account: accountOf(s),
|
||||
authorization: "t" in creds
|
||||
? `Bearer ${creds.t.access}`
|
||||
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
|
||||
tokens: "t" in creds ? creds.t : null,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
|
||||
@@ -59,7 +59,7 @@ test("the example's commentary cannot be mistaken for a section", () => {
|
||||
* reason: an example that no longer loads is worse than no example, because
|
||||
* the first experience of the feature is a server that refuses to start.
|
||||
*/
|
||||
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url));
|
||||
const SERVERS = fileURLToPath(new URL("../../mail-servers.example.json", import.meta.url));
|
||||
|
||||
test("the example server mapping is valid JSON", () => {
|
||||
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
/*
|
||||
* The bundle goes out compressed once, at build time, and anything revalidated
|
||||
* can be answered with a 304.
|
||||
*
|
||||
* Before this the server gzipped the bundle again for every request that asked,
|
||||
* never offered Brotli, and sent no validator for the shell -- so each reload's
|
||||
* revalidation of index.html and sw.js downloaded them in full.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
|
||||
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
|
||||
// A copy left over from an older build of the same name must not be served.
|
||||
writeFileSync(join(root, "assets", "stale-000000.js"), js);
|
||||
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
|
||||
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
const app = createApp();
|
||||
|
||||
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
|
||||
|
||||
test("Brotli is served where the browser takes it", async () => {
|
||||
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "br");
|
||||
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
||||
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
|
||||
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
|
||||
assert.equal(gz.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
|
||||
const plain = await get("/assets/app-a1b2c3.js");
|
||||
assert.equal(plain.headers.get("content-encoding"), null);
|
||||
assert.equal(await plain.text(), js);
|
||||
});
|
||||
|
||||
test("a file without a copy is compressed as before", async () => {
|
||||
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("a copy older than its file is ignored", async () => {
|
||||
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
|
||||
assert.notEqual(res.headers.get("content-encoding"), "br");
|
||||
});
|
||||
|
||||
test("the shell and the worker answer a revalidation with 304", async () => {
|
||||
for (const path of ["/", "/sw.js"]) {
|
||||
const first = await get(path);
|
||||
const etag = first.headers.get("etag");
|
||||
assert.ok(etag, `${path} carries a validator`);
|
||||
await first.arrayBuffer();
|
||||
const again = await get(path, { "if-none-match": etag! });
|
||||
assert.equal(again.status, 304, `${path} is not sent again`);
|
||||
assert.equal(await again.text(), "");
|
||||
const changed = await get(path, { "if-none-match": `"something-else"` });
|
||||
assert.equal(changed.status, 200);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
@@ -5,6 +6,35 @@ import { Readable } from "node:stream";
|
||||
import type { Context, Handler } from "hono";
|
||||
import { stripBasePath } from "../../scripts/basePath.mjs";
|
||||
|
||||
/*
|
||||
* Files that must not be served from anybody's cache, the way index.html is
|
||||
* not.
|
||||
*
|
||||
* They went out with `max-age=3600` because they are neither hashed assets nor
|
||||
* HTML, and an hour looks harmless. It is not, for two of them, and a CDN in
|
||||
* front makes it worse: on a deploy the origin had the new build while
|
||||
* Cloudflare went on handing out the previous `sw.js` for hours, with
|
||||
* `cf-cache-status: HIT` and an edge TTL of its own that was longer than what
|
||||
* we asked for. Caught on the 2026-09-08 deploy, where the new worker was live
|
||||
* at the origin and the old one was still being installed by every browser
|
||||
* that asked.
|
||||
*
|
||||
* What that costs is specific rather than general. The service worker is the
|
||||
* app's whole update mechanism: a stale one keeps serving the shell it knows
|
||||
* and never learns there is a newer build, so the deploy simply does not
|
||||
* arrive. And a manifest and a worker that disagree is worse than either being
|
||||
* old -- a fresh manifest advertising a share target to the operating system,
|
||||
* answered by a worker that has never heard of one, sends the share to the
|
||||
* server for a 405.
|
||||
*
|
||||
* `no-cache` does not mean "do not store": the browser and the CDN may both
|
||||
* keep it and revalidate, which is a 304 and costs nothing. It means neither
|
||||
* gets to serve it without asking first, which is the whole requirement.
|
||||
*/
|
||||
function isNeverStale(rel: string, ext: string): boolean {
|
||||
return ext === ".webmanifest" || rel === "/sw.js" || rel === "sw.js";
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
@@ -48,9 +78,61 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
/*
|
||||
* What a file is, for the purpose of "has it changed". The shell and the
|
||||
* never-stale files are revalidated on every load; with no validator to send
|
||||
* back, every revalidation downloaded the whole file again.
|
||||
*/
|
||||
function etagOf(size: number, mtimeMs: number): string {
|
||||
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
|
||||
}
|
||||
|
||||
function notModified(c: Context, etag: string): boolean {
|
||||
const sent = c.req.header("if-none-match");
|
||||
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
|
||||
}
|
||||
|
||||
/*
|
||||
* The encodings a build can carry beside a file, best first. See
|
||||
* scripts/precompress.mjs, which writes them.
|
||||
*/
|
||||
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
|
||||
{ token: "br", suffix: ".br", encoding: "br" },
|
||||
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
|
||||
];
|
||||
|
||||
function accepts(c: Context, token: string): boolean {
|
||||
const header = c.req.header("accept-encoding") ?? "";
|
||||
return header.split(",").some((part) => {
|
||||
const [name, ...params] = part.trim().split(";");
|
||||
if (name?.trim().toLowerCase() !== token) return false;
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
||||
return !q || Number(q.slice(2)) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
let indexCache: { body: string; mtime: number; etag: string } | null = null;
|
||||
/** Which precompressed copies exist, per file and modification time. */
|
||||
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
|
||||
|
||||
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
|
||||
const known = variants.get(filePath);
|
||||
if (known && known.mtime === mtime) return known.found;
|
||||
const found = new Map<string, number>();
|
||||
for (const v of PRECOMPRESSED) {
|
||||
try {
|
||||
const st = await stat(filePath + v.suffix);
|
||||
// A copy older than the file it came from describes something else.
|
||||
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
variants.set(filePath, { mtime, found });
|
||||
return found;
|
||||
}
|
||||
let mismatchWarned = false;
|
||||
|
||||
/**
|
||||
@@ -59,7 +141,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.
|
||||
*/
|
||||
@@ -78,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const p = join(absRoot, "index.html");
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
const body = await readFile(p, "utf8");
|
||||
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
|
||||
mismatchWarned = false;
|
||||
}
|
||||
warnOnBaseMismatch(indexCache.body);
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
c.header("ETag", indexCache.etag);
|
||||
if (notModified(c, indexCache.etag)) return c.body(null, 304);
|
||||
return c.body(indexCache.body);
|
||||
} catch {
|
||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -99,7 +184,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);
|
||||
@@ -113,17 +198,33 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
if (!st.isFile()) return serveIndex(c);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||
c.header("Content-Length", String(st.size));
|
||||
const etag = etagOf(st.size, st.mtimeMs);
|
||||
c.header("ETag", etag);
|
||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (ext === ".html") {
|
||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
} else {
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
if (notModified(c, etag)) return c.body(null, 304);
|
||||
// Serve a copy made at build time where the browser takes one.
|
||||
let servePath = filePath;
|
||||
let size = st.size;
|
||||
const found = await variantsOf(filePath, st.mtimeMs);
|
||||
if (found.size) {
|
||||
c.header("Vary", "Accept-Encoding");
|
||||
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
|
||||
if (pick) {
|
||||
servePath = filePath + pick.suffix;
|
||||
size = found.get(pick.suffix)!;
|
||||
c.header("Content-Encoding", pick.encoding);
|
||||
}
|
||||
}
|
||||
c.header("Content-Length", String(size));
|
||||
if (c.req.method === "HEAD") return c.body(null);
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
||||
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||
return c.body(stream);
|
||||
} catch {
|
||||
// SPA fallback for client-side routes (no file extension) only.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/*
|
||||
* What may be served stale, and what may not.
|
||||
*
|
||||
* This is not a preference about freshness. The service worker is the app's
|
||||
* whole update mechanism: a browser holding an old one goes on being served
|
||||
* the shell that worker knows and never finds out a deploy happened. On
|
||||
* 2026-09-08 the origin had the new build while Cloudflare handed out the
|
||||
* previous `sw.js` for hours, because it was neither a hashed asset nor HTML
|
||||
* and so went out with an hour's max-age that the CDN then extended.
|
||||
*
|
||||
* A static root of our own, since CI runs the tests before the build and
|
||||
* `web/dist` does not exist yet.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-cache-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js"), "console.log(1)\n");
|
||||
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||
writeFileSync(join(root, "manifest.webmanifest"), `{"name":"ihasmail"}`);
|
||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||
writeFileSync(join(root, "img.png"), "not really a png");
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const cacheControl = async (path: string) => {
|
||||
const res = await createApp().request(path);
|
||||
assert.equal(res.status, 200, `${path} should be served`);
|
||||
return res.headers.get("cache-control") ?? "";
|
||||
};
|
||||
|
||||
test("the service worker is never served from a cache without asking", async () => {
|
||||
// `no-cache` permits storing it and requires revalidating it, which is a 304
|
||||
// and costs nothing. What it forbids is a browser or a CDN answering with
|
||||
// its own copy, which is the whole failure.
|
||||
assert.match(await cacheControl("/sw.js"), /no-cache/);
|
||||
});
|
||||
|
||||
test("nor is the manifest, which the worker has to agree with", async () => {
|
||||
// A fresh manifest advertising a share target, answered by a worker that has
|
||||
// never heard of one, sends the share to the server for a 405. Either being
|
||||
// old is survivable; the two disagreeing is not.
|
||||
assert.match(await cacheControl("/manifest.webmanifest"), /no-cache/);
|
||||
});
|
||||
|
||||
test("the manifest is still served as a manifest", async () => {
|
||||
const res = await createApp().request("/manifest.webmanifest");
|
||||
assert.match(res.headers.get("content-type") ?? "", /application\/manifest\+json/);
|
||||
});
|
||||
|
||||
test("index.html was already revalidated, and still is", async () => {
|
||||
assert.match(await cacheControl("/"), /no-cache/);
|
||||
});
|
||||
|
||||
test("hashed assets are still immutable for a year", async () => {
|
||||
// The name changes when the bytes do, so there is nothing to go stale --
|
||||
// and this is the caching that makes the app load quickly at all.
|
||||
const cc = await cacheControl("/assets/app-a1b2c3.js");
|
||||
assert.match(cc, /immutable/);
|
||||
assert.match(cc, /max-age=31536000/);
|
||||
});
|
||||
|
||||
test("everything else keeps its ordinary hour", async () => {
|
||||
// The rule is narrow on purpose: two files, named, rather than a policy that
|
||||
// quietly stops the icons and fonts being cached too.
|
||||
assert.match(await cacheControl("/img.png"), /max-age=3600/);
|
||||
});
|
||||
|
||||
test("under a prefix, the worker is still the worker", async () => {
|
||||
// The mount comes off before the path is matched, so this has to hold for a
|
||||
// subpath deployment as well -- where a stale worker is exactly as bad.
|
||||
const res = await createApp("/mail").request("/mail/sw.js");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("cache-control") ?? "", /no-cache/);
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { config } from "./config.js";
|
||||
import { grantsAdministration } from "./adminGate.js";
|
||||
|
||||
export interface UpstreamSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
@@ -36,7 +37,7 @@ const SESSION_CACHE_MS = 5 * 60_000;
|
||||
/**
|
||||
* The Stalwart a username belongs to.
|
||||
*
|
||||
* `STALWART_URL` is the default and is always the answer for a domain nobody
|
||||
* `MAIL_SERVER_URL` is the default and is always the answer for a domain nobody
|
||||
* mapped -- and for a bare username, which Stalwart accepts and which has no
|
||||
* domain to map (#238).
|
||||
*
|
||||
@@ -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 -- 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 (MAIL_SERVER_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,43 @@ 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 };
|
||||
|
||||
/*
|
||||
* Both caches are keyed by session, and used to lose an entry only when that
|
||||
* session signed out or was refused -- not when it simply expired, which is how
|
||||
* most sessions end. An entry past its age is never used again, so dropping
|
||||
* those on a timer is all it takes to stop them accumulating.
|
||||
*/
|
||||
export function sweepUpstreamCaches(now = Date.now()): void {
|
||||
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
|
||||
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
|
||||
}
|
||||
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
|
||||
|
||||
/** How many sessions the caches hold; for tests. */
|
||||
export function upstreamCacheSizes(): { sessions: number; info: number } {
|
||||
return { sessions: sessionCache.size, info: infoCache.size };
|
||||
}
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -153,7 +267,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 +312,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 +342,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 +353,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 */
|
||||
}
|
||||
@@ -287,10 +427,35 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
||||
/** Resolve a possibly-relative upstream URL template against MAIL_SERVER_URL. */
|
||||
/**
|
||||
* Resolve a URL Stalwart handed us against the server we were configured to
|
||||
* talk to.
|
||||
*
|
||||
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
|
||||
* and the rest -- built from its public hostname, which is always https. A
|
||||
* proxy that follows them takes every upstream call, and every held push
|
||||
* stream, out through the public route even when MAIL_SERVER_URL names a private
|
||||
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
|
||||
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
|
||||
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
|
||||
*
|
||||
* 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.
|
||||
* MAIL_SERVER_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.
|
||||
*/
|
||||
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
||||
try {
|
||||
return new URL(url, base).toString();
|
||||
const resolved = new URL(url, base);
|
||||
if (config.followAdvertisedUrls) return resolved.toString();
|
||||
const pinned = new URL(base);
|
||||
pinned.pathname = resolved.pathname;
|
||||
pinned.search = resolved.search;
|
||||
pinned.hash = "";
|
||||
return pinned.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Optional: which Stalwart a domain signs in to.",
|
||||
"",
|
||||
"STALWART_URL stays required and stays the default. This file only adds",
|
||||
"domains that go somewhere else -- delete it and nothing changes.",
|
||||
"",
|
||||
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
||||
"it read-only. Read once at startup, so editing it means restarting.",
|
||||
"",
|
||||
"A domain that is not listed here, and a bare username with no domain at",
|
||||
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
|
||||
"server is unreachable that sign-in fails, because falling back would",
|
||||
"authenticate somebody against a server their domain was routed away from.",
|
||||
"",
|
||||
"Keys are lower-cased and stripped of a trailing dot when read. Malformed",
|
||||
"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.",
|
||||
"",
|
||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||
],
|
||||
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": "https://jmap.customer-b.test"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -23,7 +23,7 @@
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>ihasmail</title>
|
||||
<title>INBUXA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,29 +6,29 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"@tanstack/react-virtual": "^3.14.12",
|
||||
"dompurify": "^3.4.15",
|
||||
"lucide-react": "^1.45.0",
|
||||
"marked": "^18.0.11",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
"wouter": "^3.11.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.8"
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |