Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
@@ -57,3 +69,41 @@ APP_NAME=ihasmail
|
||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||
# and in Settings > About.
|
||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
|
||||
# ---- 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,177 @@
|
||||
# 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]
|
||||
# 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 }}"
|
||||
@@ -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
|
||||
+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
@@ -27,6 +27,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
||||
|
||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
|
||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||
@@ -42,6 +46,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
||||
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
||||
|
||||
@@ -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,6 +2,11 @@
|
||||
<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.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
+4
-1
@@ -9,6 +9,9 @@ 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)
|
||||
- **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.
|
||||
|
||||
+8
-1
@@ -54,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:
|
||||
#
|
||||
@@ -61,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}"
|
||||
|
||||
+8
-1
@@ -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,6 +15,7 @@ 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}
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
|
||||
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",
|
||||
|
||||
+4
-1
@@ -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`);
|
||||
}
|
||||
+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 }),
|
||||
|
||||
@@ -88,3 +88,59 @@ test("a Sieve script larger than a compressing hop's threshold survives the prox
|
||||
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);
|
||||
});
|
||||
|
||||
+118
-21
@@ -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,8 +589,8 @@ 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, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
@@ -538,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");
|
||||
@@ -555,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, {
|
||||
@@ -578,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) => {
|
||||
@@ -585,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;
|
||||
}
|
||||
|
||||
@@ -697,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));
|
||||
});
|
||||
@@ -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
|
||||
@@ -130,7 +261,21 @@ export const config = {
|
||||
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",
|
||||
|
||||
+202
-7
@@ -27,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 = {
|
||||
@@ -98,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.`;
|
||||
@@ -110,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"),
|
||||
@@ -126,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;
|
||||
@@ -143,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
|
||||
@@ -197,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" });
|
||||
@@ -213,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[] = [
|
||||
@@ -474,6 +573,60 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
* 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 = {};
|
||||
@@ -613,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; });
|
||||
@@ -772,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) ?? {})) {
|
||||
@@ -949,11 +1131,24 @@ const handlers: Record<string, Handler> = {
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||
"CalendarEvent/parse": (a) => { 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/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,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,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"
|
||||
}
|
||||
@@ -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,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,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();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ describe("device-trusted storage", () => {
|
||||
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();
|
||||
|
||||
@@ -84,6 +85,14 @@ describe("device-trusted storage", () => {
|
||||
// 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", () => {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -98,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,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;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Birthdays, read off the contacts rather than stored as events.
|
||||
*
|
||||
* Nothing is written anywhere. The dates already live on the cards, and
|
||||
* copying them into real calendar events would mean two records of the same
|
||||
* fact that drift the first time somebody corrects one — and ihasmail keeping
|
||||
* a calendar of its own is exactly what it does not do. So the events are
|
||||
* derived when a view asks for a range, and vanish when the contact does.
|
||||
*/
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
export interface Birthday {
|
||||
/** Stable across renders and unique per occurrence, so React can key on it. */
|
||||
id: string;
|
||||
contactId: string;
|
||||
name: string;
|
||||
/** Local date of the occurrence, at midnight. */
|
||||
date: Date;
|
||||
/**
|
||||
* How old they turn, where the card gave a year. Many cards record only a
|
||||
* day and month, which is a real answer rather than a broken one.
|
||||
*/
|
||||
age: number | null;
|
||||
}
|
||||
|
||||
/** The prefix marking a synthesised event, so nothing tries to save one. */
|
||||
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
|
||||
|
||||
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
|
||||
export const BIRTHDAY_CALENDAR_ID = "ihm-birthdays";
|
||||
|
||||
export function isBirthdayEvent(id: string | null | undefined): boolean {
|
||||
return Boolean(id?.startsWith(BIRTHDAY_ID_PREFIX));
|
||||
}
|
||||
|
||||
/** Month and day of a card's birth anniversary, and the year where it gave one. */
|
||||
function birthDate(card: ContactCard): { month: number; day: number; year: number | null } | null {
|
||||
for (const a of Object.values(card.anniversaries ?? {})) {
|
||||
if (a?.kind !== "birth") continue;
|
||||
const d = a.date;
|
||||
if (!d) continue;
|
||||
// A PartialDate carries the parts directly; a Timestamp carries an instant.
|
||||
if (typeof d.month === "number" && typeof d.day === "number") {
|
||||
return { month: d.month, day: d.day, year: typeof d.year === "number" ? d.year : null };
|
||||
}
|
||||
if (d.utc) {
|
||||
const t = new Date(d.utc);
|
||||
if (!Number.isNaN(t.getTime())) return { month: t.getMonth() + 1, day: t.getDate(), year: t.getFullYear() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where 29 February falls in a year that has no 29 February.
|
||||
*
|
||||
* The 28th, not 1 March. Somebody born in February has a birthday in February,
|
||||
* and moving it into another month to satisfy the calendar is the arithmetic
|
||||
* winning over the fact. Every choice here is a convention; this is the one
|
||||
* that keeps the month right.
|
||||
*/
|
||||
function occurrence(year: number, month: number, day: number): Date | null {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
const d = new Date(year, month - 1, day);
|
||||
// Rolled into the next month: this day does not exist in this year.
|
||||
if (d.getMonth() !== month - 1) {
|
||||
if (month === 2 && day === 29) return new Date(year, 1, 28);
|
||||
return null;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
const displayName = (c: ContactCard): string =>
|
||||
(c.name?.full ?? "").trim() ||
|
||||
[c.name?.components?.find((p) => p.kind === "given")?.value, c.name?.components?.find((p) => p.kind === "surname")?.value]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim() ||
|
||||
Object.values(c.organizations ?? {})[0]?.name?.trim() ||
|
||||
"";
|
||||
|
||||
/**
|
||||
* Every birthday falling between `start` and `end`, one per contact per year.
|
||||
*
|
||||
* The range is walked by year rather than by day, so a month view costs one
|
||||
* pass over the contacts and a year view costs two.
|
||||
*/
|
||||
export function birthdaysInRange(cards: Iterable<ContactCard>, start: Date, end: Date): Birthday[] {
|
||||
if (!(start instanceof Date) || !(end instanceof Date) || end <= start) return [];
|
||||
const out: Birthday[] = [];
|
||||
const firstYear = start.getFullYear();
|
||||
const lastYear = end.getFullYear();
|
||||
// A range spanning more years than a calendar view ever shows is a caller
|
||||
// mistake, not something to spend a minute of CPU on.
|
||||
if (lastYear - firstYear > 5) return [];
|
||||
|
||||
for (const card of cards) {
|
||||
const born = birthDate(card);
|
||||
if (!born) continue;
|
||||
const name = displayName(card);
|
||||
if (!name) continue;
|
||||
for (let year = firstYear; year <= lastYear; year++) {
|
||||
const date = occurrence(year, born.month, born.day);
|
||||
if (!date) continue;
|
||||
if (date < start || date >= end) continue;
|
||||
out.push({
|
||||
id: `${BIRTHDAY_ID_PREFIX}${card.id}:${year}`,
|
||||
contactId: card.id,
|
||||
name,
|
||||
date,
|
||||
// Only where the card gave a year, and never negative: a birth year in
|
||||
// the future is bad data, and "turns -3" helps nobody.
|
||||
age: born.year !== null && year - born.year >= 0 ? year - born.year : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* What this instance calls itself, when nothing has said otherwise yet.
|
||||
*
|
||||
* `APP_NAME` is a runtime environment variable, so the real answer arrives
|
||||
* from the server -- on `/api/config` before anybody signs in, and on the
|
||||
* session afterwards. This is what stands in until it does, and what stands
|
||||
* for good if the request fails: a sign-in form with no name on it would be
|
||||
* worse than one with the wrong name.
|
||||
*
|
||||
* One constant rather than the string written out at each of them, because
|
||||
* three copies of a default is how two of them end up stale.
|
||||
*/
|
||||
export const DEFAULT_APP_NAME = "ihasmail";
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/** Best display name for a card. */
|
||||
export function contactDisplayName(c: ContactCard): string {
|
||||
@@ -60,7 +61,7 @@ export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||
if (!m) return null;
|
||||
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
|
||||
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
|
||||
if (m.blobId) return withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -24,6 +24,28 @@ export interface DateTimePrefs {
|
||||
|
||||
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
|
||||
|
||||
/**
|
||||
* The interface language, when one has been chosen over the default.
|
||||
*
|
||||
* Formatting and interface language are separate settings on purpose -- German
|
||||
* dates with an English interface is a real preference. But somebody who picks
|
||||
* German and is then shown "September" and "Monday" has not got what they
|
||||
* asked for: choosing a language *is* a statement about language, and month
|
||||
* names are language.
|
||||
*
|
||||
* So it joins the automatic chain, ahead of the server and the browser, and
|
||||
* only while the formatting locale is left on "Automatic". Setting one
|
||||
* explicitly still wins over everything, which is what that setting is for.
|
||||
* English is not counted, because it is the default nobody has to choose --
|
||||
* an English interface on a German browser should keep German dates, as it
|
||||
* always has.
|
||||
*/
|
||||
let uiLanguage: string | null = null;
|
||||
|
||||
export function setUiLanguageForFormatting(tag: string | null | undefined): void {
|
||||
uiLanguage = tag && tag !== "en" ? tag : null;
|
||||
}
|
||||
|
||||
let prefs: DateTimePrefs = DEFAULT_PREFS;
|
||||
let serverLocale: string | null = null;
|
||||
|
||||
@@ -90,9 +112,9 @@ export function normalizeLocale(raw: string | null | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The locale Intl should use: explicit choice → server → browser default. */
|
||||
/** Explicit choice → chosen interface language → server → browser default. */
|
||||
export function resolvedLocale(): string | undefined {
|
||||
return prefs.locale || serverLocale || undefined;
|
||||
return prefs.locale || uiLanguage || serverLocale || undefined;
|
||||
}
|
||||
|
||||
/** Where the effective locale came from — used to label the "Automatic" option. */
|
||||
@@ -524,7 +546,7 @@ let optionsExtras = "";
|
||||
* outside the generated list is still selectable).
|
||||
*/
|
||||
export function localeOptions(): LocaleOption[] {
|
||||
const extras = `${serverLocale ?? ""}|${prefs.locale}`;
|
||||
const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`;
|
||||
if (optionsCache && optionsExtras === extras) return optionsCache;
|
||||
const tags = new Set<string>(LOCALE_TAGS);
|
||||
if (serverLocale) tags.add(serverLocale);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* A filename for a message saved or attached as `.eml`.
|
||||
*
|
||||
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
|
||||
* without the `u` flag is ASCII: every character of a Russian, Japanese or
|
||||
* Chinese subject failed the class, so those messages downloaded as a row of
|
||||
* underscores. ihasmail ships in nine languages besides English, so the
|
||||
* subjects it handled worst were most of the world's.
|
||||
*
|
||||
* What is actually unsafe in a filename is a much shorter list than "not
|
||||
* ASCII": the path separators, the characters Windows reserves, and the
|
||||
* control range. Everything else is a letter to somebody.
|
||||
*
|
||||
* The test is written by code point rather than as a character class because
|
||||
* the escaping in one of those is its own small trap, and this says plainly
|
||||
* what it means.
|
||||
*/
|
||||
|
||||
/** Reserved on Windows, or a path separator. */
|
||||
const RESERVED = '<>:"/\\|?*';
|
||||
|
||||
function unsafe(ch: string): boolean {
|
||||
const c = ch.codePointAt(0) ?? 0;
|
||||
// C0 controls, and DEL.
|
||||
if (c < 0x20 || c === 0x7f) return true;
|
||||
return RESERVED.includes(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
|
||||
* once a CJK subject is three bytes a character.
|
||||
*/
|
||||
const MAX = 80;
|
||||
|
||||
/** The stem only, so a caller can put another extension on it. */
|
||||
export function sanitizeFilename(subject: string | null | undefined): string {
|
||||
const kept = [...(subject ?? "")].filter((ch) => !unsafe(ch)).join("");
|
||||
return kept
|
||||
// Whitespace becomes an underscore rather than being kept: it is what the
|
||||
// previous rule did, and it saves a quoting question in a shell later.
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, MAX)
|
||||
// Windows refuses a name ending in a dot or a space, and a leading dot
|
||||
// hides the file on Unix. Neither is worth inheriting from a subject.
|
||||
.replace(/^[.\s_]+|[.\s_]+$/g, "");
|
||||
}
|
||||
|
||||
export function emlFilename(subject: string | null | undefined): string {
|
||||
return `${sanitizeFilename(subject) || "message"}.eml`;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Moving and resizing an event by dragging it.
|
||||
*
|
||||
* The arithmetic lives here, away from the grids and under test, for the same
|
||||
* reason the swipe thresholds do: the numbers are the whole thing, and a
|
||||
* mistake in them moves somebody's meeting to the wrong hour rather than
|
||||
* merely looking wrong.
|
||||
*
|
||||
* Nothing here talks to the server or knows what a scope is. It answers one
|
||||
* question — given an event and a gesture, what are the new start and end —
|
||||
* and the caller decides whether it is allowed to save that.
|
||||
*/
|
||||
import { addMinutes } from "./dates";
|
||||
import { isBirthdayEvent } from "./birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Fifteen minutes, which is the smallest slot anybody schedules against and
|
||||
* the largest that still lands where the pointer looks like it is.
|
||||
*/
|
||||
export const SNAP_MINUTES = 15;
|
||||
|
||||
/** An event has to keep some length; dragging its end past its start is not a request. */
|
||||
export const MIN_DURATION_MINUTES = 15;
|
||||
|
||||
/** Round a count of minutes to the nearest slot, away from zero on a tie. */
|
||||
export function snap(minutes: number, slot: number = SNAP_MINUTES): number {
|
||||
return Math.round(minutes / slot) * slot;
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved by a number of minutes, keeping its length.
|
||||
*
|
||||
* Both ends move together: dragging the middle of an event is asking for it to
|
||||
* happen at another time, not to become a different length.
|
||||
*/
|
||||
export function movedBy(span: Span, deltaMinutes: number): Span {
|
||||
const delta = snap(deltaMinutes);
|
||||
return { start: addMinutes(span.start, delta), end: addMinutes(span.end, delta) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved to another day, keeping its time of day and its length.
|
||||
*
|
||||
* This is the month grid, where a cell is a day and nothing finer. An event
|
||||
* dragged from Tuesday to Friday should still be at two o'clock; changing the
|
||||
* hour as well would be answering a question nobody asked.
|
||||
*/
|
||||
export function movedToDay(span: Span, day: Date): Span {
|
||||
const length = span.end.getTime() - span.start.getTime();
|
||||
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), span.start.getHours(), span.start.getMinutes(), 0, 0);
|
||||
return { start, end: new Date(start.getTime() + length) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resized from its end, never shorter than one slot.
|
||||
*
|
||||
* The floor is a clamp rather than a refusal: a drag that goes too far is
|
||||
* still a drag, and stopping at fifteen minutes is what the reader sees
|
||||
* happening while they do it.
|
||||
*/
|
||||
export function resizedBy(span: Span, deltaMinutes: number): Span {
|
||||
const end = addMinutes(span.end, snap(deltaMinutes));
|
||||
const minimum = addMinutes(span.start, MIN_DURATION_MINUTES);
|
||||
return { start: span.start, end: end.getTime() < minimum.getTime() ? minimum : end };
|
||||
}
|
||||
|
||||
/** Seconds, as an ISO 8601 duration — the shape `duration` takes on the wire. */
|
||||
export function formatDuration(seconds: number): string {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (!total) return "PT0S";
|
||||
const time = [hours && `${hours}H`, minutes && `${minutes}M`, secs && `${secs}S`].filter(Boolean).join("");
|
||||
return `P${days ? `${days}D` : ""}${time ? `T${time}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch a move or a resize sends.
|
||||
*
|
||||
* **Computed in the event's own frame, never through an instant.** An event
|
||||
* carries a wall-clock `start` and a `timeZone`, and the grid draws it at the
|
||||
* reader's local time. Working out a new time from those local hours and then
|
||||
* re-expressing it in the event's zone converts twice, and the two conversions
|
||||
* do not cancel: an event in a zone two hours from the reader's moved two
|
||||
* hours the first time it was dragged, and then sat still, because after that
|
||||
* its stored time and the reader's happened to agree.
|
||||
*
|
||||
* Parsing the stored string into its parts and adding minutes to those parts
|
||||
* touches no zone at all, so there is nothing to get wrong. The zone itself is
|
||||
* left exactly as it was: dragging an event is not a claim about where it
|
||||
* happens.
|
||||
*/
|
||||
function parseStored(start: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(start ?? "");
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0), 0);
|
||||
}
|
||||
|
||||
function formatStored(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())}`;
|
||||
}
|
||||
|
||||
export interface DragPatch {
|
||||
start?: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
/** Moved by a number of minutes, in the event's own frame. */
|
||||
export function movePatch(storedStart: string, deltaMinutes: number): DragPatch {
|
||||
const base = parseStored(storedStart);
|
||||
if (!base) return {};
|
||||
return { start: formatStored(addMinutes(base, snap(deltaMinutes))) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved by a whole number of days, keeping the time of day it already had.
|
||||
*
|
||||
* A day *delta*, not a target date, and the difference matters whenever the
|
||||
* event's zone is not the reader's. The month grid's cells are local days; the
|
||||
* event's stored date is in its own zone. Rewriting the stored date to the day
|
||||
* that was dropped on put a Tokyo event dropped on the 11th onto the 10th,
|
||||
* because 15:00 in Tokyo on the 11th is 23:00 in Phoenix on the 10th — the
|
||||
* event went where its own calendar said, not where the pointer did.
|
||||
*
|
||||
* Shifting by the difference between the two local days moves it exactly as
|
||||
* far as the hand did, and adding whole days to a wall clock leaves the time
|
||||
* of day alone without touching the zone.
|
||||
*/
|
||||
export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
|
||||
const base = parseStored(storedStart);
|
||||
if (!base || !Number.isFinite(days)) return {};
|
||||
const moved = new Date(base.getFullYear(), base.getMonth(), base.getDate() + Math.round(days), base.getHours(), base.getMinutes(), base.getSeconds(), 0);
|
||||
return { start: formatStored(moved) };
|
||||
}
|
||||
|
||||
/** Whole days between two local dates, ignoring the time of day on each. */
|
||||
export function dayDelta(from: Date, to: Date): number {
|
||||
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||
const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime();
|
||||
return Math.round((b - a) / 86400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resized from its end. Only the duration moves, so the start -- and with it
|
||||
* the whole question of zones -- is not touched at all.
|
||||
*/
|
||||
export function resizePatch(currentSeconds: number, deltaMinutes: number): DragPatch {
|
||||
const seconds = Math.max(MIN_DURATION_MINUTES * 60, currentSeconds + snap(deltaMinutes) * 60);
|
||||
return { duration: formatDuration(seconds) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event can be dragged at all.
|
||||
*
|
||||
* Three separate reasons it might not be, and they are checked here so no grid
|
||||
* has to remember all three:
|
||||
*
|
||||
* - **A birthday is derived**, not stored. There is nothing on the server to
|
||||
* move, and the date belongs to a contact rather than to a calendar.
|
||||
* - **The calendar may be read-only** — someone else's, shared without write
|
||||
* rights. This is the same question the popover asks before offering Edit.
|
||||
* - **An event with no calendar** has nowhere to be saved.
|
||||
*/
|
||||
export function canDragEvent(event: CalendarEvent | null | undefined, calendar: { myRights?: { mayWriteAll?: boolean; mayWriteOwn?: boolean } } | undefined): boolean {
|
||||
if (!event || isBirthdayEvent(event.id)) return false;
|
||||
if (!calendar) return false;
|
||||
return Boolean(calendar.myRights?.mayWriteAll || calendar.myRights?.mayWriteOwn);
|
||||
}
|
||||
|
||||
/** How far the pointer moved, in minutes, given a grid's pixels-per-hour. */
|
||||
export function pixelsToMinutes(deltaPixels: number, hourHeight: number): number {
|
||||
if (!hourHeight) return 0;
|
||||
return (deltaPixels / hourHeight) * 60;
|
||||
}
|
||||
@@ -54,6 +54,27 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
||||
* legal moves behind a disabled drop. The server refuses those with a message
|
||||
* of its own, which is a better answer than a silent one.
|
||||
*/
|
||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
/**
|
||||
* The ids in a node drag. A multi-file selection is dragged as one payload, so
|
||||
* this is a list even when it holds one -- both drop targets read it the same
|
||||
* way and neither has to care how the drag started.
|
||||
*/
|
||||
export function readDraggedIds(dt: DataTransfer): Id[] {
|
||||
return dt.getData(NODE_MIME).split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question for a multi-file drag. Every one of them has to be able to
|
||||
* land, because the drop is one action: allowing a drag that would move four
|
||||
* of five files and silently skip the fifth is worse than refusing it.
|
||||
*/
|
||||
export function canDropFileNodes(nodes: Record<Id, FileNode>, draggedIds: Id[], targetId: Id | null): boolean {
|
||||
return draggedIds.length > 0 && draggedIds.every((id) => canDropFileNode(nodes, id, targetId));
|
||||
}
|
||||
|
||||
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
||||
const dragged = nodes[draggedId];
|
||||
if (!dragged) return false;
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
@@ -61,7 +62,7 @@ function hardenCss(css: string): string {
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return `/api/image?url=${encodeURIComponent(url)}`;
|
||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
|
||||
|
||||
/**
|
||||
* Translation, in about as little machinery as the job takes.
|
||||
*
|
||||
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
|
||||
* catalogue is loaded and returns the English if it is not there, which buys
|
||||
* three things worth more than tidy symbolic keys: there is no English
|
||||
* catalogue to keep in step with the code, a missing translation degrades to
|
||||
* readable English rather than to `mail.list.archive`, and extracting a string
|
||||
* is wrapping it rather than inventing a name for it. Names are where
|
||||
* extraction stalls -- 55 components is a lot of small naming arguments.
|
||||
*
|
||||
* The cost is that changing English copy orphans its translations. That is the
|
||||
* right trade here: the copy is the product, and a stale translation should
|
||||
* fall back to the new English rather than keep showing the old sentence in
|
||||
* German.
|
||||
*/
|
||||
|
||||
export type Vars = Record<string, string | number>;
|
||||
|
||||
/** One entry per plural category the language actually uses. */
|
||||
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
|
||||
|
||||
export interface Catalog {
|
||||
/** English source → translation. */
|
||||
strings: Record<string, string>;
|
||||
/** English `other` form → the forms this language needs. */
|
||||
plurals: Record<string, PluralForms>;
|
||||
}
|
||||
|
||||
const EMPTY: Catalog = { strings: {}, plurals: {} };
|
||||
|
||||
let current: Catalog = EMPTY;
|
||||
let currentTag: string = DEFAULT_UI_LANGUAGE;
|
||||
let version = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(): void {
|
||||
version += 1;
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in `{name}` placeholders.
|
||||
*
|
||||
* Named rather than positional, because a translator reorders a sentence and
|
||||
* positional arguments do not survive that -- German puts the verb last, and
|
||||
* "{0} of {1}" becomes a different order with the same meaning.
|
||||
*/
|
||||
export function interpolate(template: string, vars?: Vars): string {
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : whole,
|
||||
);
|
||||
}
|
||||
|
||||
/** Translate, falling back to the English that was passed in. */
|
||||
export function t(source: string, vars?: Vars): string {
|
||||
return interpolate(current.strings[source] ?? source, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate where the English word is doing two jobs.
|
||||
*
|
||||
* English-as-key has one real weakness and this is it: "Archive" is the button
|
||||
* that archives a message and the folder the message lands in, and German
|
||||
* needs "Archivieren" for the first and "Archiv" for the second. One key
|
||||
* cannot hold both. "Important" is the same — a priority tag and a folder.
|
||||
*
|
||||
* So a context can be given, and the lookup becomes context + source while the
|
||||
* fallback stays the plain English. A translator sees the context and knows
|
||||
* which sense to render; a catalogue that has not got round to it still
|
||||
* renders the English word, which was right in English all along.
|
||||
*
|
||||
* The separator is a control character rather than a punctuation mark, which
|
||||
* is the gettext convention and for the same reason: no English string can
|
||||
* contain it by accident.
|
||||
*/
|
||||
export const CONTEXT_SEPARATOR = "\u0004";
|
||||
|
||||
export function tc(context: string, source: string, vars?: Vars): string {
|
||||
const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`];
|
||||
return interpolate(keyed ?? current.strings[source] ?? source, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a counted thing.
|
||||
*
|
||||
* Two forms is an English assumption and does not survive the second phase of
|
||||
* this: Russian and Ukrainian use three, and picking between them is not
|
||||
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
|
||||
* knows, so the catalogue supplies the forms and the runtime picks.
|
||||
*
|
||||
* The English `other` form is the key, so a call site reads as the sentence it
|
||||
* produces and needs no invented name.
|
||||
*/
|
||||
export function plural(n: number, forms: PluralForms, vars?: Vars): string {
|
||||
const entry = current.plurals[forms.other] ?? forms;
|
||||
let category: Intl.LDMLPluralRule = "other";
|
||||
try {
|
||||
category = new Intl.PluralRules(currentTag).select(n);
|
||||
} catch {
|
||||
/* an unknown tag: "other" is the safe form and English's only plural */
|
||||
}
|
||||
return interpolate(entry[category] ?? entry.other, { n, ...vars });
|
||||
}
|
||||
|
||||
/**
|
||||
* A translated sentence with elements inside it.
|
||||
*
|
||||
* Some sentences have a `<code>` or a `<kbd>` in the middle of them, and the
|
||||
* two obvious approaches are both wrong. Splitting the sentence into two `t()`
|
||||
* calls hands a translator "This browser cannot register apps for" and "links,
|
||||
* in particular…", which are not sentences and cannot be reordered into a
|
||||
* language that puts the verb somewhere else. Dropping the element and
|
||||
* interpolating plain text keeps the sentence whole but loses the monospace
|
||||
* that told the reader it was a literal.
|
||||
*
|
||||
* So the sentence stays whole and the elements are placeholders in it:
|
||||
*
|
||||
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
|
||||
*
|
||||
* A translator sees one sentence with a named hole and can put the hole
|
||||
* wherever their language wants it.
|
||||
*/
|
||||
export function tNode(source: string, parts: Record<string, ReactNode>, vars?: Vars): ReactNode {
|
||||
const translated = interpolate(current.strings[source] ?? source, vars);
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
const re = /\{(\w+)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(translated))) {
|
||||
if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue;
|
||||
if (m.index > last) out.push(translated.slice(last, m.index));
|
||||
// Keyed, because this is an array and React asks; the index is stable for
|
||||
// a given rendering of a given sentence.
|
||||
out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!]));
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < translated.length) out.push(translated.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Subscribe to catalogue changes without React. Used by the tests. */
|
||||
export function subscribeForTest(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => void listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** The language in force, for anything that needs the tag itself. */
|
||||
export function currentLanguage(): string {
|
||||
return currentTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a catalogue in force.
|
||||
*
|
||||
* Exported for tests and for the loader; nothing else should call it, because
|
||||
* the tag and the catalogue have to move together or `plural` selects with one
|
||||
* language's rules against another's forms.
|
||||
*/
|
||||
export function setCatalog(tag: string, catalog: Catalog): void {
|
||||
/*
|
||||
* Publishing only when something actually changed is not an optimisation
|
||||
* here, it is the thing that stops an infinite loop.
|
||||
*
|
||||
* The root keys its tree on the language version, so a publish remounts
|
||||
* everything. Remounting re-runs the effect that fetches the account's
|
||||
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
||||
* back here -- with the identical tag and the identical catalogue. Publishing
|
||||
* that non-change bumped the version again and went round for ever: the
|
||||
* message list refetched on every pass, which is what it looked like from
|
||||
* the outside.
|
||||
*
|
||||
* Reference equality is enough. `EMPTY` is a module constant and a
|
||||
* dynamically imported catalogue is cached, so the same language really does
|
||||
* hand back the same object.
|
||||
*/
|
||||
if (currentTag === tag && current === catalog) return;
|
||||
currentTag = tag;
|
||||
current = catalog;
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and apply a language.
|
||||
*
|
||||
* English is the built-in: it is the source text, so there is nothing to fetch
|
||||
* and no chance of a missing catalogue leaving the app blank. Everything else
|
||||
* is a dynamic import, so a reader who never leaves English never downloads a
|
||||
* catalogue -- which matters, because the main bundle is already large enough
|
||||
* to warn about.
|
||||
*/
|
||||
/**
|
||||
* The catalogue load that is in flight, so the first paint can wait for it.
|
||||
*
|
||||
* Without this, a cold load paints before the catalogue lands. Components
|
||||
* recover -- the tree is rebuilt when the catalogue arrives -- but a string
|
||||
* computed in an effect does not: a toast fired in that window is emitted in
|
||||
* English and stays English, in an interface that is otherwise German.
|
||||
* Reported as a stale-folder toast that ignored the language setting.
|
||||
*/
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Resolves once the chosen language is in force. English resolves at once. */
|
||||
export function whenLanguageReady(): Promise<void> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export async function loadLanguage(tag: string): Promise<void> {
|
||||
inFlight = loadLanguageNow(tag);
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function loadLanguageNow(tag: string): Promise<void> {
|
||||
const resolved = resolveUiLanguage(tag);
|
||||
if (resolved === DEFAULT_UI_LANGUAGE) {
|
||||
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
|
||||
setCatalog(resolved, mod.catalog);
|
||||
} catch {
|
||||
// A catalogue that will not load leaves English in force rather than a
|
||||
// half-rendered page. `resolveUiLanguage` should already have prevented
|
||||
// this; it being reachable at all is why it is caught.
|
||||
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render when the language changes.
|
||||
*
|
||||
* Used once, at the root, to key the tree — rather than at each of the
|
||||
* thousand call sites, which would make `t()` a hook and extraction far more
|
||||
* invasive than wrapping a string. Language changes are rare enough that
|
||||
* re-rendering everything is the cheaper design.
|
||||
*/
|
||||
export function useLanguageVersion(): number {
|
||||
return useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
() => version,
|
||||
() => version,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* Reading an iCalendar document (RFC 5545), enough of one to draw it -- and,
|
||||
* from `toIcs` at the foot of the file, writing one back out.
|
||||
*
|
||||
* The two halves are not symmetrical and are not meant to be. Reading serves
|
||||
* subscriptions; writing serves export, and starts from the server's RFC 8984
|
||||
* objects rather than from anything this parser produced.
|
||||
*
|
||||
* This is a *subscription* parser, not an importer. A subscribed calendar is
|
||||
* read-only and redrawn from scratch on every refresh, so nothing here has to
|
||||
* round-trip, survive an edit, or preserve a property it does not understand —
|
||||
* which is most of what makes a full iCalendar implementation large. What it
|
||||
* has to do is never mis-state a time, and never hang on a document somebody
|
||||
* else wrote.
|
||||
*
|
||||
* Recurrence is deliberately not expanded. `RRULE` is a small language with a
|
||||
* lot of edge cases, and a subscription that quietly showed the wrong dates
|
||||
* would be worse than one that shows the first occurrence and says so.
|
||||
*/
|
||||
|
||||
import type { JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule } from "@/jmap/types";
|
||||
|
||||
export interface IcsEvent {
|
||||
uid: string;
|
||||
summary: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
location?: string;
|
||||
description?: string;
|
||||
/** True when the source carried an RRULE that has not been expanded. */
|
||||
recurring: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the line folding RFC 5545 requires: a continuation is any line starting
|
||||
* with a space or a tab, and it joins the one before with nothing between.
|
||||
*/
|
||||
export function unfold(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of text.split(/\r\n|\n|\r/)) {
|
||||
if ((raw.startsWith(" ") || raw.startsWith("\t")) && out.length) out[out.length - 1] += raw.slice(1);
|
||||
else out.push(raw);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
name: string;
|
||||
params: Record<string, string>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One content line, as `NAME;PARAM=VALUE:the value`.
|
||||
*
|
||||
* The colon that ends the name is the first one *outside* a quoted parameter,
|
||||
* because a parameter may legally contain one — `DTSTART;TZID="GMT+01:00":…`
|
||||
* is a real thing that a naive `indexOf(":")` reads as a property called
|
||||
* `DTSTART;TZID="GMT+01`.
|
||||
*/
|
||||
export function parseLine(line: string): Line | null {
|
||||
let quoted = false;
|
||||
let colon = -1;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') quoted = !quoted;
|
||||
else if (ch === ":" && !quoted) {
|
||||
colon = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null;
|
||||
const head = line.slice(0, colon);
|
||||
const value = line.slice(colon + 1);
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
quoted = false;
|
||||
for (const ch of head) {
|
||||
if (ch === '"') quoted = !quoted;
|
||||
if (ch === ";" && !quoted) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
} else current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
const name = (parts.shift() ?? "").toUpperCase();
|
||||
if (!name) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, "");
|
||||
}
|
||||
return { name, params, value };
|
||||
}
|
||||
|
||||
/** `\n`, `\,`, `\;` and `\\` are escapes in a TEXT value; nothing else is. */
|
||||
export function unescapeText(value: string): string {
|
||||
return value.replace(/\\([nN,;\\])/g, (_, ch: string) => (ch === "n" || ch === "N" ? "\n" : ch));
|
||||
}
|
||||
|
||||
/**
|
||||
* A DATE or DATE-TIME value.
|
||||
*
|
||||
* Three forms, and the difference between them is the whole of why calendars
|
||||
* are hard:
|
||||
*
|
||||
* - `20260904` — a date. All-day, and it means that date wherever the reader
|
||||
* is, so it is built in local time rather than at UTC midnight, which would
|
||||
* land on the day before for anyone west of Greenwich.
|
||||
* - `20260904T140000Z` — an instant, in UTC.
|
||||
* - `20260904T140000` — a wall clock, with a `TZID` naming where. Without a
|
||||
* library this cannot be converted exactly, so it is read as local time:
|
||||
* right for the overwhelmingly common case of a calendar published in the
|
||||
* reader's own zone, and wrong by the offset otherwise. That limit is
|
||||
* stated rather than hidden.
|
||||
*/
|
||||
export function parseDateValue(value: string, params: Record<string, string> = {}): { date: Date; allDay: boolean } | null {
|
||||
const v = value.trim();
|
||||
const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v);
|
||||
if (dateOnly || params.VALUE === "DATE") {
|
||||
const m = dateOnly ?? /^(\d{4})(\d{2})(\d{2})/.exec(v);
|
||||
if (!m) return null;
|
||||
return { date: new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])), allDay: true };
|
||||
}
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d, h, mi, se, z] = m;
|
||||
if (z) {
|
||||
return { date: new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se))), allDay: false };
|
||||
}
|
||||
return { date: new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se)), allDay: false };
|
||||
}
|
||||
|
||||
/** An RFC 5545 DURATION, as seconds. Only the forms a DTEND substitute uses. */
|
||||
export function parseIcsDuration(value: string): number | null {
|
||||
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const [, sign, w, d, h, mi, s] = m;
|
||||
const total = (Number(w ?? 0) * 604800) + (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(mi ?? 0) * 60) + Number(s ?? 0);
|
||||
return sign === "-" ? -total : total;
|
||||
}
|
||||
|
||||
/** Whether a document is plausibly a calendar, rather than an error page. */
|
||||
export function looksLikeCalendar(text: string): boolean {
|
||||
return /^\s*BEGIN:VCALENDAR/im.test(text);
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
events: IcsEvent[];
|
||||
/** The calendar's own name, where it gave one. */
|
||||
name: string | null;
|
||||
/** Events skipped because they carried a recurrence rule. */
|
||||
recurringCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every VEVENT in the document.
|
||||
*
|
||||
* VTODO, VJOURNAL, VFREEBUSY and VTIMEZONE are stepped over rather than
|
||||
* half-read. An event with no usable start is dropped: there is nowhere to
|
||||
* draw it, and inventing a time is the one thing worse than leaving it out.
|
||||
*/
|
||||
export function parseIcs(text: string): ParseResult {
|
||||
const events: IcsEvent[] = [];
|
||||
let name: string | null = null;
|
||||
let recurringCount = 0;
|
||||
|
||||
let current: Partial<IcsEvent> & { dtend?: Date; duration?: number; endAllDay?: boolean } | null = null;
|
||||
/** Depth of any component that is not a VEVENT, so its properties are ignored. */
|
||||
let skipping = 0;
|
||||
|
||||
for (const raw of unfold(text)) {
|
||||
const line = parseLine(raw);
|
||||
if (!line) continue;
|
||||
const { name: prop, params, value } = line;
|
||||
|
||||
if (prop === "BEGIN") {
|
||||
const kind = value.trim().toUpperCase();
|
||||
if (kind === "VEVENT" && !skipping) current = { recurring: false };
|
||||
else if (kind !== "VCALENDAR") skipping++;
|
||||
continue;
|
||||
}
|
||||
if (prop === "END") {
|
||||
const kind = value.trim().toUpperCase();
|
||||
if (kind === "VEVENT" && current) {
|
||||
const finished = finish(current);
|
||||
if (finished) {
|
||||
if (finished.recurring) recurringCount++;
|
||||
events.push(finished);
|
||||
}
|
||||
current = null;
|
||||
} else if (kind !== "VCALENDAR" && skipping) skipping--;
|
||||
continue;
|
||||
}
|
||||
if (skipping) continue;
|
||||
|
||||
if (!current) {
|
||||
// Calendar-level properties. X-WR-CALNAME is not in the RFC but is what
|
||||
// every publisher actually uses to name a calendar.
|
||||
if (prop === "X-WR-CALNAME") name = unescapeText(value).trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (prop) {
|
||||
case "UID":
|
||||
current.uid = value.trim();
|
||||
break;
|
||||
case "SUMMARY":
|
||||
current.summary = unescapeText(value).trim();
|
||||
break;
|
||||
case "LOCATION":
|
||||
current.location = unescapeText(value).trim();
|
||||
break;
|
||||
case "DESCRIPTION":
|
||||
current.description = unescapeText(value).trim();
|
||||
break;
|
||||
case "RRULE":
|
||||
current.recurring = true;
|
||||
break;
|
||||
case "DTSTART": {
|
||||
const parsed = parseDateValue(value, params);
|
||||
if (parsed) {
|
||||
current.start = parsed.date;
|
||||
current.allDay = parsed.allDay;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "DTEND": {
|
||||
const parsed = parseDateValue(value, params);
|
||||
if (parsed) {
|
||||
current.dtend = parsed.date;
|
||||
current.endAllDay = parsed.allDay;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "DURATION":
|
||||
current.duration = parseIcsDuration(value) ?? undefined;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { events, name, recurringCount };
|
||||
}
|
||||
|
||||
function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): IcsEvent | null {
|
||||
if (!e.start || Number.isNaN(e.start.getTime())) return null;
|
||||
const allDay = Boolean(e.allDay);
|
||||
let end: Date;
|
||||
if (e.dtend && !Number.isNaN(e.dtend.getTime())) end = e.dtend;
|
||||
else if (typeof e.duration === "number") end = new Date(e.start.getTime() + e.duration * 1000);
|
||||
// No end and no duration: a date is the whole day, an instant is a moment.
|
||||
else end = allDay ? new Date(e.start.getTime() + 86400_000) : new Date(e.start.getTime());
|
||||
// An end at or before the start is a document being wrong about itself.
|
||||
if (end.getTime() < e.start.getTime()) end = new Date(e.start.getTime() + (allDay ? 86400_000 : 0));
|
||||
return {
|
||||
uid: e.uid || `${e.start.getTime()}-${e.summary ?? ""}`,
|
||||
summary: e.summary || "(untitled)",
|
||||
start: e.start,
|
||||
end,
|
||||
allDay,
|
||||
location: e.location,
|
||||
description: e.description,
|
||||
recurring: Boolean(e.recurring),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Writing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* JSCalendar out to iCalendar.
|
||||
*
|
||||
* The reverse of everything above, and a narrower job than it looks: the events
|
||||
* come from the server as RFC 8984 objects, and RFC 8984 was written as a
|
||||
* restatement of RFC 5545, so most of this is renaming. Where the two disagree
|
||||
* the comments say which way it went and why.
|
||||
*
|
||||
* What is deliberately not here, stated rather than discovered:
|
||||
*
|
||||
* - **Overrides are applied at the top level only.** (See below.)
|
||||
* A recurrence override is a JSON patch, and a patch addressing
|
||||
* `locations/x/name` is not something this flattens; those paths are left on
|
||||
* the master's value. Plain overridden properties -- a moved time, a changed
|
||||
* title -- come across.
|
||||
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
|
||||
* in ihasmail sets them.
|
||||
*/
|
||||
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
|
||||
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
|
||||
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
|
||||
for (const zone of zonesUsed(events)) lines.push(...vtimezone(zone, ...windowFor(events)));
|
||||
for (const e of events) lines.push(...vevent(e));
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.map(foldLine).join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
/** Every named zone the events refer to; UTC needs no definition. */
|
||||
function zonesUsed(events: JSCalendarEvent[]): string[] {
|
||||
const zones = new Set<string>();
|
||||
for (const e of events) {
|
||||
if (e.showWithoutTime) continue;
|
||||
const tz = e.timeZone;
|
||||
if (tz && tz !== "Etc/UTC" && tz !== "UTC") zones.add(tz);
|
||||
}
|
||||
return [...zones].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The years a definition has to cover.
|
||||
*
|
||||
* A zone's rules are not a fact, they are a decision somebody makes and
|
||||
* changes, so a VTIMEZONE states them for a span rather than for ever. From the
|
||||
* year before the earliest event -- an event can be moved earlier by an
|
||||
* override -- to ten years past the latest, which covers an open-ended weekly
|
||||
* meeting for as long as anyone plans around one.
|
||||
*/
|
||||
function windowFor(events: JSCalendarEvent[]): [number, number] {
|
||||
const years = events.map((e) => Number(e.start.slice(0, 4))).filter((y) => Number.isFinite(y) && y > 1000);
|
||||
const now = new Date().getUTCFullYear();
|
||||
const first = years.length ? Math.min(...years) : now;
|
||||
const last = Math.max(now, years.length ? Math.max(...years) : now);
|
||||
return [first - 1, last + 10];
|
||||
}
|
||||
|
||||
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
|
||||
function escText(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** 75 octets is the limit; a continuation begins with one space. */
|
||||
function foldLine(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
out.push((i ? " " : "") + line.slice(i, i + 74));
|
||||
i += 74;
|
||||
}
|
||||
return out.join("\r\n");
|
||||
}
|
||||
|
||||
/** "2026-09-02T09:00:00" -> "20260902T090000"; the date half alone for all-day. */
|
||||
function stamp(local: string, dateOnly = false): string {
|
||||
const compact = local.replace(/[-:]/g, "").replace(/\.\d+/, "");
|
||||
return dateOnly ? compact.slice(0, 8) : compact.slice(0, 15);
|
||||
}
|
||||
|
||||
/** A UTC instant as iCalendar spells it. */
|
||||
function utcStamp(iso: string): string {
|
||||
return `${iso.replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15)}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date-time property with its zone said the way the zone requires.
|
||||
*
|
||||
* Three shapes, and the difference matters: a floating time carries no zone and
|
||||
* means "whatever clock the reader is on", UTC carries the Z, and everything
|
||||
* else names an IANA zone in TZID.
|
||||
*/
|
||||
function dateProp(name: string, local: string, timeZone: string | null | undefined, allDay: boolean): string {
|
||||
if (allDay) return `${name};VALUE=DATE:${stamp(local, true)}`;
|
||||
if (!timeZone) return `${name}:${stamp(local)}`;
|
||||
if (timeZone === "Etc/UTC" || timeZone === "UTC") return `${name}:${stamp(local)}Z`;
|
||||
return `${name};TZID=${timeZone}:${stamp(local)}`;
|
||||
}
|
||||
|
||||
const STATUS: Record<string, string> = { confirmed: "CONFIRMED", cancelled: "CANCELLED", tentative: "TENTATIVE" };
|
||||
const CLASS: Record<string, string> = { public: "PUBLIC", private: "PRIVATE", secret: "CONFIDENTIAL" };
|
||||
const PARTSTAT: Record<string, string> = {
|
||||
"needs-action": "NEEDS-ACTION", accepted: "ACCEPTED", declined: "DECLINED",
|
||||
tentative: "TENTATIVE", delegated: "DELEGATED",
|
||||
};
|
||||
|
||||
/** A participant's address, wherever this server keeps it. */
|
||||
function participantAddress(p: JSCalendarParticipant): string | null {
|
||||
return p.calendarAddress ?? p.sendTo?.imip ?? (p.email ? `mailto:${p.email}` : null) ?? null;
|
||||
}
|
||||
|
||||
function vevent(e: JSCalendarEvent, recurrenceId?: { local: string; timeZone: string | null | undefined; allDay: boolean }): string[] {
|
||||
const allDay = Boolean(e.showWithoutTime);
|
||||
const tz = allDay ? null : e.timeZone;
|
||||
const out = ["BEGIN:VEVENT", `UID:${e.uid}`];
|
||||
|
||||
/* DTSTAMP is required and means "when this description was made", which for
|
||||
an export is the last time the event changed. */
|
||||
out.push(`DTSTAMP:${utcStamp(e.updated ?? e.created ?? new Date().toISOString())}`);
|
||||
out.push(dateProp("DTSTART", e.start, tz, allDay));
|
||||
/* DURATION rather than DTEND, because that is what JSCalendar holds and
|
||||
converting would mean doing the zone arithmetic here to no purpose. */
|
||||
if (e.duration && e.duration !== "PT0S") out.push(`DURATION:${e.duration}`);
|
||||
if (recurrenceId) out.push(dateProp("RECURRENCE-ID", recurrenceId.local, recurrenceId.timeZone, recurrenceId.allDay));
|
||||
|
||||
if (e.title) out.push(`SUMMARY:${escText(e.title)}`);
|
||||
if (e.description) out.push(`DESCRIPTION:${escText(e.description)}`);
|
||||
const location = Object.values(e.locations ?? {}).map((l) => l.name).filter(Boolean)[0];
|
||||
if (location) out.push(`LOCATION:${escText(location)}`);
|
||||
/* A virtual location is a URL and belongs in URL, not LOCATION: putting a
|
||||
video link where a room name goes is what makes an agenda unreadable. */
|
||||
const virtual = Object.values(e.virtualLocations ?? {}).map((v) => v.uri).filter(Boolean)[0];
|
||||
const link = Object.values(e.links ?? {}).map((l) => l.href).filter(Boolean)[0];
|
||||
if (virtual ?? link) out.push(`URL:${virtual ?? link}`);
|
||||
|
||||
const categories = [...Object.keys(e.keywords ?? {}), ...Object.keys(e.categories ?? {})];
|
||||
if (categories.length) out.push(`CATEGORIES:${categories.map(escText).join(",")}`);
|
||||
if (e.status && STATUS[e.status]) out.push(`STATUS:${STATUS[e.status]}`);
|
||||
if (e.privacy && CLASS[e.privacy]) out.push(`CLASS:${CLASS[e.privacy]}`);
|
||||
/* TRANSP is about whether the time is busy, which is the same question
|
||||
freeBusyStatus answers and the opposite word for it. */
|
||||
if (e.freeBusyStatus) out.push(`TRANSP:${e.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
|
||||
if (e.priority != null) out.push(`PRIORITY:${e.priority}`);
|
||||
if (e.sequence != null) out.push(`SEQUENCE:${e.sequence}`);
|
||||
if (e.created) out.push(`CREATED:${utcStamp(e.created)}`);
|
||||
if (e.updated) out.push(`LAST-MODIFIED:${utcStamp(e.updated)}`);
|
||||
if (e.color) out.push(`COLOR:${e.color}`);
|
||||
|
||||
const organizer = e.organizerCalendarAddress ?? e.replyTo?.imip;
|
||||
if (organizer) out.push(`ORGANIZER:${organizer}`);
|
||||
for (const p of Object.values(e.participants ?? {})) {
|
||||
const address = participantAddress(p);
|
||||
if (!address) continue;
|
||||
const params = [
|
||||
p.name ? `CN=${escText(p.name)}` : "",
|
||||
p.participationStatus && PARTSTAT[p.participationStatus] ? `PARTSTAT=${PARTSTAT[p.participationStatus]}` : "",
|
||||
p.roles?.chair ? "ROLE=CHAIR" : p.roles?.optional ? "ROLE=OPT-PARTICIPANT" : "",
|
||||
p.expectReply ? "RSVP=TRUE" : "",
|
||||
].filter(Boolean);
|
||||
out.push(`ATTENDEE${params.length ? `;${params.join(";")}` : ""}:${address}`);
|
||||
}
|
||||
|
||||
/* Stalwart 0.16 names a single rule `recurrenceRule`; RFC 8984 says
|
||||
`recurrenceRules`. Both are read, because both turn up. */
|
||||
for (const rule of [...(e.recurrenceRules ?? []), ...(e.recurrenceRule ? [e.recurrenceRule] : [])]) {
|
||||
out.push(`RRULE:${rrule(rule, allDay)}`);
|
||||
}
|
||||
const excluded: string[] = [];
|
||||
const modified: Array<[string, Record<string, unknown>]> = [];
|
||||
for (const [when, patch] of Object.entries(e.recurrenceOverrides ?? {})) {
|
||||
if (patch === null || (patch as Record<string, unknown>).excluded === true) excluded.push(when);
|
||||
else modified.push([when, patch as Record<string, unknown>]);
|
||||
}
|
||||
if (excluded.length) {
|
||||
out.push(allDay
|
||||
? `EXDATE;VALUE=DATE:${excluded.map((d) => stamp(d, true)).join(",")}`
|
||||
: tz
|
||||
? `EXDATE;TZID=${tz}:${excluded.map((d) => stamp(d)).join(",")}`
|
||||
: `EXDATE:${excluded.map((d) => stamp(d)).join(",")}`);
|
||||
}
|
||||
/*
|
||||
* An alarm is a component, not a property, so it nests inside the event. Only
|
||||
* DISPLAY and EMAIL are written because they are the only two JSCalendar
|
||||
* names, and an acknowledged alert is still exported -- whether it has fired
|
||||
* is this reader's business, not the file's.
|
||||
*/
|
||||
for (const a of Object.values(e.alerts ?? {})) {
|
||||
const trigger = "offset" in a.trigger
|
||||
? `TRIGGER${a.trigger.relativeTo === "end" ? ";RELATED=END" : ""}:${a.trigger.offset}`
|
||||
: `TRIGGER;VALUE=DATE-TIME:${utcStamp(a.trigger.when)}`;
|
||||
out.push("BEGIN:VALARM", trigger, `ACTION:${a.action === "email" ? "EMAIL" : "DISPLAY"}`, `DESCRIPTION:${escText(e.title ?? "")}`, "END:VALARM");
|
||||
}
|
||||
out.push("END:VEVENT");
|
||||
|
||||
/* A changed occurrence is its own VEVENT carrying the same UID and the
|
||||
RECURRENCE-ID of the slot it replaces -- which is how iCalendar has always
|
||||
said it, and why these come after the master rather than inside it. */
|
||||
for (const [when, patch] of modified) {
|
||||
const merged = { ...e, ...patch } as JSCalendarEvent;
|
||||
delete merged.recurrenceRules;
|
||||
delete merged.recurrenceRule;
|
||||
delete merged.recurrenceOverrides;
|
||||
out.push(...vevent(merged, { local: when, timeZone: tz, allDay }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FREQ: Record<string, string> = {
|
||||
yearly: "YEARLY", monthly: "MONTHLY", weekly: "WEEKLY", daily: "DAILY",
|
||||
hourly: "HOURLY", minutely: "MINUTELY", secondly: "SECONDLY",
|
||||
};
|
||||
const DAYS: Record<string, string> = { mo: "MO", tu: "TU", we: "WE", th: "TH", fr: "FR", sa: "SA", su: "SU" };
|
||||
|
||||
function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
|
||||
const parts = [`FREQ=${FREQ[r.frequency] ?? r.frequency.toUpperCase()}`];
|
||||
if (r.interval && r.interval !== 1) parts.push(`INTERVAL=${r.interval}`);
|
||||
if (r.count != null) parts.push(`COUNT=${r.count}`);
|
||||
/* UNTIL has to match DTSTART's kind: a date for an all-day series, and a UTC
|
||||
instant otherwise. Sending a local time here is the classic way to make a
|
||||
series stop on the wrong day in another zone. */
|
||||
if (r.until) parts.push(`UNTIL=${allDay ? stamp(r.until, true) : `${stamp(r.until)}Z`}`);
|
||||
if (r.byDay?.length) parts.push(`BYDAY=${r.byDay.map((d) => `${d.nthOfPeriod ?? ""}${DAYS[d.day] ?? d.day.toUpperCase()}`).join(",")}`);
|
||||
if (r.byMonthDay?.length) parts.push(`BYMONTHDAY=${r.byMonthDay.join(",")}`);
|
||||
if (r.byMonth?.length) parts.push(`BYMONTH=${r.byMonth.join(",")}`);
|
||||
if (r.byYearDay?.length) parts.push(`BYYEARDAY=${r.byYearDay.join(",")}`);
|
||||
if (r.byWeekNo?.length) parts.push(`BYWEEKNO=${r.byWeekNo.join(",")}`);
|
||||
if (r.byHour?.length) parts.push(`BYHOUR=${r.byHour.join(",")}`);
|
||||
if (r.byMinute?.length) parts.push(`BYMINUTE=${r.byMinute.join(",")}`);
|
||||
if (r.bySecond?.length) parts.push(`BYSECOND=${r.bySecond.join(",")}`);
|
||||
if (r.bySetPosition?.length) parts.push(`BYSETPOS=${r.bySetPosition.join(",")}`);
|
||||
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Time zones */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A zone's definition, worked out from the one the browser already has.
|
||||
*
|
||||
* This exists because leaving it out was wrong, and provably so. A `TZID`
|
||||
* naming an IANA zone with nothing defining it is not resolved by ical.js --
|
||||
* Mozilla's own iCalendar library, and the one Thunderbird's calendar uses --
|
||||
* which falls back to *floating* time. A 09:00 in Phoenix then reads as 09:00
|
||||
* wherever the file is opened: seven hours out, silently, on every timed event.
|
||||
* Measured, not assumed.
|
||||
*
|
||||
* The reason it was left out -- that generating one means shipping a zone
|
||||
* database -- was also wrong. The browser has the IANA database already, behind
|
||||
* `Intl`, and an offset for an instant is a formatting question. Transitions
|
||||
* are then found by looking for the months where the answer changes and
|
||||
* bisecting inside them, rather than by knowing any rules.
|
||||
*
|
||||
* Each transition is written as its own dated sub-component instead of as an
|
||||
* RRULE. It is more lines and no cleverness: a rule has to be *derived*, and a
|
||||
* derived rule that is subtly wrong moves somebody's meeting, while a list of
|
||||
* dates can only be incomplete at the ends -- which is what the window is for.
|
||||
*/
|
||||
export function vtimezone(tzid: string, fromYear: number, toYear: number): string[] {
|
||||
let offsetAt: (d: Date) => number;
|
||||
try {
|
||||
offsetAt = offsetFinder(tzid);
|
||||
} catch {
|
||||
/* A zone `Intl` does not know: say nothing rather than say something wrong.
|
||||
The TZID stays on the events, which is where it was before this. */
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Date.UTC(fromYear, 0, 1);
|
||||
const end = Date.UTC(toYear, 11, 31);
|
||||
const MONTH = 30 * 24 * 3600 * 1000;
|
||||
|
||||
const transitions: Array<{ at: number; from: number; to: number }> = [];
|
||||
let prev = offsetAt(new Date(start));
|
||||
const firstOffset = prev;
|
||||
for (let t = start; t < end; t += MONTH) {
|
||||
const next = Math.min(t + MONTH, end);
|
||||
const here = offsetAt(new Date(next));
|
||||
if (here === prev) continue;
|
||||
// Somewhere in this month. Bisect to the minute, which is finer than any
|
||||
// transition anybody has ever scheduled.
|
||||
let lo = t;
|
||||
let hi = next;
|
||||
// All the way down, rather than to the nearest second and rounded: rounding
|
||||
// the wrong way writes a 02:00 change as 02:00:01, and thirty more halvings
|
||||
// of a range that is already one month is nothing.
|
||||
while (hi - lo > 1) {
|
||||
const mid = lo + Math.floor((hi - lo) / 2);
|
||||
if (offsetAt(new Date(mid)) === prev) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
transitions.push({ at: hi, from: prev, to: here });
|
||||
prev = here;
|
||||
}
|
||||
|
||||
const out = ["BEGIN:VTIMEZONE", `TZID:${tzid}`];
|
||||
if (!transitions.length) {
|
||||
/* A zone that does not change -- Phoenix, Tokyo, UTC+X -- is one standing
|
||||
rule, and RFC 5545 still wants a sub-component to hang it on. */
|
||||
out.push("BEGIN:STANDARD", `DTSTART:${localStamp(new Date(start), firstOffset)}`,
|
||||
`TZOFFSETFROM:${offsetText(firstOffset)}`, `TZOFFSETTO:${offsetText(firstOffset)}`,
|
||||
...tzNameLine(tzid, new Date(start)), "END:STANDARD");
|
||||
} else {
|
||||
for (const tr of transitions) {
|
||||
/* Daylight is the side with the larger offset from UTC; the names are
|
||||
only labels, but a reader that shows them should not show them
|
||||
backwards. */
|
||||
const kind = tr.to > tr.from ? "DAYLIGHT" : "STANDARD";
|
||||
out.push(`BEGIN:${kind}`,
|
||||
/* DTSTART is local time read in the *old* offset, which is what
|
||||
TZOFFSETFROM is there to say. */
|
||||
`DTSTART:${localStamp(new Date(tr.at), tr.from)}`,
|
||||
`TZOFFSETFROM:${offsetText(tr.from)}`,
|
||||
`TZOFFSETTO:${offsetText(tr.to)}`,
|
||||
...tzNameLine(tzid, new Date(tr.at + 60_000)),
|
||||
`END:${kind}`);
|
||||
}
|
||||
}
|
||||
out.push("END:VTIMEZONE");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes east of UTC at an instant, from the zone database `Intl` carries.
|
||||
*
|
||||
* Formatting the instant into the zone and reading the clock back is the
|
||||
* portable way to ask this: `timeZoneName: "longOffset"` is newer than some
|
||||
* browsers this has to run in, and the difference between the two readings is
|
||||
* the offset by definition.
|
||||
*/
|
||||
function offsetFinder(tzid: string): (d: Date) => number {
|
||||
const dtf = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tzid, hourCycle: "h23",
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
// Throws RangeError here, on construction, if the zone is not known.
|
||||
dtf.format(new Date());
|
||||
return (d: Date) => {
|
||||
const p: Record<string, string> = {};
|
||||
for (const part of dtf.formatToParts(d)) p[part.type] = part.value;
|
||||
const asUTC = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));
|
||||
return Math.round((asUTC - d.getTime()) / 60_000);
|
||||
};
|
||||
}
|
||||
|
||||
/** TZNAME, or nothing at all where there is no name worth writing. */
|
||||
function tzNameLine(tzid: string, at: Date): string[] {
|
||||
const name = zoneName(tzid, at);
|
||||
return name ? [`TZNAME:${name}`] : [];
|
||||
}
|
||||
|
||||
/** The zone's short label at an instant -- "MST", "CEST" -- or "" if it has none. */
|
||||
function zoneName(tzid: string, at: Date): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tzid, timeZoneName: "short" }).formatToParts(at);
|
||||
const name = parts.find((p) => p.type === "timeZoneName")?.value.replace(/[^A-Za-z0-9+-]/g, "") ?? "";
|
||||
/* Where a zone has no abbreviation in common use, `Intl` answers "GMT+9",
|
||||
which repeats the offset beside it and reads as a mistake. */
|
||||
return /^(GMT|UTC)[+-]?/.test(name) ? "" : name;
|
||||
} catch {
|
||||
return tzid;
|
||||
}
|
||||
}
|
||||
|
||||
/** "+0200" / "-0700", which is how iCalendar writes an offset. */
|
||||
function offsetText(minutes: number): string {
|
||||
const sign = minutes < 0 ? "-" : "+";
|
||||
const abs = Math.abs(minutes);
|
||||
return `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}${String(abs % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** An instant written as the wall clock it shows at a given offset. */
|
||||
function localStamp(at: Date, offsetMinutes: number): string {
|
||||
const shifted = new Date(at.getTime() + offsetMinutes * 60_000);
|
||||
return shifted.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Labels arranged into a tree, and which of them the sidebar draws.
|
||||
*
|
||||
* **Nesting is display only.** The keywords stay flat on the message, which is
|
||||
* what keeps them readable by every other client — moving a label under
|
||||
* another rewrites nothing in the mailbox, and a client that knows nothing
|
||||
* about ihasmail still sees the same keywords it always did.
|
||||
*/
|
||||
import type { Label, LabelVisibility } from "@/store/settings";
|
||||
|
||||
export interface LabelNode {
|
||||
label: Label;
|
||||
/** 0 at the top; only ever used to indent. */
|
||||
depth: number;
|
||||
children: LabelNode[];
|
||||
/** Unread messages carrying this keyword. Its own, not its children's. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
const visibilityOf = (l: Label): LabelVisibility => l.visibility ?? "always";
|
||||
|
||||
/**
|
||||
* Build the tree.
|
||||
*
|
||||
* Two malformed shapes have to survive, because settings sync between devices
|
||||
* and a label can be deleted on one while another is still pointing at it:
|
||||
*
|
||||
* - **A parent that no longer exists** puts its child back at the top level
|
||||
* rather than dropping it. A label that vanishes from the sidebar because
|
||||
* something else was deleted is a label the reader cannot get back.
|
||||
* - **A cycle** — a under b, b under a — is broken by treating the first
|
||||
* label that closes the loop as a root. Nothing is lost and nothing hangs.
|
||||
*/
|
||||
export function labelTree(labels: Label[], counts: Record<string, number> = {}): LabelNode[] {
|
||||
const byKeyword = new Map<string, Label>();
|
||||
for (const l of labels) byKeyword.set(l.keyword, l);
|
||||
|
||||
/** Whether following `parent` from here reaches a real root without looping. */
|
||||
const rooted = (l: Label): boolean => {
|
||||
const seen = new Set<string>([l.keyword]);
|
||||
let cur = l.parent ? byKeyword.get(l.parent) : undefined;
|
||||
while (cur) {
|
||||
if (seen.has(cur.keyword)) return false;
|
||||
seen.add(cur.keyword);
|
||||
cur = cur.parent ? byKeyword.get(cur.parent) : undefined;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const nodes = new Map<string, LabelNode>();
|
||||
for (const l of labels) nodes.set(l.keyword, { label: l, depth: 0, children: [], unread: counts[l.keyword] ?? 0 });
|
||||
|
||||
const roots: LabelNode[] = [];
|
||||
for (const l of labels) {
|
||||
const node = nodes.get(l.keyword)!;
|
||||
const parent = l.parent && l.parent !== l.keyword && rooted(l) ? nodes.get(l.parent) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
|
||||
const setDepth = (n: LabelNode, depth: number) => {
|
||||
n.depth = depth;
|
||||
for (const c of n.children) setDepth(c, depth + 1);
|
||||
};
|
||||
for (const r of roots) setDepth(r, 0);
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes the sidebar draws, flattened in the order they appear.
|
||||
*
|
||||
* `hidden` removes a label outright. `unread` shows it only while it has
|
||||
* unread mail — which is the point of it: a label you filed something under
|
||||
* two years ago should not take up a row for ever.
|
||||
*
|
||||
* **A label kept by the rule keeps its ancestors, whatever they said.** A
|
||||
* child cannot be drawn under a parent that is not there; the alternative is
|
||||
* promoting it to the top level, which silently rearranges the tree at the
|
||||
* moment the reader is least able to explain why. The parent comes back as a
|
||||
* container, and its own count still says whether it has anything of its own.
|
||||
*/
|
||||
export function visibleLabels(roots: LabelNode[]): LabelNode[] {
|
||||
const keep = new Set<LabelNode>();
|
||||
|
||||
const walk = (n: LabelNode): boolean => {
|
||||
// Depth-first: a node's fate depends on its descendants, not the reverse.
|
||||
let keptChild = false;
|
||||
for (const c of n.children) keptChild = walk(c) || keptChild;
|
||||
|
||||
const v = visibilityOf(n.label);
|
||||
const self = v === "always" || (v === "unread" && n.unread > 0);
|
||||
if (self || keptChild) {
|
||||
keep.add(n);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const r of roots) walk(r);
|
||||
|
||||
const out: LabelNode[] = [];
|
||||
const emit = (n: LabelNode) => {
|
||||
if (!keep.has(n)) return;
|
||||
out.push(n);
|
||||
for (const c of n.children) emit(c);
|
||||
};
|
||||
for (const r of roots) emit(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords that would become unreachable if `keyword` were re-parented under
|
||||
* `candidate` — used to keep the parent picker from offering a cycle.
|
||||
*/
|
||||
export function descendantKeywords(roots: LabelNode[], keyword: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const find = (n: LabelNode): LabelNode | null => {
|
||||
if (n.label.keyword === keyword) return n;
|
||||
for (const c of n.children) {
|
||||
const hit = find(c);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let node: LabelNode | null = null;
|
||||
for (const r of roots) {
|
||||
node = find(r);
|
||||
if (node) break;
|
||||
}
|
||||
if (!node) return out;
|
||||
const collect = (n: LabelNode) => {
|
||||
for (const c of n.children) {
|
||||
out.add(c.label.keyword);
|
||||
collect(c);
|
||||
}
|
||||
};
|
||||
collect(node);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The interface languages that actually have strings shipped.
|
||||
*
|
||||
* Deliberately not `lib/locales.ts`. That list is every tag CLDR can format a
|
||||
* date in — about 620 of them — and it answers a different question: what
|
||||
* calendar, clock and numerals to use. This one answers "what language is the
|
||||
* app written in", and the only honest entries are the ones somebody has
|
||||
* translated. Offering a language with no strings behind it would set
|
||||
* `<html lang>` to a language the page is not in, which is worse than not
|
||||
* offering it: it stops Chrome offering to translate a page the reader cannot
|
||||
* read.
|
||||
*
|
||||
* Wanting German dates with an English interface is a real preference, and so
|
||||
* is the reverse, which is why `uiLanguage` and `locale` are separate settings
|
||||
* rather than one.
|
||||
*
|
||||
* Adding a language means adding its catalogue and then adding it here, in
|
||||
* that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout
|
||||
* work well beyond strings, so they are not simply a matter of another entry.
|
||||
*/
|
||||
export interface UiLanguage {
|
||||
/** BCP 47, and what `<html lang>` is set to. */
|
||||
tag: string;
|
||||
/** The language's name in that language, which is how a picker should read. */
|
||||
name: string;
|
||||
/**
|
||||
* Machine-translated and not yet checked by somebody who speaks it.
|
||||
*
|
||||
* Stays true until a native speaker has actually read the catalogue and said
|
||||
* so. It is not a measure of how complete the file is -- a catalogue can be
|
||||
* word-for-word finished and still read like a machine wrote it, which is
|
||||
* the thing this flag is about. Removing it is a deliberate act by a person,
|
||||
* not something a coverage number earns.
|
||||
*/
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
export const UI_LANGUAGES: readonly UiLanguage[] = [
|
||||
{ tag: "en", name: "English" },
|
||||
{ tag: "de", name: "Deutsch", beta: true },
|
||||
{ tag: "es", name: "Español", beta: true },
|
||||
{ tag: "fr", name: "Français", beta: true },
|
||||
{ tag: "nl", name: "Nederlands", beta: true },
|
||||
{ tag: "pt-BR", name: "Português (Brasil)", beta: true },
|
||||
{ tag: "ja", name: "日本語", beta: true },
|
||||
{ tag: "ru", name: "Русский", beta: true },
|
||||
{ tag: "uk", name: "Українська", beta: true },
|
||||
{ tag: "zh-Hans", name: "简体中文", beta: true },
|
||||
];
|
||||
|
||||
/** Where to report a bad translation. Beta languages depend on it. */
|
||||
export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20";
|
||||
|
||||
export const DEFAULT_UI_LANGUAGE = "en";
|
||||
|
||||
/**
|
||||
* The language to actually render in.
|
||||
*
|
||||
* A stored preference is only honoured if its strings are still shipped: a
|
||||
* catalogue can be withdrawn, and an account carrying `de` from another
|
||||
* machine must not leave this one claiming to be German while showing English.
|
||||
*/
|
||||
export function resolveUiLanguage(stored: string | undefined | null): string {
|
||||
if (!stored) return DEFAULT_UI_LANGUAGE;
|
||||
return UI_LANGUAGES.some((l) => l.tag === stored) ? stored : DEFAULT_UI_LANGUAGE;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Just enough LDIF to read an address book out of one (RFC 2849).
|
||||
*
|
||||
* Unlike vCard, which the server parses for us, nothing on the JMAP side reads
|
||||
* LDIF -- so this does. It is a reader and not a writer, and it stops at the
|
||||
* syntax: what the attributes *mean* is a schema question, and lives in
|
||||
* `mozillaAb.ts` next door, because LDIF says nothing about either.
|
||||
*/
|
||||
|
||||
/** One entry: its distinguished name, and its attributes in file order. */
|
||||
export interface LdifRecord {
|
||||
dn: string;
|
||||
/**
|
||||
* Attribute name, lowercased and stripped of options, to every value given
|
||||
* for it. Names are case-insensitive in LDAP and exporters disagree in
|
||||
* practice -- SOGo writes `mozillahomepostalcode`, the schema documents
|
||||
* `mozillaHomePostalCode` -- so they are folded here rather than at each of
|
||||
* the fifty-odd places that reads one.
|
||||
*/
|
||||
attrs: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo line folding: a line beginning with a single space continues the one
|
||||
* before it, which is how LDIF fits a long value into 78 columns. Done first
|
||||
* and for every line, so nothing downstream has to think about it -- including
|
||||
* comments, which fold the same way.
|
||||
*/
|
||||
function unfold(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
||||
// A continuation with nothing above it to continue is not a continuation.
|
||||
if (raw.startsWith(" ") && out.length && out[out.length - 1] !== "") {
|
||||
out[out.length - 1] += raw.slice(1);
|
||||
continue;
|
||||
}
|
||||
out.push(raw);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `::` means the value is base64, which is how a non-ASCII name or one with
|
||||
* awkward whitespace survives the format.
|
||||
*
|
||||
* A value that will not decode is dropped rather than thrown: one mangled line
|
||||
* in a thousand-entry export should cost that line, not the import.
|
||||
*/
|
||||
function decodeBase64(value: string): string | null {
|
||||
try {
|
||||
const binary = atob(value.replace(/\s+/g, ""));
|
||||
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `name:`, `name::` for base64, or `name:<` for a URL we are in no position to follow. */
|
||||
const LINE = /^([A-Za-z0-9;.-]+):([:<]?)[ ]*(.*)$/;
|
||||
|
||||
export function parseLdif(text: string): LdifRecord[] {
|
||||
const records: LdifRecord[] = [];
|
||||
let current: LdifRecord | null = null;
|
||||
|
||||
const finish = () => {
|
||||
// A record is only a record once it has said what it is about. This is also
|
||||
// what makes the `version: 1` header at the top of a file disappear on its
|
||||
// own, rather than needing to be named and skipped.
|
||||
if (current && Object.keys(current.attrs).length) records.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of unfold(text)) {
|
||||
if (line.trim() === "") {
|
||||
finish();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#")) continue;
|
||||
const m = LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, rawName, marker, rawValue] = m;
|
||||
// An external file reference. We are a browser reading one file; there is
|
||||
// nothing to fetch and pretending otherwise would invent data.
|
||||
if (marker === "<") continue;
|
||||
const value = marker === ":" ? decodeBase64(rawValue!) : rawValue!;
|
||||
if (value === null) continue;
|
||||
const name = rawName!.split(";")[0]!.toLowerCase();
|
||||
if (name === "dn") {
|
||||
finish();
|
||||
current = { dn: value, attrs: {} };
|
||||
continue;
|
||||
}
|
||||
// Attributes before any `dn` belong to no entry.
|
||||
if (!current) continue;
|
||||
(current.attrs[name] ??= []).push(value);
|
||||
}
|
||||
finish();
|
||||
|
||||
// A change record describes an edit to a directory, not a person in it.
|
||||
// "add" is the only one that carries a whole entry; the rest are instructions
|
||||
// about an entry that lives somewhere else, and importing them as contacts
|
||||
// would produce cards with a field or two and no name.
|
||||
return records.filter((r) => {
|
||||
const change = r.attrs.changetype?.[0]?.toLowerCase();
|
||||
return !change || change === "add";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* What a click on a message row means.
|
||||
*
|
||||
* Lifted out of the list so the rules sit together and can be tested. They had
|
||||
* drifted apart while they were two branches of one handler: shift-click
|
||||
* selected the whole range including the row it started from, and ctrl-click
|
||||
* selected only the row clicked, leaving the message you had open highlighted
|
||||
* but unticked. Both looked picked; one was. That is issue #186, and the reason
|
||||
* this is a function rather than a comment asking the next person to be careful.
|
||||
*/
|
||||
|
||||
export type RowClick =
|
||||
| { kind: "open" }
|
||||
| { kind: "select"; ids: Id[]; on: boolean; moveAnchor: boolean };
|
||||
|
||||
export function rowClick(opts: {
|
||||
/** The row clicked. */
|
||||
rowId: Id;
|
||||
/** Every row on screen, in the order they are shown. */
|
||||
ids: Id[];
|
||||
/** The row a range would extend from: the last one clicked without shift. */
|
||||
anchor: Id | null;
|
||||
selected: Record<Id, boolean>;
|
||||
modifiers: { shift: boolean; ctrl: boolean };
|
||||
isMobile: boolean;
|
||||
}): RowClick {
|
||||
const { rowId, ids, anchor, selected, modifiers, isMobile } = opts;
|
||||
const selectedCount = Object.keys(selected).length;
|
||||
|
||||
// A range, from the anchor to here, inclusive at both ends.
|
||||
if (modifiers.shift && anchor) {
|
||||
const from = ids.indexOf(anchor);
|
||||
const to = ids.indexOf(rowId);
|
||||
if (from >= 0 && to >= 0) {
|
||||
const [start, end] = from < to ? [from, to] : [to, from];
|
||||
// The anchor stays where it is, so extending the range again grows it
|
||||
// from the same place rather than from wherever it last reached.
|
||||
return { kind: "select", ids: ids.slice(start, end + 1), on: true, moveAnchor: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiers.ctrl) {
|
||||
/*
|
||||
* The row that was already current joins the selection.
|
||||
*
|
||||
* Opening a message does not select it -- it is highlighted because it is
|
||||
* the one being read, which is a different state -- so picking a second one
|
||||
* with ctrl used to select only the second, and every action that followed
|
||||
* quietly applied to half of what the screen showed.
|
||||
*
|
||||
* Only while nothing is selected yet. Once there is a selection, ctrl-click
|
||||
* toggles exactly one row, which is the whole point of it.
|
||||
*/
|
||||
if (!selectedCount && anchor && anchor !== rowId && ids.includes(anchor)) {
|
||||
return { kind: "select", ids: [anchor, rowId], on: true, moveAnchor: true };
|
||||
}
|
||||
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||
}
|
||||
|
||||
// On a touchscreen, once anything is selected a plain tap goes on selecting:
|
||||
// there is no modifier to hold, and opening a message mid-selection is almost
|
||||
// never what the tap meant.
|
||||
if (selectedCount > 0 && isMobile) {
|
||||
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||
}
|
||||
|
||||
return { kind: "open" };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user