Compare commits
279
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e1c85cf82e | ||
|
|
1734ed0439 | ||
|
|
93856de143 | ||
|
|
cfcaf5f573 | ||
|
|
3582ad116e | ||
|
|
9f4bd65fd5 | ||
|
|
af0e8b2be2 | ||
|
|
171c11fc92 | ||
|
|
2a41a31bb9 | ||
|
|
607afeb4ad | ||
|
|
f6f6ce0b5e | ||
|
|
b10ce2f9dd | ||
|
|
45f1309d06 | ||
|
|
483aac849a | ||
|
|
a8cf8ce3d7 | ||
|
|
9622875659 | ||
|
|
0d5975d427 | ||
|
|
a24b4c5538 | ||
|
|
cff7f6c11b | ||
|
|
c31a653a04 | ||
|
|
6821d6a93f | ||
|
|
457ea53ca3 | ||
|
|
a01e1874d8 | ||
|
|
0e58b886b5 | ||
|
|
5f35428ddb | ||
|
|
a1fe4fea1a | ||
|
|
85289b0c34 | ||
|
|
1a6158aa70 | ||
|
|
cb69115be1 | ||
|
|
8badf48c4a | ||
|
|
a61fe28523 | ||
|
|
74f6d1d0aa | ||
|
|
f1a8f162cf | ||
|
|
5e6e049eef | ||
|
|
8e9259638d | ||
|
|
1611ae6918 | ||
|
|
3f33b61369 | ||
|
|
6e1ac42d5a | ||
|
|
4a99b77bc3 | ||
|
|
4121f9263b | ||
|
|
6b8ac393c9 | ||
|
|
17bd548524 | ||
|
|
b0edce1d9b | ||
|
|
4570df6eab | ||
|
|
8962065782 | ||
|
|
7b3069e41b | ||
|
|
44b676c55d | ||
|
|
1b4788c0a7 | ||
|
|
13a6bcd66b | ||
|
|
4d18d94b63 | ||
|
|
b4ee545856 | ||
|
|
a30f96f76b | ||
|
|
0ac5f78789 | ||
|
|
c838569638 | ||
|
|
72742ef30a | ||
|
|
9aa0eda0d5 | ||
|
|
34fc5ab81f | ||
|
|
6c7c6d19b3 | ||
|
|
c5be74fe57 | ||
|
|
60d275f38b | ||
|
|
1ed8531764 | ||
|
|
c9ab203b76 | ||
|
|
be8b89f5ab | ||
|
|
9cace7c90c | ||
|
|
9a474ef2c8 | ||
|
|
f7ef886b45 | ||
|
|
8bee0eb3c8 | ||
|
|
d300107be0 | ||
|
|
255616341b | ||
|
|
5b18f1d5d7 | ||
|
|
93d0a32af2 | ||
|
|
4c54eb74f9 | ||
|
|
e4b82783c2 | ||
|
|
ccc9f73a71 | ||
|
|
790213cc17 | ||
|
|
1ea813940b | ||
|
|
97f8b34e8b | ||
|
|
ec8e2ad2d0 | ||
|
|
3c3ffbd425 | ||
|
|
132d0b5205 | ||
|
|
b83de657d7 | ||
|
|
7d9a01cb93 | ||
|
|
785570a41d | ||
|
|
0db795371e | ||
|
|
63c2839602 | ||
|
|
c85525f3ed | ||
|
|
34578ba426 | ||
|
|
00b580bad8 | ||
|
|
b5ca0021ef | ||
|
|
a4c0e04ab9 | ||
|
|
8d562628ff | ||
|
|
15f2c3d357 | ||
|
|
c170b8554c | ||
|
|
984f0474e3 | ||
|
|
49f9a06009 | ||
|
|
a33260966c | ||
|
|
a16faa1789 | ||
|
|
0fb7fa09f6 | ||
|
|
4912ea8ef4 | ||
|
|
f25b559cb5 | ||
|
|
fa55636fb0 | ||
|
|
8116cd0393 | ||
|
|
bbdceb2697 | ||
|
|
0871d3291b | ||
|
|
2beedc0f3c | ||
|
|
e380e168cd | ||
|
|
0c1820e2bb | ||
|
|
e1402e472f | ||
|
|
fb1ad3c5c8 | ||
|
|
c4649e0084 | ||
|
|
df9349935d | ||
|
|
fc0e2b2b3e | ||
|
|
5a7acb7306 | ||
|
|
02c0332d8f | ||
|
|
53513e6744 | ||
|
|
6431ec87f5 | ||
|
|
1a29784848 | ||
|
|
d3e173c9c1 | ||
|
|
b63f53c55b | ||
|
|
1e9d7bb596 | ||
|
|
2dd0203ae1 | ||
|
|
b862f61cde | ||
|
|
16308431b8 | ||
|
|
7ba749148f | ||
|
|
3517d48a90 | ||
|
|
94639e8420 | ||
|
|
20929ddc2c | ||
|
|
6c958b8609 | ||
|
|
100f07b580 | ||
|
|
ded2f4dc1b | ||
|
|
874887eb36 | ||
|
|
c26ca90e01 | ||
|
|
2380ed282e | ||
|
|
635c4c7e52 | ||
|
|
67f5a88ff6 | ||
|
|
ba4d2105a4 | ||
|
|
9b7f66a2c0 | ||
|
|
a6863e98cc | ||
|
|
51dabd9cab | ||
|
|
a4f7d386a6 | ||
|
|
f94cc2ce51 | ||
|
|
a440e54922 | ||
|
|
3ab2b02ad9 | ||
|
|
94e387267b | ||
|
|
2626b7a333 | ||
|
|
5ee9b1f1b1 | ||
|
|
a2a339ce32 | ||
|
|
1eb31450a4 | ||
|
|
12f08acd32 | ||
|
|
988e741b79 | ||
|
|
5737362621 | ||
|
|
f9d521a412 | ||
|
|
8270843c03 | ||
|
|
ce28e014d3 | ||
|
|
461f803f4c | ||
|
|
70af64b126 | ||
|
|
d75618401a | ||
|
|
fcbe268715 | ||
|
|
49e703b8bb | ||
|
|
4ecfbd25a5 | ||
|
|
fc5c8dd4fa | ||
|
|
8d19108498 | ||
|
|
e9009f7aaf | ||
|
|
53c38ed3c3 | ||
|
|
8cbc04e730 | ||
|
|
71dd2e108f | ||
|
|
87383440bb | ||
|
|
d0cbfc7870 | ||
|
|
3f4b33cb51 | ||
|
|
46c1dc28e3 | ||
|
|
8ea611f7f7 | ||
|
|
95dcb96086 | ||
|
|
90ed579876 | ||
|
|
be1d787b5f | ||
|
|
35c6060ceb | ||
|
|
d255c20215 | ||
|
|
2bf3137484 | ||
|
|
562cee82ce | ||
|
|
e17d109ece | ||
|
|
42dfdc5a44 | ||
|
|
c987ecccf3 | ||
|
|
b2769b9011 | ||
|
|
c1c448b94d | ||
|
|
f736bf0c34 | ||
|
|
0fb504748d | ||
|
|
34e37e1786 | ||
|
|
806e63d071 | ||
|
|
06943fd473 | ||
|
|
362bd8b282 | ||
|
|
91481965bc | ||
|
|
5ced44ec13 | ||
|
|
dd8998f178 | ||
|
|
6ec2304fc2 | ||
|
|
d6f9ef1243 | ||
|
|
b0cb73a924 | ||
|
|
373822c2ae | ||
|
|
12157f47bf | ||
|
|
e41742a26c | ||
|
|
1e01b34384 | ||
|
|
95f640b24c | ||
|
|
826a13ab39 | ||
|
|
2f55b1e3e1 | ||
|
|
d75e9bc769 | ||
|
|
08fd08e6fe | ||
|
|
ecc030aa3f | ||
|
|
8844fc9836 | ||
|
|
ef7823d6de | ||
|
|
8d475e2b07 | ||
|
|
0277b5b6a8 | ||
|
|
22c8eb4af6 | ||
|
|
bd3c4bf964 | ||
|
|
7eca17665a | ||
|
|
b6327ffb98 | ||
|
|
d8fc47d765 | ||
|
|
ddd1bbf9b3 | ||
|
|
0b01956535 | ||
|
|
045dda109b | ||
|
|
1c678fabed |
+51
-1
@@ -12,6 +12,18 @@ APP_SECRET=change-me
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# Serve the app from a subpath instead of the domain root, for a reverse proxy
|
||||
# that maps https://example.com/mail/ here. Leave it unset for the root, which
|
||||
# is what every deployment gets unless it asks otherwise. "/mail", "mail" and
|
||||
# "/mail/" all mean the same thing.
|
||||
#
|
||||
# The prefix must reach ihasmail intact -- do not strip it in the proxy -- and
|
||||
# it has to be set for the *build* as well as the run: the web bundle writes
|
||||
# its own asset URLs, so a build that does not know the prefix produces an app
|
||||
# that cannot load itself under one. With Docker that means
|
||||
# `--build-arg BASE_PATH=/mail` alongside `-e BASE_PATH=/mail`.
|
||||
# BASE_PATH=/mail
|
||||
|
||||
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts
|
||||
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
|
||||
TRUST_PROXY=1
|
||||
@@ -56,4 +68,42 @@ APP_NAME=ihasmail
|
||||
# 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/LINUXexpert-org/ihasmail
|
||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
|
||||
# ---- Settings this installation decides (all optional) ----
|
||||
#
|
||||
# Seed what a new account starts on, lock what nobody may change, and turn
|
||||
# something on once for accounts that already exist. Setting none of these --
|
||||
# the default -- behaves exactly as ihasmail always has.
|
||||
#
|
||||
# A file is easier once there are `changes` in it. See the shipped
|
||||
# settings-policy.example.json, and mount it read-only:
|
||||
#
|
||||
# -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro
|
||||
#
|
||||
# SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
|
||||
#
|
||||
# Or inline, which is what an immutable deployment with no volume wants. These
|
||||
# are ignored entirely when SETTINGS_POLICY_FILE is set, so a file and a stray
|
||||
# variable cannot half-apply between them.
|
||||
#
|
||||
# SETTINGS_DEFAULTS={"externalSenderBanner":true}
|
||||
# SETTINGS_ENFORCED={"externalRecipientConfirm":true}
|
||||
# SETTINGS_CHANGES=[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]
|
||||
#
|
||||
# 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) ----
|
||||
#
|
||||
# Choose the upstream by the domain someone signs in with. STALWART_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
|
||||
# read-only:
|
||||
#
|
||||
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||
#
|
||||
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
|
||||
#
|
||||
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
|
||||
# listed domain never falls back. Read once at startup: editing means a restart.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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:
|
||||
# Pinned to a commit rather than a moving major tag. This action is
|
||||
# handed `packages: write` and its whole job is deletion, so a tag
|
||||
# repointed at something else -- by a compromise or a mistake upstream --
|
||||
# is a bad day. v1.2.2.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
|
||||
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 }}
|
||||
@@ -0,0 +1,202 @@
|
||||
# Publish the container image to GHCR.
|
||||
#
|
||||
# The README and the docs site have told people to run
|
||||
# `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
|
||||
# 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
|
||||
# a public repository, and an anonymous `docker pull` will still answer
|
||||
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
||||
# by hand under the package's settings, and until it is, this looks like it
|
||||
# worked while the docs stay just as wrong as before. Check with a logged-out
|
||||
# pull, not with one from a machine that has credentials.
|
||||
#
|
||||
# Two architectures, each built on its own native runner rather than under
|
||||
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
||||
# instruction translation, which takes tens of minutes and occasionally runs
|
||||
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
||||
# the same work at native speed. The cost is the by-digest dance below: each
|
||||
# runner pushes an untagged image, and a final job joins the two digests into
|
||||
# one multi-arch tag.
|
||||
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
|
||||
# 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.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
default: main
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
# The version is worked out once and handed to both builds, so the two
|
||||
# architectures cannot disagree about what they are. scripts/version.mjs
|
||||
# reads the commit date and how the commit arrived, so it needs real history
|
||||
# rather than a shallow clone.
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- id: v
|
||||
run: |
|
||||
V="$(node scripts/version.mjs)"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
# A Docker tag may not contain '+', so build metadata becomes '-'.
|
||||
# The build is still *told* the real form, which is what About and
|
||||
# /api/health report.
|
||||
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V -> tag ${V/+/-}"
|
||||
|
||||
build:
|
||||
needs: version
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
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
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
|
||||
# Attestations are off deliberately: they add manifests of their own
|
||||
# to the index, and `imagetools create` below expects the two entries
|
||||
# it pushed rather than four.
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Save the digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
# The prefix is stripped here and put back in the merge job, so the
|
||||
# filename is the bare hash. Leaving it on produces
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# Joins the per-architecture digests into a single tagged manifest, so
|
||||
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
|
||||
publish:
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create the manifest
|
||||
run: |
|
||||
# Arrays rather than a string: the tags and the digest references
|
||||
# have to reach docker as separate arguments, and building them by
|
||||
# word-splitting an unquoted variable is the version of this that
|
||||
# breaks the day a value contains a space.
|
||||
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
|
||||
# :latest follows real releases only. A prerelease that moved it
|
||||
# would hand every `:latest` deployment an unfinished build, and a
|
||||
# dispatch run has to ask for it on purpose.
|
||||
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
fi
|
||||
refs=()
|
||||
for f in /tmp/digests/*; do
|
||||
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
||||
done
|
||||
echo "tags: ${tags[*]}"
|
||||
echo "refs: ${refs[*]}"
|
||||
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,155 @@
|
||||
# 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:00 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||
# can delay a run by a good while when the queue is busy, so do not read
|
||||
# the exact minute as a promise. Note also that GitHub disables scheduled
|
||||
# workflows in a repository with no activity for 60 days -- not a concern
|
||||
# while this one is being worked on weekly, but it is why a silent stop is
|
||||
# worth checking for before assuming the file is broken.
|
||||
- cron: "0 9 * * 1"
|
||||
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@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- 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@v4
|
||||
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
|
||||
@@ -6,3 +6,6 @@ dist/
|
||||
server/data/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Worktrees used by parallel agents; never part of a commit.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Upstream palette values, fetched from source (all MIT)
|
||||
|
||||
Fetched 2026-09-02 from the projects' own repositories, not from any
|
||||
reimplementation.
|
||||
|
||||
## Dracula — dracula/dracula-theme, MIT
|
||||
README section is titled "Color Palette (OSS)" and contains BOTH variants,
|
||||
so Alucard is open source and not PRO-only.
|
||||
|
||||
### Dracula (dark)
|
||||
Background #282a36 · Current Line #44475a · Selection #44475a
|
||||
Foreground #f8f8f2 · Comment #6272a4
|
||||
Cyan #8be9fd · Green #50fa7b · Orange #ffb86c · Pink #ff79c6
|
||||
Purple #bd93f9 · Red #ff5555 · Yellow #f1fa8c
|
||||
|
||||
### Alucard (light)
|
||||
Background #fffbeb · Current Line #6c664b · Selection #cfcfde
|
||||
Foreground #1f1f1f · Comment #6c664b
|
||||
Cyan #036a96 · Green #14710a · Orange #a34d14 · Pink #a3144d
|
||||
Purple #644ac9 · Red #cb3a2a · Yellow #846e15
|
||||
|
||||
## Gruvbox — morhetz/gruvbox, MIT
|
||||
dark0_hard #1d2021 · dark0 #282828 · dark0_soft #32302f · dark1 #3c3836
|
||||
dark2 #504945 · dark3 #665c54 · dark4 #7c6f64 · gray #928374
|
||||
light0_hard #f9f5d7 · light0 #fbf1c7 · light0_soft #f2e5bc · light1 #ebdbb2
|
||||
light2 #d5c4a1 · light3 #bdae93 · light4 #a89984
|
||||
bright: red #fb4934 green #b8bb26 yellow #fabd2f blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019
|
||||
neutral: red #cc241d green #98971a yellow #d79921 blue #458588 purple #b16286 aqua #689d6a orange #d65d0e
|
||||
faded: red #9d0006 green #79740e yellow #b57614 blue #076678 purple #8f3f71 aqua #427b58 orange #af3a03
|
||||
|
||||
## Rosé Pine — rose-pine/palette, MIT (palette.json)
|
||||
### main (dark)
|
||||
base #191724 surface #1f1d2e overlay #26233a muted #6e6a86 subtle #908caa text #e0def4
|
||||
love #eb6f92 gold #f6c177 rose #ebbcba pine #31748f foam #9ccfd8 iris #c4a7e7
|
||||
### dawn (light)
|
||||
base #faf4ed surface #fffaf3 overlay #f2e9e1 muted #9893a5 subtle #797593 text #464261
|
||||
love #b4637a gold #ea9d34 rose #d7827e pine #286983 foam #56949f iris #907aa9
|
||||
|
||||
## Tokyo Night — enkia/tokyo-night-vscode-theme, MIT
|
||||
### Night (dark)
|
||||
bg #1a1b26 · bg_dark #16161e · fg #a9b1d6 · line numbers #363b54 · border #101014
|
||||
selection #202330 · link #6183bb
|
||||
accents: purple #bb9af7 · text-bright #c0caf5 · red #f7768e · cyan #0db9d7
|
||||
blue #7aa2f7 · light-cyan #7dcfff · yellow #e0af68 · teal #73daca · green #9ece6a
|
||||
### Day (light)
|
||||
bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c1c2c7
|
||||
link #2959aa
|
||||
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
|
||||
yellow #8f5e15 · teal #33635c · green #385f0d
|
||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
**johnellisATlinuxDOTcom**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
|
||||
+22
-2
@@ -7,6 +7,15 @@ FROM node:22-alpine AS build
|
||||
# Left empty, the build falls back to the base version from package.json.
|
||||
ARG IHASMAIL_VERSION=""
|
||||
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
|
||||
# The subpath the app will be served from, e.g. /mail. Empty -- the default --
|
||||
# is the domain root and is what every deployment gets unless it asks
|
||||
# otherwise. Unlike the rest of ihasmail's configuration this cannot wait for
|
||||
# the process to start: the web build writes its own asset URLs into
|
||||
# index.html, so a build that does not know the prefix produces a shell that
|
||||
# cannot load itself under one. It is therefore a build argument here and an
|
||||
# environment variable in the runtime stage, from the same value.
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY server/package.json server/
|
||||
@@ -19,12 +28,14 @@ RUN npm run build
|
||||
FROM node:22-alpine AS runtime
|
||||
# Re-declared: an ARG does not cross stages.
|
||||
ARG IHASMAIL_VERSION=""
|
||||
ARG BASE_PATH=""
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
STATIC_DIR=/app/web/dist \
|
||||
SESSION_FILE=/data/sessions.json \
|
||||
IHASMAIL_VERSION=$IHASMAIL_VERSION
|
||||
IHASMAIL_VERSION=$IHASMAIL_VERSION \
|
||||
BASE_PATH=$BASE_PATH
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY server/package.json server/
|
||||
@@ -47,5 +58,14 @@ USER node
|
||||
# that want the sessions to survive say so themselves: docker-compose.yml and
|
||||
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
|
||||
# Shell form, so $BASE_PATH is expanded by the container rather than baked in
|
||||
# empty at build time: the health endpoint moves with the mount.
|
||||
#
|
||||
# The two substitutions repeat, in sh, what scripts/basePath.mjs does in
|
||||
# JavaScript -- drop a trailing slash, add a leading one -- because this runs
|
||||
# before there is a Node process to ask. It is worth the duplication: an
|
||||
# operator who writes BASE_PATH=mail/ gets a working server, and without this
|
||||
# a healthcheck that says the working server is unhealthy and has Docker
|
||||
# restart it forever.
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD BP="${BASE_PATH%/}"; case "$BP" in ""|/*) ;; *) BP="/$BP";; esac; wget -qO- "http://127.0.0.1:8080$BP/api/health" || exit 1
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
|
||||
+1359
File diff suppressed because it is too large
Load Diff
+29
-9
@@ -4,11 +4,17 @@ 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.19**, and as of **2026-08-26 there is nothing
|
||||
left pending**: every entry below has been exercised against it. 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.
|
||||
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
|
||||
@@ -19,7 +25,13 @@ 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/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
[`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.
|
||||
@@ -29,11 +41,19 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **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/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not 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/LINUXexpert-org/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/LINUXexpert-org/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 has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 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.
|
||||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Third-party notices
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
derived by `scripts/build-palettes.py`, which also lifts any tier that would
|
||||
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
|
||||
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.
|
||||
|
||||
### 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".
|
||||
|
||||
### Tokyo Night
|
||||
|
||||
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
|
||||
Licensed under the MIT licence. The light variant is "Day".
|
||||
|
||||
---
|
||||
|
||||
The MIT licence, under which all four are used:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -2,11 +2,16 @@
|
||||
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
|
||||
</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>
|
||||
|
||||
<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.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?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://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
|
||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
@@ -24,6 +29,7 @@ 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 |
|
||||
|
||||
@@ -44,12 +50,14 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
||||
|
||||
## 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, multi-composer rich-text editing with signatures, scheduled send and undo send
|
||||
- **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
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
@@ -64,8 +72,8 @@ 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.
|
||||
|
||||
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/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.
|
||||
- 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.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
@@ -86,6 +94,32 @@ Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### Container images
|
||||
|
||||
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`:
|
||||
|
||||
```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
|
||||
@@ -109,6 +143,163 @@ 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
|
||||
|
||||
```
|
||||
@@ -161,21 +352,39 @@ the sign-in refusal can be tested.
|
||||
|
||||
### Version numbers
|
||||
|
||||
`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
||||
this build targets, `84` the pull request the commit came from. The first two
|
||||
live in the root `package.json`; the third comes from git at build time, since
|
||||
it does not exist until the PR has merged. A commit that did not arrive through
|
||||
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
||||
`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:2.16 .
|
||||
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 falls back to the base version
|
||||
from `package.json`, so a version with no PR number means whoever built the
|
||||
image did not pass one.
|
||||
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
|
||||
|
||||
@@ -188,7 +397,7 @@ container, waits for healthy, then prunes all but the newest
|
||||
```bash
|
||||
./deploy.sh # origin/main, asks before shipping new commits
|
||||
./deploy.sh --dry-run # run the guards and stop
|
||||
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
./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.
|
||||
@@ -200,7 +409,7 @@ container, waits for healthy, then prunes all but the newest
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
|
||||
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
|
||||
|
||||
+10
-5
@@ -1,12 +1,17 @@
|
||||
# Roadmap / not yet
|
||||
|
||||
Things ihasmail does not do, and why. Anything with an issue number is tracked
|
||||
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
|
||||
rest is here because the answer is "no", not "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)
|
||||
- Translations (strings are English-only for now)
|
||||
- **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. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
|
||||
- **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.
|
||||
|
||||
+40
-21
@@ -20,8 +20,12 @@
|
||||
# whatever main happens to have picked up since the last
|
||||
# release.
|
||||
#
|
||||
# --dry-run runs both guards, says what it would deploy, and stops before
|
||||
# building or touching the container.
|
||||
# --dry-run checks the hold list, says what it would deploy, and stops
|
||||
# before building or touching the container. It does not ask
|
||||
# for confirmation: there is nothing to agree to when nothing
|
||||
# changes, and needing a terminal would make it useless over
|
||||
# SSH -- which is where wanting to look before leaping is most
|
||||
# likely.
|
||||
#
|
||||
# The container is replaced rather than restarted, because the image is rebuilt
|
||||
# from the new checkout. Data lives in a named volume and survives that; the
|
||||
@@ -50,6 +54,13 @@ VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
||||
# somewhere -- refuses to start here instead of looking fine until the next
|
||||
# redeploy signs everyone out.
|
||||
#
|
||||
# It defaults to on, and the reason is what happens when it does not. Forgetting
|
||||
# the variable used to hand back a writable container with a volume mounted --
|
||||
# quietly, and then report healthy. Nothing in the output said the immutability
|
||||
# had gone; `docker inspect` was the only place it showed. So the safe posture
|
||||
# is what you get by default, and giving it up is the half that has to be
|
||||
# deliberate, which is the way round these two should always have been.
|
||||
#
|
||||
# The standing cost is that sessions do not outlive a deploy, because there is
|
||||
# nowhere left to keep them. Going back is this variable and nothing else:
|
||||
#
|
||||
@@ -57,7 +68,7 @@ VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
||||
#
|
||||
# The named volume is never touched either way, so whatever was in it when the
|
||||
# switch was thrown is still there to come back to.
|
||||
IMMUTABLE="${IHASMAIL_IMMUTABLE:-0}"
|
||||
IMMUTABLE="${IHASMAIL_IMMUTABLE:-1}"
|
||||
# Image repository. Each build is tagged with its version as well, so an
|
||||
# earlier one can be run again without rebuilding it.
|
||||
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
|
||||
@@ -97,7 +108,7 @@ for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
-n|--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
|
||||
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*)
|
||||
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
|
||||
@@ -145,28 +156,35 @@ if [ -n "$NEW" ]; then
|
||||
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
|
||||
echo "==> introduces:"
|
||||
printf '%s\n' "$NEW" | sed 's/^/ /'
|
||||
if [ "$ASSUME_YES" -ne 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
read -r -p "deploy these to production? [y/N] " reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "aborted."; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
|
||||
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||
fi
|
||||
|
||||
# A dry run has now said everything it has to say, so it stops here -- before
|
||||
# the confirmation rather than after it. Asking whether to go ahead with
|
||||
# something that is not going to happen is noise at a terminal; over SSH it was
|
||||
# worse, because the refusal came out *instead of* the report above and a dry
|
||||
# run could not be used from another machine at all. Which is the machine you
|
||||
# are most likely to be on when you want one.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
read -r -p "deploy these to production? [y/N] " reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "aborted."; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
|
||||
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git reset --hard --quiet "$TARGET"
|
||||
|
||||
# The version is worked out here, from the checkout, because the image build
|
||||
@@ -197,10 +215,11 @@ prune_old_images() {
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain "+", which a version for a commit that did not
|
||||
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
|
||||
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
|
||||
# so About and /api/health still report it correctly.
|
||||
# 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
|
||||
# *told* it is keeps the real form, so About and /api/health still report it
|
||||
# correctly.
|
||||
TAG="${VERSION//+/-}"
|
||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
||||
docker build \
|
||||
|
||||
+9
-2
@@ -1,6 +1,12 @@
|
||||
services:
|
||||
ihasmail:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Passed to the build as well as the run because the web bundle writes
|
||||
# its own asset URLs: a build that does not know the prefix produces an
|
||||
# app that cannot load itself under one. Empty is the domain root.
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -9,7 +15,8 @@ services:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -13,7 +13,7 @@ const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--window-size=1420,790", "--force-device-scale-factor=1",
|
||||
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
|
||||
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
|
||||
|
||||
@@ -48,7 +48,7 @@ const PORT = 9333;
|
||||
const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
|
||||
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
|
||||
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (path) => {
|
||||
|
||||
Generated
+19
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "2.0.0",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ihasmail",
|
||||
"version": "2.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
"server",
|
||||
@@ -2321,6 +2321,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
|
||||
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -3814,7 +3826,8 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@ihasmail/server",
|
||||
"version": "2.0.0",
|
||||
"version": "2.16.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"hono": "^4.7.4"
|
||||
@@ -3827,11 +3840,13 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@ihasmail/web",
|
||||
"version": "2.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"marked": "^18.0.11",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "2.16.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -21,7 +21,10 @@
|
||||
"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:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
||||
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs",
|
||||
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Types for `basePath.mjs`, which is plain JS so both packages can import it. */
|
||||
export function normalizeBasePath(value: string | undefined | null): string;
|
||||
export function baseUrlOf(basePath: string | undefined | null): string;
|
||||
export function stripBasePath(basePath: string | undefined | null, pathname: string): string | null;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The subpath ihasmail is mounted at, from `BASE_PATH`.
|
||||
*
|
||||
* Plain JS, and here rather than in either package, because both halves of the
|
||||
* app have to agree on the answer: `web/vite.config.ts` bakes it into the built
|
||||
* asset URLs and `server/src/config.ts` reads it again to decide where the
|
||||
* routes live. Two implementations of "what does /mail/ mean" is exactly the
|
||||
* bug where the server serves an app whose own script tags point somewhere
|
||||
* else, and the page comes up blank with no clue why.
|
||||
*
|
||||
* The canonical form is a leading slash and no trailing one -- `/mail` -- with
|
||||
* the empty string for the root. Empty is the ordinary case and it is chosen
|
||||
* so that the concatenation `${base}/api/health` is right without a branch:
|
||||
* anything with a trailing slash would need one, and every caller that forgot
|
||||
* would produce `//api/health`, which browsers read as a *protocol-relative
|
||||
* URL* and send to a host called `api`. Getting that wrong once, quietly, in
|
||||
* one call site is worse than the small awkwardness of an empty string.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reduce whatever the operator wrote to the canonical form.
|
||||
*
|
||||
* Accepts `/mail`, `mail`, `/mail/`, `mail/`, `//mail//`, an empty string and
|
||||
* undefined, because the variable is typed by a human into a compose file or a
|
||||
* `docker run` line and every one of those is a reasonable thing to write.
|
||||
* Being strict here would mean an instance that refuses to start over a
|
||||
* trailing slash, which teaches nobody anything.
|
||||
*
|
||||
* A value of `/` means the root and is returned as empty, since `/` and `""`
|
||||
* describe the same mount and only one of them can be the canonical one.
|
||||
*/
|
||||
export function normalizeBasePath(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
// Collapse repeated separators before trimming: `//mail//` is a typo, not a
|
||||
// path with empty segments in it, and `path.posix.normalize` is not
|
||||
// available to the browser bundle that also uses this.
|
||||
const trimmed = value.trim().replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
||||
if (!trimmed) return "";
|
||||
return `/${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same value as a directory URL -- `/` or `/mail/`.
|
||||
*
|
||||
* This is the form Vite's `base` and the PWA scope want, both of which are
|
||||
* about "the directory the app lives in" rather than a path to join onto.
|
||||
*/
|
||||
export function baseUrlOf(basePath) {
|
||||
return `${normalizeBasePath(basePath)}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `pathname` falls inside the mount, and what is left of it if so.
|
||||
*
|
||||
* Returns null for anything outside, so a caller can 404 rather than guess.
|
||||
* The bare mount with no trailing slash -- a request for `/mail` -- yields
|
||||
* `/`, because that is the app's own index and typing the prefix without the
|
||||
* slash is how people reach it.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function stripBasePath(basePath, pathname) {
|
||||
const base = normalizeBasePath(basePath);
|
||||
if (!base) return pathname;
|
||||
if (pathname === base) return "/";
|
||||
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Run: python3 scripts/build-palettes.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CSS = ROOT / "web/src/styles/app.css"
|
||||
|
||||
BEGIN = "/* === generated palettes: begin === */"
|
||||
END = "/* === generated palettes: end === */"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- colour maths
|
||||
|
||||
def parse(hex_: str) -> tuple[float, float, float]:
|
||||
h = hex_.lstrip("#")
|
||||
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def to_hex(rgb: tuple[float, float, float]) -> str:
|
||||
return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
|
||||
|
||||
|
||||
def _lin(c: float) -> float:
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def luminance(hex_: str) -> float:
|
||||
r, g, b = (_lin(c) for c in parse(hex_))
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast(a: str, b: str) -> float:
|
||||
la, lb = luminance(a), luminance(b)
|
||||
hi, lo = max(la, lb), min(la, lb)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def mix(a: str, b: str, t: float) -> str:
|
||||
ca, cb = parse(a), parse(b)
|
||||
return to_hex(tuple(ca[i] + (cb[i] - ca[i]) * t for i in range(3)))
|
||||
|
||||
|
||||
def rgba(hex_: str, alpha: float) -> str:
|
||||
r, g, b = (round(c * 255) for c in parse(hex_))
|
||||
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`.
|
||||
|
||||
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.
|
||||
"""
|
||||
if contrast(colour, bg) >= target:
|
||||
return colour
|
||||
anchor = "#ffffff" if dark_ui else "#000000"
|
||||
best = colour
|
||||
for i in range(1, 101):
|
||||
candidate = mix(colour, anchor, i / 100)
|
||||
best = candidate
|
||||
if contrast(candidate, bg) >= target:
|
||||
return candidate
|
||||
return best
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- palettes
|
||||
# Roles as each project publishes them. Nothing here is invented; see
|
||||
# .palette-sources/palettes-upstream.md for where each value came from.
|
||||
|
||||
# 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
|
||||
# here, which is why it appears in LIGHT_ONLY.
|
||||
LIGHT_ONLY = {"ihasmail"}
|
||||
|
||||
SOURCES = {
|
||||
"ihasmail": {
|
||||
# Daylight over the same teal-navy: the dark palette's background
|
||||
# becomes the text, so the two halves are recognisably one palette read
|
||||
# from either end. The cat is still orange, so the star still is.
|
||||
"light": dict(
|
||||
bg="#f4f9f9", elev="#ffffff", sunken="#e7f1f2", line="#cfe2e4",
|
||||
fg="#0d2430", muted="#4a6b74", accent="#46cac3", link="#0e7490",
|
||||
danger="#dc2626", warn="#b45309", success="#15803d", star="#f9a34b",
|
||||
q1="#0e7490", q2="#15803d", q3="#7c3aed",
|
||||
),
|
||||
"dark": {}, # see LIGHT_ONLY
|
||||
},
|
||||
"dracula": {
|
||||
"dark": dict(
|
||||
bg="#282a36", elev="#2f3140", sunken="#21222c", line="#44475a",
|
||||
fg="#f8f8f2", muted="#6272a4", accent="#bd93f9", link="#8be9fd",
|
||||
danger="#ff5555", warn="#ffb86c", success="#50fa7b", star="#f1fa8c",
|
||||
q1="#8be9fd", q2="#50fa7b", q3="#ff79c6",
|
||||
),
|
||||
"light": dict( # Alucard
|
||||
bg="#fffbeb", elev="#ffffff", sunken="#f6f1de", line="#cfcfde",
|
||||
fg="#1f1f1f", muted="#6c664b", accent="#644ac9", link="#036a96",
|
||||
danger="#cb3a2a", warn="#a34d14", success="#14710a", star="#846e15",
|
||||
q1="#036a96", q2="#14710a", q3="#a3144d",
|
||||
),
|
||||
},
|
||||
"gruvbox": {
|
||||
"dark": dict(
|
||||
bg="#282828", elev="#32302f", sunken="#1d2021", line="#504945",
|
||||
fg="#ebdbb2", muted="#a89984", accent="#83a598", link="#8ec07c",
|
||||
danger="#fb4934", warn="#fe8019", success="#b8bb26", star="#fabd2f",
|
||||
q1="#83a598", q2="#b8bb26", q3="#d3869b",
|
||||
),
|
||||
"light": dict(
|
||||
bg="#fbf1c7", elev="#f9f5d7", sunken="#f2e5bc", line="#d5c4a1",
|
||||
fg="#3c3836", muted="#7c6f64", accent="#076678", link="#427b58",
|
||||
danger="#9d0006", warn="#af3a03", success="#79740e", star="#b57614",
|
||||
q1="#076678", q2="#79740e", q3="#8f3f71",
|
||||
),
|
||||
},
|
||||
"rose-pine": {
|
||||
"dark": dict( # main
|
||||
bg="#191724", elev="#1f1d2e", sunken="#14121f", line="#26233a",
|
||||
fg="#e0def4", muted="#908caa", accent="#c4a7e7", link="#9ccfd8",
|
||||
danger="#eb6f92", warn="#f6c177", success="#31748f", star="#f6c177",
|
||||
q1="#9ccfd8", q2="#31748f", q3="#c4a7e7",
|
||||
),
|
||||
"light": dict( # dawn
|
||||
bg="#faf4ed", elev="#fffaf3", sunken="#f2e9e1", line="#dfd9d2",
|
||||
fg="#464261", muted="#797593", accent="#907aa9", link="#286983",
|
||||
danger="#b4637a", warn="#ea9d34", success="#56949f", star="#ea9d34",
|
||||
q1="#286983", q2="#56949f", q3="#907aa9",
|
||||
),
|
||||
},
|
||||
"tokyo-night": {
|
||||
"dark": dict( # night
|
||||
bg="#1a1b26", elev="#1f2130", sunken="#16161e", line="#363b54",
|
||||
fg="#c0caf5", muted="#a9b1d6", accent="#7aa2f7", link="#7dcfff",
|
||||
danger="#f7768e", warn="#e0af68", success="#9ece6a", star="#e0af68",
|
||||
q1="#7dcfff", q2="#9ece6a", q3="#bb9af7",
|
||||
),
|
||||
"light": dict( # day
|
||||
bg="#e6e7ed", elev="#f2f3f7", sunken="#d6d8df", line="#c1c2c7",
|
||||
fg="#343b59", muted="#484c61", accent="#2959aa", link="#006c86",
|
||||
danger="#8c4351", warn="#8f5e15", success="#385f0d", star="#8f5e15",
|
||||
q1="#006c86", q2="#385f0d", q3="#65359d",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# What each token has to clear, and against which surface. Normal text is 4.5;
|
||||
# the three-to-one entries are borders and large or non-essential marks, which
|
||||
# is the ratio WCAG asks of a UI component rather than of prose.
|
||||
TEXT_ON_BG = {"fg": 7.0, "muted": 4.5, "faint": 4.5, "link": 4.5, "danger": 4.5, "warn": 4.5, "success": 4.5}
|
||||
UI_ON_BG = {"accent": 3.0, "border-strong": 3.0, "star": 3.0}
|
||||
|
||||
|
||||
def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
||||
dark = mode == "dark"
|
||||
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})")
|
||||
return out
|
||||
|
||||
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.
|
||||
faint = lift("faint", mix(muted, bg, 0.30), TEXT_ON_BG["faint"])
|
||||
link = lift("link", src["link"], TEXT_ON_BG["link"])
|
||||
danger = lift("danger", src["danger"], TEXT_ON_BG["danger"])
|
||||
warn = lift("warn", src["warn"], TEXT_ON_BG["warn"])
|
||||
success = lift("success", src["success"], TEXT_ON_BG["success"])
|
||||
accent = lift("accent", src["accent"], UI_ON_BG["accent"])
|
||||
star = lift("star", src["star"], UI_ON_BG["star"])
|
||||
border_strong = lift("border-strong", mix(src["line"], fg, 0.15), UI_ON_BG["border-strong"])
|
||||
|
||||
accent_soft = rgba(accent, 0.16) if dark else mix(accent, bg, 0.86)
|
||||
accent_soft_bg = mix(accent, bg, 0.84) if dark else mix(accent, bg, 0.86)
|
||||
accent_soft_fg = toward_contrast(accent, accent_soft_bg, 4.5, dark)
|
||||
accent_fg = "#ffffff" if contrast("#ffffff", accent) >= contrast(bg, accent) else bg
|
||||
|
||||
tokens = {
|
||||
"--bg": bg,
|
||||
"--bg-elev": src["elev"],
|
||||
"--bg-sunken": src["sunken"],
|
||||
"--bg-hover": rgba(fg, 0.06),
|
||||
"--bg-active": rgba(fg, 0.11),
|
||||
"--fg": fg,
|
||||
"--fg-muted": muted,
|
||||
"--fg-faint": faint,
|
||||
"--border": src["line"],
|
||||
"--border-strong": border_strong,
|
||||
"--accent": accent,
|
||||
"--accent-fg": accent_fg,
|
||||
"--accent-soft": accent_soft,
|
||||
"--accent-soft-fg": accent_soft_fg,
|
||||
"--danger": danger,
|
||||
"--danger-soft": rgba(danger, 0.15),
|
||||
"--warn": warn,
|
||||
"--warn-soft": rgba(warn, 0.15),
|
||||
"--success": success,
|
||||
"--success-soft": rgba(success, 0.15),
|
||||
"--link": link,
|
||||
"--unread-bg": src["elev"] if dark else "#ffffff",
|
||||
"--read-bg": src["sunken"] if dark else mix(bg, fg, 0.03),
|
||||
"--selected-bg": rgba(accent, 0.18) if dark else mix(accent, bg, 0.86),
|
||||
"--focus-ring": f"0 0 0 3px {rgba(accent, 0.40)}",
|
||||
"--star": star,
|
||||
"--q1": lift("q1", src["q1"], 4.5),
|
||||
"--q2": lift("q2", src["q2"], 4.5),
|
||||
"--q3": lift("q3", src["q3"], 4.5),
|
||||
"--scrollbar": rgba(muted, 0.35),
|
||||
"color-scheme": "dark" if dark else "light",
|
||||
}
|
||||
if dark:
|
||||
tokens["--shadow-1"] = "0 1px 2px rgba(0, 0, 0, 0.45)"
|
||||
tokens["--shadow-2"] = "0 8px 24px rgba(0, 0, 0, 0.55)"
|
||||
tokens["--shadow-3"] = "0 22px 60px -28px rgba(0, 0, 0, 0.75)"
|
||||
return tokens, notes
|
||||
|
||||
|
||||
def verify(pid: str, mode: str, tokens: dict[str, str]) -> list[str]:
|
||||
"""Fail loudly rather than emit a palette that breaks the AA claim."""
|
||||
bg = tokens["--bg"]
|
||||
bad = []
|
||||
for token, target in [
|
||||
("--fg", 7.0), ("--fg-muted", 4.5), ("--fg-faint", 4.5), ("--link", 4.5),
|
||||
("--danger", 4.5), ("--warn", 4.5), ("--success", 4.5),
|
||||
("--accent", 3.0), ("--border-strong", 3.0), ("--star", 3.0),
|
||||
("--q1", 4.5), ("--q2", 4.5), ("--q3", 4.5),
|
||||
]:
|
||||
ratio = contrast(tokens[token], bg)
|
||||
if ratio + 1e-9 < target:
|
||||
bad.append(f"{pid}/{mode} {token} {tokens[token]} on {bg}: {ratio:.2f} < {target}")
|
||||
ratio = contrast(tokens["--accent-soft-fg"], tokens["--bg-elev"])
|
||||
return bad
|
||||
|
||||
|
||||
def css_for(pid: str, mode: str, tokens: dict[str, str]) -> str:
|
||||
sel = f':root[data-palette="{pid}"]' if mode == "light" else f':root[data-theme="dark"][data-palette="{pid}"]'
|
||||
lines = [f"{sel} {{"]
|
||||
for k, v in tokens.items():
|
||||
lines.append(f" {k}: {v};")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
blocks: list[str] = [
|
||||
BEGIN,
|
||||
"/*",
|
||||
" * 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",
|
||||
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
|
||||
" * between them are derived, and every text colour 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",
|
||||
" * 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.",
|
||||
" */",
|
||||
]
|
||||
problems: list[str] = []
|
||||
for pid, modes in SOURCES.items():
|
||||
for mode in ("light", "dark"):
|
||||
if pid in LIGHT_ONLY and mode == "dark":
|
||||
continue
|
||||
tokens, notes = build(pid, mode, modes[mode])
|
||||
problems += verify(pid, mode, tokens)
|
||||
if notes:
|
||||
blocks.append(f"/* {pid} ({mode}) lifted for contrast: " + "; ".join(notes) + " */")
|
||||
blocks.append(css_for(pid, mode, tokens))
|
||||
blocks.append(END)
|
||||
generated = "\n\n".join(blocks) + "\n"
|
||||
|
||||
if problems:
|
||||
print("Contrast check failed:", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(" " + p, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
css = CSS.read_text(encoding="utf-8")
|
||||
if BEGIN in css:
|
||||
css = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", generated, css, flags=re.S)
|
||||
else:
|
||||
css = css.rstrip() + "\n\n" + generated
|
||||
CSS.write_text(css, encoding="utf-8")
|
||||
print(f"Wrote {len(SOURCES) * 2} palette blocks to {CSS.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Check a catalogue against the strings the code actually asks for.
|
||||
*
|
||||
* Two failures, and only one of them is visible without this.
|
||||
*
|
||||
* A *missing* key renders English. That is the designed fallback and shows up
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
const wanted = 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.
|
||||
*/
|
||||
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)) {
|
||||
const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); };
|
||||
if (n.initializer) walk(n.initializer);
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
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}`);
|
||||
}
|
||||
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
||||
for (const p of n.arguments[1].properties) {
|
||||
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(src);
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
* 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
|
||||
* a silent no-op and nothing anywhere complained.
|
||||
*/
|
||||
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", "")));
|
||||
|
||||
let failed = false;
|
||||
for (const tag of catalogues) {
|
||||
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)) {
|
||||
failed = true;
|
||||
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of globSync("web/src/locales/*.ts")) {
|
||||
const tag = file.split("/").pop().replace(".ts", "");
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const have = new Set();
|
||||
const visit = (n) => {
|
||||
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text);
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(src);
|
||||
const stale = [...have].filter((k) => !wanted.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`);
|
||||
}
|
||||
if (process.argv.includes("--missing")) {
|
||||
console.log(`\n missing:`);
|
||||
for (const k of missing) console.log(` ${JSON.stringify(k)}`);
|
||||
}
|
||||
}
|
||||
if (failed && process.argv.includes("--check")) process.exit(1);
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* How much of the interface is extracted, and what is left.
|
||||
*
|
||||
* Extraction is ~1,000 strings across ~56 files, which is far too many to
|
||||
* carry in anyone's head or to eyeball in review. This counts what is still
|
||||
* hardcoded so the work can be done a file at a time and the remainder is
|
||||
* always a number rather than a feeling.
|
||||
*
|
||||
* It is a progress report, not a gate: run it, do a file, run it again. It
|
||||
* 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";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/** Attributes a person reads. `className` and `key` are not among them. */
|
||||
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
|
||||
/* Text that is not prose: punctuation, separators, and the single glyphs used
|
||||
as dividers. Counting these as untranslated would put a floor under the
|
||||
number that no amount of work could reach. */
|
||||
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
|
||||
/*
|
||||
* Text that is deliberately not translated is not "remaining work". Counting
|
||||
* it put a floor under the number that no amount of effort could reach -- the
|
||||
* report sat at 21 with only 6 real items left, which makes the number
|
||||
* something to argue with rather than act on. Same rule the codemod uses.
|
||||
*/
|
||||
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
|
||||
const optedOut = (node, src) => {
|
||||
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
|
||||
return Boolean(opening?.attributes.properties.some((a) =>
|
||||
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
|
||||
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
|
||||
};
|
||||
|
||||
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
|
||||
const rows = [];
|
||||
let done = 0, todo = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
let left = 0;
|
||||
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
|
||||
const visit = (node) => {
|
||||
if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
|
||||
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
|
||||
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
|
||||
const i = node.initializer;
|
||||
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
|
||||
// The same prose test the text nodes get. Without it, placeholders that
|
||||
// are format examples -- "123456" for a one-time code, "+1 555 0100" for
|
||||
// a phone -- counted as untranslated work forever.
|
||||
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(src);
|
||||
done += wrapped;
|
||||
todo += left;
|
||||
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
|
||||
}
|
||||
|
||||
rows.sort((a, b) => b[1] - a[1]);
|
||||
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
|
||||
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
|
||||
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
|
||||
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
|
||||
}
|
||||
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
|
||||
if (process.argv.includes("--check") && todo > 0) process.exit(1);
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Wrap the strings a codemod can safely wrap, and report the ones it cannot.
|
||||
*
|
||||
* Roughly 1,000 strings is too many to hand-edit without introducing typos
|
||||
* into the copy itself, and a parser does not get bored. But it must not be
|
||||
* trusted with everything: text that is split around an interpolation arrives
|
||||
* as separate fragments, and wrapping each fragment on its own produces
|
||||
* "Move " and " messages", which no translator can do anything with. Those are
|
||||
* left alone and listed, because they need a sentence built by hand.
|
||||
*
|
||||
* node scripts/i18n-extract.mjs <file...> rewrite in place
|
||||
* node scripts/i18n-extract.mjs --dry <file...>
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
|
||||
const NOT_PROSE = /^[\s·—–\-:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
|
||||
/*
|
||||
* Elements whose text is not prose however much it looks like it. `label:name`
|
||||
* inside <code> is a search operator: translating it breaks the thing it
|
||||
* documents. The first run of this wrapped exactly that, which is why the list
|
||||
* exists.
|
||||
*/
|
||||
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
|
||||
/*
|
||||
* JSX decodes HTML entities in text; a JS string literal does not. Moving
|
||||
* `Language & region` into t("...") without decoding renders the entity
|
||||
* literally on screen -- which the first run of this did, and which no
|
||||
* typecheck or test noticed. It took looking at the page.
|
||||
*/
|
||||
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "—", ndash: "–", hellip: "…", times: "×", middot: "·" };
|
||||
const decode = (s) => s.replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
|
||||
const tagOf = (node, src) => (ts.isJsxElement(node) ? node.openingElement.tagName.getText(src) : "");
|
||||
const optedOut = (node, src) => {
|
||||
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
|
||||
return Boolean(opening?.attributes.properties.some((a) =>
|
||||
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
|
||||
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
|
||||
};
|
||||
|
||||
const dry = process.argv.includes("--dry");
|
||||
const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
||||
let wrapped = 0;
|
||||
const skipped = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
/*
|
||||
* `t` is a natural name for a callback parameter, and several files already
|
||||
* use it -- `(t: SieveTest) => ...`, `.map((t) => ...)`. An import called
|
||||
* `t` is shadowed inside those callbacks, silently where the local happens
|
||||
* to be callable. So the name is checked first and aliased where it is
|
||||
* taken, per file, rather than assumed to be free.
|
||||
*/
|
||||
let bound = false;
|
||||
const scan = (n) => {
|
||||
if ((ts.isParameter(n) || ts.isVariableDeclaration(n) || ts.isBindingElement(n)) && n.name && ts.isIdentifier(n.name) && n.name.text === "t") bound = true;
|
||||
ts.forEachChild(n, scan);
|
||||
};
|
||||
scan(src);
|
||||
const T = bound ? "translate" : "t";
|
||||
/** [start, end, replacement] — applied back-to-front so offsets hold. */
|
||||
const edits = [];
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
|
||||
if (CODE_TAGS.has(tagOf(node, src).toLowerCase()) || optedOut(node, src)) return; // and not its children
|
||||
const kids = node.children;
|
||||
const meaningful = kids.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
|
||||
for (const c of kids) {
|
||||
if (!ts.isJsxText(c)) continue;
|
||||
const raw = c.text;
|
||||
const body = raw.trim();
|
||||
if (body.length < 2 || NOT_PROSE.test(body)) continue;
|
||||
/*
|
||||
* "Split around an interpolation" is the dangerous case, and it is
|
||||
* narrower than "has siblings". `<Plus /> New rule` is a phrase next
|
||||
* to an icon: wrapping it alone is correct, and refusing it left a
|
||||
* third of the remaining work to be done by hand for no reason.
|
||||
* `Your script “{name}” was written by hand` is the real thing --
|
||||
* a sibling that renders text, so the fragments are not sentences.
|
||||
*/
|
||||
const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => {
|
||||
let jsx = false;
|
||||
const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); };
|
||||
w(k.expression);
|
||||
return jsx;
|
||||
})());
|
||||
if (textSibling) {
|
||||
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
|
||||
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
|
||||
continue;
|
||||
}
|
||||
if (decode(body).includes('"')) {
|
||||
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
|
||||
skipped.push({ file, line: line + 1, why: "contains a quote", text: body.slice(0, 52) });
|
||||
continue;
|
||||
}
|
||||
// Keep the original leading/trailing whitespace: JSX collapses it, and
|
||||
// reflowing here would change the rendered spacing.
|
||||
const lead = raw.slice(0, raw.indexOf(body[0]));
|
||||
const tail = raw.slice(raw.lastIndexOf(body[body.length - 1]) + 1);
|
||||
edits.push([c.getStart(src), c.getEnd(), `${lead}{${T}("${decode(body.replace(/\s+/g, " "))}")}${tail}`]);
|
||||
wrapped++;
|
||||
}
|
||||
}
|
||||
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
|
||||
const i = node.initializer;
|
||||
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
|
||||
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text)) {
|
||||
if (decode(lit.text).includes('"')) {
|
||||
const { line } = src.getLineAndCharacterOfPosition(lit.getStart(src));
|
||||
skipped.push({ file, line: line + 1, why: "contains a quote", text: lit.text.slice(0, 52) });
|
||||
} else {
|
||||
edits.push([i.getStart(src), i.getEnd(), `{${T}("${decode(lit.text)}")}`]);
|
||||
wrapped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(src);
|
||||
if (!edits.length) continue;
|
||||
|
||||
let out = text;
|
||||
for (const [start, end, rep] of edits.sort((a, b) => b[0] - a[0])) out = out.slice(0, start) + rep + out.slice(end);
|
||||
if (!/from "@\/lib\/i18n"/.test(out)) {
|
||||
const lastImport = [...out.matchAll(/^import .*?;$/gm)].pop();
|
||||
const decl = bound ? 'import { t as translate } from "@/lib/i18n";' : 'import { t } from "@/lib/i18n";';
|
||||
if (lastImport) out = out.slice(0, lastImport.index + lastImport[0].length) + "\n" + decl + out.slice(lastImport.index + lastImport[0].length);
|
||||
}
|
||||
if (!dry) writeFileSync(file, out);
|
||||
}
|
||||
|
||||
console.log(`${dry ? "would wrap" : "wrapped"} ${wrapped} strings across ${files.length} files`);
|
||||
if (skipped.length) {
|
||||
console.log(`\n${skipped.length} left for a person:`);
|
||||
for (const s of skipped) console.log(` ${s.file.replace("web/src/", "")}:${s.line} (${s.why}) ${s.text}`);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* User-visible English the extraction pass cannot see.
|
||||
*
|
||||
* `i18n:coverage` reads JSX text, and reported 100% while the calendar's
|
||||
* Day/Week/Month/Agenda buttons rendered English in all nine languages. It was
|
||||
* not wrong about what it measured -- those labels were never JSX text. They
|
||||
* were built from an expression, and so was every toast argument, every
|
||||
* `confirmDialog({ title })`, and every `Could not save: ${err}`.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
* cannot be translated at all, however many languages ship.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
/* Where a string literal in this position is shown to somebody. */
|
||||
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"]);
|
||||
const TOASTS = new Set(["error", "success", "info", "show"]);
|
||||
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
|
||||
const EQUALITY = new Set([
|
||||
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
|
||||
]);
|
||||
|
||||
/*
|
||||
* Product names, example addresses and URL scaffolding. These reach t() and
|
||||
* are deliberately absent from every catalogue -- translating "ihasmail" or
|
||||
* "[email protected]" would be a bug, not a feature -- so they would otherwise
|
||||
* be reported for ever.
|
||||
*/
|
||||
const NEVER_TRANSLATED = new Set([
|
||||
"ihasmail", "ihasmail.org", "ihasmail test", "Stalwart", "Stalwart Mail Server",
|
||||
"AGPL-3.0-or-later · {source}", "•••", "https://", "https://…",
|
||||
"https://meet.example.com/…", "[email protected]", "[email protected]",
|
||||
"[email protected]", "List-Id", "X-Spam-Status",
|
||||
]);
|
||||
|
||||
/* Prose, not an identifier: opens like a sentence, and has lower-case letters. */
|
||||
const looksLikeUi = (s) =>
|
||||
/[a-z]/.test(s) && /^[A-Z(“]/.test(s) && (/\s/.test(s) || /[.?!…]$/.test(s));
|
||||
|
||||
const keys = new Set();
|
||||
{
|
||||
const src = readFileSync("web/src/locales/de.ts", "utf8");
|
||||
for (const m of src.matchAll(/^\s{4}"((?:[^"\\]|\\.)*)":/gm)) keys.add(m[1].replace("\\u0004", ""));
|
||||
}
|
||||
|
||||
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;
|
||||
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
|
||||
found.push({ file, line: line + 1, text });
|
||||
};
|
||||
|
||||
/*
|
||||
* Literals that are not text on their way to a reader.
|
||||
*
|
||||
* Two kinds. One is already inside t("...") -- walking into the call would
|
||||
* report the very string that proves it is handled. The other is an operand
|
||||
* of an equality test: `rule.name === "New filter"` compares against a
|
||||
* sentinel stored in the Sieve script, and translating it would not change
|
||||
* what a reader sees, it would break the comparison.
|
||||
*/
|
||||
const exempt = new Set();
|
||||
const mark = (n) => {
|
||||
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && WRAPPERS.includes(n.expression.text)) {
|
||||
const walk = (x) => { if (ts.isStringLiteral(x)) exempt.add(x); ts.forEachChild(x, walk); };
|
||||
for (const a of n.arguments) walk(a);
|
||||
}
|
||||
if (ts.isBinaryExpression(n) && EQUALITY.has(n.operatorToken.kind)) {
|
||||
for (const side of [n.left, n.right]) if (ts.isStringLiteral(side)) exempt.add(side);
|
||||
}
|
||||
ts.forEachChild(n, mark);
|
||||
};
|
||||
mark(src);
|
||||
const wrapped = exempt;
|
||||
|
||||
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.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 (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
|
||||
};
|
||||
walk(n.initializer);
|
||||
}
|
||||
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
|
||||
&& 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. */
|
||||
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(src);
|
||||
}
|
||||
|
||||
if (!found.length) {
|
||||
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue 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.");
|
||||
process.exit(process.argv.includes("--check") ? 1 : 0);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Every source string a catalogue needs, straight out of the calls.
|
||||
*
|
||||
* The English text is the key, so the catalogue'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
|
||||
* 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";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
const strings = new Set();
|
||||
const plurals = new Set();
|
||||
|
||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) {
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
||||
const fn = node.expression.text;
|
||||
const a0 = node.arguments[0];
|
||||
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text);
|
||||
if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
|
||||
const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other");
|
||||
const forms = {};
|
||||
for (const p of node.arguments[1].properties) {
|
||||
if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text;
|
||||
}
|
||||
if (other) plurals.add(JSON.stringify(forms));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(src);
|
||||
}
|
||||
|
||||
const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) };
|
||||
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
|
||||
else {
|
||||
console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`);
|
||||
const short = out.strings.filter((s) => s.length <= 30).length;
|
||||
console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
||||
export function baseVersion(): string;
|
||||
export const UNVERSIONED: string;
|
||||
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
|
||||
export function versionFromGit(): string | null;
|
||||
export function resolveVersion(): string;
|
||||
|
||||
+63
-38
@@ -1,38 +1,51 @@
|
||||
/**
|
||||
* Work out this build's version: `2.16.57`.
|
||||
* Work out this build's version: `2026.8.30+pr129`.
|
||||
*
|
||||
* 2 ihasmail's own major
|
||||
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
|
||||
* 57 the pull request the checked-out commit came from
|
||||
* 2026.8.30 the date of the commit this was built from
|
||||
* +pr129 the pull request it arrived through
|
||||
*
|
||||
* The first two are the `version` in the root package.json, so there is one
|
||||
* place to bump them; the third is read from git, because it does not exist
|
||||
* until the pull request has actually merged. Nothing writes a version back
|
||||
* into the tree: a committed one would always be describing a merge that had
|
||||
* not happened yet, and every branch would collide on the same line.
|
||||
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
|
||||
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
|
||||
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
|
||||
* every image and About screen would read as a downgrade. Tying our
|
||||
* numbering to somebody else's was the mistake; which Stalwart a build needs is
|
||||
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
|
||||
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
|
||||
*
|
||||
* A commit that did not arrive through a pull request has no number of its
|
||||
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
|
||||
* — which is honest about being past that PR rather than silently claiming to
|
||||
* be it.
|
||||
* The pull request moved into build metadata, after the `+`, because it is
|
||||
* provenance rather than a position in a sequence: at a hundred merges a week
|
||||
* it climbs without bound and says nothing about how new a build is. SemVer
|
||||
* ignores everything after the `+` when comparing versions, which is the right
|
||||
* reading -- two builds from the same day differ in where they came from, not
|
||||
* in rank. Nothing here relies on that comparison anyway: images are pruned
|
||||
* oldest-first by creation time and rollbacks name a git ref.
|
||||
*
|
||||
* A commit that did not arrive through a pull request carries its short SHA
|
||||
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
|
||||
* that day rather than claiming a pull request it was only built after.
|
||||
*
|
||||
* The date is the commit's own, not today's, so rebuilding an old commit gives
|
||||
* the same answer it gave the first time. It comes from the commit object,
|
||||
* timezone included, so two machines agree.
|
||||
*
|
||||
* Nothing writes a version back into the tree: a committed one would always be
|
||||
* describing a merge that had not happened yet, and every branch would collide
|
||||
* on the same line. `package.json` no longer carries it either -- npm wants the
|
||||
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
|
||||
* is meant to look wrong.
|
||||
*
|
||||
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
|
||||
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
|
||||
* whoever builds is responsible for computing it — see ihasmail-deploy.sh.
|
||||
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
|
||||
export function baseVersion() {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const [major, minor] = String(pkg.version).split(".");
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
/** What a build with nothing to go on reports, and it should look wrong. */
|
||||
export const UNVERSIONED = "0.0.0";
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
@@ -40,41 +53,53 @@ function git(...args) {
|
||||
|
||||
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
|
||||
|
||||
/**
|
||||
* The version for a commit, from the three things about it that decide one.
|
||||
* Pure, so the rules can be exercised without a repository staged to produce
|
||||
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
|
||||
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
|
||||
*
|
||||
* Leading zeros are stripped because a version field may not carry them, so
|
||||
* September is `9` rather than `09`.
|
||||
*/
|
||||
export function formatVersion({ date, subject = "", sha }) {
|
||||
const [y, m, d] = date.split("-");
|
||||
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version for the commit checked out here, or null when there is no git to
|
||||
* ask — an unpacked tarball, or the Docker build context.
|
||||
* ask -- an unpacked tarball, or the Docker build context.
|
||||
*/
|
||||
export function versionFromGit() {
|
||||
let head;
|
||||
let date;
|
||||
try {
|
||||
head = git("rev-parse", "--short", "HEAD");
|
||||
// %cs is the committer date in the commit's own timezone, which is stored
|
||||
// in the commit -- so this does not depend on the clock or zone of whoever
|
||||
// is building.
|
||||
date = git("show", "-s", "--format=%cs", "HEAD");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const base = baseVersion();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
let subject = "";
|
||||
try {
|
||||
// Walk back over first parents: a merge commit's subject names its PR, and
|
||||
// anything after the newest one is work that has not been through one.
|
||||
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
|
||||
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
|
||||
for (const [sha, subject = ""] of commits) {
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
if (!pr) continue;
|
||||
// The PR's own merge commit is the version; anything above it is past it.
|
||||
const exact = sha.startsWith(git("rev-parse", "HEAD"));
|
||||
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
|
||||
}
|
||||
subject = git("show", "-s", "--format=%s", "HEAD");
|
||||
} catch {
|
||||
/* a shallow clone, or no history to read */
|
||||
/* no subject to read; fall through to the SHA */
|
||||
}
|
||||
return `${base}.0+g${head}`;
|
||||
return formatVersion({ date, subject, sha: head });
|
||||
}
|
||||
|
||||
/** Whatever the environment was told, else git, else just the base. */
|
||||
/** Whatever the environment was told, else git, else an answer that looks wrong. */
|
||||
export function resolveVersion() {
|
||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return versionFromGit() ?? `${baseVersion()}.0`;
|
||||
return versionFromGit() ?? UNVERSIONED;
|
||||
}
|
||||
|
||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
||||
"mock": "tsx src/mock/index.ts",
|
||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts",
|
||||
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
|
||||
@@ -65,7 +65,7 @@ function accountId(ctx: Ctx): string {
|
||||
type Invocation = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl, ctx.session.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
||||
|
||||
@@ -35,3 +35,112 @@ test("image proxy refuses private targets", async () => {
|
||||
const res = await app.request("/api/image?url=http://127.0.0.1/x");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
// gzip: the body we forward has already been decompressed, so the length on
|
||||
// the wire describes different bytes and must not be copied (issue #76).
|
||||
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
|
||||
assert.equal(forwardedContentLength(gz), null);
|
||||
// identity, spelled out or absent: the length describes the body we send.
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
|
||||
// Nothing to forward is not an error.
|
||||
assert.equal(forwardedContentLength(new Headers()), null);
|
||||
});
|
||||
|
||||
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
|
||||
const http = await import("node:http");
|
||||
const zlib = await import("node:zlib");
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
|
||||
const script =
|
||||
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
|
||||
["a", "b", "c"]
|
||||
.map(
|
||||
(k) =>
|
||||
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
|
||||
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
|
||||
)
|
||||
.join("");
|
||||
const gz = zlib.gzipSync(Buffer.from(script));
|
||||
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
|
||||
|
||||
// A hop that compresses regardless of what we asked for.
|
||||
const origin = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
|
||||
res.end(gz);
|
||||
});
|
||||
await new Promise<void>((r) => origin.listen(0, () => r()));
|
||||
const port = (origin.address() as { port: number }).port;
|
||||
|
||||
try {
|
||||
const up = await fetch(`http://127.0.0.1:${port}/`);
|
||||
// What the blob route forwards.
|
||||
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
|
||||
const cl = forwardedContentLength(up.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
|
||||
assert.equal(out.headers.get("content-length"), null);
|
||||
assert.equal(await out.text(), script);
|
||||
} finally {
|
||||
origin.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("only a PDF blob may be framed, and only by us", async () => {
|
||||
/*
|
||||
* The PDF preview is an iframe, and the blanket X-Frame-Options: DENY on
|
||||
* every response blocked it -- the dialog showed Chrome's "refused to
|
||||
* connect" where the file should have been. The middleware now leaves a
|
||||
* header a route has already set, so this pins both halves: the exception
|
||||
* exists, and it did not become the rule.
|
||||
*/
|
||||
const app = createApp();
|
||||
const health = await app.request("/api/health");
|
||||
assert.equal(health.headers.get("x-frame-options"), "DENY");
|
||||
|
||||
const { securityHeadersFor } = await import("./app.js");
|
||||
assert.equal(securityHeadersFor("application/pdf", true), "SAMEORIGIN");
|
||||
assert.equal(securityHeadersFor("application/pdf", false), "DENY");
|
||||
assert.equal(securityHeadersFor("image/png", true), "DENY");
|
||||
assert.equal(securityHeadersFor("text/html", true), "DENY");
|
||||
});
|
||||
|
||||
/*
|
||||
* #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,
|
||||
* 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.
|
||||
*/
|
||||
test("an unreachable upstream does not spend login attempts", async () => {
|
||||
const app = createApp();
|
||||
const login = () =>
|
||||
app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
|
||||
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
|
||||
});
|
||||
|
||||
// Comfortably past LOGIN_RATE_LIMIT, which defaults to 10.
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const res = await login();
|
||||
assert.notEqual(res.status, 429, `attempt ${i + 1} was rate limited`);
|
||||
assert.ok(res.status === 502 || res.status === 504, `attempt ${i + 1} said ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable upstream says it is not the password", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
|
||||
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
|
||||
});
|
||||
const body = (await res.json()) as { error: string; message: string };
|
||||
assert.notEqual(body.error, "invalid_credentials");
|
||||
assert.match(body.message, /not a problem with your password/i);
|
||||
});
|
||||
|
||||
+149
-23
@@ -16,6 +16,7 @@ import {
|
||||
forgetUpstreamSession,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
upstreamFor,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
@@ -30,12 +31,27 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
|
||||
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
||||
/*
|
||||
* The backstop that is never refunded.
|
||||
*
|
||||
* `loginLimiter` guards password guessing and gives its attempts back when the
|
||||
* upstream never judged the password (#239) -- otherwise retrying through an
|
||||
* outage locks somebody out until after it has ended. But "not counted" cannot
|
||||
* mean "unlimited": each attempt still costs ihasmail an outbound connection
|
||||
* that may sit there until `UPSTREAM_TIMEOUT`, so a flood during an outage is
|
||||
* the one moment the endpoint is cheapest to abuse.
|
||||
*
|
||||
* Hence a second ceiling, per address, twenty times looser and refunded never.
|
||||
* A person retrying an outage will not come near it; something hammering will.
|
||||
*/
|
||||
const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_000);
|
||||
/**
|
||||
* Credential changes verify the current password upstream, and Stalwart's
|
||||
* fail2ban counts those failures against the *caller's* IP — which for a proxy
|
||||
@@ -82,7 +98,9 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
const h = c.res.headers;
|
||||
h.set("X-Content-Type-Options", "nosniff");
|
||||
h.set("X-Frame-Options", "DENY");
|
||||
/* A route that must be framable says so; everything else is DENY. The blob
|
||||
route is the only one, and only for PDFs -- see the note there. */
|
||||
if (!h.has("X-Frame-Options")) h.set("X-Frame-Options", "DENY");
|
||||
h.set("Referrer-Policy", "no-referrer");
|
||||
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
||||
h.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
@@ -114,12 +132,28 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scope the session cookie to the mount, not the whole host.
|
||||
*
|
||||
* Under a prefix the browser is talking to a hostname that other applications
|
||||
* share, and a cookie at `/` would be sent to every one of them. Path scoping
|
||||
* is not a security boundary -- anything on the origin can reach the cookie
|
||||
* jar -- but it keeps the credential out of requests that have no business
|
||||
* carrying it, and it lets two ihasmail instances live at `/mail` and
|
||||
* `/mail2` on one host without signing each other out, which a shared cookie
|
||||
* name at `/` would do.
|
||||
*
|
||||
* `/` for the root case: an empty Path is not the same thing and browsers
|
||||
* would fall back to the directory of the request that set it.
|
||||
*/
|
||||
const cookiePath = config.basePath || "/";
|
||||
|
||||
function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
setCookie(c, config.cookieName, value, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: "/",
|
||||
path: cookiePath,
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
@@ -130,13 +164,18 @@ function upstreamFailure(c: Context, err: unknown) {
|
||||
}
|
||||
const name = (err as Error)?.name ?? "";
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
|
||||
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time. This is not a problem with your password." }, 504);
|
||||
}
|
||||
console.error("[ihasmail] upstream failure:", err);
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server. This is not a problem with your password." }, 502);
|
||||
}
|
||||
|
||||
export function createApp(): Hono<Env> {
|
||||
/**
|
||||
* `basePath` is a parameter rather than read straight from the config so the
|
||||
* tests can mount the same app twice, at the root and under a prefix, without
|
||||
* re-importing the module to change one environment variable.
|
||||
*/
|
||||
export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
|
||||
@@ -151,6 +190,9 @@ export function createApp(): Hono<Env> {
|
||||
sourceUrl: config.sourceUrl,
|
||||
imageProxy: config.imageProxy,
|
||||
maxUploadBytes: config.maxUploadBytes,
|
||||
/* 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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -169,7 +211,24 @@ export function createApp(): Hono<Env> {
|
||||
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
|
||||
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
|
||||
|
||||
/*
|
||||
* Three checks, answering different questions.
|
||||
*
|
||||
* `limitKey` is this username from this address, and `ip` 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
|
||||
* budget, and behind one office NAT that budget belongs to the whole
|
||||
* building.
|
||||
*
|
||||
* 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)) {
|
||||
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);
|
||||
@@ -179,12 +238,16 @@ export function createApp(): Hono<Env> {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||
try {
|
||||
const upstream = await fetchUpstreamSession(authorization);
|
||||
const upstream = await fetchUpstreamSession(authorization, upstreamFor(username));
|
||||
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
||||
// clearly, rather than signing someone in and letting Files, the account
|
||||
// locale and self-service credentials each fail in their own way with
|
||||
// nothing to connect them. The credentials were good, so say so.
|
||||
if (!hasStalwartRegistry(upstream)) {
|
||||
// The credentials were accepted; only the server is too old. Not an
|
||||
// attempt worth counting against them.
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
@@ -232,6 +295,16 @@ export function createApp(): Hono<Env> {
|
||||
401,
|
||||
);
|
||||
}
|
||||
/*
|
||||
* A 401 is a judgement 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);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
@@ -239,13 +312,13 @@ export function createApp(): Hono<Env> {
|
||||
api.get("/auth/session", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username), c.req.query("refresh") === "1");
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -258,7 +331,7 @@ export function createApp(): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -456,8 +529,8 @@ export function createApp(): Hono<Env> {
|
||||
return c.json({ error: "unsupported_media_type" }, 415);
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: session.authorization,
|
||||
@@ -471,7 +544,7 @@ export function createApp(): Hono<Env> {
|
||||
if (res.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
return passthrough(res);
|
||||
@@ -490,8 +563,8 @@ export function createApp(): Hono<Env> {
|
||||
// suggestion; count the bytes as they go past.
|
||||
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }), upstream.baseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -516,17 +589,20 @@ export function createApp(): Hono<Env> {
|
||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||
const inline = c.req.query("inline") === "1";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: session.authorization },
|
||||
// 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" },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
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 = res.headers.get("content-length");
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
@@ -535,7 +611,19 @@ export function createApp(): Hono<Env> {
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||
if (!(safeInline && type === "application/pdf")) {
|
||||
if (securityHeadersFor(type, safeInline) === "SAMEORIGIN") {
|
||||
/*
|
||||
* The one response on the server that may be framed.
|
||||
*
|
||||
* A PDF is shown in an iframe -- it is its own document and the app
|
||||
* cannot lay it out -- and the blanket X-Frame-Options: DENY above
|
||||
* blocked that, so the preview showed Chrome's "refused to connect"
|
||||
* instead of the file. SAMEORIGIN, not a relaxation to any site: the
|
||||
* frame is ours, on our origin, and the app's own CSP already says
|
||||
* frame-src 'self'. Nothing else here is framed, so nothing else asks.
|
||||
*/
|
||||
headers.set("X-Frame-Options", "SAMEORIGIN");
|
||||
} 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");
|
||||
@@ -552,8 +640,8 @@ export function createApp(): Hono<Env> {
|
||||
const closeafter = c.req.query("closeafter") ?? "no";
|
||||
const ping = c.req.query("ping") ?? "30";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||
const controller = new AbortController();
|
||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||
const res = await fetch(url, {
|
||||
@@ -575,6 +663,9 @@ export function createApp(): Hono<Env> {
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
api.get("/image", requireSession, 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.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||
api.onError((err, c) => {
|
||||
@@ -582,10 +673,10 @@ export function createApp(): Hono<Env> {
|
||||
return c.json({ error: "internal_error" }, 500);
|
||||
});
|
||||
|
||||
app.route("/api", api);
|
||||
app.route(`${basePath}/api`, api);
|
||||
|
||||
// ---------- Static SPA ----------
|
||||
app.get("*", staticHandler(config.staticDir));
|
||||
app.get("*", staticHandler(config.staticDir, basePath));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -651,6 +742,32 @@ function passthrough(res: Response): Response {
|
||||
return new Response(res.body, { status: res.status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream content-length, but only when it describes the bytes we are
|
||||
* about to forward.
|
||||
*
|
||||
* A compressed response is decompressed for us before we ever see the body --
|
||||
* undici does it transparently -- while the content-length header is left
|
||||
* describing the *compressed* length. Copying it onto the longer body we then
|
||||
* send makes the browser stop reading exactly that many bytes in and call the
|
||||
* download complete, so the file arrives silently truncated.
|
||||
*
|
||||
* That is the second half of issue #76. A hop in front of Stalwart compressed
|
||||
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
|
||||
* pushed it past the threshold and it came back cut off mid-rule. Nothing
|
||||
* reported an error: the script parsed, just with rules missing, and saving
|
||||
* wrote that shortened version back over the real one.
|
||||
*
|
||||
* We ask for `identity` above so the usual case still carries a length the
|
||||
* browser can show progress against; this is the guard for a hop that
|
||||
* compresses anyway.
|
||||
*/
|
||||
export function forwardedContentLength(headers: Headers): string | null {
|
||||
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
|
||||
if (encoding && encoding !== "identity") return null;
|
||||
return headers.get("content-length");
|
||||
}
|
||||
|
||||
function sanitizeContentType(ct: string): string {
|
||||
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
||||
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
||||
@@ -668,6 +785,15 @@ function sanitizeContentType(ct: string): string {
|
||||
return lower || "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* What X-Frame-Options a blob response carries. Exported so the rule is
|
||||
* testable without standing up an upstream: a PDF served inline may be framed
|
||||
* by us and nothing else may be framed at all.
|
||||
*/
|
||||
export function securityHeadersFor(type: string, safeInline: boolean): "SAMEORIGIN" | "DENY" {
|
||||
return safeInline && type.split(";")[0]!.trim() === "application/pdf" ? "SAMEORIGIN" : "DENY";
|
||||
}
|
||||
|
||||
function isInlineSafe(type: string): boolean {
|
||||
const t = type.split(";")[0]!.trim();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
/**
|
||||
* `BASE_PATH` is read once, into `config`, so these mount the app by argument
|
||||
* instead of re-importing the module with a different environment. The
|
||||
* root-mounted half is the one that matters most: every instance in existence
|
||||
* is at `/`, and this feature has to be invisible to them.
|
||||
*/
|
||||
|
||||
test("at the root, the API is exactly where it was", async () => {
|
||||
const app = createApp("");
|
||||
const res = await app.request("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("under a prefix, the API moves with it", async () => {
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/mail/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { ok?: boolean };
|
||||
assert.equal(body.ok, true);
|
||||
});
|
||||
|
||||
test("under a prefix, the unprefixed API is gone", async () => {
|
||||
// Not merely unrouted: a proxy that forwards without the prefix, against a
|
||||
// server told to expect one, would otherwise appear to half-work -- the API
|
||||
// answering while the app shell it belongs to 404s.
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/api/health");
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
/*
|
||||
* Whether a route reached the static handler, without depending on there being
|
||||
* a web build in the tree. With one it serves the index; without one it says
|
||||
* the build is missing. Either is proof the request got that far -- a routing
|
||||
* mistake is the 404, and asserting on 200 or 503 would make these tests pass
|
||||
* or fail on whether somebody had run `npm run build` first.
|
||||
*/
|
||||
const reachedTheApp = (status: number) => status === 200 || status === 503;
|
||||
|
||||
test("a deep SPA route under the prefix reaches the static handler", async () => {
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/mail/calendar/week/2026-09-01");
|
||||
assert.ok(reachedTheApp(res.status), `expected the app shell, got ${res.status}`);
|
||||
});
|
||||
|
||||
test("a path that only shares the prefix's letters is not the app", async () => {
|
||||
// `/mailbox` under a `/mail` mount belongs to whatever else the proxy
|
||||
// serves on this host; answering it with our shell would shadow it.
|
||||
const app = createApp("/mail");
|
||||
assert.equal((await app.request("/mailbox")).status, 404);
|
||||
assert.equal((await app.request("/")).status, 404);
|
||||
});
|
||||
|
||||
test("the root mount still serves the SPA from the root", async () => {
|
||||
const app = createApp("");
|
||||
assert.ok(reachedTheApp((await app.request("/calendar/week/2026-09-01")).status));
|
||||
assert.ok(reachedTheApp((await app.request("/")).status));
|
||||
});
|
||||
+146
-1
@@ -1,4 +1,5 @@
|
||||
import { resolveVersion } from "../../scripts/version.mjs";
|
||||
import { normalizeBasePath } from "../../scripts/basePath.mjs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
@@ -110,9 +111,139 @@ export function assertImmutable(sessionFile: string, root: string): void {
|
||||
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
|
||||
/**
|
||||
* Settings an installation decides, rather than each reader.
|
||||
*
|
||||
* A school turning on "warn about outside senders" for three thousand pupils
|
||||
* cannot ask three thousand pupils to turn it on -- issue #207. Two sections,
|
||||
* which are two different powers:
|
||||
*
|
||||
* - `defaults` seed an account that has never had settings of its own. The
|
||||
* reader can change any of them afterwards; they are a starting point, not a
|
||||
* rule.
|
||||
* - `enforced` are applied on every load and cannot be changed here at all. The
|
||||
* controls stay visible and go dead, which the issue asked for by name: a
|
||||
* missing control confuses somebody who has used ihasmail elsewhere.
|
||||
* - `changes` are applied once each, to everybody, including accounts that
|
||||
* already exist -- and can be changed back afterwards. Each carries its own
|
||||
* `version`, which is how an account remembers the ones it has had. The
|
||||
* reporter's own analogy is a schema migration and this is that shape.
|
||||
*
|
||||
* Read from a file or straight from the environment, because ihasmail's own
|
||||
* production runs read-only with no volume -- an installation that cannot mount
|
||||
* a file can still set a variable.
|
||||
*/
|
||||
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown>; changes: Array<{ version: string; settings: Record<string, unknown> }> } {
|
||||
const parse = (raw: string, where: string): Record<string, unknown> => {
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
|
||||
return v as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
/* Loud, and fatal. A policy that silently did not apply would look like
|
||||
the feature not working, and the admin would have no way to tell. */
|
||||
throw new Error(`Invalid ${where}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A change list, checked rather than trusted.
|
||||
*
|
||||
* Every entry needs a `version` that is unique within the file: it is what an
|
||||
* account stores to say it has had this one, so a duplicate would make two
|
||||
* changes indistinguishable and a missing one would apply for ever.
|
||||
*/
|
||||
const parseChanges = (v: unknown, where: string): Array<{ version: string; settings: Record<string, unknown> }> => {
|
||||
if (v === undefined) return [];
|
||||
if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`);
|
||||
const seen = new Set<string>();
|
||||
return v.map((entry, i) => {
|
||||
const e = entry as { version?: unknown; settings?: unknown };
|
||||
const version = typeof e.version === "string" ? e.version.trim() : "";
|
||||
if (!version) throw new Error(`Invalid ${where}: changes[${i}] has no "version"`);
|
||||
if (seen.has(version)) throw new Error(`Invalid ${where}: two changes share the version "${version}"`);
|
||||
seen.add(version);
|
||||
if (!e.settings || typeof e.settings !== "object" || Array.isArray(e.settings)) {
|
||||
throw new Error(`Invalid ${where}: changes[${i}] ("${version}") has no "settings" object`);
|
||||
}
|
||||
return { version, settings: e.settings as Record<string, unknown> };
|
||||
});
|
||||
};
|
||||
|
||||
const file = process.env.SETTINGS_POLICY_FILE;
|
||||
if (file) {
|
||||
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
|
||||
const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`);
|
||||
return {
|
||||
defaults: (whole.defaults as Record<string, unknown>) ?? {},
|
||||
enforced: (whole.enforced as Record<string, unknown>) ?? {},
|
||||
changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`),
|
||||
};
|
||||
}
|
||||
return {
|
||||
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
|
||||
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
|
||||
changes: process.env.SETTINGS_CHANGES ? parseChanges(JSON.parse(process.env.SETTINGS_CHANGES), "SETTINGS_CHANGES") : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Stalwart a domain signs in to.
|
||||
*
|
||||
* `STALWART_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.
|
||||
*
|
||||
* Read once at boot and never written, so it mounts read-only and costs
|
||||
* nothing in immutability -- the same shape as the settings policy.
|
||||
*
|
||||
* Servers are deliberately **not** probed here. A mapping is a routing table,
|
||||
* not a health check, and refusing to boot because one of five customers is
|
||||
* 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}`);
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
|
||||
/* Lower-cased and stripped of the root dot, because that is how a domain
|
||||
taken off a username will arrive and comparing them any other way means
|
||||
a mapping that silently never matches. */
|
||||
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
|
||||
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
|
||||
}
|
||||
out[domain] = rawUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
settingsPolicy: readSettingsPolicy(),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
|
||||
@@ -127,10 +258,24 @@ export const config = {
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
/**
|
||||
* The subpath this instance answers on: `/mail` for a proxy that maps
|
||||
* `https://example.com/mail/` here, and `""` -- the default -- for the root.
|
||||
*
|
||||
* The prefix is expected to arrive intact: a proxy that strips it before
|
||||
* forwarding should leave BASE_PATH unset, because then as far as this
|
||||
* process is concerned it *is* at the root. What must match is the web
|
||||
* build, which bakes the same variable into its asset URLs; a server that
|
||||
* strips a prefix the bundle still asks for serves an app that cannot load
|
||||
* its own scripts. `staticHandler` says so at the first request rather than
|
||||
* leaving a blank page to explain itself.
|
||||
*/
|
||||
basePath: normalizeBasePath(process.env.BASE_PATH),
|
||||
stalwartUrl,
|
||||
stalwartServers: readStalwartServers(),
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.STALWART_URL = "https://default.example";
|
||||
|
||||
const { upstreamFor } = await import("./upstream.js");
|
||||
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
|
||||
* that go elsewhere, so an installation with no mapping behaves exactly as it
|
||||
* always has -- which is what these first cases pin.
|
||||
*/
|
||||
|
||||
test("with no mapping at all, everything goes to the default", () => {
|
||||
assert.deepEqual(config.stalwartServers, {});
|
||||
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||
});
|
||||
|
||||
test("a bare username has no domain to map, so it goes to the default", () => {
|
||||
// Stalwart accepts a login with no domain at all.
|
||||
assert.equal(upstreamFor("demo"), "https://default.example");
|
||||
assert.equal(upstreamFor(""), "https://default.example");
|
||||
});
|
||||
|
||||
test("a mapped domain goes to its own server", () => {
|
||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||
try {
|
||||
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
|
||||
} finally {
|
||||
delete config.stalwartServers["mapped.test"];
|
||||
}
|
||||
});
|
||||
|
||||
test("an unmapped domain still goes to the default while others are mapped", () => {
|
||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||
try {
|
||||
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||
} finally {
|
||||
delete config.stalwartServers["mapped.test"];
|
||||
}
|
||||
});
|
||||
|
||||
test("the domain is matched however it was typed", () => {
|
||||
// Keys are normalised on load; the username has to be normalised the same
|
||||
// way or a mapping silently never matches.
|
||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||
try {
|
||||
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
|
||||
assert.equal(upstreamFor("[email protected]."), "https://mail.mapped.test", "root dot");
|
||||
assert.equal(upstreamFor("someone@ mapped.test "), "https://mail.mapped.test", "stray spaces");
|
||||
} finally {
|
||||
delete config.stalwartServers["mapped.test"];
|
||||
}
|
||||
});
|
||||
|
||||
test("an address with an @ in the local part maps on the last one", () => {
|
||||
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||
try {
|
||||
assert.equal(upstreamFor('"odd@name"@mapped.test'), "https://mail.mapped.test");
|
||||
} finally {
|
||||
delete config.stalwartServers["mapped.test"];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.APP_SECRET = "test-secret-for-ics-proxy";
|
||||
|
||||
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
|
||||
|
||||
/**
|
||||
* Subscribing to a calendar makes the server fetch a URL a stranger published,
|
||||
* which is the second time this app knocks on a door somebody else chose. It
|
||||
* goes through the same guard as the first — these tests are about that guard
|
||||
* being reached, and about `webcal:` not being a way around it.
|
||||
*/
|
||||
|
||||
test("a calendar URL is refused before any connection when it points somewhere private", async () => {
|
||||
for (const url of [
|
||||
"http://127.0.0.1/calendar.ics",
|
||||
"http://169.254.169.254/latest/meta-data/", // cloud metadata
|
||||
"http://[::1]/calendar.ics",
|
||||
"http://10.0.0.1/c.ics",
|
||||
"https://192.168.1.1/c.ics",
|
||||
]) {
|
||||
const got = await safeFetch(url, 500);
|
||||
assert.equal(got, "forbidden_target", url);
|
||||
}
|
||||
});
|
||||
|
||||
test("webcal: is treated as https rather than waved through", async () => {
|
||||
// Every subscription URL people are given is a webcal: one. It has to be
|
||||
// understood, and it must not be a way past the address check.
|
||||
const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500);
|
||||
assert.equal(got, "forbidden_target");
|
||||
});
|
||||
|
||||
test("schemes that are not http, https or webcal are refused", async () => {
|
||||
for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) {
|
||||
const got = await safeFetch(url, 500);
|
||||
assert.equal(got, "bad_scheme", url);
|
||||
}
|
||||
});
|
||||
|
||||
test("a URL carrying credentials is refused", async () => {
|
||||
// Credentials in a subscription URL would be sent by the server on the
|
||||
// reader's behalf to a host the reader may not have looked at.
|
||||
assert.equal(await safeFetch("http://user:[email protected]/c.ics", 500), "bad_url");
|
||||
});
|
||||
|
||||
test("nonsense is refused rather than guessed at", async () => {
|
||||
for (const url of ["", "not a url", "://missing-scheme"]) {
|
||||
assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url));
|
||||
}
|
||||
});
|
||||
|
||||
test("each refusal has a status that says which kind it was", () => {
|
||||
assert.equal(safeFetchStatus("forbidden_target"), 403);
|
||||
assert.equal(safeFetchStatus("bad_scheme"), 400);
|
||||
assert.equal(safeFetchStatus("bad_url"), 400);
|
||||
assert.equal(safeFetchStatus("bad_redirect"), 400);
|
||||
assert.equal(safeFetchStatus("dns_failure"), 502);
|
||||
assert.equal(safeFetchStatus("fetch_failed"), 502);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Context } from "hono";
|
||||
import { safeFetch, safeFetchStatus } from "./imageproxy.js";
|
||||
|
||||
/**
|
||||
* Fetching a calendar somebody has subscribed to.
|
||||
*
|
||||
* The browser cannot do this itself: a calendar URL belongs to whoever
|
||||
* published it and almost none of them send CORS headers, so the request has
|
||||
* to be made from here. That makes it the second place ihasmail reaches out to
|
||||
* an address a stranger chose, and it goes through exactly the same guard as
|
||||
* the first — `safeFetch` resolves the name, refuses private space on every
|
||||
* answer, pins the connection to the address it checked, and re-checks each
|
||||
* redirect. There is deliberately no second implementation of that.
|
||||
*
|
||||
* **Nothing is stored.** The text goes straight back to the browser, which
|
||||
* parses it and holds the result in memory for as long as the tab is open. The
|
||||
* server keeps no copy, no cache and no schedule, which is what lets an
|
||||
* immutable container serve this at all.
|
||||
*/
|
||||
|
||||
/** Generous for a calendar, small enough that nobody can post a film through it. */
|
||||
const MAX_ICS_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Types a calendar is served as in practice. `text/plain` and the octet-stream
|
||||
* are here because a great many servers get this wrong, and refusing a real
|
||||
* calendar over a header the publisher chose badly helps nobody -- the parser
|
||||
* checks the content itself, which is the claim that actually matters.
|
||||
*/
|
||||
const ACCEPTABLE = new Set(["text/calendar", "text/plain", "application/octet-stream", "application/ics", ""]);
|
||||
|
||||
export async function icsProxyHandler(c: Context) {
|
||||
const got = await safeFetch(c.req.query("url") ?? "", 20_000);
|
||||
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
|
||||
const { res, done } = got;
|
||||
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed", status: res.statusCode ?? 0 }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!ACCEPTABLE.has(type)) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "not_calendar", type }, 415);
|
||||
}
|
||||
const declared = Number(res.headers["content-length"] ?? "0");
|
||||
if (declared > MAX_ICS_BYTES) {
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
|
||||
// Read it here rather than streaming: the browser needs the whole document
|
||||
// to parse it, and the cap has to hold whether or not a length was declared.
|
||||
let total = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_ICS_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error("too_large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve());
|
||||
res.on("error", reject);
|
||||
});
|
||||
} catch (err) {
|
||||
done();
|
||||
return c.json({ error: (err as Error).message === "too_large" ? "too_large" : "fetch_failed" }, 502);
|
||||
}
|
||||
done();
|
||||
|
||||
return c.body(Buffer.concat(chunks).toString("utf8"), 200, {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
// Never stored on disk, and never held by anything in between either.
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
}
|
||||
+60
-22
@@ -98,32 +98,50 @@ export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promi
|
||||
* Gmail-style remote content proxy: hides the reader's IP address and
|
||||
* user-agent from tracking pixels, and blocks SSRF to internal networks.
|
||||
*/
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const raw = c.req.query("url") ?? "";
|
||||
/** Why a guarded fetch refused, in the words the handlers answer with. */
|
||||
export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect";
|
||||
|
||||
export interface SafeFetchResult {
|
||||
res: IncomingMessage;
|
||||
/** The URL actually fetched, which is not the one asked for if it redirected. */
|
||||
url: URL;
|
||||
done: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL nobody here chose, with every check the image proxy has always
|
||||
* made — and made in one place, because a second copy of an SSRF guard is how
|
||||
* one of them ends up missing a case.
|
||||
*
|
||||
* The name is resolved first and *every* answer has to be acceptable, the
|
||||
* connection is pinned to the address that was checked, and each redirect hop
|
||||
* is re-resolved and re-pinned rather than handed to the socket library.
|
||||
*/
|
||||
export async function safeFetch(raw: string, timeoutMs = 15_000): Promise<SafeFetchResult | SafeFetchError> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return c.json({ error: "bad_url" }, 400);
|
||||
return "bad_url";
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
|
||||
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
|
||||
// webcal: is an http URL wearing a different word; nothing else is allowed.
|
||||
if (url.protocol === "webcal:") url = new URL(`https:${raw.slice(raw.indexOf(":") + 1)}`);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "bad_scheme";
|
||||
if (url.username || url.password) return "bad_url";
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
let res: IncomingMessage;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const done = () => clearTimeout(timer);
|
||||
try {
|
||||
let addr: string;
|
||||
try {
|
||||
addr = await resolveAllowed(url.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
}
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
let res = await fetchPinned(url, addr, controller.signal);
|
||||
|
||||
// Follow a limited number of redirects, re-checking and re-pinning each hop.
|
||||
let hops = 0;
|
||||
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
|
||||
const loc = res.headers.location;
|
||||
@@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.resume(); // discard the redirect body
|
||||
const next = new URL(loc, url);
|
||||
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "bad_redirect" }, 400);
|
||||
done();
|
||||
return "bad_redirect";
|
||||
}
|
||||
try {
|
||||
addr = await resolveAllowed(next.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
}
|
||||
url = next;
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
hops++;
|
||||
}
|
||||
return { res, url, done };
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
done();
|
||||
return "fetch_failed";
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_FETCH_STATUS: Record<SafeFetchError, number> = {
|
||||
bad_url: 400,
|
||||
bad_scheme: 400,
|
||||
bad_redirect: 400,
|
||||
forbidden_target: 403,
|
||||
dns_failure: 502,
|
||||
fetch_failed: 502,
|
||||
};
|
||||
|
||||
export function safeFetchStatus(err: SafeFetchError): number {
|
||||
return SAFE_FETCH_STATUS[err];
|
||||
}
|
||||
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const got = await safeFetch(c.req.query("url") ?? "");
|
||||
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
|
||||
const { res, done } = got;
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!type.startsWith("image/") || type === "image/svg+xml") {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "not_image" }, 415);
|
||||
}
|
||||
const len = Number(res.headers["content-length"] ?? "0");
|
||||
if (len > MAX_IMAGE_BYTES) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
@@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) {
|
||||
else controller2.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
res.on("close", () => clearTimeout(timer));
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
|
||||
+374
-16
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
|
||||
@@ -26,6 +27,8 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||
const MAX_DELAYED_SEND = 86400 * 30;
|
||||
const ACCOUNT = "a1";
|
||||
/** How long a push subscription lives before the server drops it. */
|
||||
const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** An account somebody has shared with the demo user. See the session below. */
|
||||
const SHARED_ACCOUNT = "a2";
|
||||
const SHARED_CAPS: Obj = {
|
||||
@@ -97,7 +100,43 @@ const subjects = [
|
||||
];
|
||||
const emails: Obj[] = [];
|
||||
let counter = 1;
|
||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${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.`;
|
||||
@@ -109,6 +148,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
|
||||
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"),
|
||||
@@ -125,6 +168,14 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
|
||||
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: 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;
|
||||
@@ -142,6 +193,7 @@ for (let i = 0; i < 45; i++) {
|
||||
}
|
||||
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 };
|
||||
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
|
||||
@@ -196,6 +248,13 @@ const events: Obj[] = [];
|
||||
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" });
|
||||
@@ -212,10 +271,51 @@ 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.
|
||||
*/
|
||||
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. */
|
||||
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||
|
||||
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||
/** One per contact, by index; a gap means that card has no birthday. */
|
||||
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 },
|
||||
];
|
||||
|
||||
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 };
|
||||
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 };
|
||||
});
|
||||
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" }));
|
||||
const fileNodes: Obj[] = [
|
||||
@@ -399,6 +499,24 @@ function genericGet(list: Obj[]) {
|
||||
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 index falls outside the series
|
||||
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
||||
* server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
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.slot);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
@@ -436,6 +554,179 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 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 organiser 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.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
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.
|
||||
*/
|
||||
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
|
||||
@@ -475,7 +766,26 @@ const handlers: Record<string, Handler> = {
|
||||
"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));
|
||||
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
|
||||
/*
|
||||
* Honour 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; });
|
||||
@@ -634,8 +944,18 @@ const handlers: Record<string, Handler> = {
|
||||
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
||||
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
||||
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
||||
created[cid] = { id, expires: null };
|
||||
/*
|
||||
* 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();
|
||||
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, 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) ?? {})) {
|
||||
@@ -774,23 +1094,61 @@ const handlers: Record<string, Handler> = {
|
||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||
"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),
|
||||
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
||||
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(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, slotOfOccurrence(e, occ)));
|
||||
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;
|
||||
if (!ids) return genericGet(list)(a);
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), 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": genericSet(events, "ev", (o) => {
|
||||
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
|
||||
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;
|
||||
return Object.assign(o, { uid: o.uid ?? randomUUID() });
|
||||
}),
|
||||
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); 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`); parsed[b] = { "@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: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { 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" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"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),
|
||||
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
|
||||
// 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) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||
"AddressBook/set": (a) => {
|
||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
* against something. What it has to get right is not the expansion — that is
|
||||
* the easy half — but the three things a live server does that a client will
|
||||
* otherwise be written against wrongly:
|
||||
*
|
||||
* - every expanded id is synthetic, one-offs included;
|
||||
* - an occurrence carries a `recurrenceId` and no rule;
|
||||
* - a per-occurrence patch loses some properties in silence.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
||||
|
||||
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
||||
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
||||
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
||||
|
||||
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||
|
||||
describe("expandOccurrences", () => {
|
||||
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(series(), a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
||||
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a one-off exactly one occurrence, at index 0", () => {
|
||||
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
||||
const out = expandOccurrences(oneOff(), a, b);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0]!.index, 0);
|
||||
});
|
||||
|
||||
it("honours 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);
|
||||
});
|
||||
|
||||
it("drops an excluded date from the expansion, keeping the series positions", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
// The position within the series is unchanged — Wednesday is still the
|
||||
// third date the rule produces, whatever happened to Tuesday. It is the
|
||||
// *id* built on top of that which moves, and only after a write.
|
||||
assert.equal(out[1]!.index, 2);
|
||||
});
|
||||
|
||||
it("carries an override onto the occurrence it keys", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrenceView", () => {
|
||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||
const base = series();
|
||||
const occ = occurrenceAt(base, 1)!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.id, syntheticId("ev1", 1));
|
||||
assert.equal(view.baseEventId, "ev1");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
assert.equal(view.recurrenceRule, undefined);
|
||||
assert.equal(view.recurrenceOverrides, undefined);
|
||||
});
|
||||
|
||||
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
||||
// 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");
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
assert.notEqual(view.id, view.baseEventId);
|
||||
assert.equal(view.recurrenceId, undefined);
|
||||
});
|
||||
|
||||
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)!);
|
||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||
assert.equal(view.title, "Moved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
||||
});
|
||||
it("does not claim a stored id", () => {
|
||||
assert.equal(parseSyntheticId("ev1"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOccurrencePatch", () => {
|
||||
it("applies what an occurrence takes", () => {
|
||||
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
||||
});
|
||||
|
||||
it("refuses an event-level property by name", () => {
|
||||
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
||||
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
||||
});
|
||||
|
||||
it("drops an inherited property in silence, which is the dangerous half", () => {
|
||||
// No `rejected`, nothing applied, and a real server would still answer
|
||||
// "updated". Anything that trusts the response believes this landed.
|
||||
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, {});
|
||||
});
|
||||
|
||||
it("judges a pointer patch on its first token", () => {
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
||||
{ "participants/me/participationStatus": "accepted" });
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("synthetic ids are only true until the next 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.
|
||||
*/
|
||||
it("makes a cached id address a different 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 after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.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);
|
||||
});
|
||||
|
||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("an override that moves an occurrence", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
|
||||
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
|
||||
* still at 09:00 — the slot the rule made, which the move does not touch.
|
||||
*
|
||||
* The mock used to clobber the override's `start` with the slot time, so a
|
||||
* moved occurrence did not move. That made per-occurrence *time* editing —
|
||||
* one of the main things the feature is for — look broken against the mock
|
||||
* and fine against the server.
|
||||
*/
|
||||
const moved = () => ({
|
||||
...series(),
|
||||
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
|
||||
});
|
||||
|
||||
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
assert.equal(occ.start, "2026-09-08T14:00:00");
|
||||
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("shows the moved time on the occurrence a get returns", () => {
|
||||
const base = moved();
|
||||
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
|
||||
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.start, "2026-09-08T14:00:00");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("keeps the occurrence findable by recurrenceId after the move", () => {
|
||||
// This is the property the store depends on: `recurrenceId` survives both
|
||||
// a renumbering and a move, so it is the handle a mutation resolves from.
|
||||
const base = moved();
|
||||
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
|
||||
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||
*
|
||||
* 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
|
||||
* against, and all three had already cost a debugging session:
|
||||
*
|
||||
* - 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 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:
|
||||
* what Stalwart rejects is rejected here, and what it drops in silence is
|
||||
* dropped here, in silence, on purpose.
|
||||
*/
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
/** How far the expander will walk before giving up on a rule. */
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||
export function localDateTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const parseLocal = (s: string): Date => new Date(s);
|
||||
|
||||
export interface Occurrence {
|
||||
index: number;
|
||||
/** The slot in the series this instance fills, which keys any override. */
|
||||
recurrenceId: string;
|
||||
start: string;
|
||||
/** Set when a `recurrenceOverrides` entry applies to this date. */
|
||||
override?: Obj;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
frequency?: string;
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: string;
|
||||
byDay?: { day: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of `base` between `from` and `to`, in series order.
|
||||
*
|
||||
* An event with no rule has exactly one, at index 0 — which is what gives a
|
||||
* one-off the synthetic id a real server would give it.
|
||||
*/
|
||||
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const startStr = base.start as string;
|
||||
if (!startStr) return [];
|
||||
const first = parseLocal(startStr);
|
||||
const rule = base.recurrenceRule as Rule | undefined;
|
||||
|
||||
const out: 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.
|
||||
if (override?.excluded === true) return true;
|
||||
/*
|
||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||
* are two different times: the slot it fills 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`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||
const shown = parseLocal(start);
|
||||
if (shown >= from && shown < to) {
|
||||
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
|
||||
}
|
||||
return at < to;
|
||||
};
|
||||
|
||||
if (!rule?.frequency) {
|
||||
emit(0, first);
|
||||
return out;
|
||||
}
|
||||
|
||||
const interval = Math.max(1, rule.interval ?? 1);
|
||||
const until = rule.until ? parseLocal(rule.until) : null;
|
||||
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
|
||||
|
||||
let index = 0;
|
||||
let emitted = 0;
|
||||
const cursor = new Date(first);
|
||||
|
||||
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||
if (until && cursor > until) break;
|
||||
if (rule.count != null && emitted >= rule.count) break;
|
||||
|
||||
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
|
||||
if (matches) {
|
||||
emitted++;
|
||||
const keepGoing = emit(index, new Date(cursor));
|
||||
index++;
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
|
||||
// A rule with byDay walks day by day and keeps the days it names; without
|
||||
// one it steps by its own frequency.
|
||||
if (byDay) cursor.setDate(cursor.getDate() + 1);
|
||||
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
|
||||
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
|
||||
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fields that describe the series and never travel down to one instance. */
|
||||
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.baseEventId = base.id;
|
||||
view.start = occ.start;
|
||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||
// its single occurrence does not, or every one-off would look recurring.
|
||||
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
|
||||
delete view.excluded;
|
||||
return view;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
export const OCCURRENCE_REJECTED = new Set([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dropped from the patch, with the response still reporting success.
|
||||
*
|
||||
* This is the half that has to be reproduced most carefully. A mock that
|
||||
* *applied* these would agree with a client that sends them, and the belief
|
||||
* would ship — which is exactly the road #26 took to a live server.
|
||||
*/
|
||||
export const OCCURRENCE_INHERITED = new Set([
|
||||
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Split a per-occurrence patch the way the server's validator does.
|
||||
*
|
||||
* `rejected` is the first property that would be refused, if any; `applied` is
|
||||
* what actually lands on the override. Everything else vanishes without a word.
|
||||
*/
|
||||
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
|
||||
const applied: Obj = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const [head, , third] = key.split("/");
|
||||
const root = head ?? key;
|
||||
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
|
||||
if (OCCURRENCE_INHERITED.has(root)) continue;
|
||||
if (root === "participants" && third === "calendarAddress") continue;
|
||||
if (root === "id") continue;
|
||||
applied[key] = value;
|
||||
}
|
||||
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;
|
||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||
return all.find((o) => o.index === index) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
|
||||
/**
|
||||
* The limiter's job is to slow down password guessing. #239 is about the
|
||||
* attempts it takes for outcomes that were never a guess: ihasmail runs apart
|
||||
* from Stalwart, so an upstream that refuses a connection is ordinary, and
|
||||
* retrying through one used to spend the window and lock somebody out until
|
||||
* after the cause had gone.
|
||||
*/
|
||||
|
||||
test("check allows up to the limit and then refuses", () => {
|
||||
const rl = new RateLimiter(3, 60_000);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), false);
|
||||
});
|
||||
|
||||
test("refund gives back exactly one attempt", () => {
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
rl.check("k");
|
||||
rl.check("k");
|
||||
assert.equal(rl.check("k"), false, "spent");
|
||||
rl.refund("k");
|
||||
assert.equal(rl.check("k"), true, "one back");
|
||||
assert.equal(rl.check("k"), false, "and only one");
|
||||
});
|
||||
|
||||
test("refunding every attempt leaves the key spending nothing", () => {
|
||||
// The outage case: every try refunded, so a person retrying through it is
|
||||
// not locked out when the server returns.
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
assert.equal(rl.check("k"), true, `attempt ${i} allowed`);
|
||||
rl.refund("k");
|
||||
}
|
||||
});
|
||||
|
||||
test("a run of real failures still adds up around a refunded one", () => {
|
||||
// Refund takes one attempt back, not the key's whole history -- an outage in
|
||||
// the middle of somebody guessing must not clear what they spent before it.
|
||||
const rl = new RateLimiter(3, 60_000);
|
||||
rl.check("k"); // a wrong password
|
||||
rl.check("k"); // another
|
||||
rl.check("k"); rl.refund("k"); // an outage, given back
|
||||
assert.equal(rl.check("k"), true, "third real attempt");
|
||||
assert.equal(rl.check("k"), false, "and now spent");
|
||||
});
|
||||
|
||||
test("refunding a key that never spent anything is harmless", () => {
|
||||
const rl = new RateLimiter(1, 60_000);
|
||||
rl.refund("never-seen");
|
||||
assert.equal(rl.check("never-seen"), true);
|
||||
});
|
||||
|
||||
test("reset clears the key, refund does not", () => {
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
rl.check("k");
|
||||
rl.check("k");
|
||||
rl.refund("k");
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), false);
|
||||
rl.reset("k");
|
||||
assert.equal(rl.check("k"), true, "reset is the successful-sign-in case");
|
||||
});
|
||||
@@ -23,6 +23,28 @@ export class RateLimiter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back the attempt `check` just took.
|
||||
*
|
||||
* For an outcome that says nothing about whether the credentials were right.
|
||||
* ihasmail runs in its own container, usually on its own host, so an upstream
|
||||
* that never answered is an ordinary Tuesday rather than an attack -- and the
|
||||
* limiter exists to slow down password guessing, which a server that refused
|
||||
* the connection has not told us anything about. Without this, retrying
|
||||
* through a thirty-second outage spends the window and locks somebody out
|
||||
* until well after the cause has gone (#239).
|
||||
*
|
||||
* Refunds one attempt rather than clearing the key, so a run of real failures
|
||||
* with an outage in the middle still adds up.
|
||||
*/
|
||||
refund(key: string): void {
|
||||
const arr = this.hits.get(key);
|
||||
if (!arr?.length) return;
|
||||
arr.pop();
|
||||
if (arr.length) this.hits.set(key, arr);
|
||||
else this.hits.delete(key);
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.hits.delete(key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* The shipped example policy, checked against the rules the server enforces.
|
||||
*
|
||||
* An example that has drifted out of step with the parser is worse than no
|
||||
* example: somebody copies it, the server refuses to start, and the first
|
||||
* experience of the feature is a crash loop. This does not import the config
|
||||
* module -- reading it has side effects and wants a whole environment -- so the
|
||||
* rules it checks are restated here, and both are short enough that saying them
|
||||
* twice is cheaper than the machinery to say them once.
|
||||
*/
|
||||
const EXAMPLE = fileURLToPath(new URL("../../settings-policy.example.json", import.meta.url));
|
||||
|
||||
test("the example policy is valid JSON", () => {
|
||||
assert.doesNotThrow(() => JSON.parse(readFileSync(EXAMPLE, "utf8")));
|
||||
});
|
||||
|
||||
test("the example policy has the three sections, in the shapes the server reads", () => {
|
||||
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record<string, unknown>;
|
||||
for (const section of ["defaults", "enforced"]) {
|
||||
const v = p[section];
|
||||
assert.ok(v && typeof v === "object" && !Array.isArray(v), `${section} must be an object`);
|
||||
}
|
||||
assert.ok(Array.isArray(p.changes), "changes must be a list");
|
||||
});
|
||||
|
||||
test("every change in the example has a unique version and settings", () => {
|
||||
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as { changes: Array<{ version?: unknown; settings?: unknown }> };
|
||||
const seen = new Set<string>();
|
||||
for (const [i, c] of p.changes.entries()) {
|
||||
assert.equal(typeof c.version, "string", `changes[${i}] needs a string version`);
|
||||
assert.ok((c.version as string).trim(), `changes[${i}] needs a non-empty version`);
|
||||
assert.ok(!seen.has(c.version as string), `changes[${i}] repeats version ${String(c.version)}`);
|
||||
seen.add(c.version as string);
|
||||
assert.ok(c.settings && typeof c.settings === "object" && !Array.isArray(c.settings), `changes[${i}] needs a settings object`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the example's commentary cannot be mistaken for a section", () => {
|
||||
/*
|
||||
* JSON has no comments, so the example explains itself in `_`-prefixed keys.
|
||||
* The server reads three names and ignores everything else, which is what
|
||||
* makes that safe -- but only for as long as no comment key collides with a
|
||||
* real one.
|
||||
*/
|
||||
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record<string, unknown>;
|
||||
const real = new Set(["defaults", "enforced", "changes"]);
|
||||
for (const key of Object.keys(p)) {
|
||||
assert.ok(real.has(key) || key.startsWith("_"), `unexpected top-level key ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The shipped server-mapping example, checked the same way and for the same
|
||||
* 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));
|
||||
|
||||
test("the example server mapping is valid JSON", () => {
|
||||
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
|
||||
});
|
||||
|
||||
test("every entry in the example mapping is a domain and an http(s) URL", () => {
|
||||
const m = JSON.parse(readFileSync(SERVERS, "utf8")) as Record<string, unknown>;
|
||||
const seen = new Set<string>();
|
||||
for (const [key, value] of Object.entries(m)) {
|
||||
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`);
|
||||
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`);
|
||||
}
|
||||
assert.ok(seen.size > 0, "the example should show at least one mapping");
|
||||
});
|
||||
+35
-2
@@ -3,6 +3,7 @@ import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import type { Context, Handler } from "hono";
|
||||
import { stripBasePath } from "../../scripts/basePath.mjs";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -47,9 +48,30 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
export function staticHandler(root: string): Handler {
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
let mismatchWarned = false;
|
||||
|
||||
/**
|
||||
* A build that does not know the prefix loads nothing under it, and says so
|
||||
* with a blank page and a 404 in a console nobody has open. The shell is
|
||||
* 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
|
||||
* misconfiguration, and a wrong guess that stops the server from starting is
|
||||
* worse than the problem it is describing.
|
||||
*/
|
||||
function warnOnBaseMismatch(body: string) {
|
||||
if (mismatchWarned || !basePath) return;
|
||||
if (body.includes(`src="${basePath}/assets/`)) return;
|
||||
mismatchWarned = true;
|
||||
console.warn(
|
||||
`[ihasmail] BASE_PATH is ${basePath}, but the web build in ${absRoot} references its assets elsewhere. ` +
|
||||
`The prefix is baked in at build time: rebuild with BASE_PATH=${basePath} set, or the app will not load.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function serveIndex(c: Context) {
|
||||
try {
|
||||
@@ -57,7 +79,9 @@ export function staticHandler(root: string): Handler {
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
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);
|
||||
@@ -70,7 +94,16 @@ export function staticHandler(root: string): Handler {
|
||||
|
||||
return async (c) => {
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
|
||||
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
/*
|
||||
* Everything below works in paths relative to the mount, so the prefix
|
||||
* 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.
|
||||
*/
|
||||
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
const urlPath = stripBasePath(basePath, fullPath);
|
||||
if (urlPath === null) return c.text("Not Found", 404);
|
||||
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
|
||||
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = join(absRoot, rel);
|
||||
|
||||
+42
-12
@@ -10,6 +10,15 @@ export interface UpstreamSession {
|
||||
uploadUrl: string;
|
||||
eventSourceUrl: string;
|
||||
state: string;
|
||||
/**
|
||||
* Which Stalwart this document came from.
|
||||
*
|
||||
* Recorded rather than looked up again, because the relative URLs inside it
|
||||
* -- apiUrl, uploadUrl and the rest -- only mean anything against the server
|
||||
* that issued them. Anything holding a session already knows where to send
|
||||
* the next request. Not part of the JMAP session resource; ours.
|
||||
*/
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export class UpstreamError extends Error {
|
||||
@@ -24,16 +33,37 @@ export class UpstreamError extends Error {
|
||||
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
|
||||
const SESSION_CACHE_MS = 5 * 60_000;
|
||||
|
||||
export function wellKnownUrl(): string {
|
||||
return `${config.stalwartUrl}/.well-known/jmap`;
|
||||
/**
|
||||
* The Stalwart a username belongs to.
|
||||
*
|
||||
* `STALWART_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).
|
||||
*
|
||||
* A *mapped* domain never falls back. If its server is unreachable that
|
||||
* sign-in fails, because falling back would authenticate somebody against a
|
||||
* server their domain was deliberately routed away from -- and if the same
|
||||
* account name exists there, they would land in another tenant's mailbox. The
|
||||
* fallback is a decision about unmapped domains, taken before any network
|
||||
* call, not a recovery path.
|
||||
*/
|
||||
export function upstreamFor(username: string): string {
|
||||
const at = username.lastIndexOf("@");
|
||||
if (at < 0) return config.stalwartUrl;
|
||||
const domain = username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||
return config.stalwartServers[domain] ?? config.stalwartUrl;
|
||||
}
|
||||
|
||||
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||
return `${base}/.well-known/jmap`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session resource from Stalwart using the given Authorization
|
||||
* header. Throws UpstreamError(401) on bad credentials.
|
||||
*/
|
||||
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
|
||||
const res = await fetch(wellKnownUrl(), {
|
||||
export async function fetchUpstreamSession(authorization: string, base: string = config.stalwartUrl): Promise<UpstreamSession> {
|
||||
const res = await fetch(wellKnownUrl(base), {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
@@ -46,13 +76,13 @@ export async function fetchUpstreamSession(authorization: string): Promise<Upstr
|
||||
}
|
||||
const session = (await res.json()) as UpstreamSession;
|
||||
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
|
||||
return session;
|
||||
return { ...session, baseUrl: base };
|
||||
}
|
||||
|
||||
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
|
||||
export async function getUpstreamSession(sessionId: string, authorization: string, base: string = config.stalwartUrl, force = false) {
|
||||
const cached = sessionCache.get(sessionId);
|
||||
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
|
||||
const session = await fetchUpstreamSession(authorization);
|
||||
const session = await fetchUpstreamSession(authorization, base);
|
||||
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
|
||||
return session;
|
||||
}
|
||||
@@ -210,9 +240,9 @@ 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.
|
||||
*/
|
||||
async function fetchEdition(authorization: string): Promise<string | null> {
|
||||
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account`, {
|
||||
const res = await fetch(`${base}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
@@ -230,7 +260,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
info = { ...info, edition: await fetchEdition(authorization) };
|
||||
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
@@ -258,9 +288,9 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
||||
}
|
||||
|
||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
||||
export function absoluteUpstream(url: string): string {
|
||||
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
||||
try {
|
||||
return new URL(url, config.stalwartUrl).toString();
|
||||
return new URL(url, base).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
|
||||
|
||||
/**
|
||||
* The version is this build's public identity: it names the image, and it is
|
||||
* what About and /api/health report. It had no tests while it was
|
||||
* `2.16.<pr>`; it has them now that the rules moved.
|
||||
*/
|
||||
|
||||
test("a pull request merge is named by its number", () => {
|
||||
assert.equal(
|
||||
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
|
||||
"2026.8.30+pr129",
|
||||
);
|
||||
});
|
||||
|
||||
test("a commit that did not come through a pull request carries its SHA", () => {
|
||||
// Claiming the last PR would say it *is* that PR rather than something after it.
|
||||
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
|
||||
});
|
||||
|
||||
test("leading zeros are stripped, since a version field may not carry them", () => {
|
||||
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
|
||||
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
|
||||
});
|
||||
|
||||
test("it sorts forward from the versions it replaces", () => {
|
||||
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
|
||||
// whole reason the Stalwart generation left the version.
|
||||
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
|
||||
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
|
||||
});
|
||||
|
||||
test("two builds from the same day differ, even though they rank the same", () => {
|
||||
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
|
||||
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a.split("+")[0], b.split("+")[0]);
|
||||
});
|
||||
|
||||
test("the same commit always resolves to the same version", () => {
|
||||
// Built from the commit's own date, not today's, so an old commit rebuilt
|
||||
// now reports what it reported then.
|
||||
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
|
||||
assert.equal(formatVersion(commit), formatVersion(commit));
|
||||
});
|
||||
|
||||
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
|
||||
const before = process.env.IHASMAIL_VERSION;
|
||||
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
|
||||
try {
|
||||
assert.equal(resolveVersion(), "2026.8.30+pr129");
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.IHASMAIL_VERSION;
|
||||
else process.env.IHASMAIL_VERSION = before;
|
||||
}
|
||||
});
|
||||
|
||||
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
|
||||
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
|
||||
assert.equal(UNVERSIONED, "0.0.0");
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"_comment": [
|
||||
"A settings policy: what this installation decides, rather than each reader.",
|
||||
"Point at it with SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json and mount it",
|
||||
"read-only. Read once at startup, so editing it means restarting.",
|
||||
"Delete the sections you do not want -- all three are optional, and an",
|
||||
"installation that sets none of them behaves exactly as ihasmail always has.",
|
||||
"Keys and values are the ones a settings export uses: configure one account",
|
||||
"by hand, Settings > General > Export, and copy out what you care about.",
|
||||
"Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides"
|
||||
],
|
||||
|
||||
"_defaults_comment": [
|
||||
"A starting point for accounts that have never had settings of their own.",
|
||||
"The reader can change any of these afterwards. An account that already",
|
||||
"exists never sees them -- use `changes` below to reach those."
|
||||
],
|
||||
"defaults": {
|
||||
"externalSenderBanner": true,
|
||||
"conversationMode": true
|
||||
},
|
||||
|
||||
"_enforced_comment": [
|
||||
"Reapplied on every load, and the reader cannot change them at all. Their",
|
||||
"controls stay visible in Settings and go dead with a line saying why.",
|
||||
"Reset, an imported settings file, and a settings file synced from a device",
|
||||
"that predates this policy all cannot get around them."
|
||||
],
|
||||
"enforced": {
|
||||
"externalRecipientConfirm": true
|
||||
},
|
||||
|
||||
"_changes_comment": [
|
||||
"Applied once each, to everybody, including accounts that already exist --",
|
||||
"and the reader may change them back afterwards, which sticks.",
|
||||
"",
|
||||
"Each entry needs a `version` that is unique in this file. It is opaque: a",
|
||||
"timestamp sorts and never repeats, but any unique string works. Every",
|
||||
"account remembers the versions it has had, so a change runs exactly once",
|
||||
"per person -- not once per browser.",
|
||||
"",
|
||||
"Note that a change DOES override a decision a reader has already made. That",
|
||||
"is the point of it: it reaches people who are already here. If you want it",
|
||||
"to stay on regardless of what they do next, that is `enforced`, not this."
|
||||
],
|
||||
"changes": [
|
||||
{
|
||||
"version": "20260902084513",
|
||||
"settings": { "externalSenderBanner": true }
|
||||
},
|
||||
{
|
||||
"version": "20261014091500",
|
||||
"settings": { "externalLinkWarning": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"_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"
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ihasmail/web",
|
||||
"version": "2.16.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"marked": "^18.0.11",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
"name": "ihasmail",
|
||||
"short_name": "ihasmail",
|
||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
||||
"start_url": "mail",
|
||||
"scope": "./",
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "mailto",
|
||||
"url": "/mail?mailto=%s"
|
||||
"url": "mail?mailto=%s"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
@@ -16,17 +17,17 @@
|
||||
"theme_color": "#0f766e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icon-192.png",
|
||||
"src": "img/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon-512.png",
|
||||
"src": "img/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon-maskable.png",
|
||||
"src": "img/icon-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
@@ -35,16 +36,16 @@
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Compose",
|
||||
"url": "/mail?compose=new",
|
||||
"url": "mail?compose=new",
|
||||
"description": "Write a new message"
|
||||
},
|
||||
{
|
||||
"name": "Calendar",
|
||||
"url": "/calendar"
|
||||
"url": "calendar"
|
||||
},
|
||||
{
|
||||
"name": "Contacts",
|
||||
"url": "/contacts"
|
||||
"url": "contacts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+42
-12
@@ -3,7 +3,22 @@
|
||||
are never cached), and Web Push, which is the only part of ihasmail that runs
|
||||
when no tab is open. */
|
||||
const VERSION = "ihasmail-v2";
|
||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||
|
||||
/*
|
||||
* The mount, worked out rather than configured.
|
||||
*
|
||||
* This file is copied to the build verbatim -- Vite's `base` never touches
|
||||
* public/ -- so there is nothing to substitute BASE_PATH into. It does not
|
||||
* need one: the worker is served from the mount, so its own address says
|
||||
* where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is
|
||||
* the same canonical form the rest of the app uses.
|
||||
*
|
||||
* Deriving it here also means the worker cannot disagree with the page that
|
||||
* registered it, which a second copy of the value in a build-time constant
|
||||
* eventually would.
|
||||
*/
|
||||
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
||||
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
@@ -20,10 +35,10 @@ self.addEventListener("fetch", (event) => {
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
@@ -36,7 +51,7 @@ self.addEventListener("fetch", (event) => {
|
||||
|
||||
// Navigations & everything else: network-first, fall back to cached shell.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req).catch(() => caches.match("/")));
|
||||
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
@@ -59,7 +74,18 @@ self.addEventListener("fetch", (event) => {
|
||||
* credentials), so it is stashed for a tab to collect and confirm.
|
||||
*/
|
||||
|
||||
const VERIFY_KEY = "ihasmail-push-verification";
|
||||
/*
|
||||
* Absolute, and anchored to the mount rather than to whatever page happens to
|
||||
* be open.
|
||||
*
|
||||
* A relative key is resolved against the URL of whoever is asking: the worker
|
||||
* lives at `<base>/sw.js`, so it stored this under `<base>/…`, while a tab at
|
||||
* `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever
|
||||
* agreed when the open page was the root, so a verification code that arrived
|
||||
* with no tab open was written where the next tab would not look -- and the
|
||||
* subscription stayed silent, which is the same thing push failing looks like.
|
||||
*/
|
||||
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
||||
|
||||
function textOf(email) {
|
||||
const from = email?.from?.[0];
|
||||
@@ -98,7 +124,7 @@ self.addEventListener("push", (event) => {
|
||||
// A StateChange, or a payload too large to carry the message. Say
|
||||
// something true rather than inventing a sender.
|
||||
await self.registration.showNotification("New mail", {
|
||||
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" },
|
||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -108,10 +134,10 @@ self.addEventListener("push", (event) => {
|
||||
const { title, body, preview } = textOf(email);
|
||||
await self.registration.showNotification(title, {
|
||||
body: preview ? `${body}\n${preview}` : body,
|
||||
icon: "/img/icon-192.png",
|
||||
badge: "/img/favicon-64.png",
|
||||
icon: `${BASE}/img/icon-192.png`,
|
||||
badge: `${BASE}/img/favicon-64.png`,
|
||||
tag: `ihasmail-${email.id || body}`,
|
||||
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
|
||||
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
|
||||
});
|
||||
}
|
||||
})());
|
||||
@@ -119,12 +145,16 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/mail";
|
||||
const url = event.notification.data?.url || `${BASE}/mail`;
|
||||
event.waitUntil((async () => {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
||||
// Reuse a tab if one is open rather than piling up windows.
|
||||
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
||||
// not enough under a prefix: `includeUncontrolled` widens the match to the
|
||||
// whole origin, so on a host that also serves something else this would
|
||||
// navigate a stranger's tab to our inbox.
|
||||
for (const c of clients) {
|
||||
if (new URL(c.url).origin === self.location.origin) {
|
||||
const at = new URL(c.url);
|
||||
if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) {
|
||||
await c.focus();
|
||||
if ("navigate" in c) await c.navigate(url).catch(() => {});
|
||||
return;
|
||||
|
||||
+150
-15
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Route, Switch, Redirect, useLocation } from "wouter";
|
||||
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
@@ -9,7 +9,7 @@ import { useFiles } from "@/store/files";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { push } from "@/jmap/push";
|
||||
import { client } from "@/jmap/client";
|
||||
import { ToastHost } from "@/ui/toast";
|
||||
import { ToastHost, toast } from "@/ui/toast";
|
||||
import { ConfirmHost } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { LoginPage } from "@/views/Login";
|
||||
@@ -17,9 +17,14 @@ import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { listenForVerification } from "@/lib/webpushEnable";
|
||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
|
||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
@@ -29,11 +34,39 @@ const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m)
|
||||
export function App() {
|
||||
const status = useSession((s) => s.status);
|
||||
const bootstrap = useSession((s) => s.bootstrap);
|
||||
/*
|
||||
* Subscribed once, here, and used as a key below.
|
||||
*
|
||||
* `t()` is a plain function rather than a hook, so a component has no way of
|
||||
* knowing its strings just changed. Rather than make every one of the
|
||||
* thousand call sites a subscriber -- which would turn extracting a string
|
||||
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
|
||||
* away and rebuilt when the catalogue changes. Picking a language is a
|
||||
* once-in-an-account event; paying for it there is far cheaper than paying
|
||||
* for it on every render everywhere.
|
||||
*/
|
||||
const languageVersion = useLanguageVersion();
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
if (status === "loading") {
|
||||
/*
|
||||
* Wait for the catalogue before the first paint.
|
||||
*
|
||||
* The tree is rebuilt when a catalogue lands, so components recover on
|
||||
* their own -- but a string computed in an effect does not. A toast fired
|
||||
* in the gap is emitted in English and stays English, in an interface that
|
||||
* is otherwise not. The wait costs nothing visible: the session bootstrap
|
||||
* is already showing a spinner, and English resolves immediately.
|
||||
*/
|
||||
const [languageReady, setLanguageReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void whenLanguageReady().finally(() => live && setLanguageReady(true));
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
|
||||
if (status === "loading" || !languageReady) {
|
||||
return (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
@@ -41,11 +74,43 @@ export function App() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
||||
/*
|
||||
* Every in-app navigation runs through `aroundNav` -- links, redirects and
|
||||
* `navigate()` alike, since wouter routes them all through the same place.
|
||||
* That is what makes the guard hold for the app rail and the settings nav
|
||||
* without either of them knowing an editor exists.
|
||||
*
|
||||
* The back button is the gap: by the time `popstate` arrives the history
|
||||
* has already moved, and the only way to hold the page would be to push an
|
||||
* entry back, which breaks the button for everyone who has nothing pending.
|
||||
* Reload and tab close are covered by `beforeunload` instead.
|
||||
*/
|
||||
<Router
|
||||
/*
|
||||
* The one place the mount prefix enters the router. Every `<Route path>`,
|
||||
* `<Link href>` and `navigate()` in the app stays written root-absolute
|
||||
* -- `/mail/:mailboxId?` -- and wouter strips the base off the address
|
||||
* before matching and puts it back on when it navigates. So a deep link
|
||||
* to `/mail/inbox/abc` under a `/mail` mount is `/mail/mail/inbox/abc`
|
||||
* and nothing in the views has to know it.
|
||||
*
|
||||
* Empty is wouter's own default, so the root case is untouched.
|
||||
*/
|
||||
base={BASE_PATH}
|
||||
aroundNav={(navigate, to, options) => {
|
||||
if (!hasUnsavedChanges()) {
|
||||
navigate(to, options);
|
||||
return;
|
||||
}
|
||||
void confirmLeaveUnsaved().then((ok) => {
|
||||
if (ok) navigate(to, options);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,16 +118,66 @@ function AuthedApp() {
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const [location] = useLocation();
|
||||
|
||||
// Settings that live with the account rather than the browser. The cached
|
||||
// ones have already painted, so this only has to correct them (issue #54).
|
||||
/*
|
||||
* Settings that live with the account rather than the browser.
|
||||
*
|
||||
* When this browser has them cached they have already painted, and this only
|
||||
* has to correct them (issue #54). When it does not -- an untrusted device,
|
||||
* or the sign-out that every deploy causes -- the first frame is the
|
||||
* defaults, and the defaults are English. Rendering then means anything
|
||||
* computed before the settings land is computed in the wrong language: not
|
||||
* the interface, which is rebuilt when the catalogue arrives, but a string
|
||||
* emitted once, like a toast. That is why the stale-folder toast came out
|
||||
* in English on an otherwise German screen.
|
||||
*
|
||||
* So without a cache the tree waits, which costs nothing: there was nothing
|
||||
* worth painting yet. With one it does not wait, and the screen is as quick
|
||||
* as it was.
|
||||
*
|
||||
* Once per account, not once per mount: this subtree is keyed on the
|
||||
* language version, so picking a language throws it away and builds it
|
||||
* again. Re-reading the settings file there would apply a copy written
|
||||
* before the change and undo it.
|
||||
*/
|
||||
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
if (settingsAlreadyLoadedFor(accountId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
/* Before the account's own settings, so both the seeding below and the
|
||||
enforcement inside `hydrate` have something to apply. */
|
||||
await loadSettingsPolicy();
|
||||
if (cancelled) return;
|
||||
const remote = await loadRemoteSettings();
|
||||
if (cancelled) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// Pushes were held back until now so they could not race the load.
|
||||
// No settings file: this account has never had settings of its own, so
|
||||
// the installation's defaults are what it starts on rather than
|
||||
// ihasmail's. Issue #207.
|
||||
else useSettings.getState().seedFromPolicy();
|
||||
/*
|
||||
* After both, and for everybody: a change the installation wants applied
|
||||
* once has to reach accounts that already exist, which is the whole of
|
||||
* why it is not just a default. Each is remembered, so a reader who turns
|
||||
* one back off keeps it off. Issue #207.
|
||||
*/
|
||||
const applied = useSettings.getState().applyPolicyChanges();
|
||||
if (applied.length) {
|
||||
toast.show(plural(applied.length, {
|
||||
one: "Your administrator changed {n} setting",
|
||||
other: "Your administrator changed {n} settings",
|
||||
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
|
||||
}
|
||||
// The catalogue for whatever language that turned out to be. Hydrating
|
||||
// asks for it; this is waiting for the answer.
|
||||
await whenLanguageReady();
|
||||
if (cancelled) return;
|
||||
setReady(true);
|
||||
// Pushes were held back until now so they could not race the load. A
|
||||
// change made while it was in flight was kept, and goes out here.
|
||||
armSettingsSync();
|
||||
// No file yet — seed one from what this browser has, so the next device
|
||||
// to sign in starts from these rather than from the defaults.
|
||||
@@ -91,6 +206,16 @@ function AuthedApp() {
|
||||
// A push subscription stays silent until its verification code is echoed
|
||||
// back, and the code may have arrived while no tab was open.
|
||||
listenForVerification();
|
||||
/*
|
||||
* And a subscription expires -- seven days is the ceiling JMAP puts on one,
|
||||
* and re-registering before that is the client's job. Nothing did it, so
|
||||
* background notifications lapsed within a week of being switched on and
|
||||
* only came back if somebody
|
||||
* happened to toggle the switch. Opening the app is the only moment this
|
||||
* can be done -- registering is a JMAP call, and the service worker has no
|
||||
* session to make one with -- so it is done on every start.
|
||||
*/
|
||||
void renewWebPush();
|
||||
const pending = new Map<string, Set<string>>();
|
||||
let timer: number | null = null;
|
||||
const unsub = push.subscribe((acct, type) => {
|
||||
@@ -130,7 +255,7 @@ function AuthedApp() {
|
||||
const id = s.roleId("inbox");
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
@@ -144,6 +269,16 @@ function AuthedApp() {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
}, [notif]);
|
||||
|
||||
// Nothing worth painting until the account's settings are in force; see the
|
||||
// comment on `ready` above. With a cache this was true from the first frame.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Suspense fallback={<Spinner size="lg" />}>
|
||||
|
||||
+12
-4
@@ -1,4 +1,5 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export const CAP = {
|
||||
core: "urn:ietf:params:jmap:core",
|
||||
@@ -62,9 +63,16 @@ export type ResultRef = { resultOf: string; name: string; path: string };
|
||||
|
||||
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
|
||||
/**
|
||||
* Generic fetch against our same-origin API with CSRF header + auth handling.
|
||||
*
|
||||
* `path` is written root-absolute at every call site -- `/api/jmap` -- and the
|
||||
* mount prefix is added here rather than there. One place to get it right, and
|
||||
* the `startsWith` below keeps working on the path as written rather than on
|
||||
* whatever the deployment happens to be called.
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
const res = await fetch(withBase(path), {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
|
||||
credentials: "same-origin",
|
||||
@@ -276,12 +284,12 @@ export class JmapClient {
|
||||
}
|
||||
|
||||
uploadUrl(accountId: Id): string {
|
||||
return `/api/upload/${encodeURIComponent(accountId)}`;
|
||||
return withBase(`/api/upload/${encodeURIComponent(accountId)}`);
|
||||
}
|
||||
|
||||
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
||||
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
||||
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
|
||||
const u = withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`);
|
||||
return inline ? `${u}&inline=1` : u;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Id, StateChange } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export type PushListener = (accountId: Id, type: string, newState: string) => void;
|
||||
|
||||
@@ -70,7 +71,7 @@ class PushManager {
|
||||
private connect(): void {
|
||||
if (this.stopped || this.es) return;
|
||||
if (this.state !== "connected") this.setState("connecting");
|
||||
const url = `/api/events?types=*&closeafter=no&ping=30`;
|
||||
const url = withBase(`/api/events?types=*&closeafter=no&ping=30`);
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
es.onopen = () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* A reminder made out of a mail: the subject becomes the title and the body
|
||||
* becomes the description, and the reader supplies the one thing the message
|
||||
* cannot — when it happens. What these pin is that the copy is faithful and
|
||||
* bounded, because everything else about the event is the editor's job.
|
||||
*/
|
||||
|
||||
function part(partId: string, type: string): EmailBodyPart {
|
||||
return { partId, type } as EmailBodyPart;
|
||||
}
|
||||
|
||||
function email(parts: Partial<Email>): Email {
|
||||
return { id: "m1", subject: null, ...parts } as Email;
|
||||
}
|
||||
|
||||
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
|
||||
const key = type === "text/plain" ? "textBody" : "htmlBody";
|
||||
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
|
||||
}
|
||||
|
||||
const text = (value: string) => body("Water bill", "text/plain", value);
|
||||
|
||||
describe("the time an appointment starts", () => {
|
||||
it("rounds up to the next half hour", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("moves on from a time already on the boundary, rather than starting now", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("runs for an hour", () => {
|
||||
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
|
||||
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
|
||||
expect(d.allDay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is copied from the message", () => {
|
||||
it("takes the subject as the title and the body as the description", () => {
|
||||
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
|
||||
expect(d.title).toBe("Water bill");
|
||||
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
|
||||
});
|
||||
|
||||
it("reads an HTML-only message as text, so the description is not markup", () => {
|
||||
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
|
||||
expect(d.description).toBe("Renews Friday");
|
||||
});
|
||||
|
||||
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
|
||||
expect(appointmentDraft(email({ subject: null })).title).toBe("");
|
||||
});
|
||||
|
||||
/*
|
||||
* A newsletter is a message too. The whole body would be stored on the
|
||||
* event, synced everywhere, and shown in a three-row box, so the tail is
|
||||
* dropped — visibly, so a truncated bill is not read as the whole of it.
|
||||
*/
|
||||
it("truncates a body too long to be a description", () => {
|
||||
const d = appointmentDraft(text("x".repeat(9000)));
|
||||
expect(d.description).toHaveLength(5001);
|
||||
expect(d.description.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const between = (parts: Partial<Email>) => email({ subject: "Kickoff", ...parts });
|
||||
const addr = (email: string, name: string | null = null) => ({ name, email });
|
||||
|
||||
describe("who is invited", () => {
|
||||
it("carries the sender and everyone it was addressed to", () => {
|
||||
const d = appointmentDraft(
|
||||
between({ from: [addr("[email protected]", "Grace")], to: [addr("[email protected]"), addr("[email protected]")], cc: [addr("[email protected]")] }),
|
||||
new Date(),
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]", "[email protected]", "[email protected]"]);
|
||||
expect(d.attendees[0]?.name).toBe("Grace");
|
||||
});
|
||||
|
||||
it("leaves the reader out, whatever case their address was written in", () => {
|
||||
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
|
||||
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("counts someone once, however many headers they appear in", () => {
|
||||
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], cc: [addr("[email protected]")] }));
|
||||
expect(d.attendees).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* On a message the reader sent, a blind copy is still a recipient — and
|
||||
* putting one on a guest list shows them to every other guest. Turning a
|
||||
* hidden copy into a visible one is not something a menu item may do.
|
||||
*/
|
||||
it("never turns a blind copy into a guest", () => {
|
||||
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], bcc: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
|
||||
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("invites nobody when the message has no addresses at all", () => {
|
||||
expect(appointmentDraft(between({})).attendees).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
|
||||
|
||||
/**
|
||||
* The dates below are written as local-time strings on purpose. The segments
|
||||
* follow the reader's timezone, so a test pinned to UTC instants would pass or
|
||||
* fail depending on where it ran.
|
||||
*/
|
||||
describe("archiveSegments", () => {
|
||||
it("gives the year, and the zero-padded month", () => {
|
||||
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
|
||||
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
|
||||
});
|
||||
|
||||
it("zero-pads every month below October, so the folders sort", () => {
|
||||
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
|
||||
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
|
||||
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
|
||||
});
|
||||
|
||||
it("returns nothing to append when the date cannot be read", () => {
|
||||
// Archive itself, rather than a folder named after a guess.
|
||||
expect(archiveSegments(null, "month")).toEqual([]);
|
||||
expect(archiveSegments(undefined, "month")).toEqual([]);
|
||||
expect(archiveSegments("", "month")).toEqual([]);
|
||||
expect(archiveSegments("not a date", "month")).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins to a path", () => {
|
||||
expect(archivePath(["2026", "09"])).toBe("2026/09");
|
||||
expect(archivePath([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByArchivePath", () => {
|
||||
it("keeps one destination for a selection from one month", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]!.segments).toEqual(["2026", "09"]);
|
||||
expect(groups[0]!.ids).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("splits a selection that spans months, which is the case that matters", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
|
||||
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups.map((g) => g.segments)).toEqual([
|
||||
["2026", "09"],
|
||||
["2026", "08"],
|
||||
]);
|
||||
expect(groups[0]!.ids).toEqual(["a", "c"]);
|
||||
expect(groups[1]!.ids).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("collapses the same span back to one group at year granularity", () => {
|
||||
const entries = [
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
|
||||
];
|
||||
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
|
||||
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("orders groups by where their first message appeared", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
|
||||
],
|
||||
"year",
|
||||
);
|
||||
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
|
||||
});
|
||||
|
||||
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: null },
|
||||
{ id: "c", receivedAt: "bad" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]!.segments).toEqual([]);
|
||||
expect(groups[1]!.ids).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("has nothing to do with an empty selection", () => {
|
||||
expect(groupByArchivePath([], "month")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { availabilityWindow } from "@/lib/availabilityWindow";
|
||||
|
||||
const at = (s: string) => new Date(s);
|
||||
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
|
||||
|
||||
describe("the span an availability bar covers", () => {
|
||||
it("covers the whole day for an event inside one", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
|
||||
expect(w.start.getHours()).toBe(0);
|
||||
expect(w.days).toBe(1);
|
||||
expect(w.end.getDate()).toBe(3);
|
||||
expect(w.end.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("stretches to cover an event running over several days", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
|
||||
expect(w.days).toBe(3);
|
||||
expect(w.start.getDate()).toBe(2);
|
||||
expect(w.end.getDate()).toBe(5);
|
||||
});
|
||||
|
||||
it("ends an event on the day it ends on, not the midnight it stops at", () => {
|
||||
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
|
||||
// not touch the 3rd and the bar should not show it.
|
||||
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
|
||||
expect(w.days).toBe(1);
|
||||
expect(w.end.getDate()).toBe(3);
|
||||
});
|
||||
|
||||
it("never collapses to nothing, even when start and end are the same moment", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
|
||||
expect(w.days).toBe(1);
|
||||
expect(w.span).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks a single day every three hours, labelling every six", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
||||
expect(w.scale).toBe("hours");
|
||||
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
|
||||
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
|
||||
});
|
||||
|
||||
it("thins the marks out to every six hours across two days", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
|
||||
expect(w.scale).toBe("hours");
|
||||
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
|
||||
});
|
||||
|
||||
it("marks day boundaries once there are more than two", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
|
||||
expect(w.scale).toBe("days");
|
||||
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
|
||||
expect(w.ticks.every((t) => t.major)).toBe(true);
|
||||
});
|
||||
|
||||
it("puts every mark at its true fraction of the span", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
||||
expect(w.ticks[0]!.at).toBe(0);
|
||||
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
|
||||
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops at a week and says how much it left out", () => {
|
||||
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
|
||||
expect(w.days).toBe(7);
|
||||
expect(w.daysHidden).toBe(23);
|
||||
});
|
||||
|
||||
it("hides nothing when the event fits", () => {
|
||||
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
|
||||
});
|
||||
|
||||
it("lands on real midnights, and measures the span between them", () => {
|
||||
/*
|
||||
* The span is what every position is a fraction of, so it has to be the
|
||||
* distance between the two boundaries rather than a count of 24-hour days:
|
||||
* on the day a clock changes those differ by an hour, which would end the
|
||||
* bar early and put every block after the change in the wrong place. This
|
||||
* asserts the relationship; whether the run happens to sit in a zone with
|
||||
* DST is not something a test should depend on.
|
||||
*/
|
||||
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
|
||||
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
|
||||
expect(w.start.getHours(), day).toBe(0);
|
||||
expect(w.end.getHours(), day).toBe(0);
|
||||
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("looking around the event without changing it", () => {
|
||||
it("slides the whole window forward, keeping its width", () => {
|
||||
const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
|
||||
const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 });
|
||||
expect(later.days).toBe(here.days);
|
||||
expect(later.start.getDate()).toBe(5);
|
||||
expect(later.end.getDate()).toBe(8);
|
||||
});
|
||||
|
||||
it("slides backwards, across the end of a month", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 });
|
||||
expect(w.start.getMonth()).toBe(7); // August
|
||||
expect(w.start.getDate()).toBe(30);
|
||||
expect(w.days).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the marks in step with where the window moved to", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 });
|
||||
expect(w.ticks[0]!.time.getDate()).toBe(3);
|
||||
expect(w.ticks[0]!.at).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { baseUrlOf, normalizeBasePath, stripBasePath } from "../../../../scripts/basePath.mjs";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
|
||||
/**
|
||||
* `BASE_PATH` is typed into a compose file or a `docker run` line by hand, and
|
||||
* the four spellings below are all reasonable things for someone to write.
|
||||
* The one that has to be exactly right is the empty one: every deployment that
|
||||
* exists today is at the root, and this feature must be invisible to them.
|
||||
*
|
||||
* The canonical form is a leading slash and no trailing one, so that the
|
||||
* concatenation `${base}/api/health` is correct with no branch. A trailing
|
||||
* slash would make the empty case produce `//api/health`, which is not a path
|
||||
* on this host but a protocol-relative URL pointing at a host called `api` --
|
||||
* which is why the tests below check the joined result and not just the value.
|
||||
*/
|
||||
describe("normalizing what the operator wrote", () => {
|
||||
it("leaves the canonical form alone", () => {
|
||||
expect(normalizeBasePath("/mail")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a missing leading slash", () => {
|
||||
expect(normalizeBasePath("mail")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a trailing slash", () => {
|
||||
expect(normalizeBasePath("/mail/")).toBe("/mail");
|
||||
expect(normalizeBasePath("mail/")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a nested mount, however it is punctuated", () => {
|
||||
expect(normalizeBasePath("apps/mail")).toBe("/apps/mail");
|
||||
expect(normalizeBasePath("/apps/mail/")).toBe("/apps/mail");
|
||||
});
|
||||
|
||||
it("tidies away doubled separators and stray whitespace", () => {
|
||||
expect(normalizeBasePath("//mail//")).toBe("/mail");
|
||||
expect(normalizeBasePath(" /mail ")).toBe("/mail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the root, which must behave exactly as it did", () => {
|
||||
it("is the empty string for every way of saying it", () => {
|
||||
expect(normalizeBasePath("")).toBe("");
|
||||
expect(normalizeBasePath("/")).toBe("");
|
||||
expect(normalizeBasePath("///")).toBe("");
|
||||
expect(normalizeBasePath(undefined)).toBe("");
|
||||
expect(normalizeBasePath(null)).toBe("");
|
||||
});
|
||||
|
||||
it("joins onto an app path without doubling the slash", () => {
|
||||
// `//api/health` would be read as a protocol-relative URL and sent to a
|
||||
// host called `api`. This is the assertion the whole canonical form is for.
|
||||
expect(`${normalizeBasePath("/")}/api/health`).toBe("/api/health");
|
||||
expect(`${normalizeBasePath("/mail")}/api/health`).toBe("/mail/api/health");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the directory form Vite and the PWA scope want", () => {
|
||||
it("always ends in a slash", () => {
|
||||
expect(baseUrlOf("")).toBe("/");
|
||||
expect(baseUrlOf("mail")).toBe("/mail/");
|
||||
expect(baseUrlOf("/mail/")).toBe("/mail/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking the prefix off an incoming request", () => {
|
||||
it("passes everything through untouched at the root", () => {
|
||||
expect(stripBasePath("", "/")).toBe("/");
|
||||
expect(stripBasePath("", "/assets/index.js")).toBe("/assets/index.js");
|
||||
expect(stripBasePath("", "/mail/inbox/abc")).toBe("/mail/inbox/abc");
|
||||
});
|
||||
|
||||
it("strips the mount and keeps the rest", () => {
|
||||
expect(stripBasePath("/mail", "/mail/assets/index.js")).toBe("/assets/index.js");
|
||||
expect(stripBasePath("/mail", "/mail/api/health")).toBe("/api/health");
|
||||
});
|
||||
|
||||
it("treats the bare mount as the app's index", () => {
|
||||
// Typing the prefix without the trailing slash is how people reach it.
|
||||
expect(stripBasePath("/mail", "/mail")).toBe("/");
|
||||
expect(stripBasePath("/mail", "/mail/")).toBe("/");
|
||||
});
|
||||
|
||||
it("refuses a path that merely starts with the same letters", () => {
|
||||
// A plain startsWith would hand `/mailbox` the app shell, shadowing
|
||||
// whatever else the proxy serves on this host.
|
||||
expect(stripBasePath("/mail", "/mailbox")).toBe(null);
|
||||
expect(stripBasePath("/mail", "/mailing/list")).toBe(null);
|
||||
});
|
||||
|
||||
it("refuses anything outside the mount", () => {
|
||||
expect(stripBasePath("/mail", "/")).toBe(null);
|
||||
expect(stripBasePath("/mail", "/other-app/thing")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser's view of the mount", () => {
|
||||
/*
|
||||
* Vitest builds with Vite's default base, so this is the root deployment --
|
||||
* which is the case that must not regress, and the reason these assertions
|
||||
* are worth writing down rather than dismissing as trivial.
|
||||
*/
|
||||
it("is empty in a root build", () => {
|
||||
expect(BASE_PATH).toBe("");
|
||||
});
|
||||
|
||||
it("leaves app paths exactly as written", () => {
|
||||
expect(withBase("/api/health")).toBe("/api/health");
|
||||
expect(withBase("/img/logo.png")).toBe("/img/logo.png");
|
||||
expect(withBase("/sw.js")).toBe("/sw.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { birthdaysInRange, isBirthdayEvent, BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const card = (id: string, full: string, date: { year?: number; month?: number; day?: number; utc?: string } | null, kind = "birth"): ContactCard =>
|
||||
({
|
||||
id,
|
||||
uid: id,
|
||||
addressBookIds: { b1: true },
|
||||
name: { full },
|
||||
...(date ? { anniversaries: { a1: { kind, date } } } : {}),
|
||||
}) as ContactCard;
|
||||
|
||||
const range = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||
const names = (b: ReturnType<typeof birthdaysInRange>) => b.map((x) => `${x.name} ${x.date.toISOString().slice(0, 10)}${x.age === null ? "" : ` (${x.age})`}`);
|
||||
|
||||
describe("birthdaysInRange", () => {
|
||||
it("puts a birthday in the year the range covers, with the age", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
expect(names(birthdaysInRange([card("c1", "Ada Lovelace", { year: 1990, month: 6, day: 15 })], s, e))).toEqual(["Ada Lovelace 2026-06-15 (36)"]);
|
||||
});
|
||||
|
||||
it("gives no age when the card recorded only a day and month", () => {
|
||||
// Very common, and a real answer rather than a broken one.
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
||||
expect(out[0]!.age).toBeNull();
|
||||
expect(out[0]!.date.getMonth()).toBe(5);
|
||||
});
|
||||
|
||||
it("emits one occurrence per year across a range that spans years", () => {
|
||||
const [s, e] = range("2025-06-01", "2027-06-01");
|
||||
expect(names(birthdaysInRange([card("c1", "Ada", { year: 2000, month: 12, day: 25 })], s, e))).toEqual([
|
||||
"Ada 2025-12-25 (25)",
|
||||
"Ada 2026-12-25 (26)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out a birthday outside the range", () => {
|
||||
const [s, e] = range("2026-07-01", "2026-08-01");
|
||||
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts 29 February on the 28th in a year that has no 29th", () => {
|
||||
// The month is the fact; moving it to 1 March is the arithmetic winning.
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
|
||||
expect(out[0]!.date.getMonth()).toBe(1);
|
||||
expect(out[0]!.date.getDate()).toBe(28);
|
||||
});
|
||||
|
||||
it("keeps 29 February on the 29th in a leap year", () => {
|
||||
const [s, e] = range("2028-01-01", "2029-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
|
||||
expect(out[0]!.date.getDate()).toBe(29);
|
||||
});
|
||||
|
||||
it("reads a timestamp date as well as a partial one", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { utc: "1990-06-15T00:00:00Z" })], s, e);
|
||||
expect(out[0]!.date.getMonth()).toBe(5);
|
||||
expect(out[0]!.age).toBe(36);
|
||||
});
|
||||
|
||||
it("ignores anniversaries that are not birthdays", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 }, "wedding")], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a card with no anniversary and one with no usable name", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
expect(birthdaysInRange([card("c1", "Ada", null)], s, e)).toEqual([]);
|
||||
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to a name built from components, then to the organisation", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const parts = {
|
||||
id: "c1",
|
||||
uid: "c1",
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: "given", value: "Grace" }, { kind: "surname", value: "Hopper" }] },
|
||||
anniversaries: { a1: { kind: "birth", date: { month: 12, day: 9 } } },
|
||||
} as unknown as ContactCard;
|
||||
expect(birthdaysInRange([parts], s, e)[0]!.name).toBe("Grace Hopper");
|
||||
|
||||
const org = {
|
||||
id: "c2",
|
||||
uid: "c2",
|
||||
addressBookIds: {},
|
||||
organizations: { o1: { name: "Acme Ltd" } },
|
||||
anniversaries: { a1: { kind: "birth", date: { month: 3, day: 1 } } },
|
||||
} as unknown as ContactCard;
|
||||
expect(birthdaysInRange([org], s, e)[0]!.name).toBe("Acme Ltd");
|
||||
});
|
||||
|
||||
it("never reports a negative age from a birth year in the future", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
expect(birthdaysInRange([card("c1", "Ada", { year: 2040, month: 6, day: 15 })], s, e)[0]!.age).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores an impossible date rather than inventing one", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
expect(birthdaysInRange([card("c1", "Ada", { month: 13, day: 40 })], s, e)).toEqual([]);
|
||||
expect(birthdaysInRange([card("c1", "Ada", { month: 4, day: 31 })], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns them in date order, whatever order the contacts were in", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange(
|
||||
[card("c1", "Zoe", { month: 11, day: 2 }), card("c2", "Amy", { month: 2, day: 3 })],
|
||||
s,
|
||||
e,
|
||||
);
|
||||
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
|
||||
});
|
||||
|
||||
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
|
||||
const [s, e] = range("2025-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
||||
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
|
||||
expect(out.every((b) => isBirthdayEvent(b.id))).toBe(true);
|
||||
expect(out[0]!.id.startsWith(BIRTHDAY_ID_PREFIX)).toBe(true);
|
||||
// Nothing that came off the server should ever look like one.
|
||||
expect(isBirthdayEvent("abc123")).toBe(false);
|
||||
expect(isBirthdayEvent(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("declines a range that is empty, backwards, or absurdly wide", () => {
|
||||
const cards = [card("c1", "Ada", { month: 6, day: 15 })];
|
||||
expect(birthdaysInRange(cards, new Date("2026-01-01"), new Date("2026-01-01"))).toEqual([]);
|
||||
expect(birthdaysInRange(cards, new Date("2027-01-01"), new Date("2026-01-01"))).toEqual([]);
|
||||
expect(birthdaysInRange(cards, new Date("2000-01-01"), new Date("2100-01-01"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
|
||||
/*
|
||||
* The name an instance calls itself.
|
||||
*
|
||||
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
|
||||
* the server rather than have it written in. The sign-in page did not (#236's
|
||||
* neighbour): it fetched `/api/config`, received the name and used only
|
||||
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
|
||||
* user meets first. These pin the shape of the answer rather than the name.
|
||||
*/
|
||||
|
||||
const nameFrom = (config: { appName?: unknown } | null) =>
|
||||
config && typeof config.appName === "string" && config.appName.trim() ? config.appName.trim() : DEFAULT_APP_NAME;
|
||||
|
||||
describe("resolving the instance name", () => {
|
||||
it("uses what the server says", () => {
|
||||
expect(nameFrom({ appName: "Acme Mail" })).toBe("Acme Mail");
|
||||
});
|
||||
|
||||
it("trims it, because a name with an edge of whitespace is a layout bug", () => {
|
||||
expect(nameFrom({ appName: " Acme Mail " })).toBe("Acme Mail");
|
||||
});
|
||||
|
||||
it("falls back when the request failed", () => {
|
||||
// A sign-in form with no name on it is worse than one with the wrong name.
|
||||
expect(nameFrom(null)).toBe(DEFAULT_APP_NAME);
|
||||
});
|
||||
|
||||
it("falls back on a name that is empty or only spaces", () => {
|
||||
expect(nameFrom({ appName: "" })).toBe(DEFAULT_APP_NAME);
|
||||
expect(nameFrom({ appName: " " })).toBe(DEFAULT_APP_NAME);
|
||||
});
|
||||
|
||||
it("falls back on a name that is not a string at all", () => {
|
||||
expect(nameFrom({ appName: 42 })).toBe(DEFAULT_APP_NAME);
|
||||
expect(nameFrom({})).toBe(DEFAULT_APP_NAME);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The two sentence builders, which had no tests while they were building
|
||||
* English by concatenation -- and no test would have caught the thing wrong
|
||||
* with them, since the English output was correct. These pin the two
|
||||
* properties that matter now: every fragment goes through the catalogue, and
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeRule as describeSieve } from "../sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
|
||||
import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
describe("sieve describeRule", () => {
|
||||
it("names the header and operator through the catalogue", () => {
|
||||
const s = describeSieve({
|
||||
id: "1", name: "r", join: "allof", enabled: true,
|
||||
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
||||
actions: [{ type: "fileinto", mailbox: "Work" }],
|
||||
} as never);
|
||||
expect(s).toContain("Subject");
|
||||
expect(s).toContain("contains");
|
||||
expect(s).toContain("invoice");
|
||||
expect(s).toContain("Work");
|
||||
});
|
||||
|
||||
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
|
||||
const base = {
|
||||
id: "1", name: "r", enabled: true,
|
||||
tests: [
|
||||
{ type: "header", header: "from", op: "is", value: "a@b" },
|
||||
{ type: "header", header: "to", op: "is", value: "c@d" },
|
||||
],
|
||||
actions: [{ type: "keep" }],
|
||||
};
|
||||
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
|
||||
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
|
||||
});
|
||||
|
||||
it("says 'always' when a rule has no tests", () => {
|
||||
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
|
||||
expect(s).toContain("always");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurrence describeRule", () => {
|
||||
it("describes the simple frequencies", () => {
|
||||
expect(describeRecurrence(undefined)).toBe("Does not repeat");
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
||||
});
|
||||
|
||||
it("recognises Monday to Friday as every weekday", () => {
|
||||
const rule = {
|
||||
"@type": "RecurrenceRule", frequency: "weekly",
|
||||
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
||||
};
|
||||
expect(describeRecurrence(rule as never)).toBe("Every weekday");
|
||||
});
|
||||
|
||||
it("uses a word, not a suffix, for the nth weekday of a month", () => {
|
||||
const s = describeRecurrence({
|
||||
"@type": "RecurrenceRule", frequency: "monthly",
|
||||
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
|
||||
} as never);
|
||||
expect(s).toContain("second");
|
||||
expect(s).not.toContain("2nd");
|
||||
});
|
||||
|
||||
it("wraps the sentence for count and until rather than appending to it", () => {
|
||||
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
|
||||
expect(s).toBe("Daily, 5 times");
|
||||
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
|
||||
expect(u).toBe("Daily, until 2026-05-03");
|
||||
});
|
||||
|
||||
it("takes its weekday names from the locale, not a table of English", () => {
|
||||
setUiLanguageForFormatting("de-DE");
|
||||
const names = weekdayOptions().map((w) => w.label);
|
||||
expect(names[0]).toBe("Montag");
|
||||
expect(names).toHaveLength(7);
|
||||
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
||||
// which is why they cannot be catalogue keys and come from Intl instead.
|
||||
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
||||
setUiLanguageForFormatting(null);
|
||||
});
|
||||
|
||||
it("renders a translated rule through the catalogue", () => {
|
||||
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
||||
setCatalog("en", { strings: {}, plurals: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emlFilename, sanitizeFilename } from "@/lib/emlName";
|
||||
|
||||
describe("emlFilename", () => {
|
||||
it("keeps an ordinary subject, with spaces as underscores", () => {
|
||||
expect(emlFilename("Quarterly report")).toBe("Quarterly_report.eml");
|
||||
});
|
||||
|
||||
it("keeps letters from any script, which the ASCII rule threw away", () => {
|
||||
// The whole point: none of these may come out as a row of underscores.
|
||||
expect(emlFilename("Квартальный отчёт")).toBe("Квартальный_отчёт.eml");
|
||||
expect(emlFilename("四半期報告")).toBe("四半期報告.eml");
|
||||
expect(emlFilename("Rapport trimestriel été")).toBe("Rapport_trimestriel_été.eml");
|
||||
});
|
||||
|
||||
it("keeps the punctuation that is fine in a filename", () => {
|
||||
expect(emlFilename("Re- budget (v3) [final]")).toBe("Re-_budget_(v3)_[final].eml");
|
||||
});
|
||||
|
||||
it("drops path separators and the characters Windows reserves", () => {
|
||||
expect(emlFilename("a/b\\c:d*e?f\"g<h>i|j")).toBe("abcdefghij.eml");
|
||||
});
|
||||
|
||||
it("drops control characters", () => {
|
||||
expect(emlFilename("a\u0007b\u0000c")).toBe("abc.eml");
|
||||
expect(emlFilename("a\u007fb")).toBe("ab.eml");
|
||||
});
|
||||
|
||||
it("falls back when there is no subject, or nothing survives", () => {
|
||||
expect(emlFilename("")).toBe("message.eml");
|
||||
expect(emlFilename(null)).toBe("message.eml");
|
||||
expect(emlFilename(undefined)).toBe("message.eml");
|
||||
expect(emlFilename("///")).toBe("message.eml");
|
||||
expect(emlFilename(" ")).toBe("message.eml");
|
||||
});
|
||||
|
||||
it("does not end in a dot or a space, which Windows refuses", () => {
|
||||
expect(emlFilename("Report.")).toBe("Report.eml");
|
||||
expect(emlFilename("Report ")).toBe("Report.eml");
|
||||
expect(emlFilename("...Report...")).toBe("Report.eml");
|
||||
});
|
||||
|
||||
it("does not start with a dot, which would hide the file on Unix", () => {
|
||||
expect(emlFilename(".hidden")).toBe("hidden.eml");
|
||||
});
|
||||
|
||||
it("caps the length so it survives a filesystem limit", () => {
|
||||
const name = emlFilename("x".repeat(500));
|
||||
expect(name).toBe(`${"x".repeat(80)}.eml`);
|
||||
});
|
||||
|
||||
it("exposes the stem on its own", () => {
|
||||
expect(sanitizeFilename("Quarterly report")).toBe("Quarterly_report");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDragEvent,
|
||||
formatDuration,
|
||||
MIN_DURATION_MINUTES,
|
||||
movedBy,
|
||||
movedToDay,
|
||||
pixelsToMinutes,
|
||||
resizedBy,
|
||||
snap,
|
||||
movePatch,
|
||||
moveByDaysPatch,
|
||||
dayDelta,
|
||||
resizePatch,
|
||||
SNAP_MINUTES,
|
||||
} from "@/lib/eventDrag";
|
||||
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0);
|
||||
const span = (from: Date, to: Date) => ({ start: from, end: to });
|
||||
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
|
||||
describe("snap", () => {
|
||||
it("rounds to the nearest quarter hour", () => {
|
||||
expect(snap(0)).toBe(0);
|
||||
expect(snap(7)).toBe(0);
|
||||
expect(snap(8)).toBe(15);
|
||||
expect(snap(22)).toBe(15);
|
||||
expect(snap(23)).toBe(30);
|
||||
expect(snap(-8)).toBe(-15);
|
||||
});
|
||||
|
||||
it("takes another slot when asked", () => {
|
||||
expect(snap(20, 30)).toBe(30);
|
||||
expect(snap(14, 30)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("movedBy", () => {
|
||||
it("moves both ends, so the length does not change", () => {
|
||||
const out = movedBy(span(at(14), at(15)), 30);
|
||||
expect(hhmm(out.start)).toBe("14:30");
|
||||
expect(hhmm(out.end)).toBe("15:30");
|
||||
});
|
||||
|
||||
it("snaps the drag rather than taking it literally", () => {
|
||||
const out = movedBy(span(at(14), at(15)), 7);
|
||||
expect(hhmm(out.start)).toBe("14:00");
|
||||
});
|
||||
|
||||
it("moves backwards too", () => {
|
||||
const out = movedBy(span(at(14), at(15)), -60);
|
||||
expect(hhmm(out.start)).toBe("13:00");
|
||||
expect(hhmm(out.end)).toBe("14:00");
|
||||
});
|
||||
|
||||
it("carries an event across midnight without losing its length", () => {
|
||||
const out = movedBy(span(at(23, 30), at(23, 45)), 60);
|
||||
expect(ymd(out.start)).toBe("2026-09-05");
|
||||
expect(hhmm(out.start)).toBe("00:30");
|
||||
expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("movedToDay", () => {
|
||||
it("keeps the time of day, which is what the month grid is not asking about", () => {
|
||||
// Dragged from Friday to Monday: still at two o'clock.
|
||||
const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7));
|
||||
expect(ymd(out.start)).toBe("2026-09-07");
|
||||
expect(hhmm(out.start)).toBe("14:00");
|
||||
expect(hhmm(out.end)).toBe("15:30");
|
||||
});
|
||||
|
||||
it("keeps a length that spans days", () => {
|
||||
const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20));
|
||||
expect(ymd(out.start)).toBe("2026-09-20");
|
||||
expect(ymd(out.end)).toBe("2026-09-22");
|
||||
});
|
||||
|
||||
it("moves across a month boundary", () => {
|
||||
const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1));
|
||||
expect(ymd(out.start)).toBe("2026-10-01");
|
||||
expect(hhmm(out.start)).toBe("09:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizedBy", () => {
|
||||
it("moves the end and leaves the start alone", () => {
|
||||
const out = resizedBy(span(at(14), at(15)), 30);
|
||||
expect(hhmm(out.start)).toBe("14:00");
|
||||
expect(hhmm(out.end)).toBe("15:30");
|
||||
});
|
||||
|
||||
it("clamps at one slot rather than refusing the drag", () => {
|
||||
// A drag that goes too far is still a drag; stopping is what the reader
|
||||
// sees happening while they do it.
|
||||
const out = resizedBy(span(at(14), at(15)), -600);
|
||||
expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000);
|
||||
expect(hhmm(out.end)).toBe("14:15");
|
||||
});
|
||||
|
||||
it("never lets the end cross the start", () => {
|
||||
for (const delta of [-60, -120, -1000]) {
|
||||
const out = resizedBy(span(at(9), at(9, 30)), delta);
|
||||
expect(out.end.getTime()).toBeGreaterThan(out.start.getTime());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("writes the shapes the wire expects", () => {
|
||||
expect(formatDuration(3600)).toBe("PT1H");
|
||||
expect(formatDuration(5400)).toBe("PT1H30M");
|
||||
expect(formatDuration(900)).toBe("PT15M");
|
||||
expect(formatDuration(86400)).toBe("P1D");
|
||||
expect(formatDuration(90000)).toBe("P1DT1H");
|
||||
expect(formatDuration(0)).toBe("PT0S");
|
||||
expect(formatDuration(45)).toBe("PT45S");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the patch a drag sends, computed in the event's own frame", () => {
|
||||
/*
|
||||
* The bug this shape exists to prevent: working the new time out from the
|
||||
* reader's local hours and then re-expressing it in the event's zone
|
||||
* converts twice, and the two do not cancel. An event two hours from the
|
||||
* reader jumped two hours the first time it was dragged and then sat still.
|
||||
* None of these functions touches a zone at all.
|
||||
*/
|
||||
it("moves the stored start by the snapped delta", () => {
|
||||
expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" });
|
||||
expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" });
|
||||
expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" });
|
||||
});
|
||||
|
||||
it("carries a move across midnight and across a month", () => {
|
||||
expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" });
|
||||
});
|
||||
|
||||
it("never sends a duration for a move, so the length is left alone", () => {
|
||||
expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the time of day when moving by whole days", () => {
|
||||
expect(moveByDaysPatch("2026-09-04T14:30:00", 6)).toEqual({ start: "2026-09-10T14:30:00" });
|
||||
expect(moveByDaysPatch("2026-09-04T14:30:00", -3)).toEqual({ start: "2026-09-01T14:30:00" });
|
||||
});
|
||||
|
||||
it("moves by the delta the hand made, not to the date that was dropped on", () => {
|
||||
/*
|
||||
* The month grid's cells are local days; the stored date is in the event's
|
||||
* own zone. Writing the dropped-on date put a Tokyo event dropped on the
|
||||
* 11th onto the 10th, because 15:00 in Tokyo is the previous evening in
|
||||
* Phoenix — it went where its own calendar said, not where the pointer did.
|
||||
*/
|
||||
const storedTokyo = "2026-09-04T15:00:00"; // shown to a Phoenix reader on the 3rd
|
||||
const shownOn = new Date(2026, 8, 3);
|
||||
const droppedOn = new Date(2026, 8, 11);
|
||||
const patch = moveByDaysPatch(storedTokyo, dayDelta(shownOn, droppedOn));
|
||||
// Eight days later in its own frame, so eight days later on screen too.
|
||||
expect(patch).toEqual({ start: "2026-09-12T15:00:00" });
|
||||
});
|
||||
|
||||
it("counts whole local days, ignoring the time on either side", () => {
|
||||
expect(dayDelta(new Date(2026, 8, 3, 23, 30), new Date(2026, 8, 4, 0, 30))).toBe(1);
|
||||
expect(dayDelta(new Date(2026, 8, 4), new Date(2026, 8, 4))).toBe(0);
|
||||
expect(dayDelta(new Date(2026, 8, 11), new Date(2026, 8, 3))).toBe(-8);
|
||||
expect(dayDelta(new Date(2026, 8, 30), new Date(2026, 9, 2))).toBe(2);
|
||||
});
|
||||
|
||||
it("never sends a start for a resize, so the zone question does not arise", () => {
|
||||
const patch = resizePatch(3600, 60);
|
||||
expect(patch).toEqual({ duration: "PT2H" });
|
||||
expect(patch.start).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps a resize at one slot", () => {
|
||||
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
||||
});
|
||||
|
||||
it("says nothing at all about a start it cannot read", () => {
|
||||
expect(movePatch("not a date", 30)).toEqual({});
|
||||
expect(moveByDaysPatch("", 3)).toEqual({});
|
||||
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDragEvent", () => {
|
||||
const writable = { myRights: { mayWriteAll: true } };
|
||||
const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } };
|
||||
const event = { id: "e1" } as CalendarEvent;
|
||||
|
||||
it("allows a normal event on a calendar you can write to", () => {
|
||||
expect(canDragEvent(event, writable)).toBe(true);
|
||||
expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a birthday, which is derived and has nothing to move", () => {
|
||||
expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a calendar you cannot write to, and one that is not there", () => {
|
||||
expect(canDragEvent(event, readonly)).toBe(false);
|
||||
expect(canDragEvent(event, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses nothing at all", () => {
|
||||
expect(canDragEvent(null, writable)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pixelsToMinutes", () => {
|
||||
it("converts against the grid's own scale", () => {
|
||||
expect(pixelsToMinutes(48, 48)).toBe(60);
|
||||
expect(pixelsToMinutes(24, 48)).toBe(30);
|
||||
expect(pixelsToMinutes(-48, 48)).toBe(-60);
|
||||
});
|
||||
|
||||
it("says nothing rather than dividing by zero before the grid is measured", () => {
|
||||
expect(pixelsToMinutes(100, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips through snap to the slot the pointer is over", () => {
|
||||
expect(snap(pixelsToMinutes(10, 48))).toBe(15);
|
||||
expect(snap(pixelsToMinutes(2, 48))).toBe(0);
|
||||
expect(SNAP_MINUTES).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFileNodes, NODE_MIME, readDraggedIds } from "@/lib/filenode";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
|
||||
const rights = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
|
||||
function node(id: string, parentId: Id | null, nodeType: FileNode["nodeType"] = "file"): FileNode {
|
||||
return { id, parentId, nodeType, blobId: nodeType === "file" ? `b${id}` : null, size: 1, name: id, type: "text/plain", created: "", modified: null, myRights: rights } as FileNode;
|
||||
}
|
||||
|
||||
/*
|
||||
* A multi-file drag carries its ids in one payload, because `dataTransfer`
|
||||
* holds one string per type and the drop has to be one action. These two
|
||||
* functions are the whole of that contract -- the gesture itself cannot be
|
||||
* driven synthetically, so this is what pins it.
|
||||
*/
|
||||
describe("readDraggedIds", () => {
|
||||
const dt = (value: string) => ({ getData: (type: string) => (type === NODE_MIME ? value : "") }) as DataTransfer;
|
||||
|
||||
it("reads one id as a list of one", () => {
|
||||
expect(readDraggedIds(dt("f1"))).toEqual(["f1"]);
|
||||
});
|
||||
|
||||
it("reads a whole selection", () => {
|
||||
expect(readDraggedIds(dt("f1,f2,f3"))).toEqual(["f1", "f2", "f3"]);
|
||||
});
|
||||
|
||||
it("is empty for a drag that carries nothing of ours", () => {
|
||||
// A drag from outside the app: the caller checks `types` first, but an
|
||||
// empty string here must not read as a file called "".
|
||||
expect(readDraggedIds(dt(""))).toEqual([]);
|
||||
expect(readDraggedIds(dt(",,"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDropFileNodes", () => {
|
||||
const nodes: Record<Id, FileNode> = {
|
||||
root1: node("root1", null),
|
||||
root2: node("root2", null),
|
||||
dir: node("dir", null, "directory"),
|
||||
inside: node("inside", "dir"),
|
||||
};
|
||||
|
||||
it("allows a drop only when every file can make it", () => {
|
||||
expect(canDropFileNodes(nodes, ["root1", "root2"], "dir")).toBe(true);
|
||||
// `inside` is already in `dir`, so the move is a no-op for it -- and a drop
|
||||
// that would move one of two files is refused rather than half-done.
|
||||
expect(canDropFileNodes(nodes, ["root1", "inside"], "dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder dropped into itself, whoever it is dragged with", () => {
|
||||
expect(canDropFileNodes(nodes, ["dir"], "dir")).toBe(false);
|
||||
expect(canDropFileNodes(nodes, ["root1", "dir"], "dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("has nothing to drop when nothing is dragged", () => {
|
||||
expect(canDropFileNodes(nodes, [], "dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats the top level like any other target", () => {
|
||||
expect(canDropFileNodes(nodes, ["inside"], null)).toBe(true);
|
||||
// Already at the top: nothing to do.
|
||||
expect(canDropFileNodes(nodes, ["root1"], null)).toBe(false);
|
||||
expect(canDropFileNodes(nodes, ["inside", "root1"], null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
|
||||
|
||||
const de: Catalog = {
|
||||
strings: {
|
||||
"Archive": "Archivieren",
|
||||
"Move {n} to {folder}": "{n} nach {folder} verschieben",
|
||||
// German puts the parts in a different order, which is the whole reason
|
||||
// the element is a named hole rather than a split sentence.
|
||||
"Open {scheme} links here": "{scheme}-Links hier öffnen",
|
||||
},
|
||||
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
|
||||
};
|
||||
/* Russian is the reason plural() does not take (one, other): it needs three
|
||||
forms, and which one applies is not a question about the number 1. */
|
||||
const ru: Catalog = {
|
||||
strings: {},
|
||||
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
|
||||
};
|
||||
|
||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||
|
||||
describe("t", () => {
|
||||
it("returns the English it was given when nothing is loaded", () => {
|
||||
// The whole point of English-as-key: a missing translation degrades to
|
||||
// readable English rather than to a symbolic name leaking into the UI.
|
||||
expect(t("Archive")).toBe("Archive");
|
||||
expect(currentLanguage()).toBe("en");
|
||||
});
|
||||
|
||||
it("translates once a catalogue is in force", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Archive")).toBe("Archivieren");
|
||||
});
|
||||
|
||||
it("falls back per string, not per catalogue", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Report spam")).toBe("Report spam");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpolation", () => {
|
||||
it("fills named placeholders", () => {
|
||||
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
|
||||
});
|
||||
|
||||
it("survives a translator reordering the sentence", () => {
|
||||
// Positional arguments would not: German moves the parts around and means
|
||||
// the same thing.
|
||||
setCatalog("de", de);
|
||||
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
|
||||
});
|
||||
|
||||
it("leaves an unknown placeholder alone rather than printing undefined", () => {
|
||||
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plural", () => {
|
||||
const FORMS = { one: "{n} message", other: "{n} messages" };
|
||||
|
||||
it("picks the English form without a catalogue", () => {
|
||||
expect(plural(1, FORMS)).toBe("1 message");
|
||||
expect(plural(0, FORMS)).toBe("0 messages");
|
||||
expect(plural(5, FORMS)).toBe("5 messages");
|
||||
});
|
||||
|
||||
it("uses the target language's own rule, not English's", () => {
|
||||
setCatalog("ru", ru);
|
||||
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
|
||||
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
|
||||
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
||||
});
|
||||
|
||||
it("falls back to `other` when the catalogue lacks the category", () => {
|
||||
setCatalog("de", de);
|
||||
// German has no "few"; asking for 3 must not render undefined.
|
||||
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
||||
});
|
||||
|
||||
it("takes extra variables alongside the count", () => {
|
||||
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
|
||||
.toBe("2 messages in Inbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tNode", () => {
|
||||
const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}</>);
|
||||
|
||||
it("keeps an element inside the sentence", () => {
|
||||
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
||||
.toBe("Open <code>mailto:</code> links here");
|
||||
});
|
||||
|
||||
it("lets a translator move the element", () => {
|
||||
// Splitting the sentence into two t() calls could not do this: the
|
||||
// fragments would render in the English order whatever the catalogue said.
|
||||
setCatalog("de", de);
|
||||
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
||||
.toBe("<code>mailto:</code>-Links hier öffnen");
|
||||
});
|
||||
|
||||
it("leaves a placeholder alone when nothing is supplied for it", () => {
|
||||
expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
|
||||
});
|
||||
|
||||
it("takes plain variables alongside elements", () => {
|
||||
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCatalog", () => {
|
||||
it("does not announce a change that did not happen", () => {
|
||||
/*
|
||||
* The root keys its tree on the language version, so every publish
|
||||
* remounts the app -- which re-runs the effect that loads the account's
|
||||
* settings, which calls applyLang, which lands back in setCatalog with the
|
||||
* same language. Publishing that non-change looped for ever, and from the
|
||||
* outside it looked like the message list refreshing without end.
|
||||
*/
|
||||
const seen: number[] = [];
|
||||
const stop = subscribeForTest(() => seen.push(1));
|
||||
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
|
||||
setCatalog("de", cat);
|
||||
setCatalog("de", cat);
|
||||
setCatalog("de", cat);
|
||||
expect(seen.length).toBe(1);
|
||||
setCatalog("en", { strings: {}, plurals: {} });
|
||||
expect(seen.length).toBe(2);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tc", () => {
|
||||
it("tells apart an English word doing two jobs", () => {
|
||||
// "Archive" is the button and the folder; German wants a different word
|
||||
// for each, and one key cannot hold both.
|
||||
setCatalog("de", {
|
||||
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
|
||||
plurals: {},
|
||||
});
|
||||
expect(t("Archive")).toBe("Archivieren");
|
||||
expect(tc("folder", "Archive")).toBe("Archiv");
|
||||
});
|
||||
|
||||
it("falls back to the plain translation, then to English", () => {
|
||||
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
|
||||
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
|
||||
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
|
||||
|
||||
const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
|
||||
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
|
||||
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
describe("unfold", () => {
|
||||
it("joins a continuation with nothing between, per the RFC", () => {
|
||||
expect(unfold("SUMMARY:A very\r\n long title")).toEqual(["SUMMARY:A very long title"]);
|
||||
expect(unfold("SUMMARY:A\r\n\tB")).toEqual(["SUMMARY:AB"]);
|
||||
});
|
||||
|
||||
it("handles all three line endings", () => {
|
||||
expect(unfold("A\r\nB\nC\rD")).toEqual(["A", "B", "C", "D"]);
|
||||
});
|
||||
|
||||
it("does not treat a leading space on the first line as a continuation", () => {
|
||||
expect(unfold(" oops")).toEqual([" oops"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLine", () => {
|
||||
it("splits a plain property", () => {
|
||||
expect(parseLine("SUMMARY:Standup")).toEqual({ name: "SUMMARY", params: {}, value: "Standup" });
|
||||
});
|
||||
|
||||
it("reads parameters", () => {
|
||||
expect(parseLine("DTSTART;VALUE=DATE:20260904")).toEqual({
|
||||
name: "DTSTART",
|
||||
params: { VALUE: "DATE" },
|
||||
value: "20260904",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a colon inside a quoted parameter, which is a real shape", () => {
|
||||
// A naive indexOf(":") reads this as a property called DTSTART;TZID="GMT+01
|
||||
const line = parseLine('DTSTART;TZID="GMT+01:00":20260904T140000');
|
||||
expect(line?.name).toBe("DTSTART");
|
||||
expect(line?.value).toBe("20260904T140000");
|
||||
expect(line?.params.TZID).toBe("GMT+01:00");
|
||||
});
|
||||
|
||||
it("uppercases the name, since the RFC does not require any particular case", () => {
|
||||
expect(parseLine("summary:x")?.name).toBe("SUMMARY");
|
||||
});
|
||||
|
||||
it("says nothing about a line with no colon", () => {
|
||||
expect(parseLine("NONSENSE")).toBeNull();
|
||||
expect(parseLine("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unescapeText", () => {
|
||||
it("undoes the four escapes and leaves everything else", () => {
|
||||
expect(unescapeText("a\\nb")).toBe("a\nb");
|
||||
expect(unescapeText("a\\Nb")).toBe("a\nb");
|
||||
expect(unescapeText("a\\,b\\;c")).toBe("a,b;c");
|
||||
expect(unescapeText("a\\\\b")).toBe("a\\b");
|
||||
expect(unescapeText("100% \\real")).toBe("100% \\real");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDateValue", () => {
|
||||
it("reads a date as all-day in local time, not UTC midnight", () => {
|
||||
// UTC midnight lands on the day before for anyone west of Greenwich.
|
||||
const out = parseDateValue("20260904");
|
||||
expect(out?.allDay).toBe(true);
|
||||
expect(ymd(out!.date)).toBe("2026-09-04");
|
||||
expect(hhmm(out!.date)).toBe("00:00");
|
||||
});
|
||||
|
||||
it("respects VALUE=DATE even on a longer string", () => {
|
||||
expect(parseDateValue("20260904", { VALUE: "DATE" })?.allDay).toBe(true);
|
||||
});
|
||||
|
||||
it("reads a UTC instant", () => {
|
||||
const out = parseDateValue("20260904T140000Z");
|
||||
expect(out?.allDay).toBe(false);
|
||||
expect(out?.date.toISOString()).toBe("2026-09-04T14:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reads a floating wall clock as local time", () => {
|
||||
const out = parseDateValue("20260904T140000");
|
||||
expect(out?.allDay).toBe(false);
|
||||
expect(hhmm(out!.date)).toBe("14:00");
|
||||
expect(ymd(out!.date)).toBe("2026-09-04");
|
||||
});
|
||||
|
||||
it("says nothing about a value it cannot read", () => {
|
||||
expect(parseDateValue("not a date")).toBeNull();
|
||||
expect(parseDateValue("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIcsDuration", () => {
|
||||
it("reads the forms a DTEND substitute uses", () => {
|
||||
expect(parseIcsDuration("PT1H")).toBe(3600);
|
||||
expect(parseIcsDuration("PT30M")).toBe(1800);
|
||||
expect(parseIcsDuration("P1D")).toBe(86400);
|
||||
expect(parseIcsDuration("P1W")).toBe(604800);
|
||||
expect(parseIcsDuration("P1DT2H30M")).toBe(95400);
|
||||
expect(parseIcsDuration("-PT1H")).toBe(-3600);
|
||||
});
|
||||
|
||||
it("says nothing about nonsense", () => {
|
||||
expect(parseIcsDuration("1 hour")).toBeNull();
|
||||
expect(parseIcsDuration("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeCalendar", () => {
|
||||
it("recognises a calendar and rejects an error page", () => {
|
||||
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
|
||||
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIcs", () => {
|
||||
it("reads a timed event with a summary and an end", () => {
|
||||
const { events } = parseIcs(cal(event("UID:a@x\r\nSUMMARY:Standup\r\nDTSTART:20260904T090000Z\r\nDTEND:20260904T091500Z")));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.summary).toBe("Standup");
|
||||
expect(events[0]!.uid).toBe("a@x");
|
||||
expect(events[0]!.allDay).toBe(false);
|
||||
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(15 * 60_000);
|
||||
});
|
||||
|
||||
it("reads an all-day event", () => {
|
||||
const { events } = parseIcs(cal(event("UID:b@x\r\nSUMMARY:Holiday\r\nDTSTART;VALUE=DATE:20260904")));
|
||||
expect(events[0]!.allDay).toBe(true);
|
||||
expect(ymd(events[0]!.start)).toBe("2026-09-04");
|
||||
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(86400_000);
|
||||
});
|
||||
|
||||
it("takes DURATION when there is no DTEND", () => {
|
||||
const { events } = parseIcs(cal(event("UID:c@x\r\nDTSTART:20260904T090000Z\r\nDURATION:PT90M")));
|
||||
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(90 * 60_000);
|
||||
});
|
||||
|
||||
it("reads the calendar's own name where it gives one", () => {
|
||||
expect(parseIcs(cal(`X-WR-CALNAME:Team calendar\r\n${event("UID:d\r\nDTSTART:20260904T090000Z")}`)).name).toBe("Team calendar");
|
||||
});
|
||||
|
||||
it("unfolds a long summary before reading it", () => {
|
||||
const { events } = parseIcs(cal("BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260904T090000Z\r\nSUMMARY:A very\r\n long title\r\nEND:VEVENT"));
|
||||
expect(events[0]!.summary).toBe("A very long title");
|
||||
});
|
||||
|
||||
it("steps over components that are not events", () => {
|
||||
const doc = cal(`BEGIN:VTIMEZONE\r\nTZID:Europe/London\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n${event("UID:f\r\nSUMMARY:Real\r\nDTSTART:20260904T090000Z")}\r\nBEGIN:VTODO\r\nSUMMARY:Not an event\r\nEND:VTODO`);
|
||||
const { events } = parseIcs(doc);
|
||||
expect(events.map((e) => e.summary)).toEqual(["Real"]);
|
||||
});
|
||||
|
||||
it("counts a recurring event once and does not expand it", () => {
|
||||
// Showing the wrong dates would be worse than showing the first and saying so.
|
||||
const { events, recurringCount } = parseIcs(cal(event("UID:g\r\nSUMMARY:Weekly\r\nDTSTART:20260904T090000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10")));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.recurring).toBe(true);
|
||||
expect(recurringCount).toBe(1);
|
||||
});
|
||||
|
||||
it("drops an event with no usable start rather than inventing a time", () => {
|
||||
const { events } = parseIcs(cal(event("UID:h\r\nSUMMARY:When?")));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an end that is before its start", () => {
|
||||
const { events } = parseIcs(cal(event("UID:i\r\nDTSTART:20260904T100000Z\r\nDTEND:20260904T090000Z")));
|
||||
expect(events[0]!.end.getTime()).toBeGreaterThanOrEqual(events[0]!.start.getTime());
|
||||
});
|
||||
|
||||
it("gives an event with no UID one of its own, so keys stay unique", () => {
|
||||
const { events } = parseIcs(cal(`${event("SUMMARY:One\r\nDTSTART:20260904T090000Z")}\r\n${event("SUMMARY:Two\r\nDTSTART:20260905T090000Z")}`));
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]!.uid).not.toBe(events[1]!.uid);
|
||||
});
|
||||
|
||||
it("reads several events, and survives an empty document", () => {
|
||||
const many = cal([1, 2, 3].map((n) => event(`UID:m${n}\r\nSUMMARY:E${n}\r\nDTSTART:2026090${n}T090000Z`)).join("\r\n"));
|
||||
expect(parseIcs(many).events.map((e) => e.summary)).toEqual(["E1", "E2", "E3"]);
|
||||
expect(parseIcs("").events).toEqual([]);
|
||||
expect(parseIcs("<!doctype html>").events).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toIcs, parseIcs } from "@/lib/ics";
|
||||
import type { JSCalendarEvent } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Writing iCalendar out of the server's RFC 8984 objects.
|
||||
*
|
||||
* The properties worth pinning are the ones where the two formats disagree, or
|
||||
* where getting it wrong shows up as a wrong time rather than as an error: how
|
||||
* a zone is said, what UNTIL is measured in, and where a changed occurrence
|
||||
* goes.
|
||||
*/
|
||||
|
||||
const base: JSCalendarEvent = {
|
||||
"@type": "Event", uid: "[email protected]", title: "Kickoff",
|
||||
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
|
||||
/*
|
||||
* From the first event onwards. The zone definitions above carry DTSTART and
|
||||
* TZNAME of their own, and a test asking "what is this event's DTSTART" must
|
||||
* not be answered by a transition rule.
|
||||
*/
|
||||
const eventLines = (e: JSCalendarEvent[]) => {
|
||||
const all = lines(e);
|
||||
return all.slice(all.indexOf("BEGIN:VEVENT"));
|
||||
};
|
||||
const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l) => l.startsWith(prefix));
|
||||
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||
|
||||
describe("the document around the events", () => {
|
||||
it("is a calendar a reader will recognise", () => {
|
||||
const l = lines([base]);
|
||||
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
||||
expect(l).toContain("VERSION:2.0");
|
||||
expect(l).toContain("END:VCALENDAR");
|
||||
expect(l.some((x) => x.startsWith("PRODID:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the calendar's name where a reader will look for it", () => {
|
||||
expect(lines([base], "Work")).toContain("X-WR-CALNAME:Work");
|
||||
});
|
||||
|
||||
it("ends every line the way the format requires", () => {
|
||||
expect(toIcs([base]).endsWith("\r\n")).toBe(true);
|
||||
expect(toIcs([base]).includes("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("times and zones", () => {
|
||||
it("names the zone rather than converting, so a series survives a DST change", () => {
|
||||
expect(one(base, "DTSTART")).toBe("DTSTART;TZID=Europe/Berlin:20260902T090000");
|
||||
});
|
||||
|
||||
it("writes UTC as UTC", () => {
|
||||
expect(one({ ...base, timeZone: "Etc/UTC" }, "DTSTART")).toBe("DTSTART:20260902T090000Z");
|
||||
});
|
||||
|
||||
it("leaves a floating time floating, with no zone at all", () => {
|
||||
// No zone means "whatever clock the reader is on", which is a real and
|
||||
// different thing from UTC -- a 09:00 alarm clock, not an instant.
|
||||
expect(one({ ...base, timeZone: null }, "DTSTART")).toBe("DTSTART:20260902T090000");
|
||||
});
|
||||
|
||||
it("writes an all-day event as a date, not as midnight", () => {
|
||||
const e = { ...base, showWithoutTime: true, duration: "P1D" };
|
||||
expect(one(e, "DTSTART")).toBe("DTSTART;VALUE=DATE:20260902");
|
||||
});
|
||||
|
||||
it("keeps the duration rather than working out an end", () => {
|
||||
expect(one(base, "DURATION")).toBe("DURATION:PT1H");
|
||||
});
|
||||
|
||||
it("says nothing about duration when the event has none", () => {
|
||||
expect(find([{ ...base, duration: undefined }], "DURATION")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurrence", () => {
|
||||
const weekly = { ...base, recurrenceRule: { frequency: "weekly" as const, byDay: [{ day: "we" as const }] } };
|
||||
|
||||
it("writes the rule rather than expanding it into a year of events", () => {
|
||||
expect(one(weekly, "RRULE")).toBe("RRULE:FREQ=WEEKLY;BYDAY=WE");
|
||||
expect(find([weekly], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads the array form as well as the single rule Stalwart stores", () => {
|
||||
const e = { ...base, recurrenceRules: [{ frequency: "monthly" as const, interval: 2, count: 5 }] };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5");
|
||||
});
|
||||
|
||||
it("measures UNTIL in UTC, so a series does not stop a day early elsewhere", () => {
|
||||
const e = { ...base, recurrenceRule: { frequency: "weekly" as const, until: "2026-12-30T09:00:00" } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=WEEKLY;UNTIL=20261230T090000Z");
|
||||
});
|
||||
|
||||
it("measures UNTIL as a date when the series is all-day", () => {
|
||||
const e = { ...base, showWithoutTime: true, recurrenceRule: { frequency: "daily" as const, until: "2026-12-30T00:00:00" } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=DAILY;UNTIL=20261230");
|
||||
});
|
||||
|
||||
it("keeps the nth-weekday form that BYDAY carries a number for", () => {
|
||||
const e = { ...base, recurrenceRule: { frequency: "monthly" as const, byDay: [{ day: "th" as const, nthOfPeriod: -1 }] } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
||||
});
|
||||
|
||||
it("turns a cancelled occurrence into an EXDATE", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats an override marked excluded the same way", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
});
|
||||
|
||||
it("gives a changed occurrence its own event, sharing the uid", () => {
|
||||
/*
|
||||
* Which is how iCalendar has always said it: the same UID, plus the
|
||||
* RECURRENCE-ID of the slot being replaced. The master keeps its rule and
|
||||
* the override must not.
|
||||
*/
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Kickoff (moved)" } } };
|
||||
const l = lines([e]);
|
||||
expect(l.filter((x) => x === "BEGIN:VEVENT")).toHaveLength(2);
|
||||
expect(l.filter((x) => x === "UID:[email protected]")).toHaveLength(2);
|
||||
expect(l).toContain("RECURRENCE-ID;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(l).toContain("SUMMARY:Kickoff (moved)");
|
||||
// One RRULE in the file, on the master.
|
||||
expect(l.filter((x) => x.startsWith("RRULE:"))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of an event", () => {
|
||||
it("escapes what the format uses as punctuation", () => {
|
||||
const e = { ...base, title: "Budget; Q4, final", description: "line one\nline two" };
|
||||
// Both escapes doubled here for JS's sake: what reaches the file is one
|
||||
// backslash before each of the two characters the format reserves.
|
||||
expect(one(e, "SUMMARY")).toBe("SUMMARY:Budget\\; Q4\\, final");
|
||||
expect(one(e, "DESCRIPTION")).toBe("DESCRIPTION:line one\\nline two");
|
||||
});
|
||||
|
||||
it("folds a long line rather than writing it past the limit", () => {
|
||||
const e = { ...base, title: "x".repeat(200) };
|
||||
for (const l of lines([e])) expect(l.length).toBeLessThanOrEqual(75);
|
||||
});
|
||||
|
||||
it("puts a room in LOCATION and a video link in URL", () => {
|
||||
// A meeting URL where a room name goes is what makes a printed agenda
|
||||
// useless, and they are different fields in both formats.
|
||||
const e = {
|
||||
...base,
|
||||
locations: { l1: { name: "Room 3" } },
|
||||
virtualLocations: { v1: { uri: "https://meet.example.org/abc" } },
|
||||
} as JSCalendarEvent;
|
||||
expect(one(e, "LOCATION")).toBe("LOCATION:Room 3");
|
||||
expect(one(e, "URL")).toBe("URL:https://meet.example.org/abc");
|
||||
});
|
||||
|
||||
it("maps the words the two formats spell differently", () => {
|
||||
const e = { ...base, status: "tentative" as const, privacy: "secret" as const, freeBusyStatus: "free" as const };
|
||||
expect(one(e, "STATUS")).toBe("STATUS:TENTATIVE");
|
||||
expect(one(e, "CLASS")).toBe("CLASS:CONFIDENTIAL");
|
||||
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
||||
});
|
||||
|
||||
it("writes the organiser and the guests, with what each answered", () => {
|
||||
const e = {
|
||||
...base,
|
||||
organizerCalendarAddress: "mailto:[email protected]",
|
||||
participants: {
|
||||
p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:[email protected]", participationStatus: "accepted" as const, expectReply: true },
|
||||
p2: { roles: { optional: true }, sendTo: { imip: "mailto:[email protected]" }, participationStatus: "needs-action" as const },
|
||||
},
|
||||
} as JSCalendarEvent;
|
||||
expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:[email protected]");
|
||||
const att = find([e], "ATTENDEE");
|
||||
expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:[email protected]");
|
||||
expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:[email protected]");
|
||||
});
|
||||
|
||||
it("skips a participant with no address at all rather than writing a broken line", () => {
|
||||
const e = { ...base, participants: { p1: { roles: { attendee: true }, name: "Nobody" } } } as JSCalendarEvent;
|
||||
expect(find([e], "ATTENDEE")).toEqual([]);
|
||||
});
|
||||
|
||||
it("nests an alarm inside the event it belongs to", () => {
|
||||
const e = { ...base, alerts: { a1: { trigger: { offset: "-PT15M" } } } } as JSCalendarEvent;
|
||||
const l = lines([e]);
|
||||
expect(l).toContain("BEGIN:VALARM");
|
||||
expect(l).toContain("TRIGGER:-PT15M");
|
||||
expect(l).toContain("ACTION:DISPLAY");
|
||||
expect(l.indexOf("BEGIN:VALARM")).toBeLessThan(l.indexOf("END:VEVENT"));
|
||||
});
|
||||
|
||||
it("says when an alarm hangs off the end rather than the start", () => {
|
||||
const e = { ...base, alerts: { a1: { trigger: { offset: "PT5M", relativeTo: "end" as const } } } } as JSCalendarEvent;
|
||||
expect(one(e, "TRIGGER")).toBe("TRIGGER;RELATED=END:PT5M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what comes back out of the parser", () => {
|
||||
/*
|
||||
* Not a full round trip -- the reader is a subscription parser and keeps far
|
||||
* less than the writer emits -- but what it does read should be what went in.
|
||||
*/
|
||||
it("reads back the events it wrote", () => {
|
||||
const two = [base, { ...base, uid: "[email protected]", title: "Retro", start: "2026-09-09T14:00:00" }];
|
||||
const back = parseIcs(toIcs(two));
|
||||
expect(back.events.map((e) => e.uid)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(back.events.map((e) => e.summary)).toEqual(["Kickoff", "Retro"]);
|
||||
});
|
||||
|
||||
it("reads back a title that needed escaping, unescaped", () => {
|
||||
const back = parseIcs(toIcs([{ ...base, title: "Budget; Q4, final" }]));
|
||||
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Time zone definitions.
|
||||
*
|
||||
* These exist because leaving them out was wrong, and measurably: ical.js --
|
||||
* Mozilla's library, the one Thunderbird's calendar uses -- reads a TZID with
|
||||
* nothing defining it as *floating*, so a 09:00 in Phoenix opened anywhere else
|
||||
* reads as 09:00 there. Seven hours out, silently, on every timed event.
|
||||
*/
|
||||
describe("the zones an export names", () => {
|
||||
const inZone = (uid: string, tz: string, start = "2026-09-02T09:00:00") =>
|
||||
({ ...base, uid, timeZone: tz, start }) as JSCalendarEvent;
|
||||
|
||||
it("defines every zone its events refer to", () => {
|
||||
const l = lines([inZone("a", "America/Phoenix"), inZone("b", "Asia/Tokyo")]);
|
||||
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(2);
|
||||
expect(l).toContain("TZID:America/Phoenix");
|
||||
expect(l).toContain("TZID:Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("defines a zone once however many events use it", () => {
|
||||
const l = lines([inZone("a", "Europe/Berlin"), inZone("b", "Europe/Berlin"), inZone("c", "Europe/Berlin")]);
|
||||
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says nothing about UTC, which needs no definition", () => {
|
||||
expect(lines([inZone("a", "Etc/UTC")]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("says nothing about an all-day event, which has no zone to define", () => {
|
||||
const e = { ...base, showWithoutTime: true, timeZone: "Europe/Berlin" } as JSCalendarEvent;
|
||||
expect(lines([e]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes a zone that never changes as one standing rule", () => {
|
||||
// Phoenix keeps MST all year: one sub-component, and the two offsets equal.
|
||||
const l = lines([inZone("a", "America/Phoenix")]);
|
||||
expect(l.filter((x) => x === "BEGIN:DAYLIGHT")).toHaveLength(0);
|
||||
expect(l.filter((x) => x === "BEGIN:STANDARD")).toHaveLength(1);
|
||||
expect(l).toContain("TZOFFSETFROM:-0700");
|
||||
expect(l).toContain("TZOFFSETTO:-0700");
|
||||
expect(l).toContain("TZNAME:MST");
|
||||
});
|
||||
|
||||
it("finds the transitions of a zone that does change", () => {
|
||||
const l = lines([inZone("a", "Europe/Berlin")]);
|
||||
// Both directions, and at the hours the EU actually changes at.
|
||||
expect(l).toContain("DTSTART:20260329T020000");
|
||||
expect(l).toContain("DTSTART:20261025T030000");
|
||||
const spring = l.indexOf("DTSTART:20260329T020000");
|
||||
expect(l[spring - 1]).toBe("BEGIN:DAYLIGHT");
|
||||
expect(l[spring + 1]).toBe("TZOFFSETFROM:+0100");
|
||||
expect(l[spring + 2]).toBe("TZOFFSETTO:+0200");
|
||||
});
|
||||
|
||||
it("covers years around the events rather than only the year they fall in", () => {
|
||||
// An open-ended weekly meeting outlives the year it was created in, so a
|
||||
// definition that stopped at that year would leave later occurrences
|
||||
// undefined.
|
||||
const l = lines([inZone("a", "Europe/Berlin")]);
|
||||
const years = new Set(l.filter((x) => x.startsWith("DTSTART:")).map((x) => x.slice(8, 12)));
|
||||
expect(years.size).toBeGreaterThan(5);
|
||||
expect([...years].some((y) => Number(y) > 2030)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out a zone name that only repeats the offset", () => {
|
||||
// Intl answers "GMT+9" for Tokyo, which says nothing TZOFFSETTO has not.
|
||||
const l = lines([inZone("a", "Asia/Tokyo")]);
|
||||
expect(l.some((x) => x.startsWith("TZNAME:GMT"))).toBe(false);
|
||||
expect(l).toContain("TZOFFSETTO:+0900");
|
||||
});
|
||||
|
||||
it("says nothing at all about a zone the browser does not know", () => {
|
||||
// Rather than writing a definition made up out of nothing. The TZID stays
|
||||
// on the event, which is where it was before any of this.
|
||||
const l = lines([inZone("a", "Mars/Olympus_Mons")]);
|
||||
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
|
||||
expect(l).toContain("DTSTART;TZID=Mars/Olympus_Mons:20260902T090000");
|
||||
});
|
||||
|
||||
it("puts the definitions before the events that use them", () => {
|
||||
const l = lines([inZone("a", "Europe/Berlin")]);
|
||||
expect(l.indexOf("BEGIN:VTIMEZONE")).toBeLessThan(l.indexOf("BEGIN:VEVENT"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
|
||||
|
||||
describe("idle sign-out on an untrusted device", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
stopIdleLogout();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("signs out after five minutes of nothing happening", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the clock again on any sign of a person", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires once, not repeatedly, and stops listening afterwards", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A late event must not resurrect a timer for a session that has ended.
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops cleanly, so a trusted sign-in is never signed out", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
stopIdleLogout();
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
|
||||
/*
|
||||
* Shortcuts after a click on a checkbox (#260).
|
||||
*
|
||||
* The guard that stops "a" archiving while you are typing into the search box
|
||||
* tested `tagName === "INPUT"`, which is also true of a checkbox. A checkbox
|
||||
* keeps focus after a click, so ticking "select all" disabled every shortcut
|
||||
* until the reader clicked somewhere else — and nothing about a checkbox
|
||||
* swallows a keystroke in the first place.
|
||||
*/
|
||||
|
||||
const pressFrom = (el: Element, key: string) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
let pop: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
pop?.();
|
||||
pop = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("isTextEntry", () => {
|
||||
const input = (type?: string) => {
|
||||
const el = document.createElement("input");
|
||||
if (type) el.setAttribute("type", type);
|
||||
return el;
|
||||
};
|
||||
|
||||
it("is false for the inputs you cannot type into", () => {
|
||||
for (const type of ["checkbox", "radio", "button", "submit", "reset", "file", "color", "range"]) {
|
||||
expect(isTextEntry(input(type)), type).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is true for the ones you can", () => {
|
||||
for (const type of ["text", "search", "email", "url", "tel", "password", "number", "date", "time"]) {
|
||||
expect(isTextEntry(input(type)), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an input with no type as text, which is what the browser does", () => {
|
||||
expect(isTextEntry(input())).toBe(true);
|
||||
});
|
||||
|
||||
it("covers textarea, select and contenteditable", () => {
|
||||
expect(isTextEntry(document.createElement("textarea"))).toBe(true);
|
||||
// A select takes letters too: typing jumps to the matching option, and a
|
||||
// shortcut would steal that.
|
||||
expect(isTextEntry(document.createElement("select"))).toBe(true);
|
||||
const div = document.createElement("div");
|
||||
div.contentEditable = "true";
|
||||
Object.defineProperty(div, "isContentEditable", { value: true });
|
||||
expect(isTextEntry(div)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a button and for nothing at all", () => {
|
||||
expect(isTextEntry(document.createElement("button"))).toBe(false);
|
||||
expect(isTextEntry(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcuts with a checkbox focused", () => {
|
||||
it("still fire — the reported bug", () => {
|
||||
const handler = vi.fn();
|
||||
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
|
||||
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
document.body.appendChild(box);
|
||||
box.focus();
|
||||
|
||||
pressFrom(box, "e");
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still do not fire from a text field", () => {
|
||||
const handler = vi.fn();
|
||||
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
|
||||
|
||||
const field = document.createElement("input");
|
||||
field.type = "search";
|
||||
document.body.appendChild(field);
|
||||
field.focus();
|
||||
|
||||
pressFrom(field, "e");
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
*
|
||||
* "Go to folder" is `g o` while `o` on its own opens a conversation (#233), so
|
||||
* the whole feature rests on a pending prefix being tried before a bare key.
|
||||
* That was true when it was written and nothing said so out loud, which is the
|
||||
* kind of thing a later refactor quietly reverses.
|
||||
*/
|
||||
|
||||
const press = (key: string) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
let pop: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pop?.();
|
||||
pop = null;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("a sequence sharing its second key with a single binding", () => {
|
||||
it("runs the sequence, not the single key", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
press("o");
|
||||
expect(seq).toHaveBeenCalledOnce();
|
||||
expect(single).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the single key when no prefix is pending", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("o");
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forgets the prefix after a pause, so a later key means itself again", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
vi.advanceTimersByTime(2000);
|
||||
press("o");
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("swallows the prefix rather than letting it act on its own", () => {
|
||||
// `g` is not a binding by itself; pressing it must not fall through to
|
||||
// anything, or holding it would type into the page.
|
||||
const seq = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
]);
|
||||
const e = press("g");
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a key that completes no sequence still act as itself", () => {
|
||||
/*
|
||||
* `g` then `z`, where `g z` is nothing. The prefix is dropped and `z` runs
|
||||
* on that same press rather than being eaten — so a mistyped prefix costs
|
||||
* the prefix and not the keystroke after it.
|
||||
*/
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "z", description: "Zed", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
press("z");
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/labelTree";
|
||||
import type { Label } from "@/store/settings";
|
||||
|
||||
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
|
||||
|
||||
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
|
||||
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
|
||||
|
||||
describe("labelTree", () => {
|
||||
it("nests a label under its parent and indents it", () => {
|
||||
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(roots[0]!.label.keyword).toBe("work");
|
||||
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
|
||||
expect(roots[0]!.children[0]!.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("nests three deep", () => {
|
||||
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
|
||||
});
|
||||
|
||||
it("puts a label back at the top when its parent no longer exists", () => {
|
||||
// Settings sync between devices; a parent can be deleted on one while
|
||||
// another still points at it. Dropping the child would lose it for good.
|
||||
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
|
||||
});
|
||||
|
||||
it("survives a cycle rather than hanging", () => {
|
||||
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("survives a label parented to itself", () => {
|
||||
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("carries each label's own unread count, not its children's", () => {
|
||||
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
|
||||
expect(roots[0]!.unread).toBe(2);
|
||||
expect(roots[0]!.children[0]!.unread).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleLabels", () => {
|
||||
it("draws everything set to always", () => {
|
||||
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("never draws a hidden label", () => {
|
||||
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("draws an unread-only label just while it has unread mail", () => {
|
||||
const labels = [L("a", { visibility: "unread" })];
|
||||
expect(flat(labels, { a: 0 })).toEqual([]);
|
||||
expect(flat(labels, { a: 1 })).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
|
||||
// A child cannot be drawn under a parent that is not there, and promoting
|
||||
// it would silently rearrange the tree. The parent comes back as a
|
||||
// container instead.
|
||||
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
|
||||
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
|
||||
});
|
||||
|
||||
it("keeps a hidden parent too, when a child survives", () => {
|
||||
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
|
||||
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
|
||||
});
|
||||
|
||||
it("drops a whole branch when nothing in it survives", () => {
|
||||
const labels = [
|
||||
L("work", { visibility: "unread" }),
|
||||
L("work_urgent", { parent: "work", visibility: "unread" }),
|
||||
L("other"),
|
||||
];
|
||||
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
|
||||
});
|
||||
|
||||
it("keeps a grandparent when only a grandchild survives", () => {
|
||||
const labels = [
|
||||
L("a", { visibility: "hidden" }),
|
||||
L("b", { parent: "a", visibility: "hidden" }),
|
||||
L("c", { parent: "b" }),
|
||||
];
|
||||
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
|
||||
});
|
||||
|
||||
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
|
||||
const l = L("a");
|
||||
expect(l.visibility).toBeUndefined();
|
||||
expect(flat([l], {})).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descendantKeywords", () => {
|
||||
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
|
||||
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
|
||||
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
|
||||
expect([...descendantKeywords(roots, "d")]).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about a label that is not there", () => {
|
||||
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_UI_LANGUAGE, UI_LANGUAGES, resolveUiLanguage } from "@/lib/languages";
|
||||
import { DEFAULT_SETTINGS, acceptRemote } from "@/store/settings";
|
||||
|
||||
/**
|
||||
* The interface language decides what `<html lang>` claims, and a wrong claim
|
||||
* is exactly what makes Chrome offer to translate a page that needs no
|
||||
* translating — which is the offer that ends in a rewritten DOM and a crashed
|
||||
* component tree. So the resolution is deliberately narrow.
|
||||
*/
|
||||
describe("resolveUiLanguage", () => {
|
||||
it("is English when nothing has been chosen", () => {
|
||||
// The absent case covers both a new account and every settings file
|
||||
// written before this setting existed.
|
||||
expect(resolveUiLanguage(undefined)).toBe("en");
|
||||
expect(resolveUiLanguage(null)).toBe("en");
|
||||
expect(resolveUiLanguage("")).toBe("en");
|
||||
expect(DEFAULT_SETTINGS.uiLanguage).toBe(DEFAULT_UI_LANGUAGE);
|
||||
});
|
||||
|
||||
it("refuses a language whose strings are not shipped", () => {
|
||||
// The account travels between machines and can outlive a catalogue. A
|
||||
// page that says lang="fr" while rendering English is worse than one that
|
||||
// admits to English: it stops the reader translating it themselves.
|
||||
// Derived rather than named, so shipping another language does not turn
|
||||
// this into a failing test that is really just out of date.
|
||||
const unshipped = ["cy", "is", "mt", "eu"].find((tag) => !UI_LANGUAGES.some((l) => l.tag === tag))!;
|
||||
expect(resolveUiLanguage(unshipped)).toBe("en");
|
||||
expect(resolveUiLanguage("xx-XX")).toBe("en");
|
||||
});
|
||||
|
||||
it("carries the Beta flag until a person has signed the language off", () => {
|
||||
// Not a completeness measure. A catalogue can be word-for-word finished
|
||||
// and still read like a machine wrote it, which is what this marks.
|
||||
// Every shipped language except English is unreviewed, and stays marked
|
||||
// until a person says otherwise.
|
||||
for (const l of UI_LANGUAGES) {
|
||||
if (l.tag === "en") expect(l.beta).toBeUndefined();
|
||||
else expect(l.beta).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours one that is", () => {
|
||||
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
});
|
||||
|
||||
it("only offers languages that resolve to themselves", () => {
|
||||
// Guards the ordering mistake: adding a picker entry before its catalogue.
|
||||
for (const l of UI_LANGUAGES) {
|
||||
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
expect(l.name.trim()).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the account rather than the device", () => {
|
||||
// Language is a preference about the person, not the screen: it is not in
|
||||
// DEVICE_KEYS, so it rides in the settings file like the rest.
|
||||
expect(acceptRemote({ uiLanguage: "en" })).toEqual({ uiLanguage: "en" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLdif } from "@/lib/ldif";
|
||||
|
||||
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
|
||||
const SOGO = `dn: cn=Jane Doe
|
||||
objectClass: top
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: mozillaAbPersonAlpha
|
||||
givenName: Jane
|
||||
description: Description
|
||||
sn: Doe
|
||||
cn: Jane Doe
|
||||
mail: jane.doe@example.com
|
||||
telephoneNumber: +1-555-0199
|
||||
mobile: +1-555-0188
|
||||
mozillahomepostalcode: 10000
|
||||
c: ExampleCountry
|
||||
postalcode: 10000
|
||||
l: Examplecity
|
||||
mozillahomecountryname: ExampleCountry
|
||||
mozillahomelocalityname: Examplecity
|
||||
mozillahomestreet: Street Number
|
||||
street: Street Number
|
||||
`;
|
||||
|
||||
describe("parseLdif", () => {
|
||||
it("reads an entry and keeps repeated attributes in file order", () => {
|
||||
const [r] = parseLdif(SOGO);
|
||||
expect(r!.dn).toBe("cn=Jane Doe");
|
||||
expect(r!.attrs.cn).toEqual(["Jane Doe"]);
|
||||
expect(r!.attrs.objectclass).toEqual(["top", "inetOrgPerson", "mozillaAbPersonAlpha"]);
|
||||
expect(r!.attrs.mail).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("folds attribute names to one case, since exporters disagree", () => {
|
||||
const [r] = parseLdif("dn: cn=X\nMozillaHomeStreet: One\ntelephonenumber: 2\n");
|
||||
expect(r!.attrs.mozillahomestreet).toEqual(["One"]);
|
||||
expect(r!.attrs.telephonenumber).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("drops attribute options, keeping the attribute", () => {
|
||||
const [r] = parseLdif("dn: cn=X\nmail;pref: [email protected]\ncn;lang-de: Herr X\n");
|
||||
expect(r!.attrs.mail).toEqual(["[email protected]"]);
|
||||
expect(r!.attrs.cn).toEqual(["Herr X"]);
|
||||
});
|
||||
|
||||
it("splits entries on blank lines", () => {
|
||||
const two = parseLdif("dn: cn=One\ncn: One\n\ndn: cn=Two\ncn: Two\n");
|
||||
expect(two.map((r) => r.attrs.cn?.[0])).toEqual(["One", "Two"]);
|
||||
});
|
||||
|
||||
it("starts a new entry at a dn even without a blank line between", () => {
|
||||
const two = parseLdif("dn: cn=One\ncn: One\ndn: cn=Two\ncn: Two\n");
|
||||
expect(two).toHaveLength(2);
|
||||
expect(two[1]!.attrs.cn).toEqual(["Two"]);
|
||||
});
|
||||
|
||||
it("unfolds a value continued on the next line", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ndescription: this note runs on\n and on\n");
|
||||
expect(r!.attrs.description).toEqual(["this note runs on and on"]);
|
||||
});
|
||||
|
||||
it("decodes a base64 value, including one that is not ASCII", () => {
|
||||
// "Zoë Müller" in UTF-8, base64.
|
||||
const b64 = Buffer.from("Zoë Müller", "utf8").toString("base64");
|
||||
const [r] = parseLdif(`dn: cn=X\ncn:: ${b64}\n`);
|
||||
expect(r!.attrs.cn).toEqual(["Zoë Müller"]);
|
||||
});
|
||||
|
||||
it("drops a value that will not decode rather than the whole import", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ncn: Real Name\ndescription:: !!!not base64!!!\n");
|
||||
expect(r!.attrs.cn).toEqual(["Real Name"]);
|
||||
expect(r!.attrs.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a URL reference, which a browser reading one file cannot follow", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ncn: X\njpegPhoto:< file:///photos/x.jpg\n");
|
||||
expect(r!.attrs.jpegphoto).toBeUndefined();
|
||||
expect(r!.attrs.cn).toEqual(["X"]);
|
||||
});
|
||||
|
||||
it("ignores comments and the version header", () => {
|
||||
const rs = parseLdif("version: 1\n# exported by something\n# a comment\n that folds\n\ndn: cn=X\ncn: X\n");
|
||||
expect(rs).toHaveLength(1);
|
||||
expect(rs[0]!.attrs.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an add change record and drops the rest", () => {
|
||||
const rs = parseLdif(
|
||||
"dn: cn=Kept\nchangetype: add\ncn: Kept\n\ndn: cn=Gone\nchangetype: modify\ncn: Gone\n\ndn: cn=Also gone\nchangetype: delete\n",
|
||||
);
|
||||
expect(rs.map((r) => r.attrs.cn?.[0])).toEqual(["Kept"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a file that is not LDIF at all", () => {
|
||||
expect(parseLdif("this is a shopping list\nmilk\n")).toEqual([]);
|
||||
expect(parseLdif("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives CRLF, which is what a file from Windows arrives as", () => {
|
||||
const [r] = parseLdif("dn: cn=X\r\ncn: X\r\nsn: Y\r\n");
|
||||
expect(r!.attrs.cn).toEqual(["X"]);
|
||||
expect(r!.attrs.sn).toEqual(["Y"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowClick, type RowClick } from "@/lib/listSelection";
|
||||
|
||||
const IDS = ["a", "b", "c", "d", "e"];
|
||||
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||
rowClick({
|
||||
rowId: "c", ids: IDS, anchor: null, selected: {},
|
||||
modifiers: { shift: false, ctrl: false }, isMobile: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("a plain click", () => {
|
||||
it("opens the message rather than selecting it", () => {
|
||||
expect(click()).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("opens it even when another message is already open", () => {
|
||||
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("goes on selecting on a touchscreen once a selection exists", () => {
|
||||
// There is no modifier to hold on a phone, and opening a message in the
|
||||
// middle of picking several is almost never what the tap meant.
|
||||
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("still opens on a touchscreen when nothing is selected", () => {
|
||||
expect(click({ isMobile: true })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctrl-clicking", () => {
|
||||
it("takes the message that was already current with it", () => {
|
||||
// Issue #186: this used to select only the row clicked, leaving the open
|
||||
// message highlighted but unticked, so actions applied to one of two.
|
||||
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("toggles one row once there is a selection, and leaves the rest alone", () => {
|
||||
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
|
||||
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("selects just the row when there is nothing current to bring along", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not bring along a row that has scrolled out of the list", () => {
|
||||
// The anchor can name a message from a folder that is no longer shown.
|
||||
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not pair a row with itself", () => {
|
||||
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift-clicking", () => {
|
||||
it("takes the whole run, including the row it started from", () => {
|
||||
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("works the same way backwards", () => {
|
||||
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("leaves the anchor where it is, so the range grows from one place", () => {
|
||||
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
|
||||
expect(first).toMatchObject({ moveAnchor: false });
|
||||
// Extending again still starts at "a" rather than at "c".
|
||||
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
|
||||
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
|
||||
});
|
||||
|
||||
it("falls back to opening when there is nothing to extend from", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("falls back when the anchor is no longer in the list", () => {
|
||||
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two rules agree with each other", () => {
|
||||
it("both include the row the selection started from", () => {
|
||||
// The bug was that only one of them did. Whatever else changes, a modifier
|
||||
// click that begins a selection has to contain the anchor.
|
||||
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
|
||||
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
|
||||
for (const result of [withCtrl, withShift]) {
|
||||
expect(result.kind, JSON.stringify(result)).toBe("select");
|
||||
expect((result as { ids: string[] }).ids).toContain("b");
|
||||
expect((result as { ids: string[] }).ids).toContain("d");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appliesTo, comparatorsFor, isOptionalSort, MAX_LEVELS, withoutOptionalSorts } from "@/lib/listSort";
|
||||
|
||||
describe("comparatorsFor, presets", () => {
|
||||
it("puts newest first by default, and can reverse it", () => {
|
||||
expect(comparatorsFor("newest")).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||
// No tiebreak appended: receivedAt already *is* the tiebreaker, and adding
|
||||
// a contradictory second one after it would say nothing.
|
||||
expect(comparatorsFor("oldest")).toEqual([{ property: "receivedAt", isAscending: true }]);
|
||||
});
|
||||
|
||||
it("sorts unread first as $seen ASCENDING, because false sorts before true", () => {
|
||||
// Getting this backwards puts exactly the mail you were looking for at the
|
||||
// bottom, which is why it is asserted rather than assumed.
|
||||
expect(comparatorsFor("unreadFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$seen", isAscending: true });
|
||||
});
|
||||
|
||||
it("sorts starred first as $flagged DESCENDING, which is the other way round", () => {
|
||||
expect(comparatorsFor("starredFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$flagged", isAscending: false });
|
||||
});
|
||||
|
||||
it("handles the plain field presets", () => {
|
||||
expect(comparatorsFor("largest")[0]).toEqual({ property: "size", isAscending: false });
|
||||
expect(comparatorsFor("sender")[0]).toEqual({ property: "from", isAscending: true });
|
||||
expect(comparatorsFor("subject")[0]).toEqual({ property: "subject", isAscending: true });
|
||||
});
|
||||
|
||||
it("falls back to newest for a preset it does not know", () => {
|
||||
expect(comparatorsFor("nonsense" as never)).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparatorsFor, the tiebreak", () => {
|
||||
it("always ends newest-first, so a tie does not shuffle between loads", () => {
|
||||
for (const p of ["unreadFirst", "starredFirst", "largest", "sender", "subject"] as const) {
|
||||
const out = comparatorsFor(p);
|
||||
expect(out[out.length - 1]).toEqual({ property: "receivedAt", isAscending: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add a second one when the sort already ends on receivedAt", () => {
|
||||
expect(comparatorsFor("newest")).toHaveLength(1);
|
||||
expect(comparatorsFor("oldest")).toHaveLength(1);
|
||||
expect(comparatorsFor("custom", [{ field: "date", descending: false }])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparatorsFor, custom levels", () => {
|
||||
it("keeps the levels in the order given", () => {
|
||||
const out = comparatorsFor("custom", [
|
||||
{ field: "starred", descending: true },
|
||||
{ field: "unread", descending: true },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ property: "hasKeyword", keyword: "$flagged", isAscending: false },
|
||||
{ property: "hasKeyword", keyword: "$seen", isAscending: true },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes at most three, since past that nobody can predict the result", () => {
|
||||
const out = comparatorsFor("custom", [
|
||||
{ field: "starred", descending: true },
|
||||
{ field: "unread", descending: true },
|
||||
{ field: "from", descending: false },
|
||||
{ field: "size", descending: true },
|
||||
]);
|
||||
expect(out.filter((c) => c.property === "size")).toEqual([]);
|
||||
expect(out).toHaveLength(MAX_LEVELS + 1); // three levels plus the tiebreak
|
||||
});
|
||||
|
||||
it("drops a field repeated at two levels, which can only be a mistake", () => {
|
||||
const out = comparatorsFor("custom", [
|
||||
{ field: "from", descending: false },
|
||||
{ field: "from", descending: true },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ property: "from", isAscending: true },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the tiebreak alone when no levels were given", () => {
|
||||
expect(comparatorsFor("custom", [])).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||
});
|
||||
|
||||
it("reverses a date level without losing the tiebreak", () => {
|
||||
const out = comparatorsFor("custom", [{ field: "sent", descending: false }]);
|
||||
expect(out).toEqual([
|
||||
{ property: "sentAt", isAscending: true },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional sorts, which a server is allowed to refuse", () => {
|
||||
it("recognises the keyword properties", () => {
|
||||
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
||||
expect(isOptionalSort({ property: "size" })).toBe(false);
|
||||
});
|
||||
|
||||
it("strips them, leaving something the server must accept", () => {
|
||||
const out = withoutOptionalSorts(comparatorsFor("custom", [
|
||||
{ field: "unread", descending: true },
|
||||
{ field: "size", descending: true },
|
||||
]));
|
||||
expect(out).toEqual([
|
||||
{ property: "size", isAscending: false },
|
||||
{ property: "receivedAt", isAscending: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still ends on the tiebreak when stripping removed everything else", () => {
|
||||
expect(withoutOptionalSorts(comparatorsFor("unreadFirst"))).toEqual([{ property: "receivedAt", isAscending: false }]);
|
||||
});
|
||||
|
||||
it("leaves a sort that was never optional alone", () => {
|
||||
const plain = comparatorsFor("largest");
|
||||
expect(withoutOptionalSorts(plain)).toEqual(plain);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appliesTo", () => {
|
||||
it("covers only the inbox on the narrow scope", () => {
|
||||
// Unread-first is what people want where they triage, and confusing in
|
||||
// Sent, where everything is read.
|
||||
expect(appliesTo("inbox", "inbox")).toBe(true);
|
||||
expect(appliesTo("inbox", "sent")).toBe(false);
|
||||
expect(appliesTo("inbox", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("covers everything on the wide one", () => {
|
||||
expect(appliesTo("all", "sent")).toBe(true);
|
||||
expect(appliesTo("all", null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Stalwart names the standard folders once, at account creation, and never
|
||||
* renames them — so a German reader on an English-provisioned account would
|
||||
* otherwise see "Deleted Items" in an otherwise German app. The role is what
|
||||
* lets ihasmail say "Papierkorb" without writing anything to the server.
|
||||
*/
|
||||
const de: Catalog = {
|
||||
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
|
||||
plurals: {},
|
||||
};
|
||||
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
|
||||
({ id, name, role, parentId } as unknown as Mailbox);
|
||||
|
||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||
|
||||
describe("mailboxDisplayName", () => {
|
||||
it("is the server's name until a catalogue says otherwise", () => {
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||
});
|
||||
|
||||
it("follows the interface language for a folder carrying a role", () => {
|
||||
setCatalog("de", de);
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
|
||||
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
|
||||
});
|
||||
|
||||
it("leaves a folder somebody made alone", () => {
|
||||
// "Newsletters" is their word. Translating it would name a folder they
|
||||
// never created, and it would not match what any other client shows.
|
||||
setCatalog("de", de);
|
||||
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
|
||||
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
|
||||
});
|
||||
|
||||
it("survives a missing mailbox rather than printing undefined", () => {
|
||||
expect(mailboxDisplayName(null)).toBe("");
|
||||
expect(mailboxDisplayName(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalisedName", () => {
|
||||
it("tells an editor when the name on screen is not the server's", () => {
|
||||
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||
// the moment somebody pressed Save — a real change made by accident.
|
||||
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailboxDisplayPath", () => {
|
||||
it("localises each part that has a role and leaves the rest", () => {
|
||||
setCatalog("de", de);
|
||||
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||
});
|
||||
|
||||
it("stops rather than looping on a parent cycle", () => {
|
||||
// A malformed tree from the server must not hang the folder picker.
|
||||
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
|
||||
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
|
||||
describe("isMarkdown", () => {
|
||||
it("takes the type when there is one", () => {
|
||||
expect(isMarkdown("text/markdown", "a")).toBe(true);
|
||||
expect(isMarkdown("text/x-markdown; charset=utf-8", "a")).toBe(true);
|
||||
expect(isMarkdown("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the name, which is the usual case for an upload", () => {
|
||||
expect(isMarkdown("application/octet-stream", "README.md")).toBe(true);
|
||||
expect(isMarkdown("application/octet-stream", "NOTES.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdown(null, "changelog.mkd")).toBe(true);
|
||||
expect(isMarkdown(null, "readme.txt")).toBe(false);
|
||||
expect(isMarkdown(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("renders the ordinary things", () => {
|
||||
const html = renderMarkdown("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("renders GitHub tables and fenced code", () => {
|
||||
const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nconst x = 1;\n```\n");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<pre>");
|
||||
});
|
||||
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitiser.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("does not keep a javascript: link", () => {
|
||||
const html = renderMarkdown("[click](javascript:alert(1))");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("shows an image as a link instead of fetching it", () => {
|
||||
// A remote image in a file is a tracking pixel by another name; this app
|
||||
// blocks those in mail and does not undo that here.
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain('class="md-img"');
|
||||
expect(html).toContain("a diagram");
|
||||
expect(html).toContain("https://tracker.example/px.png");
|
||||
});
|
||||
|
||||
it("keeps a relative image visible even though it cannot resolve", () => {
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("local");
|
||||
// Nothing to link to, so it is text rather than a dead link.
|
||||
expect(html).not.toContain('href="./diagram.png"');
|
||||
});
|
||||
|
||||
it("sends links out of the app safely", () => {
|
||||
const html = renderMarkdown("[docs](https://docs.ihasmail.org)");
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLdif } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const card = (ldif: string) => cardFromLdif(parseLdif(ldif)[0]!);
|
||||
const values = <T,>(m: Record<string, T> | undefined) => Object.values(m ?? {});
|
||||
/** Address components as `kind: value`, which is easier to assert than the array. */
|
||||
const parts = (a: NonNullable<ContactCard["addresses"]>[string]) => (a.components ?? []).map((c) => `${c.kind}: ${c.value}`);
|
||||
|
||||
/** The entry from issue #174, exactly as SOGo wrote it. */
|
||||
const JANE = `dn: cn=Jane Doe
|
||||
objectClass: top
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: mozillaAbPersonAlpha
|
||||
givenName: Jane
|
||||
description: Description
|
||||
sn: Doe
|
||||
cn: Jane Doe
|
||||
mail: jane.doe@example.com
|
||||
telephoneNumber: +1-555-0199
|
||||
mobile: +1-555-0188
|
||||
mozillahomepostalcode: 10000
|
||||
c: ExampleCountry
|
||||
postalcode: 10000
|
||||
l: Examplecity
|
||||
mozillahomecountryname: ExampleCountry
|
||||
mozillahomelocalityname: Examplecity
|
||||
mozillahomestreet: Street Number
|
||||
street: Street Number
|
||||
`;
|
||||
|
||||
describe("the entry from the issue", () => {
|
||||
const c = card(JANE)!;
|
||||
|
||||
it("becomes a person with a name", () => {
|
||||
expect(c.kind).toBe("individual");
|
||||
expect(c.name?.full).toBe("Jane Doe");
|
||||
expect(c.name?.components).toEqual([
|
||||
{ "@type": "NameComponent", kind: "given", value: "Jane" },
|
||||
{ "@type": "NameComponent", kind: "surname", value: "Doe" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the address, marked as the one to use", () => {
|
||||
const emails = values(c.emails);
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]).toMatchObject({ address: "[email protected]", pref: 1 });
|
||||
});
|
||||
|
||||
it("tells the work phone from the mobile", () => {
|
||||
const phones = values(c.phones);
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0199", contexts: { work: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0188", features: { mobile: true } }));
|
||||
});
|
||||
|
||||
it("splits the two addresses the schema keeps apart", () => {
|
||||
const addrs = values(c.addresses);
|
||||
expect(addrs).toHaveLength(2);
|
||||
const work = addrs.find((a) => a.contexts?.work)!;
|
||||
const home = addrs.find((a) => a.contexts?.private)!;
|
||||
expect(parts(work)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
|
||||
expect(parts(home)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
|
||||
});
|
||||
|
||||
it("keeps the description as the note", () => {
|
||||
expect(values(c.notes)[0]?.note).toBe("Description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of the schema", () => {
|
||||
it("reads the second email, after the first", () => {
|
||||
const c = card("dn: cn=X\nmail: [email protected]\nmozillaSecondEmail: [email protected]\n")!;
|
||||
const emails = values(c.emails);
|
||||
expect(emails.map((e) => e.address)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(emails[0]!.pref).toBe(1);
|
||||
expect(emails[1]!.pref).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads every kind of phone the schema has", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nhomePhone: 1\nfacsimileTelephoneNumber: 2\npager: 3\n")!;
|
||||
const phones = values(c.phones);
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "1", contexts: { private: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "2", features: { fax: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
||||
});
|
||||
|
||||
it("reads the organisation, its units and the job title", () => {
|
||||
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
||||
expect(values(c.organizations)[0]).toMatchObject({
|
||||
name: "Example Corp",
|
||||
units: [{ "@type": "OrgUnit", name: "Research" }, { "@type": "OrgUnit", name: "Optics" }],
|
||||
});
|
||||
expect(values(c.titles)[0]).toMatchObject({ name: "Lens Grinder", kind: "title" });
|
||||
});
|
||||
|
||||
it("reads the nickname, the web pages and the messaging handle", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nmozillaNickname: Zed\nmozillaWorkUrl: https://work.example\nmozillaHomeUrl: https://home.example\nnsAIMid: zedzed\n")!;
|
||||
expect(values(c.nicknames)[0]?.name).toBe("Zed");
|
||||
expect(values(c.links).map((l) => l.uri)).toEqual(["https://work.example", "https://home.example"]);
|
||||
expect(values(c.onlineServices)[0]).toMatchObject({ service: "AIM", user: "zedzed" });
|
||||
});
|
||||
|
||||
it("keeps both street lines and the post office box", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nstreet: 1 Long Road\nmozillaWorkStreet2: Floor 4\npostOfficeBox: PO 12\n")!;
|
||||
expect(parts(values(c.addresses)[0]!)).toEqual([
|
||||
"name: 1 Long Road",
|
||||
"name: Floor 4",
|
||||
"postOfficeBox: PO 12",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the custom fields in the note rather than dropping them", () => {
|
||||
const c = card("dn: cn=X\ncn: X\ndescription: A note\nmozillaCustom1: Met at a conference\nmozillaCustom3: Renewal in May\n")!;
|
||||
expect(values(c.notes)[0]?.note).toBe("A note\nCustom 1: Met at a conference\nCustom 3: Renewal in May");
|
||||
});
|
||||
|
||||
it("prefers the directory's own rendering of a name when it differs", () => {
|
||||
// "Doe, Jane" is not what the parts put back together, and is what the
|
||||
// export meant to display.
|
||||
const c = card("dn: cn=Doe, Jane\ngivenName: Jane\nsn: Doe\ncn: Doe, Jane\n")!;
|
||||
expect(c.name?.full).toBe("Doe, Jane");
|
||||
expect(c.name?.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("takes displayName over cn, which is what Thunderbird shows", () => {
|
||||
const c = card("dn: cn=X\ncn: Robert Smith\ndisplayName: Bob\n")!;
|
||||
expect(c.name?.full).toBe("Bob");
|
||||
});
|
||||
|
||||
it("manages an entry that is only an address", () => {
|
||||
const c = card("dn: cn=X\nmail: [email protected]\n")!;
|
||||
expect(c.name).toBeUndefined();
|
||||
expect(values(c.emails)[0]?.address).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("refuses an entry with neither a name nor an address", () => {
|
||||
expect(card("dn: cn=X\nobjectClass: top\ntelephoneNumber: 1\n")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves out every section the entry said nothing about", () => {
|
||||
const c = card("dn: cn=X\ncn: X\n")!;
|
||||
for (const empty of ["emails", "phones", "addresses", "links", "notes", "organizations", "titles", "nicknames", "onlineServices"] as const) {
|
||||
expect(c[empty], empty).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectiveMode, legacyTheme, migrateTheme, paletteMeta, PALETTES, toggleTarget } from "@/lib/palette";
|
||||
|
||||
describe("the palettes themselves", () => {
|
||||
it("has a light and a dark half for every one of them", () => {
|
||||
// The reason there is no "this palette is dark only" machinery: there is
|
||||
// no such palette. ihasmail's own gained a light half, and the override,
|
||||
// the toggle's memory and a greyed-out control all went with it.
|
||||
expect(PALETTES.map((p) => p.id)).toEqual(["default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night"]);
|
||||
});
|
||||
|
||||
it("credits every borrowed palette and neither of ihasmail's own", () => {
|
||||
for (const p of PALETTES) {
|
||||
if (p.id === "default" || p.id === "ihasmail") expect(p.credit).toBeUndefined();
|
||||
else expect(p.credit).toMatch(/MIT/);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the default for an id it does not know", () => {
|
||||
expect(paletteMeta("nonsense").id).toBe("default");
|
||||
expect(paletteMeta(null).id).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveMode", () => {
|
||||
it("follows the system when asked to", () => {
|
||||
expect(effectiveMode("system", true)).toBe("dark");
|
||||
expect(effectiveMode("system", false)).toBe("light");
|
||||
});
|
||||
|
||||
it("takes an explicit mode over the system", () => {
|
||||
expect(effectiveMode("light", true)).toBe("light");
|
||||
expect(effectiveMode("dark", false)).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateTheme, which has to keep working indefinitely", () => {
|
||||
it("reads every value the old enum could hold", () => {
|
||||
expect(migrateTheme("ihasmail")).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||
expect(migrateTheme("light")).toEqual({ palette: "default", mode: "light" });
|
||||
expect(migrateTheme("dark")).toEqual({ palette: "default", mode: "dark" });
|
||||
expect(migrateTheme("system")).toEqual({ palette: "default", mode: "system" });
|
||||
});
|
||||
|
||||
it("gives a new account what it would have got anyway", () => {
|
||||
// Absent, unknown, or written by something newer.
|
||||
for (const v of [undefined, null, "", "gruvbox-ish", "whatever"]) {
|
||||
expect(migrateTheme(v)).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacyTheme, read by a device still on an older build", () => {
|
||||
it("round-trips the four values the old enum had", () => {
|
||||
for (const v of ["ihasmail", "light", "dark", "system"] as const) {
|
||||
expect(legacyTheme(migrateTheme(v))).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("expresses a new palette as the light or dark it actually is", () => {
|
||||
// It cannot say "Gruvbox", but it can say dark, which is the half that
|
||||
// stops an older device showing a theme nobody chose.
|
||||
expect(legacyTheme({ palette: "gruvbox", mode: "dark" })).toBe("dark");
|
||||
expect(legacyTheme({ palette: "rose-pine", mode: "light" })).toBe("light");
|
||||
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, true)).toBe("dark");
|
||||
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, false)).toBe("light");
|
||||
// ihasmail's light half is new and has no old name, so an older build is
|
||||
// told "light" rather than being handed a word it would read as dark.
|
||||
expect(legacyTheme({ palette: "ihasmail", mode: "light" })).toBe("light");
|
||||
expect(legacyTheme({ palette: "ihasmail", mode: "dark" })).toBe("ihasmail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTarget", () => {
|
||||
it("flips the mode and keeps the colours, whatever the palette", () => {
|
||||
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
|
||||
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
|
||||
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the system when the mode is system", () => {
|
||||
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
|
||||
expect(toggleTarget({ palette: "default", mode: "system" }, false).mode).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openableInTab, previewKind } from "@/lib/preview";
|
||||
|
||||
describe("previewKind", () => {
|
||||
it("goes by the declared type when there is one", () => {
|
||||
expect(previewKind("image/png", "photo.png")).toBe("image");
|
||||
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
|
||||
expect(previewKind("text/plain", "notes.txt")).toBe("text");
|
||||
expect(previewKind("text/markdown", "README.md")).toBe("text");
|
||||
expect(previewKind("application/json", "data.json")).toBe("text");
|
||||
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
|
||||
});
|
||||
|
||||
it("falls back to the name when the type is a generic wrapper", () => {
|
||||
// What an upload gets when the browser cannot guess -- files.ts stores
|
||||
// `f.type || "application/octet-stream"`, so this is the common case for
|
||||
// anything unusual, and it is what made the old exact-type check useless
|
||||
// on real uploads.
|
||||
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
|
||||
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
|
||||
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
|
||||
expect(previewKind("", "notes.txt")).toBe("text");
|
||||
expect(previewKind(null, "deploy.sh")).toBe("text");
|
||||
expect(previewKind(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let the name override a type the server was specific about", () => {
|
||||
// A .txt served as a zip is a zip. Guessing from the name here would be
|
||||
// taking the sender's word for the extension over the server's for the
|
||||
// bytes.
|
||||
expect(previewKind("application/zip", "archive.txt")).toBeNull();
|
||||
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves SVG alone", () => {
|
||||
// It carries script and the server refuses to serve it inline; it stays a
|
||||
// download until that is decided deliberately.
|
||||
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
|
||||
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("has nothing to show for the rest", () => {
|
||||
expect(previewKind("application/zip", "backup.zip")).toBeNull();
|
||||
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openableInTab", () => {
|
||||
/*
|
||||
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
|
||||
* "open in a new tab" button silently starts downloading instead, because
|
||||
* the server sends Content-Disposition: attachment for anything not on its
|
||||
* list. These cases are the list.
|
||||
*/
|
||||
it("matches what the server will serve inline", () => {
|
||||
expect(openableInTab("image/png")).toBe(true);
|
||||
expect(openableInTab("video/mp4")).toBe(true);
|
||||
expect(openableInTab("audio/mpeg")).toBe(true);
|
||||
expect(openableInTab("application/pdf")).toBe(true);
|
||||
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
|
||||
expect(openableInTab("text/calendar")).toBe(true);
|
||||
expect(openableInTab("text/vcard")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses what the server will not", () => {
|
||||
expect(openableInTab("image/svg+xml")).toBe(false);
|
||||
expect(openableInTab("text/html")).toBe(false);
|
||||
expect(openableInTab("text/markdown")).toBe(false);
|
||||
expect(openableInTab("application/json")).toBe(false);
|
||||
expect(openableInTab("application/octet-stream")).toBe(false);
|
||||
expect(openableInTab(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is narrower than what we can show ourselves", () => {
|
||||
// Markdown is the case that proves the two questions are different: the
|
||||
// dialog reads it with fetch, which ignores Content-Disposition.
|
||||
expect(previewKind("text/markdown", "README.md")).toBe("text");
|
||||
expect(openableInTab("text/markdown")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withBase, BASE_PATH } from "@/lib/basePath";
|
||||
|
||||
/**
|
||||
* The verification code a push subscription needs is written by the service
|
||||
* worker when no tab is open, and collected by the next tab to start. Both
|
||||
* sides have to name the same cache entry.
|
||||
*
|
||||
* A relative key does not do that. It is resolved against the URL of whoever
|
||||
* is asking: the worker lives at `<base>/sw.js`, so it wrote under `<base>/…`,
|
||||
* while a tab at `/mail/inbox/abc` looked under `/mail/inbox/…`. They agreed
|
||||
* only when the open page happened to be the root — and a subscription that
|
||||
* never gets its code back stays silent, which is indistinguishable from push
|
||||
* simply not working.
|
||||
*
|
||||
* These tests pin the shape of the key rather than the plumbing: what matters
|
||||
* is that it is absolute and anchored to the mount, so it cannot vary with the
|
||||
* route.
|
||||
*/
|
||||
|
||||
const KEY = "/ihasmail-push-verification";
|
||||
|
||||
describe("the push verification cache key", () => {
|
||||
it("is absolute, so it does not depend on which page is open", () => {
|
||||
expect(withBase(KEY).startsWith("/")).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same string wherever it is asked for", () => {
|
||||
// The bug was that this was not true: the page and the worker each
|
||||
// resolved a relative key against their own URL.
|
||||
expect(withBase(KEY)).toBe(withBase(KEY));
|
||||
});
|
||||
|
||||
it("is anchored to the mount, which is what the worker anchors to", () => {
|
||||
// The worker builds `${BASE}/ihasmail-push-verification`, where BASE comes
|
||||
// from `new URL("./", self.location)` — the same mount this derives from.
|
||||
expect(withBase(KEY)).toBe(`${BASE_PATH}${KEY}`);
|
||||
});
|
||||
|
||||
it("carries no route in it", () => {
|
||||
for (const route of ["mail", "inbox", "calendar", "settings"]) {
|
||||
expect(withBase(KEY)).not.toContain(`/${route}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, syncedPart, type Settings } from "@/store/settings";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
||||
import { isAppFolder } from "../appFolder";
|
||||
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
||||
|
||||
/**
|
||||
* Settings used to live only in localStorage, so nothing followed the user
|
||||
@@ -42,20 +43,27 @@ describe("which settings follow the account", () => {
|
||||
});
|
||||
|
||||
describe("applying a settings file", () => {
|
||||
/*
|
||||
* A file carrying the old `theme` and no palette is read through the old
|
||||
* enum, so these gain the two fields it resolves to. That is the migration,
|
||||
* not a leak: see the palette tests for the rule itself.
|
||||
*/
|
||||
const MIGRATED_DARK = { theme: "dark", palette: "default", mode: "dark" };
|
||||
|
||||
it("takes known, non-device keys", () => {
|
||||
const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" });
|
||||
expect(applied).toEqual({ theme: "dark", weekStart: 0, locale: "de-DE" });
|
||||
expect(applied).toEqual({ ...MIGRATED_DARK, weekStart: 0, locale: "de-DE" });
|
||||
});
|
||||
|
||||
it("ignores keys it has never heard of", () => {
|
||||
// A newer ihasmail's settings, or a hand-edited file.
|
||||
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual({ theme: "dark" });
|
||||
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual(MIGRATED_DARK);
|
||||
});
|
||||
|
||||
it("refuses device keys even when the file carries them", () => {
|
||||
// An earlier build wrote the whole settings object up; that file must not
|
||||
// now drag one machine's pane width onto every other one.
|
||||
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual({ theme: "dark" });
|
||||
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual(MIGRATED_DARK);
|
||||
});
|
||||
|
||||
it("does not invent keys from an empty file", () => {
|
||||
@@ -81,3 +89,47 @@ describe("the client's own folder", () => {
|
||||
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "file" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Picking a language used to come undone.
|
||||
*
|
||||
* The subtree that reads the account's settings file is keyed on the language
|
||||
* version, so choosing a language throws it away and builds it again. The
|
||||
* remount re-read the file — which still held the old language, because the
|
||||
* write is debounced by three seconds — and applied it, putting the old
|
||||
* language back. Reported as "sometimes it takes several clicks": the click
|
||||
* that appeared to work was the one made after the previous write had landed.
|
||||
*/
|
||||
describe("a change made but not yet written up", () => {
|
||||
it("is not read back over by the file it has not reached yet", () => {
|
||||
const current: Settings = { ...DEFAULT_SETTINGS, uiLanguage: "ja" };
|
||||
const file = { uiLanguage: "en", theme: "dark" };
|
||||
const merged = mergeRemote(current, file, new Set(["uiLanguage"]));
|
||||
expect(merged.uiLanguage).toBe("ja");
|
||||
// Only the queued key is held back; the rest of the file still applies.
|
||||
expect(merged.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies the whole file when nothing is queued", () => {
|
||||
const current: Settings = { ...DEFAULT_SETTINGS, uiLanguage: "ja" };
|
||||
const merged = mergeRemote(current, { uiLanguage: "en" });
|
||||
expect(merged.uiLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("reads the file again for an account after a sign-out", () => {
|
||||
stopSettingsSync();
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
||||
// The remount that a language change causes must not read it a second time.
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
|
||||
// Signing out drops the claim, so signing back in reads the file rather
|
||||
// than trusting whatever the previous session left behind.
|
||||
stopSettingsSync();
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
||||
stopSettingsSync();
|
||||
});
|
||||
|
||||
it("treats a missing account as already loaded, so nothing is fetched", () => {
|
||||
expect(settingsAlreadyLoadedFor(null)).toBe(true);
|
||||
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spamReport } from "@/lib/spamScore";
|
||||
|
||||
const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v });
|
||||
const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v });
|
||||
|
||||
describe("spamReport, SpamAssassin-shaped headers", () => {
|
||||
it("reads the verdict, score, threshold and tests", () => {
|
||||
const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.verdict).toBe("spam");
|
||||
expect(r!.score).toBe(6.7);
|
||||
expect(r!.threshold).toBe(5);
|
||||
expect(r!.source).toBe("spamassassin");
|
||||
// Biggest mover first, so the reason it was scored reads off the top.
|
||||
expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]);
|
||||
});
|
||||
|
||||
it("reads a negative score and a clean verdict", () => {
|
||||
const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]");
|
||||
expect(r!.verdict).toBe("clean");
|
||||
expect(r!.score).toBe(-2.6);
|
||||
expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 });
|
||||
});
|
||||
|
||||
it("survives a folded header, which is how they arrive", () => {
|
||||
const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]");
|
||||
expect(r!.score).toBe(6.7);
|
||||
expect(r!.rules).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a verdict that states no score, and a score that states no verdict", () => {
|
||||
expect(sa("Yes")!.verdict).toBe("spam");
|
||||
expect(sa("Yes")!.score).toBeNull();
|
||||
const scoreOnly = sa("score=1.2 required=5.0");
|
||||
expect(scoreOnly!.verdict).toBeNull();
|
||||
expect(scoreOnly!.score).toBe(1.2);
|
||||
});
|
||||
|
||||
it("says nothing when there is nothing it understands", () => {
|
||||
expect(sa("")).toBeNull();
|
||||
expect(sa("something else entirely")).toBeNull();
|
||||
expect(spamReport({})).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a malformed test rather than scoring it as zero", () => {
|
||||
const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]");
|
||||
expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamReport, Rspamd", () => {
|
||||
it("reads the action, score, threshold and rules with their notes", () => {
|
||||
const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]");
|
||||
expect(r!.verdict).toBe("clean");
|
||||
expect(r!.score).toBe(1.2);
|
||||
expect(r!.threshold).toBe(15);
|
||||
expect(r!.source).toBe("rspamd");
|
||||
expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 });
|
||||
expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com");
|
||||
// An empty bracket is not a note.
|
||||
expect(r!.rules[0]!.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats the acting verdicts as spam and False as clean", () => {
|
||||
expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean");
|
||||
});
|
||||
|
||||
it("declines to call greylisting a verdict about the message", () => {
|
||||
const r = rs("default: greylist [8.00 / 15.00];");
|
||||
expect(r!.verdict).toBeNull();
|
||||
expect(r!.score).toBe(8);
|
||||
});
|
||||
|
||||
it("says nothing for a header it cannot read", () => {
|
||||
expect(rs("")).toBeNull();
|
||||
expect(rs("default: False")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamReport, precedence and fallback", () => {
|
||||
it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => {
|
||||
const r = spamReport({
|
||||
"header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0",
|
||||
"header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];",
|
||||
});
|
||||
expect(r!.source).toBe("spamassassin");
|
||||
expect(r!.verdict).toBe("spam");
|
||||
});
|
||||
|
||||
it("falls back to a bare score, with no threshold to read it against", () => {
|
||||
const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" });
|
||||
expect(r!.score).toBe(4.1);
|
||||
expect(r!.threshold).toBeNull();
|
||||
expect(r!.verdict).toBeNull();
|
||||
expect(r!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not invent a verdict from score against threshold", () => {
|
||||
// Above the threshold, but the filter did not say "Yes" -- so neither do we.
|
||||
const r = sa("score=9.9 required=5.0 tests=[X=9.9]");
|
||||
expect(r!.verdict).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
accountKey,
|
||||
clearAllData,
|
||||
clearSignedInData,
|
||||
isDeviceTrusted,
|
||||
loadJson,
|
||||
loadRaw,
|
||||
saveJson,
|
||||
setDeviceTrusted,
|
||||
} from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* The gate is a privacy boundary rather than a convenience, so it is tested
|
||||
* from both sides: that a trusted device still works exactly as it did, and
|
||||
* that an untrusted one leaves nothing to find.
|
||||
*/
|
||||
describe("device-trusted storage", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
|
||||
it("writes nothing at all when the device is not trusted", () => {
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not read residue left by an earlier trusted session", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
setDeviceTrusted(false);
|
||||
// The bytes are still on disk until a purge; the gate must not serve them.
|
||||
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips normally on a trusted device", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
|
||||
expect(isDeviceTrusted()).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
|
||||
setDeviceTrusted(true);
|
||||
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
|
||||
setDeviceTrusted(false);
|
||||
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson("mbx-expanded", { a: true });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
|
||||
store.set("ihasmail:pushEnabled", "1");
|
||||
|
||||
clearSignedInData();
|
||||
|
||||
expect(store.has("ihasmail:settings")).toBe(false);
|
||||
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
|
||||
expect(store.has("ihasmail:acct1:recent")).toBe(false);
|
||||
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
|
||||
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
|
||||
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
|
||||
/*
|
||||
* Kept for the ending that is not a sign-out. A deploy expires every
|
||||
* session, and that path clears local data without unsubscribing -- there
|
||||
* is no session left to unsubscribe with. Losing the flag there would
|
||||
* strand a live subscription with nothing renewing it, and the switch in
|
||||
* Settings would still say background notifications were on.
|
||||
*/
|
||||
expect(store.get("ihasmail:pushEnabled")).toBe("1");
|
||||
});
|
||||
|
||||
it("clears everything, lastUser included, for an untrusted sign-in", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
|
||||
clearAllData();
|
||||
|
||||
expect([...store.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves keys belonging to anything else alone", () => {
|
||||
setDeviceTrusted(true);
|
||||
store.set("someone-elses-key", "keep me");
|
||||
clearAllData();
|
||||
expect(store.get("someone-elses-key")).toBe("keep me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||
|
||||
/**
|
||||
* A swipe names what it is about to do on a coloured strip the reader sees for
|
||||
* about a third of a second before letting go. These check that the name is
|
||||
* true in the folder it is being read in — which is the whole reason the
|
||||
* descriptor exists rather than a fixed label per setting.
|
||||
*/
|
||||
|
||||
const inbox = { role: "inbox", unread: false, starred: false };
|
||||
|
||||
describe("describeSwipe", () => {
|
||||
it("offers nothing for a direction turned off", () => {
|
||||
expect(describeSwipe("none", inbox)).toBe(null);
|
||||
});
|
||||
|
||||
it("refuses to archive out of the archive", () => {
|
||||
expect(describeSwipe("archive", { ...inbox, role: "archive" })).toBe(null);
|
||||
expect(describeSwipe("archive", inbox)).toMatchObject({ label: "Archive", removes: true });
|
||||
});
|
||||
|
||||
it("says out loud that a delete from Deleted Items is permanent", () => {
|
||||
expect(describeSwipe("delete", inbox)?.label).toBe("Delete");
|
||||
expect(describeSwipe("delete", { ...inbox, role: "trash" })?.label).toBe("Delete forever");
|
||||
});
|
||||
|
||||
it("turns the spam action around inside the junk folder", () => {
|
||||
expect(describeSwipe("spam", inbox)).toMatchObject({ label: "Report spam", icon: "spam" });
|
||||
expect(describeSwipe("spam", { ...inbox, role: "junk" })).toMatchObject({ label: "Not spam", icon: "not-spam" });
|
||||
});
|
||||
|
||||
it("has no opinion on whether your own mail is spam", () => {
|
||||
expect(describeSwipe("spam", { ...inbox, role: "drafts" })).toBe(null);
|
||||
expect(describeSwipe("spam", { ...inbox, role: "sent" })).toBe(null);
|
||||
});
|
||||
|
||||
it("names the state a toggle is about to set, and carries it", () => {
|
||||
expect(describeSwipe("read", { ...inbox, unread: true })).toMatchObject({ label: "Mark as read", icon: "read", on: true });
|
||||
expect(describeSwipe("read", { ...inbox, unread: false })).toMatchObject({ label: "Mark as unread", icon: "unread", on: false });
|
||||
expect(describeSwipe("star", { ...inbox, starred: false })).toMatchObject({ label: "Add star", on: true });
|
||||
expect(describeSwipe("star", { ...inbox, starred: true })).toMatchObject({ label: "Remove star", on: false });
|
||||
});
|
||||
|
||||
it("brings a row home for the actions that open something instead", () => {
|
||||
// The row has to be back under the finger before the folder picker covers
|
||||
// the list, or it is still hanging half-open when the picker closes again.
|
||||
expect(describeSwipe("move", inbox)).toMatchObject({ label: "Move to…", removes: false });
|
||||
for (const action of ["archive", "delete", "spam"] as const) {
|
||||
expect(describeSwipe(action, inbox)?.removes).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("can describe everything the settings picker offers", () => {
|
||||
// A choice the picker offers and the list cannot describe is a direction
|
||||
// that silently does nothing — the one failure nobody would report.
|
||||
for (const { value } of SWIPE_CHOICES) {
|
||||
const d = describeSwipe(value as SwipeAction, inbox);
|
||||
if (value === "none") expect(d).toBe(null);
|
||||
else expect(d?.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
// Not because the consequence is bigger -- stepping back undoes it -- but
|
||||
// because this gesture reveals nothing on the way and offers no Undo
|
||||
// after, so the distance is the only chance to not mean it.
|
||||
for (const width of [320, 360, 414, 768, 1024]) {
|
||||
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
|
||||
}
|
||||
});
|
||||
|
||||
it("is a share of the width, bounded at both ends", () => {
|
||||
expect(navSwipeThreshold(360)).toBe(108);
|
||||
expect(navSwipeThreshold(200)).toBe(80); // floor
|
||||
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeNavDirection", () => {
|
||||
const W = 400; // threshold is 120 at this width
|
||||
|
||||
it("goes forward when the finger drags left, the way pages turn", () => {
|
||||
expect(swipeNavDirection(-200, W)).toBe(1);
|
||||
});
|
||||
|
||||
it("goes back when the finger drags right", () => {
|
||||
expect(swipeNavDirection(200, W)).toBe(-1);
|
||||
});
|
||||
|
||||
it("does nothing short of the threshold, in either direction", () => {
|
||||
expect(swipeNavDirection(-60, W)).toBe(0);
|
||||
expect(swipeNavDirection(60, W)).toBe(0);
|
||||
expect(swipeNavDirection(0, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly at the threshold and not a pixel before", () => {
|
||||
const at = navSwipeThreshold(W);
|
||||
expect(swipeNavDirection(-at, W)).toBe(1);
|
||||
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
|
||||
expect(swipeNavDirection(at, W)).toBe(-1);
|
||||
expect(swipeNavDirection(at - 1, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("scales with the width, so a tablet asks for more than a phone", () => {
|
||||
// The same 120px drag commits on a narrow screen and does not on a wide one.
|
||||
expect(swipeNavDirection(-120, 360)).toBe(1);
|
||||
expect(swipeNavDirection(-120, 1024)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the axis lock this shares with the row swipe", () => {
|
||||
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
|
||||
// The day view scrolls through the hours; a scroll misread as a swipe
|
||||
// throws the reader into another day.
|
||||
expect(lockAxis(20, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("commits to sideways only when it is clearly sideways", () => {
|
||||
expect(lockAxis(40, 10)).toBe("x");
|
||||
});
|
||||
|
||||
it("is undecided until the drag has moved at all", () => {
|
||||
expect(lockAxis(2, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
|
||||
const AT = new Date("2026-03-04T15:07:00Z");
|
||||
|
||||
function ctx(over: Partial<PlaceholderContext> = {}): PlaceholderContext {
|
||||
return {
|
||||
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
|
||||
from: { name: "Grace Hopper", email: "[email protected]" },
|
||||
subject: "Quarterly report",
|
||||
now: AT,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fillPlaceholders", () => {
|
||||
it("fills the names it knows", () => {
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,");
|
||||
expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace <[email protected]>");
|
||||
expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper");
|
||||
expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report");
|
||||
});
|
||||
|
||||
it("tolerates spaces inside the braces but not a different case", () => {
|
||||
expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("[email protected]");
|
||||
expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}");
|
||||
});
|
||||
|
||||
it("leaves a placeholder it cannot answer exactly as written", () => {
|
||||
// The case the design is about: a template inserted before the message is
|
||||
// addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished.
|
||||
const unaddressed = ctx({ to: [] });
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},");
|
||||
expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}");
|
||||
expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}");
|
||||
});
|
||||
|
||||
it("leaves a name it does not know alone rather than eating it", () => {
|
||||
expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}");
|
||||
});
|
||||
|
||||
it("falls back to the local part when a recipient has no name", () => {
|
||||
const c = ctx({ to: [{ name: null, email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
});
|
||||
|
||||
it("escapes a substituted value on the way into HTML, and not into a subject", () => {
|
||||
const c = ctx({ to: [{ name: 'Ada <script>alert("x")</script>', email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
|
||||
});
|
||||
|
||||
it("repeats a placeholder as many times as it appears", () => {
|
||||
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
|
||||
});
|
||||
|
||||
it("answers date and time from the injected clock", () => {
|
||||
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
|
||||
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
|
||||
expect(date).not.toBe("{{date}}");
|
||||
expect(date).toMatch(/2026/);
|
||||
expect(time).not.toBe("{{time}}");
|
||||
expect(time).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("names every resolver in the list Settings shows", () => {
|
||||
expect(PLACEHOLDER_NAMES).toEqual([
|
||||
"recipientName",
|
||||
"recipientFirstName",
|
||||
"recipientEmail",
|
||||
"myName",
|
||||
"myEmail",
|
||||
"subject",
|
||||
"date",
|
||||
"time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, useSettings, type Theme } from "@/store/settings";
|
||||
import { toggleTarget, type Mode, type PaletteId } from "@/lib/palette";
|
||||
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
||||
@@ -60,9 +61,14 @@ describe("the default theme", () => {
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
// Reads and writes are gated on device trust now, and the gate defaults to
|
||||
// closed. These tests are about `loadJson`'s merge, so open it and put it
|
||||
// back -- an untrusted device is covered by storage.test.ts instead.
|
||||
setDeviceTrusted(true);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
};
|
||||
@@ -93,55 +99,49 @@ describe("the default theme", () => {
|
||||
|
||||
describe("the top-bar toggle", () => {
|
||||
it("goes to light from anything dark", () => {
|
||||
expect(toggleTarget("dark", "ihasmail")).toBe("light");
|
||||
expect(toggleTarget("dark", "dark")).toBe("light");
|
||||
expect(toggleTarget("dark", "system")).toBe("light");
|
||||
expect(toggleTarget({ palette: "ihasmail", mode: "dark" }, false).mode).toBe("light");
|
||||
expect(toggleTarget({ palette: "default", mode: "dark" }, false).mode).toBe("light");
|
||||
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
|
||||
});
|
||||
|
||||
it("comes back to the theme you were actually on", () => {
|
||||
// The whole point: two clicks from ihasmail must return to ihasmail, not
|
||||
it("comes back to the palette you were actually on", () => {
|
||||
// The whole point: two presses from ihasmail must return to ihasmail, not
|
||||
// deposit you on plain dark.
|
||||
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
|
||||
expect(toggleTarget("light", "dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it("can bring back \"match system\", which the toggle used to strand", () => {
|
||||
expect(toggleTarget("light", "system")).toBe("system");
|
||||
});
|
||||
|
||||
it("round-trips every dark theme there is", () => {
|
||||
for (const t of ["dark", "ihasmail", "system"] as const) {
|
||||
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
|
||||
expect(toggleTarget("light", t), t).toBe(t);
|
||||
}
|
||||
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
|
||||
expect(toggleTarget(away, false).palette).toBe("ihasmail");
|
||||
expect(toggleTarget({ palette: "default", mode: "light" }, false)).toMatchObject({ palette: "default", mode: "dark" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("remembering which dark theme you were on", () => {
|
||||
const setTheme = (t: Theme) => {
|
||||
useSettings.getState().update({ theme: t });
|
||||
describe("remembering the palette you were on", () => {
|
||||
const set = (palette: PaletteId, mode: Mode) => {
|
||||
useSettings.getState().update({ palette, mode });
|
||||
return useSettings.getState().settings;
|
||||
};
|
||||
|
||||
it("records a dark theme chosen from Settings, not just from the toggle", () => {
|
||||
// update() is the single path every way of choosing a theme goes through,
|
||||
// which is why the remembering lives there rather than at the call sites.
|
||||
expect(setTheme("dark").lastDarkTheme).toBe("dark");
|
||||
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
|
||||
expect(setTheme("system").lastDarkTheme).toBe("system");
|
||||
});
|
||||
|
||||
it("does not let light overwrite it — that is the theme being toggled away from", () => {
|
||||
setTheme("ihasmail");
|
||||
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
|
||||
it("derives the legacy theme from whatever set the palette or mode", () => {
|
||||
// `theme` is no longer chosen; it is kept in step so a device on an older
|
||||
// build is not stranded on a theme nobody picked.
|
||||
expect(set("default", "dark").theme).toBe("dark");
|
||||
expect(set("ihasmail", "dark").theme).toBe("ihasmail");
|
||||
expect(set("default", "system").theme).toBe("system");
|
||||
expect(set("gruvbox", "light").theme).toBe("light");
|
||||
expect(set("dracula", "dark").theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("survives a there-and-back through the toggle", () => {
|
||||
setTheme("ihasmail");
|
||||
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
|
||||
expect(away.theme).toBe("light");
|
||||
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
|
||||
expect(back.theme).toBe("ihasmail");
|
||||
// Two presses return you exactly where you started, and the palette never
|
||||
// moves -- which is the whole of what the old lastDarkTheme existed for.
|
||||
set("ihasmail", "dark");
|
||||
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
|
||||
expect(away).toEqual({ palette: "ihasmail", mode: "light" });
|
||||
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||
});
|
||||
|
||||
it("keeps the colours when the palette has both sides", () => {
|
||||
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
|
||||
expect(away.palette).toBe("gruvbox");
|
||||
expect(away.mode).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,12 +154,30 @@ describe("where the theme settings live", () => {
|
||||
// its expectation would move too.
|
||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
||||
expect(synced).toHaveProperty("theme");
|
||||
expect(synced).toHaveProperty("lastDarkTheme");
|
||||
expect(DEVICE_KEYS.has("theme")).toBe(false);
|
||||
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
|
||||
expect(synced).toHaveProperty("palette");
|
||||
expect(synced).toHaveProperty("mode");
|
||||
for (const k of ["theme", "palette", "mode"] as const) {
|
||||
expect(DEVICE_KEYS.has(k)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is applied from a settings file another device wrote", () => {
|
||||
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
|
||||
expect(acceptRemote({ palette: "gruvbox", mode: "light" })).toEqual({ palette: "gruvbox", mode: "light" });
|
||||
});
|
||||
|
||||
it("reads a file written before palettes existed through the old enum", () => {
|
||||
// Settings live in the account's Files and are opened by whatever version
|
||||
// runs next, so this is not a one-release migration.
|
||||
expect(acceptRemote({ theme: "ihasmail" })).toMatchObject({ palette: "ihasmail", mode: "dark" });
|
||||
expect(acceptRemote({ theme: "light" })).toMatchObject({ palette: "default", mode: "light" });
|
||||
});
|
||||
|
||||
it("prefers the new fields when a file carries both", () => {
|
||||
// A file with both is newer, and its `theme` is the derived copy rather
|
||||
// than the choice -- so it must not overrule the palette beside it.
|
||||
expect(acceptRemote({ theme: "dark", palette: "rose-pine", mode: "light" })).toMatchObject({
|
||||
palette: "rose-pine",
|
||||
mode: "light",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTnef, parseTnef } from "@/lib/tnef";
|
||||
|
||||
/**
|
||||
* The blobs below are **built from the format description, not captured from
|
||||
* Outlook**. That is worth saying plainly: they prove the parser reads what the
|
||||
* spec says, and they cannot prove it reads what Outlook actually emits. The
|
||||
* cases most likely to differ in the wild are the MAPI property stream, where
|
||||
* real messages carry many more properties than these do, and named properties
|
||||
* (id >= 0x8000), which the parser stops at rather than guessing past.
|
||||
*/
|
||||
|
||||
const SIGNATURE = 0x223e9f78;
|
||||
|
||||
const ATT = {
|
||||
attachRenddata: 0x00069002,
|
||||
attachTitle: 0x00018010,
|
||||
attachData: 0x0006800f,
|
||||
attachment: 0x00069005,
|
||||
tnefVersion: 0x00089006,
|
||||
} as const;
|
||||
|
||||
const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0);
|
||||
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
|
||||
const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff];
|
||||
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
|
||||
const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0)));
|
||||
|
||||
interface Attr {
|
||||
level?: number;
|
||||
id: number;
|
||||
data: number[];
|
||||
/** Deliberately wrong, for the desync case. */
|
||||
badChecksum?: boolean;
|
||||
}
|
||||
|
||||
function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array {
|
||||
const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)];
|
||||
for (const a of attrs) {
|
||||
out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data)));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/** A MAPI property stream carrying the given string properties. */
|
||||
function mapi(props: Array<{ id: number; type: number; value: string }>): number[] {
|
||||
const out: number[] = [...u32(props.length)];
|
||||
for (const p of props) {
|
||||
out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff)));
|
||||
const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0];
|
||||
out.push(...u32(bytes.length), ...bytes);
|
||||
const pad = (4 - (bytes.length % 4)) % 4;
|
||||
for (let i = 0; i < pad; i++) out.push(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
||||
{ id: ATT.attachRenddata, data: new Array(14).fill(0) },
|
||||
{ id: ATT.attachTitle, data: [...ascii(name), 0] },
|
||||
...extra,
|
||||
{ id: ATT.attachData, data: ascii(body) },
|
||||
];
|
||||
|
||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||
|
||||
describe("isTnef", () => {
|
||||
it("recognises the types and the filename", () => {
|
||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves everything else alone", () => {
|
||||
expect(isTnef("application/pdf", "report.pdf")).toBe(false);
|
||||
expect(isTnef(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTnef", () => {
|
||||
it("pulls one attachment out, with its name and bytes", () => {
|
||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("report.pdf");
|
||||
expect(text(out[0]!.data)).toBe("hello");
|
||||
expect(out[0]!.size).toBe(5);
|
||||
// Guessed from the extension, since this blob names no type of its own.
|
||||
expect(out[0]!.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("pulls several out, in order", () => {
|
||||
const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]);
|
||||
expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]);
|
||||
expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]);
|
||||
});
|
||||
|
||||
it("prefers the long filename over the 8.3 one", () => {
|
||||
// The whole reason for reading the MAPI stream at all.
|
||||
const attrs = file("QUARTE~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx");
|
||||
});
|
||||
|
||||
it("reads a unicode long filename", () => {
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx");
|
||||
});
|
||||
|
||||
it("takes the MIME type the blob states over one guessed from the name", () => {
|
||||
const attrs = file("data.bin", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for a name that says nothing", () => {
|
||||
expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("names an attachment that carries no title at all", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }]));
|
||||
expect(out[0]!.name).toBe("attachment");
|
||||
});
|
||||
|
||||
it("ignores attributes it has no use for", () => {
|
||||
const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt"]);
|
||||
});
|
||||
|
||||
it("is not TNEF, and says so quietly", () => {
|
||||
// The caller gets here by guessing from a filename, so this is an ordinary
|
||||
// answer rather than an error worth showing anybody.
|
||||
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]);
|
||||
expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]);
|
||||
expect(parseTnef(new Uint8Array([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the stream goes out of step", () => {
|
||||
// Half the attachments beats none: the alternative is a reader who can see
|
||||
// the file is there and cannot have it.
|
||||
const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]);
|
||||
const out = parseTnef(bad);
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the blob is truncated mid-attribute", () => {
|
||||
const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]);
|
||||
const out = parseTnef(full.slice(0, full.length - 12));
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("stops at a named property rather than guessing past it", () => {
|
||||
// A named property carries a GUID before its value; the stream cannot be
|
||||
// trusted to stay aligned past one, so the long name is simply not found.
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC");
|
||||
});
|
||||
|
||||
it("survives a MAPI stream that is nonsense, keeping the attachment", () => {
|
||||
const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]);
|
||||
const out = parseTnef(tnef(attrs));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("keep.txt");
|
||||
});
|
||||
|
||||
it("drops an attachment that has a name but no data", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }]));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
|
||||
|
||||
/**
|
||||
* The arithmetic behind the touch gestures, checked without a touchscreen.
|
||||
*
|
||||
* These are the numbers that decide whether a finger meant to scroll the list
|
||||
* or to act on a message, and getting them wrong is not a crash — it is an app
|
||||
* that deletes mail when someone tried to scroll past it. Worth pinning down.
|
||||
*/
|
||||
|
||||
describe("lockAxis", () => {
|
||||
it("stays undecided until the finger has committed", () => {
|
||||
expect(lockAxis(0, 0)).toBe(null);
|
||||
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
|
||||
});
|
||||
|
||||
it("reads a clearly sideways drag as a swipe", () => {
|
||||
expect(lockAxis(40, 4)).toBe("x");
|
||||
expect(lockAxis(-40, 4)).toBe("x");
|
||||
});
|
||||
|
||||
it("gives a diagonal to the scroller, not the swipe", () => {
|
||||
// 45 degrees is more sideways than not, and is still a scroll: someone
|
||||
// flicking down a list does not travel straight down the glass.
|
||||
expect(lockAxis(30, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("counts distance on either axis towards committing", () => {
|
||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeThreshold", () => {
|
||||
it("scales with the row but never off either end", () => {
|
||||
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
|
||||
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
|
||||
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeOffset", () => {
|
||||
const width = 360;
|
||||
const limit = swipeThreshold(width);
|
||||
|
||||
it("follows the finger exactly until the action would fire", () => {
|
||||
expect(swipeOffset(20, width)).toBe(20);
|
||||
expect(swipeOffset(-20, width)).toBe(-20);
|
||||
expect(swipeOffset(limit, width)).toBe(limit);
|
||||
});
|
||||
|
||||
it("resists past the threshold, in both directions", () => {
|
||||
const over = swipeOffset(limit + 100, width);
|
||||
expect(over).toBeGreaterThan(limit);
|
||||
expect(over).toBeLessThan(limit + 100);
|
||||
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
|
||||
});
|
||||
|
||||
it("never travels further than the row is wide", () => {
|
||||
// Past the row's own width there is nothing left to reveal, so a hard
|
||||
// flick stops there rather than accumulating travel with nowhere to show.
|
||||
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
|
||||
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullDistance", () => {
|
||||
it("ignores an upward drag", () => {
|
||||
expect(pullDistance(0)).toBe(0);
|
||||
expect(pullDistance(-50)).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
|
||||
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
|
||||
});
|
||||
|
||||
it("stops coming down however hard it is pulled", () => {
|
||||
expect(pullDistance(400)).toBe(PULL_MAX);
|
||||
expect(pullDistance(4000)).toBe(PULL_MAX);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
/*
|
||||
* The guard's answers, and which of them the dialog leans on.
|
||||
*
|
||||
* It shipped with "Discard changes" as the only choice carrying a colour, which
|
||||
* made losing the work the easy thing to click on a dialog whose entire purpose
|
||||
* is to stop that (#175). The emphasis belongs on the safe answer; the
|
||||
* destructive one stays legible as destructive without being the loudest thing
|
||||
* in the box.
|
||||
*/
|
||||
|
||||
const choiceDialog = vi.fn();
|
||||
vi.mock("@/ui/dialog", () => ({ choiceDialog: (...args: unknown[]) => choiceDialog(...args) }));
|
||||
|
||||
const { confirmLeaveUnsaved, useUnsavedChanges } = await import("@/lib/unsavedChanges");
|
||||
|
||||
interface Choice { value: string; label: string; hint?: string; danger?: boolean; primary?: boolean }
|
||||
|
||||
const save = vi.fn(async () => true);
|
||||
const discard = vi.fn();
|
||||
|
||||
function Editor() {
|
||||
useUnsavedChanges({ dirty: true, save, discard, message: "Your filters have unsaved changes." });
|
||||
return null;
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
|
||||
/** One dirty editor on screen, which is what registers anything at all. */
|
||||
function mountDirtyEditor() {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => root!.render(<Editor />));
|
||||
}
|
||||
|
||||
const asked = () => choiceDialog.mock.calls[0]![0] as { choices: Choice[]; cancelLabel: string; title: string; message: string };
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
choiceDialog.mockReset().mockResolvedValue(null);
|
||||
save.mockClear().mockResolvedValue(true);
|
||||
discard.mockClear();
|
||||
mountDirtyEditor();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root!.unmount());
|
||||
host!.remove();
|
||||
root = null;
|
||||
host = null;
|
||||
});
|
||||
|
||||
describe("the unsaved-changes guard", () => {
|
||||
it("highlights saving, not discarding", async () => {
|
||||
await confirmLeaveUnsaved();
|
||||
const [saveChoice, discardChoice] = asked().choices;
|
||||
expect(saveChoice!.primary).toBe(true);
|
||||
expect(discardChoice!.primary).toBeFalsy();
|
||||
});
|
||||
|
||||
it("still says which answer is the destructive one", async () => {
|
||||
await confirmLeaveUnsaved();
|
||||
const [saveChoice, discardChoice] = asked().choices;
|
||||
expect(discardChoice!.danger).toBe(true);
|
||||
expect(discardChoice!.hint).toBeTruthy();
|
||||
expect(saveChoice!.danger).toBeFalsy();
|
||||
});
|
||||
|
||||
it("offers saving first, and staying as the way out", async () => {
|
||||
await confirmLeaveUnsaved();
|
||||
expect(asked().choices.map((c) => c.value)).toEqual(["save", "discard"]);
|
||||
expect(asked().cancelLabel).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says what is about to be lost, in the editor's own words", async () => {
|
||||
await confirmLeaveUnsaved();
|
||||
expect(asked().message).toBe("Your filters have unsaved changes.");
|
||||
});
|
||||
|
||||
it("stays put when the dialog is dismissed, and loses nothing", async () => {
|
||||
choiceDialog.mockResolvedValue(null);
|
||||
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(discard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves and goes when saving is chosen", async () => {
|
||||
choiceDialog.mockResolvedValue("save");
|
||||
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("holds you on the page when the save fails", async () => {
|
||||
choiceDialog.mockResolvedValue("save");
|
||||
save.mockResolvedValue(false);
|
||||
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("discards and goes when discarding is chosen", async () => {
|
||||
choiceDialog.mockResolvedValue("discard");
|
||||
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
|
||||
expect(discard).toHaveBeenCalledOnce();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
crossesRecipientThreshold,
|
||||
domainCovered,
|
||||
externalRecipients,
|
||||
internalDomains,
|
||||
isExternalSender,
|
||||
linkVerdict,
|
||||
shownDomain,
|
||||
} from "@/lib/warnings";
|
||||
|
||||
const addr = (email: string, name: string | null = null) => ({ name, email });
|
||||
|
||||
describe("internalDomains", () => {
|
||||
it("always counts your own identities, without them being configured", () => {
|
||||
// An account signed in as [email protected] warning that example.com is
|
||||
// external would be absurd, and is what an empty list would do.
|
||||
const d = internalDomains(["[email protected]", "[email protected]"], []);
|
||||
expect([...d].sort()).toEqual(["example.com", "example.org"]);
|
||||
});
|
||||
|
||||
it("adds configured domains, tolerating a leading @ and stray case", () => {
|
||||
const d = internalDomains([], ["@Partner.com", " sister.org "]);
|
||||
expect([...d].sort()).toEqual(["partner.com", "sister.org"]);
|
||||
});
|
||||
|
||||
it("ignores empty entries rather than adding an empty domain", () => {
|
||||
expect(internalDomains(["notanemail"], ["", " ", "@"]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domainCovered", () => {
|
||||
const internal = internalDomains([], ["example.com"]);
|
||||
|
||||
it("covers the domain itself and its subdomains", () => {
|
||||
expect(domainCovered("example.com", internal)).toBe(true);
|
||||
expect(domainCovered("mail.example.com", internal)).toBe(true);
|
||||
expect(domainCovered("a.b.example.com", internal)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cover a domain that merely ends with the same letters", () => {
|
||||
// The whole point of matching on a dot boundary: this is the shape an
|
||||
// attacker registers.
|
||||
expect(domainCovered("notexample.com", internal)).toBe(false);
|
||||
expect(domainCovered("example.com.evil.net", internal)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive and says no to nothing", () => {
|
||||
expect(domainCovered("MAIL.EXAMPLE.COM", internal)).toBe(true);
|
||||
expect(domainCovered("", internal)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("externalRecipients", () => {
|
||||
const internal = internalDomains(["[email protected]"], []);
|
||||
|
||||
it("returns only those outside, in the order addressed", () => {
|
||||
const out = externalRecipients(
|
||||
[addr("[email protected]"), addr("[email protected]"), addr("[email protected]"), addr("[email protected]")],
|
||||
internal,
|
||||
);
|
||||
expect(out.map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
});
|
||||
|
||||
it("is empty when everyone is inside", () => {
|
||||
expect(externalRecipients([addr("[email protected]")], internal)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExternalSender", () => {
|
||||
const internal = internalDomains(["[email protected]"], []);
|
||||
|
||||
it("reads the first From address", () => {
|
||||
expect(isExternalSender([addr("[email protected]")], internal)).toBe(true);
|
||||
expect(isExternalSender([addr("[email protected]")], internal)).toBe(false);
|
||||
});
|
||||
|
||||
it("claims nothing about a message with no sender", () => {
|
||||
expect(isExternalSender(null, internal)).toBe(false);
|
||||
expect(isExternalSender([], internal)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossesRecipientThreshold", () => {
|
||||
it("is off at zero, whatever the count", () => {
|
||||
expect(crossesRecipientThreshold(500, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("fires at the threshold and above, not below", () => {
|
||||
expect(crossesRecipientThreshold(9, 10)).toBe(false);
|
||||
expect(crossesRecipientThreshold(10, 10)).toBe(true);
|
||||
expect(crossesRecipientThreshold(11, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shownDomain", () => {
|
||||
it("reads a domain out of link text that is a URL or a bare host", () => {
|
||||
expect(shownDomain("https://example.com/x")).toBe("example.com");
|
||||
expect(shownDomain("example.com")).toBe("example.com");
|
||||
expect(shownDomain(" WWW.Example.COM ")).toBe("www.example.com");
|
||||
});
|
||||
|
||||
it("reads nothing out of text that is prose", () => {
|
||||
// "click" and "here" are not claims about a destination.
|
||||
expect(shownDomain("click here")).toBeNull();
|
||||
expect(shownDomain("here")).toBeNull();
|
||||
expect(shownDomain("")).toBeNull();
|
||||
expect(shownDomain(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkVerdict", () => {
|
||||
const trusted = ["example.com"];
|
||||
|
||||
it("says nothing about a trusted destination", () => {
|
||||
expect(linkVerdict("https://example.com/a", "example.com", trusted)).toEqual({ warn: false });
|
||||
expect(linkVerdict("https://mail.example.com/a", null, trusted)).toEqual({ warn: false });
|
||||
});
|
||||
|
||||
it("warns about an untrusted destination", () => {
|
||||
expect(linkVerdict("https://unknown.net/a", null, trusted)).toEqual({
|
||||
warn: true,
|
||||
reason: "untrusted",
|
||||
domain: "unknown.net",
|
||||
});
|
||||
});
|
||||
|
||||
it("warns about a mismatch even when the destination is trusted", () => {
|
||||
// Trusted is not the same as being the place the text claimed.
|
||||
expect(linkVerdict("https://example.com/login", "yourbank.com", trusted)).toEqual({
|
||||
warn: true,
|
||||
reason: "mismatch",
|
||||
domain: "example.com",
|
||||
shownDomain: "yourbank.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a subdomain of the claimed domain as no mismatch", () => {
|
||||
expect(linkVerdict("https://login.yourbank.com/", "yourbank.com", ["yourbank.com"])).toEqual({ warn: false });
|
||||
});
|
||||
|
||||
it("leaves alone anything that is not http or https", () => {
|
||||
// mailto opens the composer; an anchor goes nowhere. Warning about these
|
||||
// is noise, and noise is how a warning stops being read.
|
||||
expect(linkVerdict("mailto:[email protected]", null, [])).toEqual({ warn: false });
|
||||
expect(linkVerdict("#section", null, [])).toEqual({ warn: false });
|
||||
expect(linkVerdict("javascript:alert(1)", null, [])).toEqual({ warn: false });
|
||||
expect(linkVerdict("not a url at all", null, [])).toEqual({ warn: false });
|
||||
});
|
||||
|
||||
it("warns about everything when nothing is trusted yet", () => {
|
||||
const v = linkVerdict("https://example.com/a", null, []);
|
||||
expect(v).toEqual({ warn: true, reason: "untrusted", domain: "example.com" });
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import {
|
||||
applicationServerKey,
|
||||
decodeApplicationServerKey,
|
||||
encodeKey,
|
||||
findSubscription,
|
||||
needsRenewal,
|
||||
RENEW_WITHIN_MS,
|
||||
subscriptionPayload,
|
||||
pushEnabledHere,
|
||||
setPushEnabledHere,
|
||||
supportsEmailPush,
|
||||
unsubscribeThisDevice,
|
||||
webPushAvailable,
|
||||
type JmapPushSubscription,
|
||||
} from "@/lib/webpush";
|
||||
import { setDeviceTrusted } from "@/lib/storage";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
@@ -175,3 +183,123 @@ describe("the emailPush filter", () => {
|
||||
expect(filter.notKeyword).toBe("$seen");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Keeping a subscription alive.
|
||||
*
|
||||
* The failure this guards against leaves no trace anywhere: the switch says
|
||||
* background notifications are on, the browser still holds a subscription, and
|
||||
* the server quietly stopped delivering days ago because the registration
|
||||
* expired and nothing renewed it. Nobody reports that as a bug — they report
|
||||
* that push "doesn't really work".
|
||||
*/
|
||||
const sub = (deviceClientId: string, expires: string | null): JmapPushSubscription =>
|
||||
({ id: `i-${deviceClientId}`, deviceClientId, url: "https://push.example/x", expires });
|
||||
|
||||
const MINE = "ihasmail-this-browser";
|
||||
const NOW = Date.parse("2026-09-01T12:00:00Z");
|
||||
const inDays = (n: number) => new Date(NOW + n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
describe("finding this browser's subscription", () => {
|
||||
it("matches on the device id rather than taking the first one", () => {
|
||||
const subs = [sub("ihasmail-desktop", null), sub(MINE, null), sub("ihasmail-tablet", null)];
|
||||
expect(findSubscription(subs, MINE)?.deviceClientId).toBe(MINE);
|
||||
});
|
||||
|
||||
it("finds nothing when only other devices are registered", () => {
|
||||
// The bug this replaces: any subscription at all counted as this one, so a
|
||||
// phone that had never registered read as already on and stayed silent.
|
||||
expect(findSubscription([sub("ihasmail-desktop", null)], MINE)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsRenewal", () => {
|
||||
it("renews when this browser is not registered at all", () => {
|
||||
expect(needsRenewal([], MINE, NOW)).toBe(true);
|
||||
expect(needsRenewal([sub("ihasmail-desktop", inDays(6))], MINE, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a subscription alone while it has time on it", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(6))], MINE, NOW)).toBe(false);
|
||||
expect(needsRenewal([sub(MINE, inDays(3))], MINE, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("renews inside the window, so a weekend does not lose it", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(2))], MINE, NOW)).toBe(true);
|
||||
expect(needsRenewal([sub(MINE, inDays(1))], MINE, NOW)).toBe(true);
|
||||
expect(RENEW_WITHIN_MS).toBeLessThan(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("renews one that has already lapsed", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(-1))], MINE, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a subscription with no expiry alone", () => {
|
||||
// A server that never expires one has nothing to renew, and rewriting the
|
||||
// registration on every cold start would be a JMAP call for nothing.
|
||||
expect(needsRenewal([sub(MINE, null)], MINE, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("renews rather than trusts an expiry it cannot read", () => {
|
||||
expect(needsRenewal([sub(MINE, "whenever")], MINE, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether push is on *in this browser* is the flag the renewal on app start
|
||||
* keys off, so the two endings that can clear it have to be told apart.
|
||||
*
|
||||
* Signing out clears it, alongside destroying the subscription itself: a
|
||||
* browser left notifying for a mailbox nobody is signed into is somebody
|
||||
* else's mail on a shared machine. A session merely expiring must not, because
|
||||
* that path -- which is what a deploy does to everyone at once -- leaves the
|
||||
* subscription registered and has no session left to remove it with. That half
|
||||
* is enforced by `KEEP_ON_SIGN_OUT` and tested in storage.test.ts.
|
||||
*/
|
||||
describe("remembering that push is on here", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
setDeviceTrusted(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
|
||||
it("round-trips, and is off until something turns it on", () => {
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
setPushEnabledHere(true);
|
||||
expect(pushEnabledHere()).toBe(true);
|
||||
setPushEnabledHere(false);
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays off on a device nobody said was theirs", () => {
|
||||
// Push is refused there anyway; reading the flag as set would start the
|
||||
// renewal trying on every load for a subscription that cannot exist.
|
||||
setPushEnabledHere(true);
|
||||
setDeviceTrusted(false);
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
|
||||
it("is cleared by signing out, even when the server end cannot be reached", () => {
|
||||
setPushEnabledHere(true);
|
||||
vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
|
||||
return unsubscribeThisDevice().then(() => {
|
||||
// The subscription may well survive at the server; this browser must
|
||||
// still stop believing it has push, or renewal would resurrect it.
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Email, EmailAddress } from "@/jmap/types";
|
||||
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { uniqueAddresses } from "./address";
|
||||
import { toLocalDateOnly } from "./dates";
|
||||
import { htmlToText } from "./text";
|
||||
|
||||
/**
|
||||
* How much of a message body is copied into an event description.
|
||||
*
|
||||
* The reader is making a reminder out of a mail, and a newsletter is a mail
|
||||
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
|
||||
* event, synced to every device, and shown in a three-row textarea. What is
|
||||
* worth keeping is near the top -- the amount owed, the date, the address --
|
||||
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
|
||||
* bill as the whole of it.
|
||||
*/
|
||||
const MAX_DESCRIPTION = 5000;
|
||||
|
||||
/**
|
||||
* The next half-hour, which is when an appointment made now can start.
|
||||
*
|
||||
* Always forward, never the current instant: the reader still has a form to
|
||||
* fill in, and a start time that is already in the past by the time they press
|
||||
* Create is one they have to fix by hand.
|
||||
*/
|
||||
export function nextHalfHour(now: Date = new Date()): Date {
|
||||
const d = new Date(now);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The message's body as plain text, however it was sent. */
|
||||
function bodyText(email: Email): string {
|
||||
const textPart = email.textBody?.[0];
|
||||
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
if (text.trim()) return text;
|
||||
const htmlPart = email.htmlBody?.[0];
|
||||
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
return html ? htmlToText(html) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* An event seeded from a message: its subject, its body, and a time to fix.
|
||||
*
|
||||
* Deliberately nothing clever. The date is the one thing the message cannot
|
||||
* supply -- "the 14th" in a bill is not a due date the parser could trust --
|
||||
* so the editor opens with the reader's cursor on a form they finish, rather
|
||||
* than a guess they have to check.
|
||||
*/
|
||||
/**
|
||||
* Everyone the message was between, as guests: the sender and the people it
|
||||
* was addressed to.
|
||||
*
|
||||
* The reader's own addresses come out -- they are the organiser, and an
|
||||
* organiser listed among their own guests is an event that invites you to your
|
||||
* own appointment. Bcc stays out too, on a message the reader sent themselves:
|
||||
* a blind recipient added to a guest list is visible to every other guest, and
|
||||
* turning a hidden copy into a public one is not something a menu item should
|
||||
* do quietly.
|
||||
*/
|
||||
function guests(email: Email, ownEmails: string[]): EmailAddress[] {
|
||||
const own = new Set(ownEmails.map((e) => e.toLowerCase()));
|
||||
return uniqueAddresses([...(email.from ?? []), ...(email.to ?? []), ...(email.cc ?? [])]).filter((a) => !own.has(a.email.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
export function appointmentDraft(email: Email, now: Date = new Date(), ownEmails: string[] = []): EventDraft {
|
||||
const start = nextHalfHour(now);
|
||||
const body = bodyText(email).trim();
|
||||
return {
|
||||
title: email.subject?.trim() ?? "",
|
||||
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}…` : body,
|
||||
start,
|
||||
end: new Date(start.getTime() + 3600_000),
|
||||
allDay: false,
|
||||
attendees: guests(email, ownEmails),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the calendar's event editor on a draft made from this message.
|
||||
*
|
||||
* The list holds a message without its body -- only a preview -- so the full
|
||||
* one is fetched first; `getEmails` serves it from the cache when the message
|
||||
* has already been read.
|
||||
*/
|
||||
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
|
||||
const mail = useMail.getState();
|
||||
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
||||
// Which addresses are the reader's own decides who is a guest, so they are
|
||||
// worth a round trip when the session has not loaded them yet.
|
||||
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
|
||||
const draft = appointmentDraft(full, new Date(), identities.map((i) => i.email));
|
||||
useCalendar.getState().setDraft(draft);
|
||||
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Where a message goes when it is archived by date.
|
||||
*
|
||||
* The folders are **numeric and zero-padded** -- `Archive/2026`,
|
||||
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
|
||||
* about the fact that these are real server-side mailboxes rather than
|
||||
* anything of ihasmail's:
|
||||
*
|
||||
* - Every other client sees them. A folder created as "September" by someone
|
||||
* reading in English stays "September" for the same account read in
|
||||
* Japanese, because the name is stored, not translated. A number reads the
|
||||
* same in every language ihasmail ships.
|
||||
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
|
||||
* sits between "October" and nothing useful.
|
||||
*
|
||||
* The date is read in the reader's own timezone rather than UTC, because it has
|
||||
* to agree with the date shown against the message in the list. A message that
|
||||
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
|
||||
* filing it under `09` while the list says August would be the app disagreeing
|
||||
* with itself.
|
||||
*/
|
||||
|
||||
export type ArchiveGranularity = "year" | "month";
|
||||
|
||||
/**
|
||||
* Path segments below the Archive folder. Empty means "no dated subfolder" --
|
||||
* a message whose date cannot be read belongs in Archive itself rather than in
|
||||
* a folder named after a guess.
|
||||
*/
|
||||
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
|
||||
if (!when) return [];
|
||||
const d = new Date(when);
|
||||
if (Number.isNaN(d.getTime())) return [];
|
||||
const year = String(d.getFullYear());
|
||||
if (granularity === "year") return [year];
|
||||
return [year, String(d.getMonth() + 1).padStart(2, "0")];
|
||||
}
|
||||
|
||||
/** The segments as one string, for grouping and for naming the destination. */
|
||||
export function archivePath(segments: string[]): string {
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
export interface ArchiveGroup {
|
||||
segments: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a selection by where each message is going.
|
||||
*
|
||||
* Archiving by month across a selection spanning two months is two
|
||||
* destinations, not one, so this is the shape the caller needs -- and the
|
||||
* reason the action cannot simply resolve one folder up front. Groups come
|
||||
* back in the order their first message appeared, so the toast that follows
|
||||
* names them in the order the reader was looking at.
|
||||
*/
|
||||
export function groupByArchivePath(
|
||||
entries: Array<{ id: string; receivedAt?: string | null }>,
|
||||
granularity: ArchiveGranularity,
|
||||
): ArchiveGroup[] {
|
||||
const groups = new Map<string, ArchiveGroup>();
|
||||
for (const e of entries) {
|
||||
const segments = archiveSegments(e.receivedAt, granularity);
|
||||
const key = archivePath(segments);
|
||||
const existing = groups.get(key);
|
||||
if (existing) existing.ids.push(e.id);
|
||||
else groups.set(key, { segments, ids: [e.id] });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { DAY_MS } from "@/lib/dates";
|
||||
|
||||
/**
|
||||
* The span an availability bar covers, and the marks along it.
|
||||
*
|
||||
* The bar used to be a day wide whatever it was showing: it began at midnight
|
||||
* on the event's start day and stopped 24 hours later, so an event running over
|
||||
* two days showed availability for the first of them and gave no sign that
|
||||
* there was more. It also carried no marks at all, which left "is this the
|
||||
* whole day or only working hours" unanswerable without dragging the event
|
||||
* around to see where its own outline moved. That is issue #172, parts 1 and 2.
|
||||
*
|
||||
* Whole days, always: a bar that started at the event's own start time would
|
||||
* move under the reader every time they adjusted it, and "busy from about a
|
||||
* third of the way along" is not a time anybody can read.
|
||||
*/
|
||||
export interface AvailabilityWindow {
|
||||
/** Midnight at the start of the first day shown. */
|
||||
start: Date;
|
||||
/** Midnight at the end of the last day shown. */
|
||||
end: Date;
|
||||
/** Milliseconds between the two, which a DST change makes not a multiple of a day. */
|
||||
span: number;
|
||||
/** Days actually shown. */
|
||||
days: number;
|
||||
/**
|
||||
* Marks along the bar. `at` is a fraction of the span, so a caller positions
|
||||
* one with a percentage and never does date arithmetic of its own. Only
|
||||
* `major` marks are worth a label; the rest are there to read a block against.
|
||||
*/
|
||||
ticks: { at: number; time: Date; major: boolean }[];
|
||||
/** Whether marks fall on hours or on days, which decides how to label them. */
|
||||
scale: "hours" | "days";
|
||||
/**
|
||||
* Days the event covers that the bar does not. An event long enough to need
|
||||
* this is not one anybody is checking for a free slot, and drawing a month at
|
||||
* eight pixels a day would say nothing; saying how much was left out is more
|
||||
* use than showing it.
|
||||
*/
|
||||
daysHidden: number;
|
||||
}
|
||||
|
||||
/** Midnight starting the day `d` falls in, in local time. */
|
||||
function startOfDay(d: Date): Date {
|
||||
const out = new Date(d);
|
||||
out.setHours(0, 0, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `n` days on from `d`, by the calendar rather than by arithmetic: a day is 23
|
||||
* or 25 hours twice a year, and adding 24 of them lands an hour off.
|
||||
*/
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setDate(out.getDate() + n);
|
||||
out.setHours(0, 0, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** How far apart the marks go, in hours, and which of them get a label. */
|
||||
function spacing(days: number): { every: number; label: number } {
|
||||
if (days <= 1) return { every: 3, label: 6 };
|
||||
if (days <= 2) return { every: 6, label: 12 };
|
||||
return { every: 24, label: 24 };
|
||||
}
|
||||
|
||||
export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number; offsetDays?: number } = {}): AvailabilityWindow {
|
||||
const maxDays = opts.maxDays ?? 7;
|
||||
/*
|
||||
* Days moved from where the event sits, for looking around it without
|
||||
* changing it. The whole window slides rather than growing: keeping the span
|
||||
* fixed means what you compare when you step forward is the same width as
|
||||
* what you were looking at, which is the point of stepping.
|
||||
*/
|
||||
const from = addDays(startOfDay(start), opts.offsetDays ?? 0);
|
||||
// The last day is the one the event ends *on*. An event ending exactly at
|
||||
// midnight ends on the day before, not at the start of a day it never
|
||||
// touches -- that is the whole of what all-day events do.
|
||||
const lastDay = addDays(startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))), opts.offsetDays ?? 0);
|
||||
const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
|
||||
const days = Math.min(total, maxDays);
|
||||
const to = addDays(from, days);
|
||||
const span = to.getTime() - from.getTime();
|
||||
|
||||
const { every, label } = spacing(days);
|
||||
const ticks: AvailabilityWindow["ticks"] = [];
|
||||
for (let hour = 0; ; hour += every) {
|
||||
const time = new Date(from.getTime() + hour * 3600_000);
|
||||
if (time.getTime() >= to.getTime()) break;
|
||||
ticks.push({ at: (time.getTime() - from.getTime()) / span, time, major: hour % label === 0 });
|
||||
}
|
||||
|
||||
return { start: from, end: to, span, days, ticks, scale: days <= 2 ? "hours" : "days", daysHidden: total - days };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The subpath this build is mounted at, as the browser sees it.
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
|
||||
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
|
||||
* normaliser -- so this is the same answer the server reached, not a second
|
||||
* guess at it. Reading it here rather than re-deriving it from
|
||||
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
|
||||
* there is nothing in the address that says how much of it is the mount.
|
||||
*
|
||||
* Vite guarantees the value ends in a slash, so the only conversion is
|
||||
* dropping it; `""` for the root, `/mail` otherwise, matching
|
||||
* `scripts/basePath.mjs`.
|
||||
*/
|
||||
export const BASE_PATH: string = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Turn a root-absolute app path into one the server will answer.
|
||||
*
|
||||
* Every `/api/...`, `/img/...` and `/sw.js` in the app goes through here.
|
||||
* Router paths do not: wouter is given `BASE_PATH` as its base and strips and
|
||||
* re-adds the prefix itself, so `<Link href="/mail">` stays written that way.
|
||||
* Mixing the two would double the prefix, which is why this asserts nothing
|
||||
* and simply concatenates -- the discipline is at the call sites, and the
|
||||
* callers that need it are few and all in this repo.
|
||||
*/
|
||||
export function withBase(path: string): string {
|
||||
return BASE_PATH + path;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user