Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa10563ed0 | ||
|
|
b7e0fc0c7d | ||
|
|
abd2269581 | ||
|
|
3530a0cc64 | ||
|
|
ba819369f4 | ||
|
|
bae29e647e | ||
|
|
4a12a898d1 | ||
|
|
aa13fcae7f | ||
|
|
55721faed2 | ||
|
|
a9dd6b3569 | ||
|
|
1fc24e6273 | ||
|
|
ea36270adf | ||
|
|
b8ab46270d | ||
|
|
d77928426a | ||
|
|
1514fd9e0a | ||
|
|
62242493f8 | ||
|
|
f7db2f23fa | ||
|
|
4a9c0c1c55 | ||
|
|
acc9df299e | ||
|
|
0dd0aed0dd | ||
|
|
ca2c4544dc | ||
|
|
a5c971ace4 | ||
|
|
aad599d802 | ||
|
|
25046e85e3 | ||
|
|
145abef3fe | ||
|
|
e29e3b35b0 | ||
|
|
99382486b8 | ||
|
|
9e2987f43c | ||
|
|
788eb0b0e1 | ||
|
|
b7f4b4e496 | ||
|
|
0fc4bd7a52 | ||
|
|
56e1f4f1be | ||
|
|
a9231a335c | ||
|
|
00868f053c | ||
|
|
ea55b1b2aa | ||
|
|
a027b312c5 | ||
|
|
db6afb72ff | ||
|
|
047a0029bf | ||
|
|
b0be465e50 | ||
|
|
fe9285062c | ||
|
|
9a2f355a4a | ||
|
|
4740ca46eb | ||
|
|
c04bc7a31b | ||
|
|
377e3aba2a | ||
|
|
27dbc8ac41 | ||
|
|
cc073693f4 | ||
|
|
a9ff405135 | ||
|
|
65443a235a | ||
|
|
0cbebed645 | ||
|
|
16dd867707 | ||
|
|
9a7f37b540 | ||
|
|
9a8c7516d7 | ||
|
|
6170fc3944 | ||
|
|
ee998eff46 | ||
|
|
d4d218f078 | ||
|
|
e9c183f2a5 | ||
|
|
be46c0c3c9 | ||
|
|
05c9bd46c2 | ||
|
|
d1d3041ce6 | ||
|
|
b0944b2f42 | ||
|
|
ca2c71e858 | ||
|
|
2f8af1ce94 | ||
|
|
6127a77458 | ||
|
|
afecc7d1dc | ||
|
|
d756d3ca68 | ||
|
|
f1f762c228 | ||
|
|
232e518d55 | ||
|
|
287af22ef8 | ||
|
|
409e5578a0 | ||
|
|
f1a2972d3a | ||
|
|
3521487f6c | ||
|
|
f5373c6fcd | ||
|
|
c4b9741c6d | ||
|
|
1aee40a169 | ||
|
|
d5468277d6 | ||
|
|
0a49c914eb | ||
|
|
57ff18bb7f | ||
|
|
b8263aa785 | ||
|
|
ee7542fd86 | ||
|
|
c92a68aba1 | ||
|
|
41f4cc7f8c | ||
|
|
c13a5375ab | ||
|
|
4025812c5d | ||
|
|
ad0b913efb | ||
|
|
337c46ebda | ||
|
|
c55b54163f | ||
|
|
3621e81d0c | ||
|
|
c4731fc8e0 | ||
|
|
0db2acb52b | ||
|
|
15d64c6320 | ||
|
|
e87ba09d70 | ||
|
|
54c256e69e | ||
|
|
98edc18570 | ||
|
|
9cdf84203b | ||
|
|
24fac8204a | ||
|
|
eadec49b4f | ||
|
|
95e442b2dd | ||
|
|
3ec4dc44fb | ||
|
|
7caa847737 | ||
|
|
0b278ca3c0 | ||
|
|
860cda22ab | ||
|
|
ecbcd76372 | ||
|
|
4846b5515c | ||
|
|
b870ee1910 | ||
|
|
36c19d639b | ||
|
|
d0828d67ed | ||
|
|
be893ef482 | ||
|
|
86660497b1 | ||
|
|
626a48e678 | ||
|
|
49c06e6efe | ||
|
|
487da2fbca | ||
|
|
c17887e48e |
@@ -12,18 +12,6 @@ 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
|
||||
@@ -40,20 +28,8 @@ SESSION_TTL=43200
|
||||
SESSION_REMEMBER_TTL=2592000
|
||||
|
||||
# Where to persist sessions so restarts don't log everyone out (optional).
|
||||
# Leave it empty to hold sessions in memory only, which is what an immutable
|
||||
# instance does -- see IMMUTABLE below.
|
||||
SESSION_FILE=./data/sessions.json
|
||||
|
||||
# Assert that this instance is running as an immutable container: read-only
|
||||
# root filesystem, no durable state of its own. It is checked rather than
|
||||
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
|
||||
# the filesystem it is installed on turns out to be writable. Off by default.
|
||||
# Running one looks like:
|
||||
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
# The cost today is that a restart signs everyone out, since there is nowhere
|
||||
# left to keep the sessions. Removing that cost is what the OAuth work is for.
|
||||
# IMMUTABLE=1
|
||||
|
||||
# Upstream timeouts / limits
|
||||
UPSTREAM_TIMEOUT=30000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
@@ -68,42 +44,4 @@ APP_NAME=ihasmail
|
||||
# asks whoever runs a modified version to offer *that* version's source -- so if
|
||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||
# and in Settings > About.
|
||||
SOURCE_URL=https://github.com/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.
|
||||
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
|
||||
|
||||
@@ -3,14 +3,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Lets CI be run by hand against any ref, including a specific commit.
|
||||
# Without this there is no way to re-run a check that never started: a run
|
||||
# GitHub queues and then orphans -- as it did to every run created during the
|
||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
||||
# nor cancelled ("already completed"), and the workflow has no other trigger
|
||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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,6 +6,3 @@ dist/
|
||||
server/data/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Worktrees used by parallel agents; never part of a commit.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
||||
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
||||
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
||||
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
|
||||
# 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/
|
||||
@@ -26,46 +10,20 @@ RUN npm run build
|
||||
|
||||
# ---- runtime stage ----
|
||||
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 \
|
||||
BASE_PATH=$BASE_PATH
|
||||
SESSION_FILE=/data/sessions.json
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY server/package.json server/
|
||||
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
||||
# set it never looks further; without it, it falls back to package.json rather
|
||||
# than failing, since there is no git in here to ask.
|
||||
COPY scripts/ ./scripts/
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
RUN mkdir -p /data && chown -R node:node /data /app
|
||||
USER node
|
||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||
# anonymous volume mounted there anyway, and that mount stays writable even
|
||||
# under `--read-only`. So the directive quietly put a writable hole in a
|
||||
# container meant to be immutable, and left an orphaned volume behind every
|
||||
# time one was replaced -- while never persisting anything across a redeploy,
|
||||
# since each new container got a fresh empty volume of its own. Deployments
|
||||
# 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.
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
# 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
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Known issues and pending QA
|
||||
|
||||
What was checked, against which server, and when. For a failure you are hitting
|
||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
|
||||
eight seconds of downtime, and as of **2026-08-26 there is nothing left
|
||||
pending**. Every entry below was exercised against 0.16.19 on the date it
|
||||
names, and the dates still say so: the upgrade was read against the
|
||||
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
|
||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
||||
The calendar entries below carrying a 2026-08-31 date are the exception: those
|
||||
were exercised against the live 0.16.20 directly.
|
||||
What remains here is not a list of unknowns but of things worth knowing — where
|
||||
Stalwart departs from a spec, where a setting has to be turned on for a feature
|
||||
to work, and what ihasmail deliberately does not do.
|
||||
|
||||
Entries keep saying what was checked and when, because this section has been
|
||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
||||
capability looked for in the wrong place meant it had never run at all.
|
||||
|
||||
Some entries record what a live **0.15.5** proved before that server was
|
||||
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
||||
works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
||||
|
||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
|
||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
|
||||
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
|
||||
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
|
||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
||||
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
||||
|
||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
||||
|
||||
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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,78 +2,107 @@
|
||||
<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>
|
||||
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
|
||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Tested against Stalwart 0.16.19 and 0.15.5" src="https://img.shields.io/badge/Stalwart-0.16.19%20%7C%200.15.5-6366f1?style=flat-square"></a>
|
||||
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
|
||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art) — a container
|
||||
with nothing to persist, and a Gmail-class client on top of it.**
|
||||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
||||
|
||||
Mail, calendars, contacts, files and filters in a responsive single-page app
|
||||
that works equally well on a desktop monitor and a phone. It talks only JMAP
|
||||
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
|
||||
database, and with `IMMUTABLE=1` no writable filesystem either. Everything
|
||||
durable belongs to Stalwart; the container is disposable.
|
||||
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, in a responsive single-page app that works equally well on a desktop monitor and a phone. It talks only JMAP (plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no database.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| 🌐 **[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 |
|
||||
> Status: 2.0 rewrite, in QA against a live Stalwart server — **0.16.19**
|
||||
> since 2026-08-25, 0.15.5 before that. The previous FastAPI/HTMX prototype
|
||||
> has been removed entirely (only the logo survived, and it has since lost
|
||||
> the `.com` wordmark it used to carry — ihasmail is the software, not the
|
||||
> hosted instance).
|
||||
|
||||
This file is for people working *on* ihasmail. Everything about running it
|
||||
lives in the docs.
|
||||
ihasmail supports both generations of Stalwart, which are less alike than the
|
||||
version numbers suggest: 0.16 replaced the REST management API with JMAP
|
||||
registry objects, changed the shape of `FileNode`, split its rights up, and
|
||||
moved configuration into the store. Where the two differ, ihasmail detects
|
||||
which it is talking to rather than assuming — see [Known issues / pending
|
||||
QA](#known-issues--pending-qa) for what is verified on which.
|
||||
|
||||
The live instance was moved from 0.15.5 to 0.16.19 with
|
||||
[stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator), a
|
||||
companion project: an in-place upgrade tool that checkpoints every phase,
|
||||
refuses to start on the things that cannot be fixed mid-migration, and
|
||||
validates the server afterwards. The upgrade is genuinely treacherous by hand
|
||||
— the store is migrated in place with no way back, and Stalwart's own
|
||||
converter drops settings without saying so — and that migration took eight
|
||||
seconds of downtime with nothing lost.
|
||||
|
||||
## Screenshots
|
||||
|
||||
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
| **Inbox & conversation view (dark)**  | **Inbox & conversation view (light)**  |
|
||||
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
||||
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
||||
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
||||
|
||||
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
## Features
|
||||
|
||||
## What's in it
|
||||
**Mail**
|
||||
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
|
||||
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
|
||||
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
|
||||
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
|
||||
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
|
||||
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
|
||||
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
|
||||
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
|
||||
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
|
||||
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
|
||||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||||
|
||||
- **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
|
||||
**Calendar** (JMAP Calendars / JSCalendar)
|
||||
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
||||
- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour
|
||||
- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability`
|
||||
- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week)
|
||||
- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
|
||||
**Contacts** (JMAP Contacts / JSContact)
|
||||
- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact
|
||||
|
||||
## Requires Stalwart 0.16 or newer
|
||||
**Files** (JMAP FileNode)
|
||||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||
|
||||
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
|
||||
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
|
||||
and moved configuration into the store; supporting both generations meant a
|
||||
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
||||
reached production. With one supported generation a wrong guess is a loud error
|
||||
on the first call.
|
||||
**Settings**
|
||||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
|
||||
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
|
||||
|
||||
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
**Platform**
|
||||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||||
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||||
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
|
||||
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||
React SPA • session cookie ⇄ Basic auth
|
||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||
```
|
||||
|
||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
|
||||
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
||||
|
||||
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
@@ -84,238 +113,7 @@ docker compose up --build -d
|
||||
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||
```
|
||||
|
||||
Users sign in with their Stalwart mailbox credentials. **An account with
|
||||
two-factor authentication needs an app password**, created in Stalwart's own
|
||||
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
|
||||
password grant, so no client holding a username and password can exchange them
|
||||
plus a code for a token.
|
||||
|
||||
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
|
||||
and there is nothing left to write, so the container can run with no writable
|
||||
filesystem at all:
|
||||
|
||||
```bash
|
||||
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||
or if the filesystem it is installed on turns out to be writable after all.
|
||||
Without it the same misconfiguration is silent — sessions are held in memory
|
||||
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||
the first sign-in and nothing else until the instance is replaced and everyone
|
||||
is signed out.
|
||||
|
||||
That sign-out is the standing cost of this mode today, since sessions have
|
||||
nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
### Several Stalwart servers
|
||||
|
||||
One ihasmail can front more than one Stalwart, choosing by the domain somebody
|
||||
signs in with. **`STALWART_URL` stays required and stays the default**, so an
|
||||
installation that sets nothing else behaves exactly as it always has.
|
||||
|
||||
```bash
|
||||
-e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
|
||||
-v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": "https://jmap.customer-b.test"
|
||||
}
|
||||
```
|
||||
|
||||
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
|
||||
with the rules written in it.
|
||||
|
||||
A domain nobody listed — and a bare username, which Stalwart accepts and which
|
||||
has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
|
||||
back.** If its server is unreachable that sign-in fails rather than retrying
|
||||
against the default, because falling back would authenticate somebody against a
|
||||
server their domain was deliberately routed away from; if the same account name
|
||||
existed there they would land in another tenant's mailbox.
|
||||
|
||||
Read once at startup, so editing it means restarting the container. Malformed
|
||||
JSON, a duplicate domain once lower-cased, or a value that is not an `http(s)`
|
||||
URL stops the server rather than failing quietly at somebody's sign-in. The
|
||||
servers themselves are not contacted at boot — a mapping is a routing table,
|
||||
not a health check, and one customer's outage must not stop ihasmail starting
|
||||
for everybody else.
|
||||
|
||||
This is one server per *person*, chosen at sign-in. Several servers at once for
|
||||
one person, with unified or cross-account views, is not supported: JMAP account
|
||||
ids are only unique within a server, so it would mean namespacing ids through
|
||||
the proxy. Reading somebody else's mail, calendars or files on the *same* server
|
||||
already works through JMAP sharing.
|
||||
|
||||
### Settings the installation decides
|
||||
|
||||
A deployment can seed and lock user settings, which is what a school wanting
|
||||
"warn about outside senders" on for three thousand pupils needs — asking three
|
||||
thousand pupils is not a plan.
|
||||
|
||||
```bash
|
||||
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
|
||||
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
|
||||
```
|
||||
|
||||
Three powers, and the differences between them matter:
|
||||
|
||||
| Section | Applies to | Reader can change it |
|
||||
| --- | --- | --- |
|
||||
| `defaults` | accounts that have never had settings of their own | yes, at any time |
|
||||
| `enforced` | everyone, on every load | no — the control goes dead |
|
||||
| `changes` | everyone, **once each**, including existing accounts | yes, afterwards, and it stays changed |
|
||||
|
||||
`changes` is the one that needs explaining. It turns something on for people who
|
||||
are *already here* — the reason a plain default is not enough — while still
|
||||
leaving them the last word. Each entry carries its own `version`, which every
|
||||
account remembers once it has had it, so the change is applied exactly once per
|
||||
person and a reader who turns it back off keeps it off. It is a schema migration
|
||||
in shape, and that is deliberately whose idea it was ([#207]).
|
||||
|
||||
Nothing is configured by default: an installation that sets none of these
|
||||
behaves exactly as ihasmail always has.
|
||||
|
||||
### Passing a policy to Docker
|
||||
|
||||
Where a file is easier to manage than JSON quoted in a unit file — and it
|
||||
usually is once there are `changes` in it — mount one and name it:
|
||||
|
||||
```bash
|
||||
docker run -d --name ihasmail \
|
||||
-e STALWART_URL=https://mail.example.org \
|
||||
-e APP_SECRET="$(openssl rand -hex 32)" \
|
||||
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \
|
||||
-v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \
|
||||
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": { "externalSenderBanner": true },
|
||||
"enforced": { "externalRecipientConfirm": true },
|
||||
"changes": [
|
||||
{ "version": "20260902084513", "settings": { "externalSenderBanner": true } },
|
||||
{ "version": "20261014091500", "settings": { "externalLinkWarning": true } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[`settings-policy.example.json`](settings-policy.example.json) in this repo is
|
||||
that file with every section explained in it — copy it and delete what you do
|
||||
not want.
|
||||
|
||||
Mount it read-only: the server only ever reads it, and `:ro` keeps that true
|
||||
under `--read-only` as well.
|
||||
|
||||
Or without a file at all, which is what an immutable deployment with no volume
|
||||
wants:
|
||||
|
||||
```bash
|
||||
docker run -d --name ihasmail --read-only --tmpfs /tmp \
|
||||
-e IMMUTABLE=1 -e SESSION_FILE= \
|
||||
-e STALWART_URL=https://mail.example.org \
|
||||
-e APP_SECRET="$(openssl rand -hex 32)" \
|
||||
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
|
||||
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \
|
||||
-e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \
|
||||
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
|
||||
```
|
||||
|
||||
In `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ihasmail:
|
||||
image: ghcr.io/coffey-labs/ihasmail:latest
|
||||
environment:
|
||||
SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json
|
||||
volumes:
|
||||
- ./policy.json:/etc/ihasmail/policy.json:ro
|
||||
```
|
||||
|
||||
A policy is read once at startup, so **editing it means restarting the
|
||||
container**. There is no reload signal, deliberately: an installation-wide
|
||||
setting changing under a running instance would be harder to reason about than
|
||||
one that changes when you say so.
|
||||
|
||||
### Writing a policy
|
||||
|
||||
Both sections take the same names and values a settings export uses, so
|
||||
`Settings → General → Export` on one account you have configured by hand is the
|
||||
quickest way to write one — copy the keys you care about out of the file.
|
||||
|
||||
Three checks worth knowing about, because they fail loudly rather than quietly:
|
||||
|
||||
- **Malformed JSON stops the server at startup.** A policy that silently did not
|
||||
apply is indistinguishable from the feature not working.
|
||||
- **Every change needs a unique `version`.** Two changes sharing one, or a change
|
||||
with no `version` or no `settings`, is a startup error.
|
||||
- **Keys this build does not have are dropped**, the same rule an imported
|
||||
settings file gets. A `changes` entry whose keys are *all* unknown is dropped
|
||||
whole rather than recorded as applied, so it still runs on an ihasmail that
|
||||
does have the setting.
|
||||
|
||||
Enforcement is applied in the settings store rather than only on the controls,
|
||||
so an imported settings file, a settings file synced from a device that predates
|
||||
the policy, and "reset to defaults" cannot get around it. Reset returns to your
|
||||
defaults, not to ihasmail's.
|
||||
|
||||
[#207]: https://github.com/Coffey-Labs/ihasmail/issues/207
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||
React SPA • session cookie ⇄ Basic auth
|
||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||
```
|
||||
|
||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
|
||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
||||
|
||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
||||
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
|
||||
missing.
|
||||
Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -324,9 +122,18 @@ Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
||||
```bash
|
||||
npm install
|
||||
|
||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
||||
# against a real Stalwart (set STALWART_URL in .env or the environment)
|
||||
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
|
||||
|
||||
# against the built-in mock Stalwart ([email protected] / demo) — no real mailbox needed
|
||||
npm run dev:mock # mock on :8788, server on :8080, Vite on :5173
|
||||
|
||||
# the same, with the mock impersonating Stalwart 0.15 instead of 0.16
|
||||
npm run dev:mock:legacy
|
||||
|
||||
# the same, with the mock advertising FUTURERELEASE but dropping every hold —
|
||||
# the shape of a real server whose `futureRelease` setting was never turned on
|
||||
npm run dev:mock:no-future-release
|
||||
|
||||
npm run typecheck # tsc for both packages
|
||||
npm test # vitest (web) + node:test (server)
|
||||
@@ -334,89 +141,107 @@ npm run build # web/dist + server/dist
|
||||
npm start # serve the production build
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||
build. Running it for real is covered in
|
||||
[Installing](https://docs.ihasmail.org/install/) and
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
Open http://localhost:5173 in dev (or http://localhost:8080 for the production build).
|
||||
|
||||
### The mock
|
||||
### The mock, and which Stalwart it pretends to be
|
||||
|
||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||
the sign-in refusal can be tested.
|
||||
`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or
|
||||
`npm run mock:legacy`) impersonates the generation before the registry. The
|
||||
older mode is not a smaller mock — it reproduces the specific ways that
|
||||
generation differs, none of which the server reports as an error:
|
||||
|
||||
### Version numbers
|
||||
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||
parse fails the **whole request**, not the one call that wanted it. On 0.16
|
||||
it *is* known — but advertised per-account, in `primaryAccounts` and each
|
||||
account's `accountCapabilities`, never in the session-level `capabilities`.
|
||||
Stalwart validates `using` by parsing the urn rather than looking it up in
|
||||
the session, so naming it works regardless; a client that tests for it in
|
||||
the obvious place, though, mistakes every 0.16 server for an older one
|
||||
- `x:` methods do not exist, so the registry — credentials, account settings —
|
||||
is unreachable, and self-service credentials live at `POST /api/account/auth`
|
||||
- `FileNode/query` masks its results to non-containers, so it returns files and
|
||||
**never folders**, silently; `FileNode/get` has no such mask
|
||||
- FileNode has no `nodeType` (a directory is a node with no file properties),
|
||||
and rights are only `mayRead`/`mayWrite`/`mayShare`
|
||||
|
||||
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
|
||||
the pull request that commit arrived through. A commit that did not arrive
|
||||
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
|
||||
from git at build time; nothing writes a version into the tree, and
|
||||
`package.json` sits at `0.0.0` because it is no longer the source of anything.
|
||||
Both modes enforce the 2047-**byte** cap on identity signatures. Every one of
|
||||
these cost a live debugging session against a real 0.15.5 server, because the
|
||||
0.16-shaped mock could not express them; `server/src/account-legacy.test.ts`
|
||||
now pins them.
|
||||
|
||||
The date is the commit's own rather than today's, so rebuilding an old commit
|
||||
gives the version it had the first time.
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
node scripts/version.mjs # the version for the current checkout
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
|
||||
```
|
||||
All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
|
||||
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
|
||||
not pass one.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` |
|
||||
| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys |
|
||||
| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |
|
||||
| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*`, but only from a peer listed in `TRUSTED_PROXIES` |
|
||||
| `TRUSTED_PROXIES` | *(loopback + private ranges)* | Comma-separated CIDRs or addresses whose forwarding headers are believed. Anything else is attributed by its socket address, whatever it claims |
|
||||
| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev |
|
||||
| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" |
|
||||
| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) |
|
||||
| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy |
|
||||
| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) |
|
||||
| `APP_NAME` | `ihasmail` | Branding |
|
||||
|
||||
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
|
||||
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
|
||||
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
|
||||
image and About screen would read as a downgrade. Which Stalwart a build needs is
|
||||
stated where it can be precise, in the badge at the top of this file and in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
|
||||
## Keyboard shortcuts
|
||||
|
||||
The pull request lives after the `+`, as build metadata, because it is
|
||||
provenance rather than a rank: at the rate they merge here it climbs without
|
||||
bound and says nothing about how new a build is. Everything after the `+` is
|
||||
ignored when versions are compared, which is the right reading — two builds from
|
||||
the same day differ in where they came from, not in age. Nothing here depends on
|
||||
that comparison: images are pruned oldest-first by creation time, and a rollback
|
||||
names a git ref.
|
||||
Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send.
|
||||
|
||||
### Deploying
|
||||
## Known issues / pending QA
|
||||
|
||||
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
||||
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
|
||||
introduced and asks, rebuilds with the right version baked in, replaces the
|
||||
container, waits for healthy, then prunes all but the newest
|
||||
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
|
||||
The live instance ran **0.15.5** until 2026-08-25 and runs **0.16.19** now,
|
||||
so both generations have been exercised against a real server. Everything
|
||||
below says which.
|
||||
|
||||
```bash
|
||||
./deploy.sh # origin/main, asks before shipping new commits
|
||||
./deploy.sh --dry-run # run the guards and stop
|
||||
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
```
|
||||
Verified against a live **0.15.5**: the mail flows, self-service credentials
|
||||
over the REST path, Files, and signatures.
|
||||
|
||||
`--yes` does not override a hold; clearing one means deleting its line.
|
||||
The 0.16 registry path was previously recorded here as verified live. That
|
||||
was wrong, and the entry below says why: ihasmail looked for
|
||||
`urn:stalwart:jmap` in the session-level capabilities, where Stalwart has
|
||||
never put it, so **every** real 0.16 server was taken for a pre-0.16 one.
|
||||
Self-service credentials went to a REST endpoint 0.16 had removed, About
|
||||
reported the wrong generation, and Files ran on the older code path. The mock
|
||||
advertised the capability in the wrong place too, which is why nothing caught
|
||||
it. Fixed, and the mock now advertises it where the real server does — but
|
||||
the registry path is **awaiting live re-verification**.
|
||||
|
||||
## Contributing
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`); **not yet exercised against the live server**.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as pre-0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
|
||||
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 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 mock enforces the same rules either way (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. What is still mock-only is the rest of the journey: the **Scheduled** folder reconciling on the way in, and a hold actually expiring and being delivered.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
||||
## Roadmap / not yet
|
||||
|
||||
- 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)
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
|
||||
[LICENSE](LICENSE).
|
||||
Copyright (C) 2026 LINUXexpert.org
|
||||
|
||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
||||
nearly always run as a network service rather than handed to anyone as a binary,
|
||||
and the AGPL's section 13 closes that gap.
|
||||
ihasmail is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25. Webmail is
|
||||
nearly always run as a network service rather than handed to anyone as a
|
||||
binary, and the AGPL's section 13 closes that gap: anyone running a modified
|
||||
ihasmail for other people has to offer them its source, which the GPL alone
|
||||
does not require.
|
||||
|
||||
That offer has to point at *your* source, not this one. If you run a modified
|
||||
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
|
||||
Settings › About both show it. See
|
||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
||||
ihasmail, set `SOURCE_URL` to your own repository: the sign-in page and
|
||||
Settings › About both show it, so the people using your instance are told where
|
||||
the code they are actually running can be found.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Roadmap / not yet
|
||||
|
||||
Things ihasmail does not do, and why. An issue number here says where the entry
|
||||
came from, not that it is tracked elsewhere — a report can be closed because the
|
||||
bug in it was fixed while the larger thing it asked for stays on this page. What
|
||||
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
|
||||
the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
||||
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
|
||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
||||
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
|
||||
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
|
||||
#
|
||||
# Copy it, or run it as-is and set the variables below in the environment.
|
||||
# Nothing here is specific to any one host: the defaults describe the shape of
|
||||
# a deployment rather than anyone's particular one.
|
||||
#
|
||||
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
|
||||
#
|
||||
# Three guards stand between a careless run and production:
|
||||
#
|
||||
# .deploy-hold commits that must not reach prod yet, one per line. If the
|
||||
# target contains one that is not already deployed, the deploy
|
||||
# is refused outright -- `--yes` does not override it. Clearing
|
||||
# a hold means deleting its line, which is a deliberate edit.
|
||||
#
|
||||
# confirmation anything introducing new commits is listed first and has to
|
||||
# be confirmed. Over SSH, where there is no terminal to answer
|
||||
# on, that means passing --yes: a bare `deploy.sh` cannot ship
|
||||
# whatever main happens to have picked up since the last
|
||||
# release.
|
||||
#
|
||||
# --dry-run checks the hold list, says what it would deploy, and stops
|
||||
# before building or touching the container. It does not ask
|
||||
# for confirmation: there is nothing to agree to when nothing
|
||||
# changes, and needing a terminal would make it useless over
|
||||
# SSH -- which is where wanting to look before leaping is most
|
||||
# likely.
|
||||
#
|
||||
# The container is replaced rather than restarted, because the image is rebuilt
|
||||
# from the new checkout. Data lives in a named volume and survives that; the
|
||||
# environment file is never read here, only handed to Docker.
|
||||
set -euo pipefail
|
||||
|
||||
# --- what to deploy, and where ----------------------------------------------
|
||||
# The checkout to deploy from. It must be a git clone: the version number is
|
||||
# read from its history (see scripts/version.mjs).
|
||||
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
|
||||
# Environment file passed to the container. Keep it outside the repo's tracked
|
||||
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
|
||||
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
|
||||
# Commits held back from production, one per line; blank or missing is fine.
|
||||
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
|
||||
# Container name, and where to publish it. The default binds to loopback only,
|
||||
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
|
||||
NAME="${IHASMAIL_NAME:-ihasmail}"
|
||||
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
|
||||
# Named volume for /data (sessions). Unused when running immutably.
|
||||
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
||||
# Run the container immutably: read-only root filesystem, no volume, sessions
|
||||
# held in memory only. See "Running immutably" in the README. The server is told
|
||||
# the same thing through IMMUTABLE=1 and checks it, so a half-applied switch --
|
||||
# the flag without the read-only filesystem, or a SESSION_FILE still pointing
|
||||
# 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:
|
||||
#
|
||||
# IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes
|
||||
#
|
||||
# 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:-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}"
|
||||
# How long to wait for the new container to report healthy, in seconds.
|
||||
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
|
||||
# How many past versions to keep as images, for rolling back to. Each is around
|
||||
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
|
||||
# couple of releases -- and `docker image prune` will not touch them, because
|
||||
# they are tagged. 0 keeps every version.
|
||||
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
|
||||
|
||||
# --- run from a copy, if this script lives in the checkout it resets ---------
|
||||
# `git reset --hard` below rewrites the working tree, and this script may be
|
||||
# part of it. Bash does not read a script all at once -- it reads as it goes,
|
||||
# by byte offset -- so a file replaced underneath it makes the shell stop
|
||||
# wherever it had reached. Silently, and with exit status 0: a deploy that
|
||||
# stopped halfway would report success. Re-exec from a copy outside the tree so
|
||||
# the file being run cannot change while it runs.
|
||||
SELF="$(readlink -f "$0")"
|
||||
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
|
||||
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
|
||||
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
|
||||
cat "$SELF" > "$COPY"
|
||||
chmod +x "$COPY"
|
||||
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
|
||||
fi
|
||||
# The copy has served its purpose once we exit; the shell has finished reading
|
||||
# it by then.
|
||||
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
|
||||
trap 'rm -f "$SELF"' EXIT
|
||||
fi
|
||||
|
||||
REF=""
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
-n|--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*)
|
||||
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
|
||||
REF="$arg" ;;
|
||||
esac
|
||||
done
|
||||
REF="${REF:-origin/main}"
|
||||
|
||||
cd "$APP"
|
||||
git fetch --quiet origin
|
||||
|
||||
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
|
||||
echo "!! no such commit: $REF" >&2
|
||||
exit 2
|
||||
fi
|
||||
CURRENT=$(git rev-parse --verify HEAD)
|
||||
|
||||
# --- guard 1: commits held back from production -----------------------------
|
||||
if [ -f "$HOLD" ]; then
|
||||
blocked=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line%%#*}"
|
||||
line="$(printf '%s' "$line" | tr -d '[:space:]')"
|
||||
[ -z "$line" ] && continue
|
||||
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
|
||||
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
|
||||
continue
|
||||
fi
|
||||
# Only a problem if the target carries it and production does not already.
|
||||
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
|
||||
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
|
||||
fi
|
||||
done < "$HOLD"
|
||||
if [ -n "$blocked" ]; then
|
||||
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
|
||||
printf '%s' "$blocked" >&2
|
||||
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- guard 2: say what is being introduced, and get a yes --------------------
|
||||
NEW=$(git log --oneline "$CURRENT..$TARGET")
|
||||
if [ -n "$NEW" ]; then
|
||||
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
|
||||
echo "==> introduces:"
|
||||
printf '%s\n' "$NEW" | sed 's/^/ /'
|
||||
else
|
||||
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||
fi
|
||||
|
||||
# A dry run has now said everything it has to say, so it stops here -- before
|
||||
# the confirmation rather than after it. Asking whether to go ahead with
|
||||
# something that is not going to happen is noise at a terminal; over SSH it was
|
||||
# worse, because the refusal came out *instead of* the report above and a dry
|
||||
# run could not be used from another machine at all. Which is the machine you
|
||||
# are most likely to be on when you want one.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
read -r -p "deploy these to production? [y/N] " reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "aborted."; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
|
||||
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git reset --hard --quiet "$TARGET"
|
||||
|
||||
# The version is worked out here, from the checkout, because the image build
|
||||
# cannot: .dockerignore keeps .git out of the build context. Without this the
|
||||
# build falls back to the base version in package.json and every deployment
|
||||
# reports the same number -- see "Version numbers" in the README.
|
||||
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
|
||||
#
|
||||
# Only ever runs after the new container reports healthy, so a rollback target
|
||||
# is never removed while the thing replacing it is still unproven. The image in
|
||||
# use is excluded outright rather than relied on to sort newest -- docker
|
||||
# refuses to remove an image a container is using, but being refused is not the
|
||||
# same as not having tried.
|
||||
prune_old_images() {
|
||||
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
|
||||
local in_use stale
|
||||
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
# Newest first, tags only, skipping the moving ":current" pointer.
|
||||
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
|
||||
| grep -v ":current" \
|
||||
| sort -k2 -r \
|
||||
| cut -f1 \
|
||||
| grep -vxF "$in_use" \
|
||||
| tail -n +"$((KEEP_VERSIONS + 1))")"
|
||||
[ -n "$stale" ] || return 0
|
||||
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
|
||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain "+", and every version has one now:
|
||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||
# *told* it is keeps the real form, so About and /api/health still report it
|
||||
# correctly.
|
||||
TAG="${VERSION//+/-}"
|
||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
||||
docker build \
|
||||
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||
-t "$IMAGE_REPO:$TAG" \
|
||||
-t "$IMAGE_REPO:current" \
|
||||
.
|
||||
|
||||
RUN_ARGS=(-d --name "$NAME" --restart unless-stopped -p "$BIND:8080" --env-file "$ENVF")
|
||||
if [ "$IMMUTABLE" = "1" ]; then
|
||||
# -e wins over --env-file, so this clears a SESSION_FILE set there or baked
|
||||
# into the image, rather than needing the environment file edited to match.
|
||||
RUN_ARGS+=(--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=)
|
||||
echo "==> restarting container -- immutable: read-only, no volume, sessions in memory"
|
||||
echo " (everyone signed in is signed out; IHASMAIL_IMMUTABLE=0 puts it back)"
|
||||
else
|
||||
RUN_ARGS+=(-v "$VOLUME:/data")
|
||||
echo "==> restarting container"
|
||||
fi
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker run "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
|
||||
|
||||
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
|
||||
if health=$(curl -sf "http://$BIND/api/health"); then
|
||||
echo "==> healthy: $health"
|
||||
prune_old_images
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
|
||||
docker logs "$NAME" 2>&1 | tail -20 >&2
|
||||
echo "!! the previous image is still tagged, if you need it back:" >&2
|
||||
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
|
||||
exit 1
|
||||
@@ -1,12 +1,6 @@
|
||||
services:
|
||||
ihasmail:
|
||||
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:-}
|
||||
build: .
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -15,8 +9,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}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -13,7 +13,7 @@ const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--window-size=1420,790", "--force-device-scale-factor=1",
|
||||
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
|
||||
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
|
||||
|
||||
@@ -11,11 +11,6 @@
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
*
|
||||
* The files shot was taken by hand until 2026-08-27, and had gone stale twice
|
||||
* over by the time anyone noticed. Anything the docs show should be generated
|
||||
* from the mock, or it describes whatever the app looked like on the day
|
||||
* somebody had a screenshot tool open.
|
||||
*
|
||||
* Two shots are deliberately not taken here:
|
||||
*
|
||||
* - **mobile**, because at the tail of this sequence the app would not render
|
||||
@@ -48,7 +43,7 @@ const PORT = 9333;
|
||||
const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
|
||||
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
|
||||
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (path) => {
|
||||
@@ -203,26 +198,6 @@ try {
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("compose.jpg");
|
||||
|
||||
// The recipient picker, taken here because the composer is already open. The
|
||||
// site claims you can pick recipients by reading the address books rather
|
||||
// than remembering a name, and this is that claim photographed. Doing it from
|
||||
// a later step meant navigating back to the mail list, which turned out not
|
||||
// to be reliable once the run had been through Files.
|
||||
await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(x => x.getAttribute('aria-label') === 'Choose from address books');
|
||||
if (b) b.click();
|
||||
})()`);
|
||||
await waitFor("/Choose recipients/.test(document.body.innerText)", "the recipient picker");
|
||||
await evaluate(`(() => {
|
||||
// Two ticked, so the shot shows a selection rather than an empty list.
|
||||
for (const b of [...document.querySelectorAll('.menu-item input[type=checkbox]')].slice(0, 2)) b.click();
|
||||
})()`);
|
||||
await sleep(1500);
|
||||
await shot("recipients.jpg");
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); if (c) c.click(); })()`);
|
||||
await sleep(600);
|
||||
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
@@ -252,23 +227,6 @@ try {
|
||||
await sleep(1800);
|
||||
await shot("contacts.jpg");
|
||||
|
||||
// --- files ---
|
||||
// Was the one shot taken by hand, which is why it outlived two rewrites of
|
||||
// the view it was meant to show. The tree makes it worth automating: opening
|
||||
// a folder is now the difference between a screenshot of a file manager and a
|
||||
// screenshot of a list.
|
||||
await go("http://localhost:5173/files");
|
||||
await waitFor("document.querySelector('.files-table, .files-layout')", "the files view");
|
||||
await evaluate(`(() => {
|
||||
// Expand the tree and open a folder, so the shot shows the pane doing its job.
|
||||
const twisty = document.querySelector('.sidebar .nav-twisty');
|
||||
if (twisty) twisty.click();
|
||||
const folder = [...document.querySelectorAll('.sidebar .nav-item')].find(e => /Documents/.test(e.textContent || ""));
|
||||
if (folder) folder.click();
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("files.jpg");
|
||||
|
||||
// --- filters, with rules that actually say something ---
|
||||
await go("http://localhost:5173/settings/filters");
|
||||
await evaluate(HELPERS);
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ihasmail",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
"server",
|
||||
@@ -2321,18 +2321,6 @@
|
||||
"@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",
|
||||
@@ -3826,8 +3814,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@ihasmail/server",
|
||||
"version": "2.16.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"hono": "^4.7.4"
|
||||
@@ -3840,13 +3827,11 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@ihasmail/web",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "2.0.0",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -21,10 +21,8 @@
|
||||
"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\"",
|
||||
"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\""
|
||||
"dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -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\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/** 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;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/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}`);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Every source string a catalogue needs, straight out of the calls.
|
||||
*
|
||||
* The English text is the key, so the catalogue's keys are not a list somebody
|
||||
* maintains -- they are whatever t(), tNode() and plural() are actually asked
|
||||
* for. Reading them from the code means a catalogue can never drift out of
|
||||
* step with the app in the one direction that matters: a key that no longer
|
||||
* exists is dead weight, but a call with no key is an untranslated string
|
||||
* nobody noticed.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
|
||||
const strings = new Set();
|
||||
const plurals = new Set();
|
||||
|
||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) {
|
||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
||||
const fn = node.expression.text;
|
||||
const a0 = node.arguments[0];
|
||||
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text);
|
||||
if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
|
||||
const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other");
|
||||
const forms = {};
|
||||
for (const p of node.arguments[1].properties) {
|
||||
if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text;
|
||||
}
|
||||
if (other) plurals.add(JSON.stringify(forms));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(src);
|
||||
}
|
||||
|
||||
const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) };
|
||||
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
|
||||
else {
|
||||
console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`);
|
||||
const short = out.strings.filter((s) => s.length <= 30).length;
|
||||
console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
||||
export const UNVERSIONED: string;
|
||||
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
|
||||
export function versionFromGit(): string | null;
|
||||
export function resolveVersion(): string;
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Work out this build's version: `2026.8.30+pr129`.
|
||||
*
|
||||
* 2026.8.30 the date of the commit this was built from
|
||||
* +pr129 the pull request it arrived through
|
||||
*
|
||||
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
|
||||
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
|
||||
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
|
||||
* every image and About screen would read as a downgrade. Tying our
|
||||
* numbering to somebody else's was the mistake; which Stalwart a build needs is
|
||||
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
|
||||
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
|
||||
*
|
||||
* The pull request moved into build metadata, after the `+`, because it is
|
||||
* provenance rather than a position in a sequence: at a hundred merges a week
|
||||
* it climbs without bound and says nothing about how new a build is. SemVer
|
||||
* ignores everything after the `+` when comparing versions, which is the right
|
||||
* reading -- two builds from the same day differ in where they came from, not
|
||||
* in rank. Nothing here relies on that comparison anyway: images are pruned
|
||||
* oldest-first by creation time and rollbacks name a git ref.
|
||||
*
|
||||
* A commit that did not arrive through a pull request carries its short SHA
|
||||
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
|
||||
* that day rather than claiming a pull request it was only built after.
|
||||
*
|
||||
* The date is the commit's own, not today's, so rebuilding an old commit gives
|
||||
* the same answer it gave the first time. It comes from the commit object,
|
||||
* timezone included, so two machines agree.
|
||||
*
|
||||
* Nothing writes a version back into the tree: a committed one would always be
|
||||
* describing a merge that had not happened yet, and every branch would collide
|
||||
* on the same line. `package.json` no longer carries it either -- npm wants the
|
||||
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
|
||||
* is meant to look wrong.
|
||||
*
|
||||
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
|
||||
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
|
||||
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/** What a build with nothing to go on reports, and it should look wrong. */
|
||||
export const UNVERSIONED = "0.0.0";
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
}
|
||||
|
||||
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
|
||||
|
||||
/**
|
||||
* The version for a commit, from the three things about it that decide one.
|
||||
* Pure, so the rules can be exercised without a repository staged to produce
|
||||
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
|
||||
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
|
||||
*
|
||||
* Leading zeros are stripped because a version field may not carry them, so
|
||||
* September is `9` rather than `09`.
|
||||
*/
|
||||
export function formatVersion({ date, subject = "", sha }) {
|
||||
const [y, m, d] = date.split("-");
|
||||
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version for the commit checked out here, or null when there is no git to
|
||||
* ask -- an unpacked tarball, or the Docker build context.
|
||||
*/
|
||||
export function versionFromGit() {
|
||||
let head;
|
||||
let date;
|
||||
try {
|
||||
head = git("rev-parse", "--short", "HEAD");
|
||||
// %cs is the committer date in the commit's own timezone, which is stored
|
||||
// in the commit -- so this does not depend on the clock or zone of whoever
|
||||
// is building.
|
||||
date = git("show", "-s", "--format=%cs", "HEAD");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
let subject = "";
|
||||
try {
|
||||
subject = git("show", "-s", "--format=%s", "HEAD");
|
||||
} catch {
|
||||
/* no subject to read; fall through to the SHA */
|
||||
}
|
||||
return formatVersion({ date, subject, sha: head });
|
||||
}
|
||||
|
||||
/** Whatever the environment was told, else git, else an answer that looks wrong. */
|
||||
export function resolveVersion() {
|
||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return versionFromGit() ?? UNVERSIONED;
|
||||
}
|
||||
|
||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.stdout.write(resolveVersion() + "\n");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ihasmail/server",
|
||||
"version": "2.16.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -12,8 +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-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
||||
"mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts",
|
||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* The same self-service flows, against a mock impersonating Stalwart 0.15.
|
||||
*
|
||||
* That generation has no registry: credentials live behind a REST endpoint,
|
||||
* `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||
* parse fails the whole request. Until now this adapter had no coverage at all
|
||||
* — it was the least-tested code in the project, verified only by hand.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_STALWART = "0.15";
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-legacy-flows";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const app = createApp();
|
||||
let cookie = "";
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
|
||||
const res = await app.request(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
|
||||
});
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (setCookie) cookie = setCookie.split(";")[0]!;
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
before(async () => {
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 200, "login should succeed against the legacy mock");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("the older server is recognised, and reported as such", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "pre-0.16");
|
||||
assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here");
|
||||
});
|
||||
|
||||
test("credentials fall back to the REST endpoint", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "legacy");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on");
|
||||
});
|
||||
|
||||
test("app passwords round-trip, keyed by their name", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.ok(created.body.secret, "a secret is generated for the user to copy");
|
||||
assert.equal(created.body.id, "Thunderbird", "the name is the identifier here");
|
||||
|
||||
const listed = await call("/api/account/security");
|
||||
assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]);
|
||||
|
||||
await post("/api/account/app-passwords/revoke", { id: "Thunderbird" });
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("the current password is verified before it is changed", async () => {
|
||||
// The REST endpoint would take our word for it, so ihasmail proves it first.
|
||||
const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.match(wrong.body.message, /incorrect/i);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed");
|
||||
});
|
||||
|
||||
test("changing the password keeps this session working", async () => {
|
||||
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password");
|
||||
assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed");
|
||||
});
|
||||
|
||||
test("2FA is enabled with a code proved against the new secret", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
const begin = await post("/api/account/2fa/begin", {});
|
||||
const params = parseOtpauthUrl(begin.body.url);
|
||||
assert.ok(params);
|
||||
|
||||
const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored");
|
||||
|
||||
const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" });
|
||||
assert.equal(good.status, 200);
|
||||
assert.equal(good.body.sessionKept, true, "the session moved onto an app password");
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, true);
|
||||
});
|
||||
|
||||
test("2FA is switched off again", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||
const params = parseOtpauthUrl(stored!);
|
||||
assert.ok(params);
|
||||
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The mock is only worth having if it is faithful, so these pin the specific
|
||||
* behaviours that cost us a live debugging session each. Every one of them was
|
||||
* invisible to the 0.16 mock, which is how the bugs shipped.
|
||||
*/
|
||||
|
||||
const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls });
|
||||
const CORE = "urn:ietf:params:jmap:core";
|
||||
const MAIL = "urn:ietf:params:jmap:mail";
|
||||
const FILES = "urn:ietf:params:jmap:filenode";
|
||||
|
||||
test("naming a capability it cannot parse fails the whole request", async () => {
|
||||
const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
assert.notEqual(res.status, 200, "not one failed call - the entire request");
|
||||
});
|
||||
|
||||
test("x: methods do not exist, so they come back unknownMethod", async () => {
|
||||
const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.methodResponses[0][0], "error");
|
||||
assert.equal(res.body.methodResponses[0][1].type, "unknownMethod");
|
||||
});
|
||||
|
||||
test("FileNode/set refuses nodeType by name", async () => {
|
||||
const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.d.type, "invalidProperties");
|
||||
assert.deepEqual(set.notCreated.d.properties, ["nodeType"]);
|
||||
});
|
||||
|
||||
test("a directory is a node with no file properties, and query cannot see it", async () => {
|
||||
const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]);
|
||||
const id = made.body.methodResponses[0][1].created.d.id;
|
||||
assert.ok(id);
|
||||
|
||||
const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]);
|
||||
assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers");
|
||||
|
||||
// get carries no such mask, which is the only way to find a folder here.
|
||||
const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record<string, boolean> }[];
|
||||
const dir = list.find((n) => n.id === id);
|
||||
assert.ok(dir, "get returns the directory");
|
||||
assert.equal(dir!.nodeType, undefined, "nodeType is not a property here");
|
||||
assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights");
|
||||
});
|
||||
|
||||
test("FileNode/query refuses the filters and sorts this generation lacks", async () => {
|
||||
const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]);
|
||||
assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter");
|
||||
const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]);
|
||||
assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort");
|
||||
});
|
||||
|
||||
test("an identity signature is capped in bytes, not characters", async () => {
|
||||
// 1200 CJK characters: comfortably under 2047 counted as characters, and
|
||||
// 3600 bytes once encoded.
|
||||
const tooBig = "日".repeat(1200);
|
||||
assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047);
|
||||
const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notUpdated.i1.type, "invalidProperties");
|
||||
assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]);
|
||||
});
|
||||
@@ -47,25 +47,27 @@ after(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
||||
* session-level capabilities. Looking for it at the top level alone reported
|
||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
||||
* everyone out rather than merely misroute credentials.
|
||||
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
|
||||
* per-account, so a session that looks for it at the top level reports a real
|
||||
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
|
||||
* the removed REST endpoint.
|
||||
*/
|
||||
test("the session is accepted on a server that advertises the registry per-account", async () => {
|
||||
test("the session reports the 0.16 generation the server actually is", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "0.16+");
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the registry reports an account with nothing set up yet", async () => {
|
||||
test("the 0.16 registry backend is detected and reported empty", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "registry");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.deepEqual(res.body.appPasswords, []);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, false);
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
@@ -174,33 +176,3 @@ test("credential endpoints reject unauthenticated callers", async () => {
|
||||
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
||||
cookie = saved;
|
||||
});
|
||||
|
||||
/**
|
||||
* A sign-in carrying a two-factor code that the server rejects is almost never
|
||||
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
|
||||
* no password grant, so the concatenated form ihasmail sends cannot work — and
|
||||
* saying "invalid credentials" sends the user to check a password that is fine.
|
||||
*
|
||||
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
|
||||
* worked, which is Stalwart's documented route and gave no hint of itself.
|
||||
*/
|
||||
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
|
||||
const saved = cookie;
|
||||
cookie = "";
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
|
||||
cookie = saved;
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
|
||||
assert.match(res.body.message, /app password/i, "points at the route that does work");
|
||||
assert.match(res.body.message, /probably fine/i, "does not blame the password");
|
||||
});
|
||||
|
||||
test("a rejected sign-in without a code is still a plain credential failure", async () => {
|
||||
// The explanation must not leak onto ordinary typos.
|
||||
const saved = cookie;
|
||||
cookie = "";
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
|
||||
cookie = saved;
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, "invalid_credentials");
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Self-service credential management, over Stalwart's JMAP registry:
|
||||
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
||||
* and `x:AppPassword`.
|
||||
* Self-service credential management, across two incompatible Stalwart APIs.
|
||||
*
|
||||
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
|
||||
* supports. Sign-in refuses anything older, so by the time any of this runs
|
||||
* the registry is known to be there.
|
||||
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
|
||||
* password and the otpauth URL) and x:AppPassword.
|
||||
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
|
||||
*
|
||||
* The registry crate does not exist before 0.16 and the REST endpoint is gone
|
||||
* after it, so which one answers is the only reliable way to tell them apart.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
@@ -19,7 +21,10 @@ const SINGLETON = "singleton";
|
||||
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
||||
const MASKED = "[********]";
|
||||
|
||||
export type Backend = "registry" | "legacy";
|
||||
|
||||
export interface AppPasswordRow {
|
||||
/** Registry object id, or the name itself on legacy servers. */
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
@@ -27,8 +32,14 @@ export interface AppPasswordRow {
|
||||
}
|
||||
|
||||
export interface SecurityState {
|
||||
backend: Backend;
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
/**
|
||||
* Legacy servers key app passwords by name and hand back nothing else, so
|
||||
* the UI must keep names unique and cannot show when one was created.
|
||||
*/
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
/** An error with a message meant for the person using the app. */
|
||||
@@ -50,7 +61,49 @@ interface Ctx {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport */
|
||||
/* Backend detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export function forgetBackend(sessionId: string): void {
|
||||
backendCache.delete(sessionId);
|
||||
}
|
||||
|
||||
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||
const cached = backendCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||
const backend = await probeBackend(ctx);
|
||||
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (hasStalwartRegistry(ctx.session)) {
|
||||
try {
|
||||
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (name && name !== "error") return "registry";
|
||||
const type = (args as { type?: string } | undefined)?.type;
|
||||
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||
} catch {
|
||||
// The capability already told us this server has the registry, so a
|
||||
// request we could not read is a fault to surface, not evidence of an
|
||||
// older server. Falling back here would post the user's password to a
|
||||
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||
return "registry";
|
||||
}
|
||||
// It named the capability and then disowned the method: nothing else to try.
|
||||
return "registry";
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function accountId(ctx: Ctx): string {
|
||||
@@ -65,7 +118,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, ctx.session.baseUrl), {
|
||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
||||
@@ -76,6 +129,29 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||
...init,
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (res.status === 404) {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||
} catch {
|
||||
/* fall through to the generic message */
|
||||
}
|
||||
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||
}
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||
* into one error carrying whatever the server was willing to explain.
|
||||
@@ -116,7 +192,17 @@ function describeSetError(err: { type?: string; description?: string; properties
|
||||
/* Operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getState(ctx: Ctx): Promise<SecurityState> {
|
||||
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "legacy") {
|
||||
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||
return {
|
||||
backend,
|
||||
otpEnabled: Boolean(data.otpEnabled),
|
||||
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||
appPasswordsKeyedByName: true,
|
||||
};
|
||||
}
|
||||
const id = accountId(ctx);
|
||||
const res = await jmap(ctx, [
|
||||
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
||||
@@ -125,6 +211,7 @@ export async function getState(ctx: Ctx): Promise<SecurityState> {
|
||||
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
||||
const apps = listOf(res, "a");
|
||||
return {
|
||||
backend,
|
||||
// The URL itself is masked; its presence is what tells us 2FA is on.
|
||||
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
||||
appPasswords: apps.map((a) => ({
|
||||
@@ -133,6 +220,7 @@ export async function getState(ctx: Ctx): Promise<SecurityState> {
|
||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||
})),
|
||||
appPasswordsKeyedByName: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,25 +235,56 @@ function firstListItem(res: { methodResponses?: [string, unknown, string][] }, c
|
||||
return listOf(res, callId)[0] ?? null;
|
||||
}
|
||||
|
||||
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
|
||||
export async function changePassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; next: string; otpCode?: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
// The legacy endpoint changes the password without asking for the old one,
|
||||
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||
}
|
||||
|
||||
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
||||
export async function createAppPassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { description: string },
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
const description = opts.description.trim() || "App password";
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
// Legacy servers take a secret of our choosing and key it by name.
|
||||
const secret = readableSecret();
|
||||
await legacy<unknown>(ctx, {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||
});
|
||||
return { id: description, secret };
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
||||
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
return;
|
||||
}
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,8 +311,14 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||
export async function enableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { url: string; code: string; current: string },
|
||||
): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
@@ -202,9 +327,19 @@ export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; cur
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||
}
|
||||
|
||||
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
|
||||
export async function disableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; code: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
@@ -216,6 +351,49 @@ export async function disableOtp(ctx: Ctx, opts: { current: string; code: string
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||
* would otherwise take our word for it.
|
||||
*/
|
||||
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy app password a person can read off a screen and type.
|
||||
*
|
||||
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||
*/
|
||||
export function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const limit = 256 - (256 % alphabet.length);
|
||||
const chars: string[] = [];
|
||||
while (chars.length < 20) {
|
||||
for (const b of randomBytes(32)) {
|
||||
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||
chars.push(alphabet[b % alphabet.length]!);
|
||||
if (chars.length === 20) break;
|
||||
}
|
||||
}
|
||||
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
|
||||
@@ -7,8 +7,7 @@ import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./ups
|
||||
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
||||
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
||||
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
||||
* so both are asked for and whichever answers wins. Both are 0.16 methods:
|
||||
* this is a permissions fallback, not a version one.
|
||||
* so both are asked for and whichever answers wins.
|
||||
*/
|
||||
|
||||
type Responses = [string, Record<string, unknown>, string][];
|
||||
@@ -20,6 +19,7 @@ const failed = (id: string, type: string): Responses[number] => ["error", { type
|
||||
test("prefers the locale a regular user is allowed to read", () => {
|
||||
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
||||
assert.equal(info.locale, "de-DE");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
});
|
||||
|
||||
test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
@@ -27,14 +27,22 @@ test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
assert.equal(info.locale, "sr-Latn-RS");
|
||||
});
|
||||
|
||||
test("an account with no locale set yields none, rather than a guess", () => {
|
||||
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, "en-GB");
|
||||
});
|
||||
|
||||
test("a server answering the new method is 0.16+ even with no locale set", () => {
|
||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("neither answering leaves the locale unknown", () => {
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
||||
test("neither answering leaves everything unknown rather than guessing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
|
||||
assert.deepEqual(info, { locale: null, generation: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
@@ -42,18 +50,20 @@ test("locales that carry no language are dropped, not passed through", () => {
|
||||
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
||||
});
|
||||
|
||||
test("a server without the registry is not asked for anything", async () => {
|
||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
||||
// fails the whole request rather than the one call.
|
||||
test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => {
|
||||
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
|
||||
// so its absence is the answer - and asking anyway would fail the whole
|
||||
// request on those servers. This is what the live 0.15.5 box hits.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
assert.deepEqual(info, { locale: null, edition: null });
|
||||
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, null);
|
||||
assert.equal(info.edition, null);
|
||||
});
|
||||
|
||||
test("no capabilities at all is treated the same way", async () => {
|
||||
test("no capabilities at all leaves the generation unknown", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.locale, null);
|
||||
assert.equal(info.generation, null);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -63,12 +73,9 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
||||
* account's `accountCapabilities`. Looking only at the session level called
|
||||
* every real 0.16 server too old, which sent self-service credentials to a
|
||||
* every real 0.16 server pre-0.16, which sent self-service credentials to a
|
||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||
* thing.
|
||||
*
|
||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
||||
* wrong would lock every user out of a perfectly good server.
|
||||
* generation.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
@@ -95,7 +102,7 @@ test("the session level still counts, for a server that ever advertises it there
|
||||
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
||||
});
|
||||
|
||||
test("a server that advertises it nowhere is one we do not support", () => {
|
||||
test("a server that advertises it nowhere is pre-0.16", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
||||
assert.equal(hasStalwartRegistry(undefined), false);
|
||||
});
|
||||
@@ -110,3 +117,15 @@ test("a shared account carrying the capability is enough to recognise the server
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a locale request that fails does not talk us out of a generation we proved", () => {
|
||||
// The capability settled it. A forbidden reply costs the locale, nothing more.
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("a server that disowns the method is still older, whatever we came in believing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
});
|
||||
|
||||
@@ -35,112 +35,3 @@ test("image proxy refuses private targets", async () => {
|
||||
const res = await app.request("/api/image?url=http://127.0.0.1/x");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
// gzip: the body we forward has already been decompressed, so the length on
|
||||
// the wire describes different bytes and must not be copied (issue #76).
|
||||
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
|
||||
assert.equal(forwardedContentLength(gz), null);
|
||||
// identity, spelled out or absent: the length describes the body we send.
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
|
||||
// Nothing to forward is not an error.
|
||||
assert.equal(forwardedContentLength(new Headers()), null);
|
||||
});
|
||||
|
||||
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
|
||||
const http = await import("node:http");
|
||||
const zlib = await import("node:zlib");
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
|
||||
const script =
|
||||
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
|
||||
["a", "b", "c"]
|
||||
.map(
|
||||
(k) =>
|
||||
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
|
||||
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
|
||||
)
|
||||
.join("");
|
||||
const gz = zlib.gzipSync(Buffer.from(script));
|
||||
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
|
||||
|
||||
// A hop that compresses regardless of what we asked for.
|
||||
const origin = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
|
||||
res.end(gz);
|
||||
});
|
||||
await new Promise<void>((r) => origin.listen(0, () => r()));
|
||||
const port = (origin.address() as { port: number }).port;
|
||||
|
||||
try {
|
||||
const up = await fetch(`http://127.0.0.1:${port}/`);
|
||||
// What the blob route forwards.
|
||||
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
|
||||
const cl = forwardedContentLength(up.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
|
||||
assert.equal(out.headers.get("content-length"), null);
|
||||
assert.equal(await out.text(), script);
|
||||
} finally {
|
||||
origin.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("only a PDF blob may be framed, and only by us", async () => {
|
||||
/*
|
||||
* The PDF preview is an iframe, and the blanket X-Frame-Options: DENY on
|
||||
* every response blocked it -- the dialog showed Chrome's "refused to
|
||||
* connect" where the file should have been. The middleware now leaves a
|
||||
* header a route has already set, so this pins both halves: the exception
|
||||
* exists, and it did not become the rule.
|
||||
*/
|
||||
const app = createApp();
|
||||
const health = await app.request("/api/health");
|
||||
assert.equal(health.headers.get("x-frame-options"), "DENY");
|
||||
|
||||
const { securityHeadersFor } = await import("./app.js");
|
||||
assert.equal(securityHeadersFor("application/pdf", true), "SAMEORIGIN");
|
||||
assert.equal(securityHeadersFor("application/pdf", false), "DENY");
|
||||
assert.equal(securityHeadersFor("image/png", true), "DENY");
|
||||
assert.equal(securityHeadersFor("text/html", true), "DENY");
|
||||
});
|
||||
|
||||
/*
|
||||
* #239: retrying through an outage must not lock somebody out of the recovery.
|
||||
*
|
||||
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
|
||||
* so every sign-in here is the outage case. Before the fix, the eleventh of
|
||||
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
|
||||
* had actually been wrong.
|
||||
*/
|
||||
test("an unreachable upstream does not spend login attempts", async () => {
|
||||
const app = createApp();
|
||||
const login = () =>
|
||||
app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
|
||||
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
|
||||
});
|
||||
|
||||
// Comfortably past LOGIN_RATE_LIMIT, which defaults to 10.
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const res = await login();
|
||||
assert.notEqual(res.status, 429, `attempt ${i + 1} was rate limited`);
|
||||
assert.ok(res.status === 502 || res.status === 504, `attempt ${i + 1} said ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable upstream says it is not the password", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
|
||||
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
|
||||
});
|
||||
const body = (await res.json()) as { error: string; message: string };
|
||||
assert.notEqual(body.error, "invalid_credentials");
|
||||
assert.match(body.message, /not a problem with your password/i);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { SessionStore, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import {
|
||||
@@ -12,11 +12,9 @@ import {
|
||||
absoluteUpstream,
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
hasStalwartRegistry,
|
||||
forgetUpstreamSession,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
upstreamFor,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
@@ -27,31 +25,17 @@ import {
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
enableOtp,
|
||||
forgetBackend,
|
||||
getState,
|
||||
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);
|
||||
export const sessions = 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
|
||||
@@ -98,9 +82,7 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
const h = c.res.headers;
|
||||
h.set("X-Content-Type-Options", "nosniff");
|
||||
/* 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("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");
|
||||
@@ -132,28 +114,12 @@ 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: cookiePath,
|
||||
path: "/",
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
@@ -164,25 +130,20 @@ 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. This is not a problem with your password." }, 504);
|
||||
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
|
||||
}
|
||||
console.error("[ihasmail] upstream failure:", err);
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server. This is not a problem with your password." }, 502);
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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> {
|
||||
export function createApp(): Hono<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
|
||||
const api = new Hono<Env>();
|
||||
api.use("*", csrfGuard);
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" }));
|
||||
|
||||
api.get("/config", (c) =>
|
||||
c.json({
|
||||
@@ -190,9 +151,6 @@ export function createApp(basePath = config.basePath): 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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -211,24 +169,7 @@ export function createApp(basePath = config.basePath): 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);
|
||||
@@ -238,25 +179,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||
try {
|
||||
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",
|
||||
message:
|
||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
||||
},
|
||||
501,
|
||||
);
|
||||
}
|
||||
const upstream = await fetchUpstreamSession(authorization);
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
@@ -269,42 +192,6 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
// A rejected sign-in that carried a two-factor code is worth explaining
|
||||
// rather than calling "invalid credentials", because the credentials are
|
||||
// very likely fine.
|
||||
//
|
||||
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
|
||||
// interface is an OAuth client, which is why signing in there works. It
|
||||
// offers no password grant, so a client holding a username and password
|
||||
// cannot exchange them plus a code for a token, and the concatenated
|
||||
// `password$code` form ihasmail sent is not a route the server has. Its
|
||||
// documented answer for clients like this one is an app password, which
|
||||
// bypasses TOTP entirely.
|
||||
//
|
||||
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
|
||||
// app password and moves the session onto it, precisely because a plain
|
||||
// password stops working from that moment. The sign-in page was the one
|
||||
// place still pretending otherwise.
|
||||
if (totp && err instanceof UpstreamError && err.status === 401) {
|
||||
return c.json(
|
||||
{
|
||||
error: "totp_unsupported",
|
||||
message:
|
||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
||||
},
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -312,13 +199,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
api.get("/auth/session", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username), c.req.query("refresh") === "1");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, 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: cookiePath });
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -331,7 +218,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -349,8 +236,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// ---------- Self-service credentials ----------
|
||||
/**
|
||||
* Password, app passwords and 2FA. These live on the server rather than in
|
||||
* the browser because changing a credential means re-sealing the session
|
||||
* cookie that holds it, and because the browser only ever sees /api/jmap.
|
||||
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
|
||||
* only ever sees /api/jmap), and because changing a credential means
|
||||
* re-sealing the session cookie that holds it.
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
@@ -376,7 +264,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
api.get("/account/security", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
return c.json(await getState(await accountCtx(c)));
|
||||
return c.json(await getState(session.id, await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -396,7 +284,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
||||
}
|
||||
try {
|
||||
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -412,8 +300,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
api.get("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const state = await getState(await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords });
|
||||
const state = await getState(session.id, await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -426,7 +314,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
try {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -437,7 +325,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const body = await readJson<{ id?: string }>(c);
|
||||
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await revokeAppPassword(await accountCtx(c), body.id);
|
||||
await revokeAppPassword(session.id, await accountCtx(c), body.id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
@@ -478,18 +366,18 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
|
||||
} catch (err) {
|
||||
// Out of app-password quota, say. 2FA is still worth having; the user
|
||||
// just has to sign in again afterwards.
|
||||
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
if (app) {
|
||||
// Don't leave a credential behind for a change that never happened.
|
||||
await revokeAppPassword(ctx, app.id).catch(() => {});
|
||||
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
|
||||
}
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -510,7 +398,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const body = await readJson<{ current?: string; code?: string }>(c);
|
||||
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -518,6 +406,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
forgetUpstreamSession(session.id);
|
||||
forgetBackend(session.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -529,8 +418,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.json({ error: "unsupported_media_type" }, 415);
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: session.authorization,
|
||||
@@ -544,7 +433,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (res.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
return passthrough(res);
|
||||
@@ -563,8 +452,8 @@ export function createApp(basePath = config.basePath): 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, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }), upstream.baseUrl);
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -589,20 +478,17 @@ export function createApp(basePath = config.basePath): 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, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
||||
const res = await fetch(url, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
headers: { authorization: session.authorization },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
const cl = res.headers.get("content-length");
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
@@ -611,19 +497,7 @@ export function createApp(basePath = config.basePath): 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 (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 {
|
||||
if (!(safeInline && type === "application/pdf")) {
|
||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||
}
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
@@ -640,8 +514,8 @@ export function createApp(basePath = config.basePath): 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, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
||||
const controller = new AbortController();
|
||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||
const res = await fetch(url, {
|
||||
@@ -663,9 +537,6 @@ export function createApp(basePath = config.basePath): 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) => {
|
||||
@@ -673,10 +544,10 @@ api.get("/ics", requireSession, icsProxyHandler);
|
||||
return c.json({ error: "internal_error" }, 500);
|
||||
});
|
||||
|
||||
app.route(`${basePath}/api`, api);
|
||||
app.route("/api", api);
|
||||
|
||||
// ---------- Static SPA ----------
|
||||
app.get("*", staticHandler(config.staticDir, basePath));
|
||||
app.get("*", staticHandler(config.staticDir));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -707,7 +578,7 @@ function appPasswordName(c: Context): string {
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -720,7 +591,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
server: { generation: info.generation, edition: info.edition },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -742,32 +613,6 @@ function passthrough(res: Response): Response {
|
||||
return new Response(res.body, { status: res.status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream content-length, but only when it describes the bytes we are
|
||||
* about to forward.
|
||||
*
|
||||
* A compressed response is decompressed for us before we ever see the body --
|
||||
* undici does it transparently -- while the content-length header is left
|
||||
* describing the *compressed* length. Copying it onto the longer body we then
|
||||
* send makes the browser stop reading exactly that many bytes in and call the
|
||||
* download complete, so the file arrives silently truncated.
|
||||
*
|
||||
* That is the second half of issue #76. A hop in front of Stalwart compressed
|
||||
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
|
||||
* pushed it past the threshold and it came back cut off mid-rule. Nothing
|
||||
* reported an error: the script parsed, just with rules missing, and saving
|
||||
* wrote that shortened version back over the real one.
|
||||
*
|
||||
* We ask for `identity` above so the usual case still carries a length the
|
||||
* browser can show progress against; this is the guard for a hop that
|
||||
* compresses anyway.
|
||||
*/
|
||||
export function forwardedContentLength(headers: Headers): string | null {
|
||||
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
|
||||
if (encoding && encoding !== "identity") return null;
|
||||
return headers.get("content-length");
|
||||
}
|
||||
|
||||
function sanitizeContentType(ct: string): string {
|
||||
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
||||
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
||||
@@ -785,15 +630,6 @@ 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 (
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,40 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { assertImmutable } from "./config.js";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
|
||||
}
|
||||
|
||||
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("", root), /is writable/);
|
||||
assert.equal(existsSync(join(root, ".immutable-probe")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE accepts a root it cannot write to", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
chmodSync(root, 0o555);
|
||||
assert.doesNotThrow(() => assertImmutable("", root));
|
||||
} finally {
|
||||
chmodSync(root, 0o755);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
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";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
|
||||
@@ -59,198 +57,9 @@ if (!appSecret || appSecret === "change-me") {
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Declares that this instance is running as an immutable container: read-only
|
||||
* root filesystem, nothing durable of its own, replaceable by its image.
|
||||
*
|
||||
* It is a claim the process checks rather than one it takes on trust, because
|
||||
* the failure it guards against is silent. Left to itself the server survives
|
||||
* a read-only filesystem perfectly well -- sessions are held in memory and the
|
||||
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
|
||||
* is one warning at the first login, long after anyone was watching. The
|
||||
* instance looks healthy right up until it is replaced and everyone is signed
|
||||
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
|
||||
*/
|
||||
const immutable = bool("IMMUTABLE", false);
|
||||
const sessionFile = process.env.SESSION_FILE ?? "";
|
||||
|
||||
/**
|
||||
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
|
||||
* keep. Exported so it can be tested without a read-only filesystem to hand.
|
||||
*/
|
||||
export function assertImmutable(sessionFile: string, root: string): void {
|
||||
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
|
||||
// refusal rather than a formality: running immutably means clearing it. It
|
||||
// is not quietly ignored, because a configured path that silently persists
|
||||
// nothing is exactly the failure this flag exists to surface.
|
||||
if (sessionFile) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
|
||||
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
|
||||
);
|
||||
}
|
||||
// And check the property itself, not just the intention to have it. Setting
|
||||
// the variable while forgetting `--read-only` is the easy mistake, and it
|
||||
// leaves an instance claiming a guarantee it does not have.
|
||||
const probe = resolve(root, ".immutable-probe");
|
||||
let writable = false;
|
||||
try {
|
||||
writeFileSync(probe, "");
|
||||
writable = true;
|
||||
unlinkSync(probe);
|
||||
} catch {
|
||||
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
|
||||
}
|
||||
if (writable) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* the build context and nothing in there could work it out. A dev checkout
|
||||
* has git, so it falls back to asking; see `scripts/version.mjs`.
|
||||
*/
|
||||
version: resolveVersion(),
|
||||
/**
|
||||
* Where this instance's source can be had, shown to everyone who reaches it.
|
||||
*
|
||||
@@ -258,24 +67,10 @@ export const config = {
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/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),
|
||||
/**
|
||||
@@ -289,9 +84,7 @@ export const config = {
|
||||
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
|
||||
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
|
||||
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
|
||||
sessionFile,
|
||||
/** True when this instance has asserted, and verified, that it is immutable. */
|
||||
immutable,
|
||||
sessionFile: process.env.SESSION_FILE ?? "",
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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"];
|
||||
}
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -98,50 +98,32 @@ 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.
|
||||
*/
|
||||
/** 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> {
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const raw = c.req.query("url") ?? "";
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return "bad_url";
|
||||
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";
|
||||
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);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const done = () => clearTimeout(timer);
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
let res: IncomingMessage;
|
||||
try {
|
||||
let addr: string;
|
||||
try {
|
||||
addr = await resolveAllowed(url.hostname);
|
||||
} catch (err) {
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
}
|
||||
let res = await fetchPinned(url, addr, controller.signal);
|
||||
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;
|
||||
@@ -149,58 +131,38 @@ export async function safeFetch(raw: string, timeoutMs = 15_000): Promise<SafeFe
|
||||
res.resume(); // discard the redirect body
|
||||
const next = new URL(loc, url);
|
||||
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
||||
done();
|
||||
return "bad_redirect";
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "bad_redirect" }, 400);
|
||||
}
|
||||
try {
|
||||
addr = await resolveAllowed(next.hostname);
|
||||
} catch (err) {
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
}
|
||||
url = next;
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
hops++;
|
||||
}
|
||||
return { res, url, done };
|
||||
} catch {
|
||||
done();
|
||||
return "fetch_failed";
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
done();
|
||||
clearTimeout(timer);
|
||||
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") {
|
||||
done();
|
||||
clearTimeout(timer);
|
||||
res.resume();
|
||||
return c.json({ error: "not_image" }, 415);
|
||||
}
|
||||
const len = Number(res.headers["content-length"] ?? "0");
|
||||
if (len > MAX_IMAGE_BYTES) {
|
||||
done();
|
||||
clearTimeout(timer);
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
@@ -214,7 +176,7 @@ export async function imageProxyHandler(c: Context) {
|
||||
else controller2.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
res.on("close", done);
|
||||
res.on("close", () => clearTimeout(timer));
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
|
||||
* and it matters that it is enforced *there*: the alternative is signing
|
||||
* someone in and letting Files, the account locale and self-service
|
||||
* credentials each fail in their own way, with nothing to connect the three or
|
||||
* to say what the real problem is.
|
||||
*
|
||||
* The refusal also has to keep two things apart that look the same from the
|
||||
* outside. Bad credentials are a 401 the user can fix by typing again; an
|
||||
* unsupported server is not, and telling someone their password is wrong when
|
||||
* it is not would send them round in circles.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
|
||||
}
|
||||
|
||||
before(() => {
|
||||
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("a server without the registry is refused, with good credentials", async () => {
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 501);
|
||||
assert.equal(res.body.error, "unsupported_server");
|
||||
});
|
||||
|
||||
test("the message says the credentials were fine, and names the way out", async () => {
|
||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
||||
// Someone hitting this has typed a correct password. Saying so is the
|
||||
// difference between "upgrade your server" and "try your password again".
|
||||
assert.match(body.message, /credentials are fine/i);
|
||||
assert.match(body.message, /0\.16/);
|
||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
||||
});
|
||||
|
||||
test("no session is minted for a server we cannot talk to", async () => {
|
||||
// A cookie here would leave a signed-in session against a server every
|
||||
// other request is going to fail on.
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.setCookie, null);
|
||||
});
|
||||
|
||||
test("bad credentials on such a server are still a 401, not the server error", async () => {
|
||||
// The upstream session request fails first, and that answer is the honest
|
||||
// one: we never got far enough to learn what the server supports.
|
||||
const res = await login({ username: "[email protected]", password: "wrong-password" });
|
||||
assert.equal(res.status, 401);
|
||||
assert.notEqual(res.body.error, "unsupported_server");
|
||||
});
|
||||
@@ -5,18 +5,19 @@
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
* Which Stalwart generation to impersonate. "0.16" (the default) has the
|
||||
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
|
||||
* rights. "0.15" is the older shape, and differs in ways that mostly do not
|
||||
* announce themselves: its FileNode/query cannot see directories at all, it
|
||||
* refuses a `using` naming a capability it does not know, and self-service
|
||||
* credentials live behind a REST endpoint instead.
|
||||
*/
|
||||
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
const LEGACY = process.env.MOCK_STALWART === "0.15";
|
||||
/**
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||
@@ -27,15 +28,6 @@ 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 = {
|
||||
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||
};
|
||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
@@ -54,24 +46,12 @@ const state = { n: 1 };
|
||||
const nextState = () => String(state.n++);
|
||||
|
||||
/* ---------- data ---------- */
|
||||
/*
|
||||
* The names are Stalwart's own defaults, which follow the Exchange convention:
|
||||
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
|
||||
* short forms, so anything built from a folder's name read differently here
|
||||
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
|
||||
* against a real server -- and every screenshot in the README showed a folder
|
||||
* list no user has. The role is what the client branches on; the name is only
|
||||
* ever displayed, which is exactly why it has to look right.
|
||||
*/
|
||||
/** Push subscriptions, as a fresh account has none. */
|
||||
const pushSubscriptions: Obj[] = [];
|
||||
|
||||
const mailboxes: Obj[] = [
|
||||
mb("inbox", "Inbox", "inbox"),
|
||||
mb("drafts", "Drafts", "drafts"),
|
||||
mb("sent", "Sent Items", "sent"),
|
||||
mb("sent", "Sent", "sent"),
|
||||
mb("junk", "Junk Mail", "junk"),
|
||||
mb("trash", "Deleted Items", "trash"),
|
||||
mb("trash", "Trash", "trash"),
|
||||
mb("archive", "Archive", "archive"),
|
||||
mb("work", "Work", null),
|
||||
mb("work-inv", "Invoices", null, "work"),
|
||||
@@ -100,43 +80,7 @@ const subjects = [
|
||||
];
|
||||
const emails: Obj[] = [];
|
||||
let counter = 1;
|
||||
/**
|
||||
* A real TNEF blob, built to the format description, so the winmail.dat
|
||||
* decoder has something to open that is not a hand-made fixture in its own
|
||||
* test file. Two files inside, one of them carrying a long name in the MAPI
|
||||
* stream behind an 8.3 title -- which is the case the decoder exists for.
|
||||
*/
|
||||
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 }) {
|
||||
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 }) {
|
||||
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.`;
|
||||
@@ -148,10 +92,6 @@ 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"),
|
||||
@@ -168,14 +108,6 @@ 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;
|
||||
@@ -193,28 +125,8 @@ 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
|
||||
// their reply for hours, so it landed after messages that answer it and sits in
|
||||
// the middle of the conversation. Opening this thread at the newest message
|
||||
// left that reply above the fold until the mark-read timer swept it (#87).
|
||||
{
|
||||
const subj = "Compiler timings for the release";
|
||||
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
|
||||
const tid = t.threadId as string;
|
||||
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
|
||||
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
|
||||
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
|
||||
// Long enough after the unread one that the thread scrolls: opening at the
|
||||
// bottom put four messages between the reader and the mail they had not read.
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
|
||||
}
|
||||
// Invitation email
|
||||
{
|
||||
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
@@ -231,12 +143,6 @@ const identities: Obj[] = [
|
||||
];
|
||||
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
|
||||
const sieveScripts: Obj[] = [];
|
||||
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||
const sharedEvents: Obj[] = [];
|
||||
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||
const events: Obj[] = [];
|
||||
@@ -248,94 +154,25 @@ 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" });
|
||||
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||
}
|
||||
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
|
||||
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||
what a share usually is. */
|
||||
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||
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 addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }];
|
||||
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
|
||||
const [given, surname] = p[0]!.split(" ");
|
||||
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined,
|
||||
/*
|
||||
* Birthdays on most but not all of them, and one with no year, because a
|
||||
* card that records only a day and month is the common case rather than
|
||||
* the exceptional one.
|
||||
*/
|
||||
anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined };
|
||||
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 };
|
||||
});
|
||||
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[] = [
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
|
||||
];
|
||||
|
||||
/* What the shared account holds. Its own nodes, so opening the share in Files
|
||||
shows something different from the reader's own folders rather than the same
|
||||
list under another name. */
|
||||
const sharedFileNodes: Obj[] = [
|
||||
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
/** The node list an account owns. */
|
||||
const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
|
||||
|
||||
function fr() {
|
||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
// 0.16 split what used to be a single mayWrite into four.
|
||||
return LEGACY
|
||||
? { mayRead: true, mayWrite: true, mayShare: true }
|
||||
: { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
}
|
||||
|
||||
function recount() {
|
||||
@@ -479,19 +316,6 @@ function enforceLimits(name: string, args: Obj): void {
|
||||
|
||||
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||
|
||||
/*
|
||||
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
||||
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
||||
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
||||
* shared. The mock handing it over unasked meant a client that never asked
|
||||
* still saw every share, and the one place that did not -- the real server --
|
||||
* showed nothing shared at all.
|
||||
*/
|
||||
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||
if (a.properties) return res;
|
||||
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||
}
|
||||
|
||||
function genericGet(list: Obj[]) {
|
||||
return (a: Obj) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
@@ -499,24 +323,6 @@ function genericGet(list: Obj[]) {
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An id, as either a stored event or one occurrence of one.
|
||||
*
|
||||
* A synthetic id whose base is gone, or whose index falls outside the series
|
||||
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
||||
* server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||
const direct = list.find((x) => x.id === id);
|
||||
if (direct) return { base: direct };
|
||||
const parsed = parseSyntheticId(id);
|
||||
if (!parsed) return null;
|
||||
const base = list.find((x) => x.id === parsed.baseId);
|
||||
if (!base) return null;
|
||||
const occ = occurrenceAt(base, parsed.slot);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
@@ -554,179 +360,6 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- calendar events ---------- */
|
||||
|
||||
/**
|
||||
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||
*
|
||||
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||
*
|
||||
* The refusals are the point of reproducing this at all:
|
||||
*
|
||||
* - a base event and one of its instances in the same request is refused, both
|
||||
* ids at once, because the server cannot apply them in a defined order;
|
||||
* - the same id twice is "Duplicate event id.";
|
||||
* - the ten event-level properties are refused with `invalidProperties`;
|
||||
* - and the twelve inherited ones are dropped in silence, with the response
|
||||
* still saying the update succeeded. A mock that applied them would let a
|
||||
* client that sends them look correct everywhere except a real server.
|
||||
*/
|
||||
/**
|
||||
* Enough of an iCalendar reader to stand in for Stalwart's.
|
||||
*
|
||||
* It reads per VEVENT rather than across the whole file, because a file is the
|
||||
* case an emailed invitation never was: an export carries a year of them, and a
|
||||
* regex over the whole text would find the first DTSTART and call that the
|
||||
* answer. One event still comes back as a bare object, the shape this returned
|
||||
* when an invitation was all it had to handle.
|
||||
*
|
||||
* The synthetic organiser and attendee only go on events that arrived with a
|
||||
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
||||
* are; a plain export is not addressed to anyone, and inventing participants
|
||||
* for it would make imported events look like invitations nobody sent.
|
||||
*/
|
||||
function calendarEventParse(a: Obj) {
|
||||
const parsed: Obj = {};
|
||||
const notParsable: string[] = [];
|
||||
for (const b of a.blobIds as string[]) {
|
||||
const blob = blobs.get(b);
|
||||
if (!blob) { notParsable.push(b); continue; }
|
||||
const text = blob.data.toString();
|
||||
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
|
||||
const method = field(text, "METHOD");
|
||||
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
|
||||
const events = bodies.map((body) => {
|
||||
const g = (k: string) => field(body, k);
|
||||
const ds = g("DTSTART") ?? "20260101T000000Z";
|
||||
const de = g("DTEND") ?? ds;
|
||||
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
|
||||
const start = new Date(`${toLocal(ds)}Z`);
|
||||
const end = new Date(`${toLocal(de)}Z`);
|
||||
return {
|
||||
"@type": "Event",
|
||||
uid: g("UID"),
|
||||
title: g("SUMMARY"),
|
||||
start: toLocal(ds),
|
||||
timeZone: "Etc/UTC",
|
||||
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
|
||||
method,
|
||||
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
|
||||
participants: method
|
||||
? {
|
||||
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
|
||||
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
if (!events.length) { notParsable.push(b); continue; }
|
||||
parsed[b] = events.length === 1 ? events[0] : events;
|
||||
}
|
||||
return { accountId: ACCOUNT, parsed, notParsable };
|
||||
}
|
||||
|
||||
function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is
|
||||
// how #26 and #30 reached a live server unnoticed — so it does both.
|
||||
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
o.uid = o.uid ?? randomUUID();
|
||||
events.push(o);
|
||||
created[cid] = { id: o.id };
|
||||
}
|
||||
|
||||
const updates = Object.entries((a.update as Obj) ?? {});
|
||||
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||
const seen = new Set<string>();
|
||||
|
||||
/* A base and one of its instances cannot be settled in the same request. */
|
||||
const baseOf = (id: string): string | null => {
|
||||
const r = resolveEvent(events, id);
|
||||
return r ? (r.base.id as string) : null;
|
||||
};
|
||||
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||
const b = baseOf(id);
|
||||
if (!b) continue;
|
||||
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||
touched.set(b, entry);
|
||||
}
|
||||
const conflicted = new Set<string>();
|
||||
for (const [, e] of touched) {
|
||||
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||
}
|
||||
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||
|
||||
for (const [id, patch] of updates) {
|
||||
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||
seen.add(id);
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||
writeOverride(resolved.base, resolved.occ, applied);
|
||||
updated[id] = null;
|
||||
}
|
||||
|
||||
for (const id of destroys) {
|
||||
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||
if (resolved.occ) {
|
||||
// One date off a series, which is an override rather than a deletion.
|
||||
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||
destroyed.push(id);
|
||||
continue;
|
||||
}
|
||||
const i = events.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into the override for one date.
|
||||
*
|
||||
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||
* an override always carries its own timing; the mock does the same, or a
|
||||
* client could depend on inheriting them and be right only here.
|
||||
*/
|
||||
function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||
const next: Obj = { ...existing };
|
||||
if (!replace) {
|
||||
if (!("start" in next)) next.start = occ.start;
|
||||
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||
}
|
||||
applyPatch(next, patch);
|
||||
overrides[occ.recurrenceId] = next;
|
||||
base.recurrenceOverrides = overrides;
|
||||
}
|
||||
|
||||
/* ---------- submissions ---------- */
|
||||
/**
|
||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||
@@ -761,31 +394,12 @@ const handlers: Record<string, Handler> = {
|
||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||
},
|
||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||
"Mailbox/get": genericGet(mailboxes),
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
"Email/query": (a) => {
|
||||
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||
/*
|
||||
* Honour the sort rather than always answering newest-first. This used to
|
||||
* 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;
|
||||
});
|
||||
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
|
||||
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; });
|
||||
@@ -795,22 +409,7 @@ const handlers: Record<string, Handler> = {
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
|
||||
},
|
||||
"Email/get": (a) => genericGet(emails)(a),
|
||||
/*
|
||||
* Real changes, not an empty answer.
|
||||
*
|
||||
* This used to return three empty arrays whatever had happened, so the
|
||||
* client's whole reconciliation path -- `Email/changes`, then deciding what
|
||||
* to do with what came back -- never ran against the mock. A bug living in
|
||||
* that path could not be reproduced here at all, which is how one reached
|
||||
* production and survived being "fixed" once (#100). The log below is what
|
||||
* the real server can answer from.
|
||||
*/
|
||||
"Email/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
const relevant = emailChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
"Email/set": (a) => {
|
||||
const r = genericSet(emails, "e", (o) => {
|
||||
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
|
||||
@@ -831,19 +430,6 @@ const handlers: Record<string, Handler> = {
|
||||
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
|
||||
})(a);
|
||||
recount();
|
||||
nextState();
|
||||
recordEmailChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((a.update as Obj) ?? {}),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
/* A real server pushes a state change after a set, and the client acts on
|
||||
it -- `Email/changes` runs and the store reconciles what came back. The
|
||||
mock stayed silent, so that whole path never ran here and a bug living
|
||||
in it could not be reproduced: marking a message read went round the
|
||||
server and back on the live instance, and did nothing at all on the mock
|
||||
(#100). Announced now, the way Stalwart does. */
|
||||
broadcast(["Email", "Mailbox", "Thread"]);
|
||||
return r;
|
||||
},
|
||||
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||
@@ -886,95 +472,6 @@ const handlers: Record<string, Handler> = {
|
||||
state.n++;
|
||||
return setResp({ updated: { singleton: null } });
|
||||
},
|
||||
/*
|
||||
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
||||
* runs through the browser vendor's real push service, so nothing local will
|
||||
* ever make a notification appear.
|
||||
*
|
||||
* What is worth reproducing is the handshake, because it is the part that
|
||||
* fails quietly: a subscription is created unverified and stays silent until
|
||||
* the client echoes back a code the server pushed. A mock that marked one
|
||||
* verified on creation would let a client ship without ever implementing
|
||||
* that, and the symptom in production is "registered, and no notifications".
|
||||
*/
|
||||
"PushSubscription/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
||||
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
||||
// `keys` is write-only in JMAP: the server never hands it back.
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
||||
},
|
||||
"PushSubscription/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o = obj as Obj;
|
||||
const keys = (o.keys ?? {}) as Obj;
|
||||
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
||||
// API produces; padding it would be the client inventing a shape.
|
||||
for (const k of ["p256dh", "auth"]) {
|
||||
const v = String(keys[k] ?? "");
|
||||
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
||||
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (notCreated[cid]) continue;
|
||||
if (!String(o.url ?? "").startsWith("https://")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
||||
continue;
|
||||
}
|
||||
// A filter condition with a null value is not a filter -- the real server
|
||||
// answers "Invalid filter" and refuses the whole subscription. ihasmail
|
||||
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
|
||||
// all here, and the mock accepted it happily. It does not any more.
|
||||
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
|
||||
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
|
||||
return Object.values(f).some((v) => v === null || v === undefined);
|
||||
});
|
||||
if (badFilter) {
|
||||
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||
continue;
|
||||
}
|
||||
// One per device: re-subscribing replaces rather than accumulates.
|
||||
const deviceId = String(o.deviceClientId ?? "");
|
||||
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
||||
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
||||
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||
/*
|
||||
* A subscription expires, and this used to hand back `expires: null`.
|
||||
* That is the one shape that makes the client's real problem invisible in
|
||||
* development: JMAP puts a ceiling of seven days on a push subscription
|
||||
* and expects the client to re-register before it lapses, so a client
|
||||
* that never renews works perfectly against a mock that never expires
|
||||
* anything and goes silent a week after being deployed. Seven days here,
|
||||
* so "does this client renew?" is a question the mock can answer.
|
||||
*/
|
||||
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
|
||||
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) ?? {})) {
|
||||
const s = pushSubscriptions.find((x) => x.id === id);
|
||||
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
const code = (patch as Obj).verificationCode;
|
||||
if (code !== undefined) {
|
||||
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||
s.verified = true;
|
||||
}
|
||||
updated[id] = null;
|
||||
state.n++;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
||||
}
|
||||
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
||||
},
|
||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||
"x:AppPassword/set": (a) => {
|
||||
const created: Obj = {};
|
||||
@@ -1092,100 +589,61 @@ const handlers: Record<string, Handler> = {
|
||||
"SieveScript/get": genericGet(sieveScripts),
|
||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||
/*
|
||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||
* included, which is what a live 0.16.19 does and what makes `baseEventId`
|
||||
* useless as a test for a series. Without it (the `findByUid` path) the
|
||||
* stored ids come back untouched, because callers hand those straight to a
|
||||
* destroy and mean the whole event.
|
||||
*/
|
||||
"CalendarEvent/query": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const filter = (a.filter as Obj) ?? {};
|
||||
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||
if (!a.expandRecurrences) {
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||
}
|
||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||
const ids: string[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||
},
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
if (!ids) return genericGet(list)(a);
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
||||
},
|
||||
"Calendar/get": genericGet(calendars),
|
||||
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
|
||||
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
|
||||
"CalendarEvent/get": genericGet(events),
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||
"CalendarEvent/parse": (a) => calendarEventParse(a),
|
||||
"CalendarEvent/set": genericSet(events, "ev", (o) => {
|
||||
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
return Object.assign(o, { uid: o.uid ?? randomUUID() });
|
||||
}),
|
||||
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||
"Principal/get": genericGet(principals),
|
||||
// One busy block a day across whatever range was asked for. It used to answer
|
||||
// with a single block on the first day whatever the range, which was all an
|
||||
// availability bar a day wide could show -- and left a bar covering several
|
||||
// days looking as though everyone were free for all but the first of them.
|
||||
"Principal/getAvailability": (a) => {
|
||||
const from = new Date(String(a.utcStart));
|
||||
const to = new Date(String(a.utcEnd));
|
||||
const list: Obj[] = [];
|
||||
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
|
||||
const date = day.toISOString().slice(0, 11);
|
||||
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
|
||||
}
|
||||
return { accountId: ACCOUNT, list };
|
||||
},
|
||||
"AddressBook/get": (a) => 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`
|
||||
included -- "You are not allowed to modify this address book", confirmed
|
||||
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
|
||||
that accepted it would have agreed that subscribing works, which is
|
||||
exactly the belief that shipped. Calendars accept the same write; the
|
||||
difference is the server's, not ours. */
|
||||
if (a.accountId === SHARED_ACCOUNT && a.update) {
|
||||
const notUpdated: Obj = {};
|
||||
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
|
||||
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
|
||||
}
|
||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||
},
|
||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||
"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 }] }),
|
||||
"AddressBook/get": genericGet(addressBooks),
|
||||
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
|
||||
"ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }),
|
||||
"ContactCard/get": genericGet(cards),
|
||||
"ContactCard/set": genericSet(cards, "cc"),
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const fileNodes = nodesFor(a.accountId);
|
||||
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||
// directories and was handed files, which it then drew as folders.
|
||||
const list = fileNodes.filter((n) => {
|
||||
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||
return true;
|
||||
});
|
||||
if (LEGACY) {
|
||||
// Sorting is refused outright, and isTopLevel / nodeType are not filters
|
||||
// this generation knows.
|
||||
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
|
||||
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
|
||||
}
|
||||
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
// The pre-0.16 query masks its results to non-containers, so a directory
|
||||
// never comes back — with nothing to say it was left out.
|
||||
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
|
||||
"FileNode/get": (a) => {
|
||||
const res = genericGet(fileNodes)(a);
|
||||
// nodeType does not exist before 0.16; the shape is all the client gets.
|
||||
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
|
||||
return res;
|
||||
},
|
||||
"FileNode/set": (a) => {
|
||||
return genericSet(nodesFor(a.accountId), "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
if (LEGACY) {
|
||||
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
|
||||
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
|
||||
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return genericSet(fileNodes, "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
// file properties. Keep it internally so query and get stay consistent.
|
||||
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
|
||||
@@ -1226,22 +684,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
}
|
||||
|
||||
const session = () => ({
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
|
||||
"urn:ietf:params:jmap:emailpush": {},
|
||||
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
/*
|
||||
* Two accounts: the demo user's own, and one somebody has shared.
|
||||
*
|
||||
* The shared one carries the *same* capability list, because that is what
|
||||
* Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account
|
||||
* advertised mail, calendars, contacts and the rest, identical to a personal
|
||||
* one, whatever had actually been shared. Giving the mock a truthful shared
|
||||
* account is the only way to exercise the Files "Shared with me" list, and
|
||||
* the only way this stays honest about what can be inferred from a
|
||||
* capability, which is nothing.
|
||||
*/
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
@@ -1251,14 +696,6 @@ const session = () => ({
|
||||
});
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
/** What changed and when, so `Email/changes` can answer honestly. */
|
||||
const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
// A window is plenty; the client refetches from scratch if it falls behind.
|
||||
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
||||
}
|
||||
|
||||
function broadcast(types: string[]) {
|
||||
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
|
||||
for (const c of sseClients) c.write(payload);
|
||||
@@ -1272,8 +709,37 @@ export const server = createServer(async (req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
}
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
// Before 0.16, self-service credentials are a REST endpoint rather than
|
||||
// registry objects: GET reports the state, POST takes a list of actions.
|
||||
if (LEGACY && url.pathname === "/api/account/auth") {
|
||||
if (req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
|
||||
// Password and OTP changes are only accepted over Basic auth.
|
||||
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
|
||||
}
|
||||
for (const a of actions) {
|
||||
if (a.type === "setPassword") account.password = a.password ?? account.password;
|
||||
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
|
||||
else if (a.type === "disableOtpAuth") account.otpUrl = null;
|
||||
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
|
||||
else if (a.type === "removeAppPassword") {
|
||||
const i = account.appPasswords.findIndex((p) => p.description === a.name);
|
||||
if (i >= 0) account.appPasswords.splice(i, 1);
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: null }));
|
||||
}
|
||||
}
|
||||
|
||||
// 0.16's account info endpoint; the only place a server reports its edition.
|
||||
if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||
}
|
||||
@@ -1297,7 +763,7 @@ export const server = createServer(async (req, res) => {
|
||||
for (const [name, rawArgs, id] of body.methodCalls) {
|
||||
const h = handlers[name];
|
||||
// The registry, and every x: method with it, arrived in 0.16.
|
||||
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||
try {
|
||||
const args = resolveRefs(rawArgs, responses, creations);
|
||||
enforceLimits(name, args);
|
||||
@@ -1344,6 +810,7 @@ export const server = createServer(async (req, res) => {
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-stalwart] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
|
||||
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
* against something. What it has to get right is not the expansion — that is
|
||||
* the easy half — but the three things a live server does that a client will
|
||||
* otherwise be written against wrongly:
|
||||
*
|
||||
* - every expanded id is synthetic, one-offs included;
|
||||
* - an occurrence carries a `recurrenceId` and no rule;
|
||||
* - a per-occurrence patch loses some properties in silence.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
||||
|
||||
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
||||
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
||||
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
||||
|
||||
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||
|
||||
describe("expandOccurrences", () => {
|
||||
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(series(), a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
||||
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a one-off exactly one occurrence, at index 0", () => {
|
||||
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
||||
const out = expandOccurrences(oneOff(), a, b);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0]!.index, 0);
|
||||
});
|
||||
|
||||
it("honours count", () => {
|
||||
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||
});
|
||||
|
||||
it("drops an excluded date from the expansion, keeping the series positions", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
// The position within the series is unchanged — Wednesday is still the
|
||||
// third date the rule produces, whatever happened to Tuesday. It is the
|
||||
// *id* built on top of that which moves, and only after a write.
|
||||
assert.equal(out[1]!.index, 2);
|
||||
});
|
||||
|
||||
it("carries an override onto the occurrence it keys", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrenceView", () => {
|
||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||
const base = series();
|
||||
const occ = occurrenceAt(base, 1)!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.id, syntheticId("ev1", 1));
|
||||
assert.equal(view.baseEventId, "ev1");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
assert.equal(view.recurrenceRule, undefined);
|
||||
assert.equal(view.recurrenceOverrides, undefined);
|
||||
});
|
||||
|
||||
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||
const base = oneOff();
|
||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
||||
assert.equal(view.id, "ev2-o0");
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
assert.notEqual(view.id, view.baseEventId);
|
||||
assert.equal(view.recurrenceId, undefined);
|
||||
});
|
||||
|
||||
it("lets an override win over the series", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
||||
// for the id this occurrence had *before* the write is the bug below.
|
||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||
assert.equal(view.title, "Moved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
||||
});
|
||||
it("does not claim a stored id", () => {
|
||||
assert.equal(parseSyntheticId("ev1"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOccurrencePatch", () => {
|
||||
it("applies what an occurrence takes", () => {
|
||||
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
||||
});
|
||||
|
||||
it("refuses an event-level property by name", () => {
|
||||
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
||||
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
||||
});
|
||||
|
||||
it("drops an inherited property in silence, which is the dangerous half", () => {
|
||||
// No `rejected`, nothing applied, and a real server would still answer
|
||||
// "updated". Anything that trusts the response believes this landed.
|
||||
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, {});
|
||||
});
|
||||
|
||||
it("judges a pointer patch on its first token", () => {
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
||||
{ "participants/me/participationStatus": "accepted" });
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("synthetic ids are only true until the next write", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
||||
* entry renumbered a five-week series so that the *same* ids addressed
|
||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
||||
* that rather than the exact permutation, because the property that bites is
|
||||
* not which date an id moves to but that it moves at all, silently.
|
||||
*/
|
||||
it("makes a cached id address a different date after an override is written", () => {
|
||||
const before = series();
|
||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
assert.notEqual(dateAfter, dateBefore);
|
||||
// And crucially it still resolves — a stale id is wrong, not invalid, so a
|
||||
// client that trusts it gets a confident answer about the wrong day.
|
||||
assert.ok(dateAfter);
|
||||
});
|
||||
|
||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
||||
const before = series();
|
||||
const occ = occurrenceAt(before, 3)!;
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
||||
assert.equal(same!.start, occ.start);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("an override that moves an occurrence", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
|
||||
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
|
||||
* still at 09:00 — the slot the rule made, which the move does not touch.
|
||||
*
|
||||
* The mock used to clobber the override's `start` with the slot time, so a
|
||||
* moved occurrence did not move. That made per-occurrence *time* editing —
|
||||
* one of the main things the feature is for — look broken against the mock
|
||||
* and fine against the server.
|
||||
*/
|
||||
const moved = () => ({
|
||||
...series(),
|
||||
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
|
||||
});
|
||||
|
||||
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
assert.equal(occ.start, "2026-09-08T14:00:00");
|
||||
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("shows the moved time on the occurrence a get returns", () => {
|
||||
const base = moved();
|
||||
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
|
||||
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.start, "2026-09-08T14:00:00");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("keeps the occurrence findable by recurrenceId after the move", () => {
|
||||
// This is the property the store depends on: `recurrenceId` survives both
|
||||
// a renumbering and a move, so it is the handle a mutation resolves from.
|
||||
const base = moved();
|
||||
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
|
||||
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||
*
|
||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||
* things that only a live server showed were therefore impossible to develop
|
||||
* against, and all three had already cost a debugging session:
|
||||
*
|
||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
||||
* `recurrenceOverrides` entry rather than touching the series.
|
||||
*
|
||||
* A mock that agrees with the client rather than with the server is how #26 and
|
||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||
* what Stalwart rejects is rejected here, and what it drops in silence is
|
||||
* dropped here, in silence, on purpose.
|
||||
*/
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
/** How far the expander will walk before giving up on a rule. */
|
||||
const MAX_ITERATIONS = 750;
|
||||
|
||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||
|
||||
/**
|
||||
* The id an occurrence is addressed by, which is only true until the next write.
|
||||
*
|
||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||
* them, and nothing in ihasmail may read either.
|
||||
*
|
||||
* They are also deliberately **unstable**, because the real ones are.
|
||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
||||
* component that renumbers it. A five-week series held `e i m q u` over
|
||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
||||
* different dates.
|
||||
*
|
||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
||||
* would hand back a loud `notFound` and let a client that caches ids look
|
||||
* careful. So the numbering is shifted by the number of overrides — an
|
||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
||||
* matters: hold an id across a write and it silently addresses another date.
|
||||
*/
|
||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
||||
|
||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
||||
}
|
||||
|
||||
/** How far the id numbering has been rotated away from the series order. */
|
||||
function rotation(base: Obj): number {
|
||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
||||
}
|
||||
|
||||
/** The id slot this occurrence currently answers to. */
|
||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
||||
return occ.index + rotation(base);
|
||||
}
|
||||
|
||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||
export function localDateTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const parseLocal = (s: string): Date => new Date(s);
|
||||
|
||||
export interface Occurrence {
|
||||
index: number;
|
||||
/** The slot in the series this instance fills, which keys any override. */
|
||||
recurrenceId: string;
|
||||
start: string;
|
||||
/** Set when a `recurrenceOverrides` entry applies to this date. */
|
||||
override?: Obj;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
frequency?: string;
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: string;
|
||||
byDay?: { day: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of `base` between `from` and `to`, in series order.
|
||||
*
|
||||
* An event with no rule has exactly one, at index 0 — which is what gives a
|
||||
* one-off the synthetic id a real server would give it.
|
||||
*/
|
||||
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const startStr = base.start as string;
|
||||
if (!startStr) return [];
|
||||
const first = parseLocal(startStr);
|
||||
const rule = base.recurrenceRule as Rule | undefined;
|
||||
|
||||
const out: Occurrence[] = [];
|
||||
const emit = (index: number, at: Date): boolean => {
|
||||
const recurrenceId = localDateTime(at);
|
||||
const override = overrides[recurrenceId];
|
||||
// An excluded date is simply gone from the expansion. Its slot is not
|
||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
||||
if (override?.excluded === true) return true;
|
||||
/*
|
||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||
* are two different times: the slot it fills stays where the rule put it,
|
||||
* and only the clock time moves. **Confirmed live on 0.16.20
|
||||
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
|
||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||
* `2027-06-14T09:00:00`.
|
||||
*
|
||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
||||
* the one name for this instance that neither a renumbering nor a move
|
||||
* changes.
|
||||
*/
|
||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||
const shown = parseLocal(start);
|
||||
if (shown >= from && shown < to) {
|
||||
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
|
||||
}
|
||||
return at < to;
|
||||
};
|
||||
|
||||
if (!rule?.frequency) {
|
||||
emit(0, first);
|
||||
return out;
|
||||
}
|
||||
|
||||
const interval = Math.max(1, rule.interval ?? 1);
|
||||
const until = rule.until ? parseLocal(rule.until) : null;
|
||||
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
|
||||
|
||||
let index = 0;
|
||||
let emitted = 0;
|
||||
const cursor = new Date(first);
|
||||
|
||||
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||
if (until && cursor > until) break;
|
||||
if (rule.count != null && emitted >= rule.count) break;
|
||||
|
||||
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
|
||||
if (matches) {
|
||||
emitted++;
|
||||
const keepGoing = emit(index, new Date(cursor));
|
||||
index++;
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
|
||||
// A rule with byDay walks day by day and keeps the days it names; without
|
||||
// one it steps by its own frequency.
|
||||
if (byDay) cursor.setDate(cursor.getDate() + 1);
|
||||
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
|
||||
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
|
||||
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fields that describe the series and never travel down to one instance. */
|
||||
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
|
||||
|
||||
/**
|
||||
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||
*
|
||||
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
||||
* nothing else, which is the shape `isRecurring` was written against.
|
||||
*/
|
||||
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
const view: Obj = { ...base };
|
||||
for (const k of SERIES_ONLY) delete view[k];
|
||||
Object.assign(view, occ.override ?? {});
|
||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
||||
view.baseEventId = base.id;
|
||||
view.start = occ.start;
|
||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||
// its single occurrence does not, or every one-off would look recurring.
|
||||
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
|
||||
delete view.excluded;
|
||||
return view;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
export const OCCURRENCE_REJECTED = new Set([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dropped from the patch, with the response still reporting success.
|
||||
*
|
||||
* This is the half that has to be reproduced most carefully. A mock that
|
||||
* *applied* these would agree with a client that sends them, and the belief
|
||||
* would ship — which is exactly the road #26 took to a live server.
|
||||
*/
|
||||
export const OCCURRENCE_INHERITED = new Set([
|
||||
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Split a per-occurrence patch the way the server's validator does.
|
||||
*
|
||||
* `rejected` is the first property that would be refused, if any; `applied` is
|
||||
* what actually lands on the override. Everything else vanishes without a word.
|
||||
*/
|
||||
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
|
||||
const applied: Obj = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const [head, , third] = key.split("/");
|
||||
const root = head ?? key;
|
||||
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
|
||||
if (OCCURRENCE_INHERITED.has(root)) continue;
|
||||
if (root === "participants" && third === "calendarAddress") continue;
|
||||
if (root === "id") continue;
|
||||
applied[key] = value;
|
||||
}
|
||||
return { applied };
|
||||
}
|
||||
|
||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
||||
const index = slot - rotation(base);
|
||||
if (index < 0) return null;
|
||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||
return all.find((o) => o.index === index) ?? null;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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,28 +23,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -63,3 +63,37 @@ test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
||||
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||
});
|
||||
|
||||
test("generated app passwords are unbiased and long enough", async () => {
|
||||
const { readableSecret } = await import("./account.js");
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789";
|
||||
const counts = new Map<string, number>();
|
||||
let samples = 0;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const secret = readableSecret();
|
||||
assert.match(secret, /^[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}$/, secret);
|
||||
for (const ch of secret.replace(/-/g, "")) {
|
||||
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
assert.equal(samples, 2000 * 20);
|
||||
|
||||
/*
|
||||
* `% 33` over a byte maps 25 characters onto 8 values each and the last 8
|
||||
* onto 7, so the digits — the tail of the alphabet — would come up about
|
||||
* 7/8 as often as they should. Testing each character on its own cannot see
|
||||
* a skew that size against the noise, so weigh the whole tail at once:
|
||||
* uniform puts 8/33 of the draw there, the biased version 7/8 of that, and
|
||||
* over 40,000 draws the two are more than four standard deviations apart.
|
||||
*/
|
||||
const tail = alphabet.slice(25); // "23456789"
|
||||
const tailSeen = [...tail].reduce((n, ch) => n + (counts.get(ch) ?? 0), 0);
|
||||
const p = tail.length / alphabet.length;
|
||||
const expected = samples * p;
|
||||
const sigma = Math.sqrt(samples * p * (1 - p));
|
||||
assert.ok(
|
||||
Math.abs(tailSeen - expected) < 4 * sigma,
|
||||
`digits appeared ${tailSeen} times, expected ~${Math.round(expected)} (sigma ${sigma.toFixed(1)}) - modulo bias?`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,64 +34,9 @@ export interface LiveSession {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** What `/api/auth/sessions` reports about a session, with nothing secret in it. */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the rest of the server asks of a session store.
|
||||
*
|
||||
* There is one implementation today -- `SessionStore` below, which keeps the
|
||||
* records in memory and optionally mirrors them to `SESSION_FILE`. The reason
|
||||
* it is named as an interface anyway is that a second one is planned: a
|
||||
* stateless backend that carries the whole record in the cookie, so that a
|
||||
* replica can serve a session it never issued and `/data` can go away. Callers
|
||||
* written against the concrete class would all have to be revisited then.
|
||||
*
|
||||
* Five of these are already stateless in shape -- `create`, `resolve`,
|
||||
* `reseal` and `destroy` each touch exactly one session, and the sealing key is
|
||||
* derived from the cookie secret (see `crypto.ts`), so the record can move into
|
||||
* the cookie without the server keeping a map.
|
||||
*
|
||||
* The other two cannot be. `listForUser` and `destroyAllForUser` have to reach
|
||||
* sessions other than the one presenting itself, which means something has to
|
||||
* be enumerable somewhere. `destroyAllForUser` is not only the "sign out my
|
||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||
* password changes, so it carries the guarantee that changing a credential
|
||||
* invalidates the sessions still holding the old one. A stateless backend
|
||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
||||
* Stalwart's own token registry, which can already answer both questions.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
init(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
export class SessionStore implements SessionBackend {
|
||||
export class SessionStore {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
@@ -159,7 +104,13 @@ export class SessionStore implements SessionBackend {
|
||||
}
|
||||
|
||||
/** Create a session; returns the cookie value to hand to the client. */
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession } {
|
||||
create(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}): { cookie: string; session: LiveSession } {
|
||||
const id = randomToken(18);
|
||||
const secret = randomToken(32);
|
||||
const salt = randomBytes(16);
|
||||
@@ -260,7 +211,7 @@ export class SessionStore implements SessionBackend {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -3,7 +3,6 @@ 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",
|
||||
@@ -48,30 +47,9 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
export function staticHandler(root: string): 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 {
|
||||
@@ -79,9 +57,7 @@ export function staticHandler(root: string, basePath = ""): 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);
|
||||
@@ -94,16 +70,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
|
||||
return async (c) => {
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
|
||||
/*
|
||||
* 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);
|
||||
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
|
||||
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = join(absRoot, rel);
|
||||
|
||||
@@ -10,15 +10,6 @@ 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 {
|
||||
@@ -33,37 +24,16 @@ export class UpstreamError extends Error {
|
||||
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
|
||||
const SESSION_CACHE_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* The Stalwart a username belongs to.
|
||||
*
|
||||
* `STALWART_URL` is the default and is always the answer for a domain nobody
|
||||
* 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`;
|
||||
export function wellKnownUrl(): string {
|
||||
return `${config.stalwartUrl}/.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, base: string = config.stalwartUrl): Promise<UpstreamSession> {
|
||||
const res = await fetch(wellKnownUrl(base), {
|
||||
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
|
||||
const res = await fetch(wellKnownUrl(), {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
@@ -76,13 +46,13 @@ export async function fetchUpstreamSession(authorization: string, base: string =
|
||||
}
|
||||
const session = (await res.json()) as UpstreamSession;
|
||||
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
|
||||
return { ...session, baseUrl: base };
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getUpstreamSession(sessionId: string, authorization: string, base: string = config.stalwartUrl, force = false) {
|
||||
export async function getUpstreamSession(sessionId: string, authorization: string, 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, base);
|
||||
const session = await fetchUpstreamSession(authorization);
|
||||
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
|
||||
return session;
|
||||
}
|
||||
@@ -108,14 +78,10 @@ const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
* in each account's `accountCapabilities`. Checking only the session level
|
||||
* therefore reported every real 0.16 server as older than 0.16 — which routed
|
||||
* therefore reports every real 0.16 server as pre-0.16 — which routed
|
||||
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
||||
* About page the wrong thing. The session level is still checked last, in case
|
||||
* a later release advertises it there as well.
|
||||
*
|
||||
* This is now what sign-in tests to decide whether a server is supported at
|
||||
* all, so the same mistake would lock every user out of a working server
|
||||
* rather than merely misroute them.
|
||||
*/
|
||||
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
||||
if (!session) return false;
|
||||
@@ -130,13 +96,22 @@ export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities
|
||||
export interface AccountInfo {
|
||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||
locale: string | null;
|
||||
/**
|
||||
* Which generation of Stalwart's API answered: "0.16+" has the registry
|
||||
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
|
||||
* server is not Stalwart or told us nothing.
|
||||
*/
|
||||
generation: "0.16+" | "pre-0.16" | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
|
||||
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -190,9 +165,13 @@ export function normalizeLocale(raw: unknown): string | null {
|
||||
* tells us which generation we are talking to.
|
||||
*/
|
||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
// Sign-in refuses a server without the registry, so this should not happen —
|
||||
// but a session we cannot read capabilities from is not one to ask.
|
||||
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
|
||||
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
|
||||
// at all, so its absence already answers the question — and asking anyway
|
||||
// would fail the whole request, since those servers reject a `using` naming
|
||||
// a capability they cannot parse.
|
||||
// A session with no capabilities at all is not one we can read anything from.
|
||||
if (!session.capabilities) return EMPTY_INFO;
|
||||
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
@@ -210,23 +189,35 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
// A locale request that fails — a permission we lack, a hiccup upstream —
|
||||
// costs us the locale and nothing else.
|
||||
if (!res.ok) return EMPTY_INFO;
|
||||
// The registry capability already settled the generation. A locale request
|
||||
// that fails — a permission we lack, a hiccup upstream — can only cost us the
|
||||
// locale; it must not talk us out of what we know.
|
||||
if (!res.ok) return REGISTRY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
return interpretAccountInfo(body.methodResponses ?? []);
|
||||
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
|
||||
* permission the built-in user role has, and fall back to `x:Account` for the
|
||||
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
|
||||
* methods; this is a permissions fallback, not a version one.
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
|
||||
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||
* and note which generation answered.
|
||||
*/
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
export function interpretAccountInfo(
|
||||
responses: [string, Record<string, unknown>, string][],
|
||||
known: AccountInfo["generation"] = null,
|
||||
): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
||||
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||
// `known` is what the session capability already proved, and outranks a reply
|
||||
// that merely refused us.
|
||||
const generation: AccountInfo["generation"] =
|
||||
settings && settings[0] !== "error"
|
||||
? "0.16+"
|
||||
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||
? "pre-0.16"
|
||||
: known;
|
||||
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
@@ -240,9 +231,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, base: string): Promise<string | null> {
|
||||
async function fetchEdition(authorization: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/account`, {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
@@ -260,7 +251,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, session.baseUrl) };
|
||||
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
@@ -288,9 +279,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, base: string = config.stalwartUrl): string {
|
||||
export function absoluteUpstream(url: string): string {
|
||||
try {
|
||||
return new URL(url, base).toString();
|
||||
return new URL(url, config.stalwartUrl).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
|
||||
|
||||
/**
|
||||
* The version is this build's public identity: it names the image, and it is
|
||||
* what About and /api/health report. It had no tests while it was
|
||||
* `2.16.<pr>`; it has them now that the rules moved.
|
||||
*/
|
||||
|
||||
test("a pull request merge is named by its number", () => {
|
||||
assert.equal(
|
||||
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
|
||||
"2026.8.30+pr129",
|
||||
);
|
||||
});
|
||||
|
||||
test("a commit that did not come through a pull request carries its SHA", () => {
|
||||
// Claiming the last PR would say it *is* that PR rather than something after it.
|
||||
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
|
||||
});
|
||||
|
||||
test("leading zeros are stripped, since a version field may not carry them", () => {
|
||||
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
|
||||
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
|
||||
});
|
||||
|
||||
test("it sorts forward from the versions it replaces", () => {
|
||||
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
|
||||
// whole reason the Stalwart generation left the version.
|
||||
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
|
||||
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
|
||||
});
|
||||
|
||||
test("two builds from the same day differ, even though they rank the same", () => {
|
||||
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
|
||||
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a.split("+")[0], b.split("+")[0]);
|
||||
});
|
||||
|
||||
test("the same commit always resolves to the same version", () => {
|
||||
// Built from the commit's own date, not today's, so an old commit rebuilt
|
||||
// now reports what it reported then.
|
||||
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
|
||||
assert.equal(formatVersion(commit), formatVersion(commit));
|
||||
});
|
||||
|
||||
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
|
||||
const before = process.env.IHASMAIL_VERSION;
|
||||
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
|
||||
try {
|
||||
assert.equal(resolveVersion(), "2026.8.30+pr129");
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.IHASMAIL_VERSION;
|
||||
else process.env.IHASMAIL_VERSION = before;
|
||||
}
|
||||
});
|
||||
|
||||
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
|
||||
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
|
||||
assert.equal(UNVERSIONED, "0.0.0");
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"_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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Optional: which Stalwart a domain signs in to.",
|
||||
"",
|
||||
"STALWART_URL stays required and stays the default. This file only adds",
|
||||
"domains that go somewhere else -- delete it and nothing changes.",
|
||||
"",
|
||||
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
||||
"it read-only. Read once at startup, so editing it means restarting.",
|
||||
"",
|
||||
"A domain that is not listed here, and a bare username with no domain at",
|
||||
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
|
||||
"server is unreachable that sign-in fails, because falling back would",
|
||||
"authenticate somebody against a server their domain was routed away from.",
|
||||
"",
|
||||
"Keys are lower-cased and stripped of a trailing dot when read. Malformed",
|
||||
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
|
||||
"server at startup rather than failing quietly at somebody's sign-in.",
|
||||
"",
|
||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||
],
|
||||
|
||||
"example.com": "https://mail.example.com",
|
||||
"customer-b.test": "https://jmap.customer-b.test"
|
||||
}
|
||||
@@ -4,17 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!--
|
||||
One tag, no media query: applyTheme() keeps it in step with the chosen
|
||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
||||
not what the user picked here. There used to be two, both with media
|
||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
||||
neither and the colour never moved off whatever the OS implied.
|
||||
|
||||
The initial value is the default theme's background, so the browser chrome
|
||||
is right from the first paint rather than only once JS has run.
|
||||
-->
|
||||
<meta name="theme-color" content="#0d2430" />
|
||||
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ihasmail/web",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -15,7 +15,6 @@
|
||||
"@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,13 +2,12 @@
|
||||
"name": "ihasmail",
|
||||
"short_name": "ihasmail",
|
||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||
"_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": "./",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "mailto",
|
||||
"url": "mail?mailto=%s"
|
||||
"url": "/mail?mailto=%s"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
@@ -17,17 +16,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"
|
||||
@@ -36,16 +35,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/* ihasmail service worker.
|
||||
Two jobs: app-shell caching for installability and fast loads (API requests
|
||||
are never cached), and Web Push, which is the only part of ihasmail that runs
|
||||
when no tab is open. */
|
||||
/* ihasmail service worker: app-shell caching for installability & fast loads.
|
||||
API requests are never cached. */
|
||||
const VERSION = "ihasmail-v2";
|
||||
|
||||
/*
|
||||
* 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`];
|
||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
@@ -35,10 +18,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(`${BASE}/api/`)) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
@@ -51,115 +34,8 @@ 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(`${BASE}/`)));
|
||||
event.respondWith(fetch(req).catch(() => caches.match("/")));
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
});
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Web Push */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
||||
* nothing here talks to ihasmail's server. The payload is an EmailPush object
|
||||
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
|
||||
* notification without a round-trip — which matters, because when this fires
|
||||
* there may be no session to make one with.
|
||||
*
|
||||
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
||||
* until the client echoes its code back. That cannot be done from here (no
|
||||
* credentials), so it is stashed for a tab to collect and confirm.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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];
|
||||
const who = from?.name || from?.email || "New message";
|
||||
const what = email?.subject || "(no subject)";
|
||||
return { title: who, body: what, preview: email?.preview || "" };
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = event.data ? event.data.json() : null;
|
||||
} catch {
|
||||
/* not JSON: fall through to the generic notification below */
|
||||
}
|
||||
|
||||
// The verification handshake. No credentials here, so hand it to a tab —
|
||||
// an open one now, or the next one to start.
|
||||
if (data && data["@type"] === "PushVerification") {
|
||||
event.waitUntil((async () => {
|
||||
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
||||
if (clients.length) {
|
||||
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
|
||||
} else {
|
||||
const cache = await caches.open(VERSION);
|
||||
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
if (!emails.length) {
|
||||
// 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: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// One notification per message, collapsing repeats of the same message by
|
||||
// tag so a re-push does not stack.
|
||||
for (const email of emails.slice(0, 5)) {
|
||||
const { title, body, preview } = textOf(email);
|
||||
await self.registration.showNotification(title, {
|
||||
body: preview ? `${body}\n${preview}` : body,
|
||||
icon: `${BASE}/img/icon-192.png`,
|
||||
badge: `${BASE}/img/favicon-64.png`,
|
||||
tag: `ihasmail-${email.id || body}`,
|
||||
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
|
||||
});
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
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. 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(url);
|
||||
})());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Route, Switch, Redirect, useLocation } 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, toast } from "@/ui/toast";
|
||||
import { ToastHost } from "@/ui/toast";
|
||||
import { ConfirmHost } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { LoginPage } from "@/views/Login";
|
||||
@@ -17,14 +17,8 @@ import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
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";
|
||||
import { useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
|
||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
@@ -34,39 +28,11 @@ 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]);
|
||||
|
||||
/*
|
||||
* 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) {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
@@ -74,43 +40,11 @@ export function App() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
/*
|
||||
* 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>
|
||||
<>
|
||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</Router>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,66 +52,16 @@ function AuthedApp() {
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const [location] = useLocation();
|
||||
|
||||
/*
|
||||
* 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);
|
||||
// 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).
|
||||
useEffect(() => {
|
||||
if (settingsAlreadyLoadedFor(accountId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
if (!accountId) 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);
|
||||
// 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.
|
||||
// Pushes were held back until now so they could not race the load.
|
||||
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.
|
||||
@@ -203,19 +87,6 @@ function AuthedApp() {
|
||||
void useFiles.getState().init();
|
||||
void useSieve.getState().init();
|
||||
push.start();
|
||||
// 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) => {
|
||||
@@ -255,7 +126,7 @@ function AuthedApp() {
|
||||
const id = s.roleId("inbox");
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
@@ -269,16 +140,6 @@ 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" />}>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* The build's version string, substituted by Vite at build time — there is no
|
||||
* git to ask from inside a browser, or inside the Docker build. See
|
||||
* `scripts/version.mjs`.
|
||||
*/
|
||||
declare const __IHASMAIL_VERSION__: string;
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export const CAP = {
|
||||
core: "urn:ietf:params:jmap:core",
|
||||
@@ -63,16 +62,9 @@ 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.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(withBase(path), {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
|
||||
credentials: "same-origin",
|
||||
@@ -284,12 +276,12 @@ export class JmapClient {
|
||||
}
|
||||
|
||||
uploadUrl(accountId: Id): string {
|
||||
return withBase(`/api/upload/${encodeURIComponent(accountId)}`);
|
||||
return `/api/upload/${encodeURIComponent(accountId)}`;
|
||||
}
|
||||
|
||||
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
||||
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
||||
const u = withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`);
|
||||
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
|
||||
return inline ? `${u}&inline=1` : u;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Id, StateChange } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export type PushListener = (accountId: Id, type: string, newState: string) => void;
|
||||
|
||||
@@ -71,7 +70,7 @@ class PushManager {
|
||||
private connect(): void {
|
||||
if (this.stopped || this.es) return;
|
||||
if (this.state !== "connected") this.setState("connecting");
|
||||
const url = withBase(`/api/events?types=*&closeafter=no&ping=30`);
|
||||
const url = `/api/events?types=*&closeafter=no&ping=30`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
es.onopen = () => {
|
||||
|
||||
@@ -36,7 +36,8 @@ export interface JmapSession {
|
||||
userLocale?: string | null;
|
||||
/** What the upstream server was willing to say about itself. */
|
||||
server?: {
|
||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||
/** Which API generation answered: Stalwart publishes no version number. */
|
||||
generation?: "0.16+" | "pre-0.16" | null;
|
||||
edition?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { accountForCapability, ownAccountForCapability, type SessionLike } from "@/lib/accountRouting";
|
||||
|
||||
/**
|
||||
* Found by sharing a folder between two real accounts.
|
||||
*
|
||||
* Switching to the account somebody shared pointed everything at it, because
|
||||
* the rule was "use the selected account if it can do this" and a shared file
|
||||
* account can, by definition, do files. ihasmail keeps its own settings in the
|
||||
* account's Files, so changing any setting while looking at somebody's shared
|
||||
* folder wrote `settings.json` into *their* storage, creating the `ihasmail`
|
||||
* folder there to do it. Reading someone else's data by mistake is bad; writing
|
||||
* yours into it is worse, and it was the same one-line rule doing both.
|
||||
*/
|
||||
|
||||
const CAL = "urn:ietf:params:jmap:calendars";
|
||||
const FILES = "urn:ietf:params:jmap:filenode";
|
||||
const MAIL = "urn:ietf:params:jmap:mail";
|
||||
|
||||
/** Mine does everything; theirs is a shared account with only files on it. */
|
||||
const shared = (): SessionLike => ({
|
||||
accounts: {
|
||||
mine: { isPersonal: true, accountCapabilities: { [MAIL]: {}, [FILES]: {}, [CAL]: {} } },
|
||||
theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } },
|
||||
},
|
||||
primaryAccounts: { [MAIL]: "mine", [FILES]: "mine", [CAL]: "mine" },
|
||||
});
|
||||
|
||||
describe("what the reader is looking at", () => {
|
||||
it("follows the switch into a shared account for what was shared", () => {
|
||||
expect(accountForCapability(shared(), "theirs", FILES)).toBe("theirs");
|
||||
});
|
||||
|
||||
it("leaves everything else on the reader's own account", () => {
|
||||
expect(accountForCapability(shared(), "theirs", MAIL)).toBe("mine");
|
||||
expect(accountForCapability(shared(), "theirs", CAL)).toBe("mine");
|
||||
});
|
||||
|
||||
it("still follows a switch between the reader's own accounts", () => {
|
||||
const s = shared();
|
||||
s.accounts.second = { isPersonal: true, accountCapabilities: { [MAIL]: {} } };
|
||||
expect(accountForCapability(s, "second", MAIL)).toBe("second");
|
||||
});
|
||||
|
||||
it("gives up rather than aim at a shared account for something unshared", () => {
|
||||
// No primary for calendars, and theirs does not offer them. The old rule
|
||||
// fell back to the selection, which is somebody else's account.
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[CAL];
|
||||
expect(accountForCapability(s, "theirs", CAL)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets one of the reader's own accounts stand in when there is no primary", () => {
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[CAL];
|
||||
expect(accountForCapability(s, "mine", CAL)).toBe("mine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what belongs to the reader", () => {
|
||||
it("stays on their own account while they look at a shared one", () => {
|
||||
// The one that matters: settings are written through this.
|
||||
expect(ownAccountForCapability(shared(), FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("ignores a primary account the server says is not the reader's", () => {
|
||||
const s = shared();
|
||||
s.primaryAccounts[FILES] = "theirs";
|
||||
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("finds a personal account when no primary is named", () => {
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[FILES];
|
||||
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("answers nothing rather than a shared account", () => {
|
||||
const s: SessionLike = {
|
||||
accounts: { theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } } },
|
||||
primaryAccounts: {},
|
||||
};
|
||||
expect(ownAccountForCapability(s, FILES)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Whether a shared collection counts as added.
|
||||
*
|
||||
* JMAP keeps this on the collection, in `isSubscribed`, and that is the better
|
||||
* place: a preference the server holds is one every client sees. But
|
||||
* subscribing writes to the *owner's* account, and Stalwart 0.16.19 refuses
|
||||
* that for an address book shared read-only — "You are not allowed to modify
|
||||
* this address book" — while accepting the identical write on a shared
|
||||
* calendar. Confirmed against the live server on 2026-08-27, from a second
|
||||
* account holding the share.
|
||||
*
|
||||
* So there are two records and either counts. The rule is the whole of the
|
||||
* fix, which is why it is worth pinning down here rather than leaving it
|
||||
* spelled out in three components that could drift apart.
|
||||
*/
|
||||
|
||||
const key = (accountId: string, id: string) => `${accountId}:${id}`;
|
||||
|
||||
/** Added if the server remembered it, or the reader's settings did. */
|
||||
function isAdded(collection: { accountId: string; id: string; isSubscribed?: boolean }, addedShares: string[]): boolean {
|
||||
return Boolean(collection.isSubscribed) || new Set(addedShares).has(key(collection.accountId, collection.id));
|
||||
}
|
||||
|
||||
const book = (over: Partial<{ accountId: string; id: string; isSubscribed: boolean }> = {}) =>
|
||||
({ accountId: "acct", id: "ab1", ...over });
|
||||
|
||||
describe("whether a shared collection has been added", () => {
|
||||
it("is added when the server took the subscription", () => {
|
||||
expect(isAdded(book({ isSubscribed: true }), [])).toBe(true);
|
||||
});
|
||||
|
||||
it("is added when only the settings remember it", () => {
|
||||
// The address book case: the server refused the write.
|
||||
expect(isAdded(book(), ["acct:ab1"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is not added when neither says so", () => {
|
||||
expect(isAdded(book(), [])).toBe(false);
|
||||
expect(isAdded(book(), ["other:ab1", "acct:ab2"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys are account-qualified", () => {
|
||||
it("does not confuse the same id in another account", () => {
|
||||
// Two accounts each having a book "ab1" is ordinary, not unlucky.
|
||||
expect(isAdded(book({ accountId: "theirs" }), ["mine:ab1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes two collections in one account", () => {
|
||||
expect(isAdded(book({ id: "ab2" }), ["acct:ab1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
* gets wrong if you take it at face value.
|
||||
*
|
||||
* `readEntries` answers with *up to* some number of entries and signals the end
|
||||
* of a directory with an empty array, so a single call quietly loses everything
|
||||
* past the first batch — a real folder of a few hundred files would upload the
|
||||
* first hundred and look like it had finished. And a directory tree that cycles
|
||||
* has to stop somewhere the tab is still alive.
|
||||
*/
|
||||
|
||||
const file = (name: string) => new File([name], name);
|
||||
|
||||
/** A directory whose contents arrive a batch at a time, as a real one does. */
|
||||
const dir = (name: string, children: unknown[], batch = 2) => {
|
||||
let at = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb: (e: never[]) => void) => {
|
||||
const slice = children.slice(at, at + batch);
|
||||
at += slice.length;
|
||||
cb(slice as never[]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const leaf = (name: string) => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (cb: (f: File) => void) => cb(file(name)),
|
||||
});
|
||||
|
||||
describe("walking a dropped folder", () => {
|
||||
it("reads a directory across as many batches as it takes", async () => {
|
||||
// Five children, two per readEntries call: a single read would find two.
|
||||
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
|
||||
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the folder each file came from", async () => {
|
||||
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
|
||||
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
|
||||
["outer", "top"],
|
||||
["outer/inner", "deep"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a loose file at the drop itself", async () => {
|
||||
const plan = await planUpload([leaf("loose")] as never[]);
|
||||
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
|
||||
});
|
||||
|
||||
it("stops rather than following a cycle for ever", async () => {
|
||||
const loop: Record<string, unknown> = {};
|
||||
Object.assign(loop, dir("loop", []));
|
||||
(loop as { createReader: () => unknown }).createReader = () => ({
|
||||
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
|
||||
});
|
||||
// Terminating at all is the assertion; the caps decide where. Both are set
|
||||
// low so the test does not have to read twenty thousand phantom entries.
|
||||
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
|
||||
expect(plan).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a plan needs", () => {
|
||||
it("lists parents before their children", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a", "b", "c"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("names each folder once, however many files are in it", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("asks for nothing when everything lands at the drop", () => {
|
||||
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotting a folder in the drop", () => {
|
||||
it("is true when any entry is a directory", () => {
|
||||
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
|
||||
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import type { MailboxRole } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Emptying destroys everything in a folder in one action, with no undo and no
|
||||
* trip through Deleted Items. Which folders may be emptied is therefore a
|
||||
* safety property, not a presentation one — the store enforces it too, and
|
||||
* these pin the half the menus decide.
|
||||
*/
|
||||
|
||||
describe("which folders may be emptied", () => {
|
||||
it("allows exactly Deleted Items and Junk Mail", () => {
|
||||
expect(canEmpty("trash")).toBe(true);
|
||||
expect(canEmpty("junk")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses folders holding mail someone meant to keep", () => {
|
||||
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
|
||||
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a plain folder, which has no role at all", () => {
|
||||
expect(canEmpty(null)).toBe(false);
|
||||
expect(canEmpty(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the action is called", () => {
|
||||
it("says what it does to spam, rather than naming the folder", () => {
|
||||
// "Delete all spam" is what this is called everywhere else; "Empty Junk
|
||||
// Mail" would be accurate and still leave people hunting for it.
|
||||
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
|
||||
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
|
||||
});
|
||||
|
||||
it("names the folder for Deleted Items, whatever the server calls it", () => {
|
||||
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
|
||||
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
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,161 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
|
||||
import type { FileNode, JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the
|
||||
* whole create with `invalidProperties (nodeType)` — which is what uploading a
|
||||
* file or making a folder hit on the live 0.15.5 box. Those servers tell a file
|
||||
* from a directory by whether it carries file properties at all.
|
||||
*/
|
||||
|
||||
function session(caps: string[]): JmapSession {
|
||||
return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||
}
|
||||
|
||||
const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"];
|
||||
const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"];
|
||||
|
||||
/**
|
||||
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
|
||||
* per-account and never appears in the session-level capabilities, so a client
|
||||
* that only checks there drops every 0.16 server onto the older code path.
|
||||
*/
|
||||
function realStalwartSession(): JmapSession {
|
||||
return {
|
||||
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
|
||||
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
|
||||
primaryAccounts: { "urn:stalwart:jmap": "a1" },
|
||||
state: "s",
|
||||
} as unknown as JmapSession;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
client.session = null;
|
||||
});
|
||||
|
||||
describe("on Stalwart 0.16 and newer", () => {
|
||||
it("uses nodeType everywhere", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
expect(supportsNodeType()).toBe(true);
|
||||
expect(fileNodeProps()).toContain("nodeType");
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" });
|
||||
});
|
||||
|
||||
it("leaves what the server reported alone", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial<FileNode>[];
|
||||
expect(normalizeFileNodes(nodes)).toEqual(nodes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on a real 0.16 session, which advertises per-account only", () => {
|
||||
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
|
||||
client.session = realStalwartSession();
|
||||
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
|
||||
expect(supportsNodeType()).toBe(true);
|
||||
expect(queryOmitsDirectories()).toBe(false);
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("on Stalwart before 0.16", () => {
|
||||
it("never mentions nodeType, in creates or in requested properties", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(supportsNodeType()).toBe(false);
|
||||
expect(fileNodeProps()).not.toContain("nodeType");
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" });
|
||||
expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType");
|
||||
});
|
||||
|
||||
it("keeps a directory free of file properties, which is what makes it one", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const dir = directoryCreate(null, "ihasmail");
|
||||
// Setting blobId, size or type — even to null — would make this a file.
|
||||
expect(dir).not.toHaveProperty("blobId");
|
||||
expect(dir).not.toHaveProperty("size");
|
||||
expect(dir).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("still sends what a file needs", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" });
|
||||
});
|
||||
|
||||
it("works out nodeType from the file properties, so folders stay folders", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const out = normalizeFileNodes([
|
||||
{ id: "1", name: "Documents", blobId: null, size: null, type: null },
|
||||
{ id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" },
|
||||
{ id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null },
|
||||
] as Partial<FileNode>[]);
|
||||
expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]);
|
||||
});
|
||||
|
||||
it("does not overwrite a nodeType that did come back", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial<FileNode>[]);
|
||||
expect(out[0]!.nodeType).toBe("symlink");
|
||||
});
|
||||
});
|
||||
|
||||
it("assumes the older shape when there is no session yet", () => {
|
||||
client.session = null;
|
||||
expect(supportsNodeType()).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rights were split up in 0.16. Before that a node carried mayRead / mayWrite /
|
||||
* mayShare, with mayWrite covering everything the newer release names
|
||||
* separately — so Rename and Delete sat permanently greyed out, doing nothing
|
||||
* and saying nothing.
|
||||
*/
|
||||
describe("rights on a pre-0.16 server", () => {
|
||||
const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false });
|
||||
|
||||
it("widens mayWrite into the rights the UI gates on", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false });
|
||||
});
|
||||
|
||||
it("does not hand out rights the server withheld", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false });
|
||||
});
|
||||
|
||||
it("leaves rights that already use the newer names untouched", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true };
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toEqual(newer);
|
||||
});
|
||||
|
||||
it("copes with a node that reported no rights at all", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toBeUndefined();
|
||||
expect(node!.nodeType).toBe("directory");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
|
||||
* only resources that are *not* containers. It therefore returns files and
|
||||
* never folders, with no error to explain the omission: a folder created there
|
||||
* exists but never comes back in a listing. FileNode/get carries no such mask.
|
||||
*/
|
||||
describe("directory-blind query", () => {
|
||||
it("is worked around on older servers", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(queryOmitsDirectories()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not worked around where query can see folders", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
expect(queryOmitsDirectories()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFileNode } from "@/lib/filenode";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Dragging a folder into its own subtree is the move that has to be refused
|
||||
* rather than reported: the server would orphan the branch, and the folder the
|
||||
* reader was dragging would leave the tree with everything under it.
|
||||
*/
|
||||
|
||||
const rights = (over: Partial<FileNode["myRights"]> = {}) => ({
|
||||
mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true, ...over,
|
||||
});
|
||||
|
||||
/** a > b > c, plus a file in a and a second top-level folder. */
|
||||
const tree = (): Record<Id, FileNode> => {
|
||||
const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial<FileNode> = {}) =>
|
||||
({ id, parentId, nodeType, name: id, myRights: rights(), ...over }) as FileNode;
|
||||
return {
|
||||
a: mk("a", null, "directory"),
|
||||
b: mk("b", "a", "directory"),
|
||||
c: mk("c", "b", "directory"),
|
||||
other: mk("other", null, "directory"),
|
||||
doc: mk("doc", "a", "file"),
|
||||
};
|
||||
};
|
||||
|
||||
describe("what a folder may be dropped on", () => {
|
||||
it("allows a move to an unrelated folder", () => {
|
||||
expect(canDropFileNode(tree(), "a", "other")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a drop on itself", () => {
|
||||
expect(canDropFileNode(tree(), "a", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a drop into its own subtree, however deep", () => {
|
||||
expect(canDropFileNode(tree(), "a", "b")).toBe(false);
|
||||
expect(canDropFileNode(tree(), "a", "c")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the parent it already has, which is a no-op dressed as a move", () => {
|
||||
expect(canDropFileNode(tree(), "b", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a child up to the top level, but not one already there", () => {
|
||||
expect(canDropFileNode(tree(), "b", null)).toBe(true);
|
||||
expect(canDropFileNode(tree(), "a", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("targets that cannot take it", () => {
|
||||
it("refuses a file as a target", () => {
|
||||
expect(canDropFileNode(tree(), "b", "doc")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder that will not take children", () => {
|
||||
const t = tree();
|
||||
t.other = { ...t.other!, myRights: rights({ mayAddChildren: false }) };
|
||||
expect(canDropFileNode(t, "a", "other")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a target that is not there at all", () => {
|
||||
expect(canDropFileNode(tree(), "a", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a file to be moved like anything else", () => {
|
||||
expect(canDropFileNode(tree(), "doc", "other")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isShared } from "@/lib/filenode";
|
||||
|
||||
/**
|
||||
* The one thing about file sharing that a mock would never have told us.
|
||||
*
|
||||
* Stalwart 0.16.19 answers `shareWith` as `{}` for a node shared with nobody,
|
||||
* not `null` — every unshared node in a live account came back that way on
|
||||
* 2026-08-27. A truthiness test on the property is therefore true for every
|
||||
* node the server has ever returned, and a badge driven by one would report
|
||||
* the entire account as shared while being, technically, about the right
|
||||
* property.
|
||||
*/
|
||||
|
||||
describe("whether a node is shared", () => {
|
||||
it("treats the empty object Stalwart sends as not shared", () => {
|
||||
expect(isShared({ shareWith: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing or null shareWith as not shared", () => {
|
||||
expect(isShared({ shareWith: null })).toBe(false);
|
||||
expect(isShared({})).toBe(false);
|
||||
});
|
||||
|
||||
it("is shared once a principal is on it", () => {
|
||||
expect(isShared({ shareWith: { p1: { mayRead: true } } as never })).toBe(true);
|
||||
});
|
||||
|
||||
it("stays shared when the rights granted are all false", () => {
|
||||
// An entry with nothing enabled is still an entry: the principal is on the
|
||||
// list, and the owner should see that rather than an empty-looking folder.
|
||||
expect(isShared({ shareWith: { p1: { mayRead: false } } as never })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Issue #71, both halves of it, reduced to the arithmetic they turn on.
|
||||
*
|
||||
* After deleting a row from the keyboard, `focusId` used to keep pointing at
|
||||
* the row that had gone. Two things fell out of that:
|
||||
*
|
||||
* - `targetIds()` falls back to the focused id, so the next `#` re-targeted
|
||||
* the deleted message. The optimistic update had already moved it into
|
||||
* Deleted Items, so it looked like a permanent delete and raised a
|
||||
* confirmation the user had switched off.
|
||||
* - `moveFocus` read `ids.indexOf(focusId)` as -1 and treated that as
|
||||
* "before the first row", so `k` clamped to the top of the list.
|
||||
*
|
||||
* Clicking was unaffected: it sets focus to a row that exists. That is why it
|
||||
* only ever happened from the keyboard.
|
||||
*/
|
||||
|
||||
/** Where focus lands after the row at `wasAt` is removed. */
|
||||
function focusAfterRemove(freshIds: string[], wasAt: number, autoAdvance: "newer" | "older" | "list"): string | null {
|
||||
if (!freshIds.length) return null;
|
||||
if (wasAt < 0) return undefined as unknown as string;
|
||||
const want = autoAdvance === "newer" ? wasAt - 1 : wasAt;
|
||||
return freshIds[Math.max(0, Math.min(want, freshIds.length - 1))] ?? null;
|
||||
}
|
||||
|
||||
/** What moveFocus resolves to, given a focus id that may no longer exist. */
|
||||
function nextIndex(ids: string[], focus: string | null, listIndex: number, delta: number): number {
|
||||
const fromFocus = focus ? ids.indexOf(focus) : -1;
|
||||
const cur = fromFocus >= 0 ? fromFocus : listIndex;
|
||||
return Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
||||
}
|
||||
|
||||
describe("focus after deleting a row", () => {
|
||||
const after = ["b", "c", "d"]; // "a" was at 0 and has gone
|
||||
|
||||
it("lands on the row that slid into the gap", () => {
|
||||
expect(focusAfterRemove(after, 0, "older")).toBe("b");
|
||||
});
|
||||
|
||||
it("lands on the row above when auto-advance is set to newer", () => {
|
||||
// deleted "c" at index 2; newer means the one before it
|
||||
expect(focusAfterRemove(["a", "b", "d"], 2, "newer")).toBe("b");
|
||||
});
|
||||
|
||||
it("does not run off the end when the last row was deleted", () => {
|
||||
expect(focusAfterRemove(["a", "b"], 2, "older")).toBe("b");
|
||||
});
|
||||
|
||||
it("clears focus when the list is now empty", () => {
|
||||
expect(focusAfterRemove([], 0, "older")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("moving focus when the focused row has gone", () => {
|
||||
const ids = ["b", "c", "d"];
|
||||
|
||||
it("no longer sends k to the top of the list", () => {
|
||||
// The regression: focus is on the deleted "a", the list says we were at 1.
|
||||
expect(nextIndex(ids, "a", 1, -1)).toBe(0);
|
||||
// …and with focus repaired to a real row, k moves by one as it should.
|
||||
expect(ids[nextIndex(ids, "c", 1, -1)]).toBe("b");
|
||||
});
|
||||
|
||||
it("moves by one from a row that exists, in both directions", () => {
|
||||
expect(ids[nextIndex(ids, "c", 1, 1)]).toBe("d");
|
||||
expect(ids[nextIndex(ids, "b", 0, 1)]).toBe("c");
|
||||
});
|
||||
|
||||
it("stops at the ends rather than wrapping", () => {
|
||||
expect(ids[nextIndex(ids, "b", 0, -1)]).toBe("b");
|
||||
expect(ids[nextIndex(ids, "d", 2, 1)]).toBe("d");
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAlwaysVisible, visibleIdentities } from "@/lib/identityVisibility";
|
||||
|
||||
/**
|
||||
* Issue #73: a unique address per service, on a server with an alias domain,
|
||||
* gives every local part twice and a compose picker nobody can use — while only
|
||||
* a handful are ever sent from.
|
||||
*
|
||||
* The interesting cases are not the hiding. They are the three refusals, all of
|
||||
* which exist because a sender picker with nothing usable in it is worse than a
|
||||
* cluttered one.
|
||||
*/
|
||||
|
||||
const ids = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `i${i + 1}`, email: `a${i + 1}@example.com` }));
|
||||
|
||||
describe("hiding identities from the picker", () => {
|
||||
it("removes the hidden ones", () => {
|
||||
expect(visibleIdentities(ids(4), ["i2", "i4"]).map((i) => i.id)).toEqual(["i1", "i3"]);
|
||||
});
|
||||
|
||||
it("changes nothing when none are hidden", () => {
|
||||
const all = ids(3);
|
||||
expect(visibleIdentities(all, [])).toBe(all);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to hide", () => {
|
||||
it("keeps the identity the draft is already using", () => {
|
||||
// Otherwise the select has no matching option and the From line moves
|
||||
// under the writer.
|
||||
expect(visibleIdentities(ids(3), ["i2"], ["i2"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
||||
});
|
||||
|
||||
it("keeps the default, which a new draft starts on", () => {
|
||||
expect(visibleIdentities(ids(3), ["i1", "i3"], [null, "i1"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("shows everything rather than nothing when all are hidden", () => {
|
||||
const all = ids(3);
|
||||
expect(visibleIdentities(all, ["i1", "i2", "i3"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
||||
});
|
||||
|
||||
it("ignores an id for an identity that no longer exists", () => {
|
||||
// A deleted identity leaves its id behind in the setting; it must not
|
||||
// silently hide anything else or empty the list.
|
||||
expect(visibleIdentities(ids(2), ["gone"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("tolerates nulls among the ids to keep", () => {
|
||||
expect(visibleIdentities(ids(2), ["i1"], [null, undefined]).map((i) => i.id)).toEqual(["i2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the settings row may offer", () => {
|
||||
it("refuses to offer hiding for an always-visible identity", () => {
|
||||
expect(isAlwaysVisible("i1", ["i1"])).toBe(true);
|
||||
expect(isAlwaysVisible("i2", ["i1"])).toBe(false);
|
||||
expect(isAlwaysVisible("i2", [null, undefined])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
|
||||
|
||||
describe("idle sign-out on an untrusted device", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
stopIdleLogout();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("signs out after five minutes of nothing happening", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the clock again on any sign of a person", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires once, not repeatedly, and stops listening afterwards", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A late event must not resurrect a timer for a session that has ended.
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops cleanly, so a trusted sign-in is never signed out", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
stopIdleLogout();
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||