Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f5e0627b | ||
|
|
76cc2dee56 | ||
|
|
5ef874e0a6 | ||
|
|
d215d4e258 | ||
|
|
4a032ceed7 | ||
|
|
d7be002c19 | ||
|
|
67aab8015a | ||
|
|
6484645a04 | ||
|
|
795fe43cec | ||
|
|
31ab2284ed | ||
|
|
8acb1b66ad | ||
|
|
e9ff2a1e9c | ||
|
|
ea03406646 | ||
|
|
5b353d1e54 | ||
|
|
bb133b88e1 | ||
|
|
5fc63068b0 | ||
|
|
874d25a40c | ||
|
|
c927c69fe2 | ||
|
|
0e63c5d9c9 | ||
|
|
b6f73624b1 | ||
|
|
2c6df11e4b | ||
|
|
171b399e01 | ||
|
|
540554c111 | ||
|
|
7453484280 | ||
|
|
2441e47390 | ||
|
|
6e23c14132 | ||
|
|
6bfd105ad2 | ||
|
|
f627bfc123 | ||
|
|
01dc322aeb | ||
|
|
23557a72a2 | ||
|
|
d329b33912 | ||
|
|
88f9e6c50a | ||
|
|
d992442b81 | ||
|
|
07b39eb9b6 | ||
|
|
bc366ac047 | ||
|
|
05df758d0a | ||
|
|
05d1645ab7 | ||
|
|
091782ae3a | ||
|
|
c118184975 | ||
|
|
2740129c6a | ||
|
|
82dc877fe1 | ||
|
|
4c67460450 | ||
|
|
e158ebac5a | ||
|
|
786976312f | ||
|
|
5fe89d6e15 | ||
|
|
f79915aa89 | ||
|
|
191c4e7e68 | ||
|
|
4c1ceca8e9 | ||
|
|
8a08c3d6db | ||
|
|
ebf678be73 | ||
|
|
37eb145652 | ||
|
|
3d7602ce74 | ||
|
|
4054f82c37 | ||
|
|
d38dee7eb9 | ||
|
|
aa9bf1b9b2 | ||
|
|
c63fd0dfe0 | ||
|
|
f123467897 | ||
|
|
71d211a13f | ||
|
|
56bd48e891 | ||
|
|
da87925b9c | ||
|
|
360420402d | ||
|
|
8bd7904a21 | ||
|
|
6090442058 | ||
|
|
4c7b2ec370 | ||
|
|
9560ad06f4 | ||
|
|
6139031689 | ||
|
|
e3cd56314b | ||
|
|
c6dbcaef63 | ||
|
|
460760ba12 | ||
|
|
a607450aaa | ||
|
|
a9302075e7 | ||
|
|
dfe885a921 | ||
|
|
9691a7bbf5 | ||
|
|
e2b4cc18db | ||
|
|
47a2477d9f | ||
|
|
98e105efd6 | ||
|
|
b2e7db938c | ||
|
|
55fcbf72f5 | ||
|
|
d0b13272f3 | ||
|
|
5b85c254e7 | ||
|
|
bd6a605d61 | ||
|
|
5cc31037c1 | ||
|
|
e42c81ab09 | ||
|
|
4517d154a2 | ||
|
|
f7712b1c1e | ||
|
|
0bde2df69d | ||
|
|
df06b8ea04 | ||
|
|
441fb07cc9 | ||
|
|
f3ee4ff65d | ||
|
|
1ec9579db2 | ||
|
|
4cb1945eb5 |
@@ -3,4 +3,7 @@ node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
server/data
|
||||
|
||||
@@ -74,7 +74,7 @@ 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
|
||||
SOURCE_URL=https://git.coffeylabs.org/coffey-labs/ihasmail
|
||||
|
||||
# ---- Settings this installation decides (all optional) ----
|
||||
#
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
#
|
||||
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# unreviewed can be pulled in. Read the comment for the version; the digest is
|
||||
# what runs. Do not "simplify" one back to a bare tag.
|
||||
#
|
||||
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
|
||||
# address, never through the Cloudflare-proxied public name, which caps
|
||||
# request bodies at 100 MB. Images go to the registry's own DNS-only name
|
||||
# (vars.REGISTRY, an org variable).
|
||||
#
|
||||
# The weekly release is its own workflow, weekly-release.yml.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['**']
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------- test ------
|
||||
node:
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
env:
|
||||
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
|
||||
steps:
|
||||
# version.test.ts shells out to git to resolve a build version, and the
|
||||
# slim image ships without it; the checkout action installs it when it
|
||||
# is missing, so it is there for the tests too. Full history, because
|
||||
# the version is computed from it.
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||
# refused. Root ignores the permission bits, so as root that assertion
|
||||
# can never hold. The tests run as the image's unprivileged `node` user
|
||||
# for that reason; -p keeps the environment.
|
||||
#
|
||||
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves
|
||||
# to IPv4 only, and the test's control case connects to a port nothing
|
||||
# is listening on. That is a runner property, so it cannot be fixed from
|
||||
# this file -- if these tests ever fail again with ECONNREFUSED on
|
||||
# 127.0.0.1, check that the runner still puts jobs on an IPv6-enabled
|
||||
# network.
|
||||
- run: chown -R node:node "$GITHUB_WORKSPACE"
|
||||
- run: su node -p -c "npm ci --ignore-scripts"
|
||||
- run: su node -p -c "npm run typecheck"
|
||||
- run: su node -p -c "npm test"
|
||||
- run: su node -p -c "npm run build"
|
||||
|
||||
# ------------------------------------------------------------- build ------
|
||||
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
|
||||
# Dockerfile builds everything itself, so nothing is handed over from the
|
||||
# node job; `needs` only keeps the order.
|
||||
docker-build:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [node]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
|
||||
docker build -t "$tag" .
|
||||
docker image rm "$tag"
|
||||
|
||||
# ----------------------------------------------------------- publish ------
|
||||
# Tag-driven. GitHub needed a release -> publish workflow_call chain because
|
||||
# a release cut with GITHUB_TOKEN raises no event -- and Gitea behaves the
|
||||
# same way, which is why weekly-release.yml cuts its release with
|
||||
# RELEASE_TOKEN: a tag made with that token is an ordinary push, and starts
|
||||
# this workflow.
|
||||
#
|
||||
# The version the image is built with, computed the way publish.yml did it:
|
||||
# scripts/version.mjs, which needs node and the full history. The build is
|
||||
# *told* the real form (IHASMAIL_VERSION, what About and /api/health
|
||||
# report); the Docker tag gets the same string with '+' turned into '-',
|
||||
# because a tag may not contain '+'. Leaving the build arg out would ship an
|
||||
# image reporting itself unversioned -- which is exactly what
|
||||
# version.test.ts calls looking wrong.
|
||||
version:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.VERSION }}
|
||||
docker_tag: ${{ steps.v.outputs.DOCKER_TAG }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
V="$(node scripts/version.mjs)"
|
||||
echo "VERSION=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
|
||||
|
||||
# arm64 is built under QEMU on this amd64 host, not on a native runner as
|
||||
# GitHub's free `ubuntu-24.04-arm` did. It is slow -- tens of minutes for the
|
||||
# npm install and Vite build through instruction translation -- which is
|
||||
# tolerable for a weekly tag and would not be for every push. That is why
|
||||
# this job is tag-only. If arm64 ever starts timing out, the fix is an arm64
|
||||
# runner, not dropping the platform: TrueNAS and Unraid users pull it.
|
||||
#
|
||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): Gitea's
|
||||
# per-job token is refused by the container registry. The registry hands out
|
||||
# its push tokens from its own name, so unlike on GitLab nothing here has to
|
||||
# be pointed at a public address.
|
||||
publish:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [node, version]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
test -n "$REGISTRY" && test -n "$VERSION" && test -n "$DOCKER_TAG"
|
||||
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||
- run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||
--provenance=false --sbom=false \
|
||||
--tag "$IMAGE:$DOCKER_TAG" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--push .
|
||||
docker buildx imagetools inspect "$IMAGE:$DOCKER_TAG"
|
||||
# Gitea keeps a container package on its owner; linking it shows it on
|
||||
# the repository's Packages tab. Idempotent.
|
||||
- run: |
|
||||
apk add --no-cache -q curl
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||
|| echo "package already linked (or link refused); not fatal"
|
||||
- if: always()
|
||||
run: docker logout "$REGISTRY" || true
|
||||
@@ -0,0 +1,101 @@
|
||||
# Weekly release, ported from the weekly-release job in .gitlab-ci.yml (itself
|
||||
# a port of .github/workflows/release.yml): cut a release once a week, but
|
||||
# only when there is something in it. The decision is unchanged -- count the
|
||||
# commits on main since the newest published release, and skip the week if
|
||||
# there are none or if the tag already exists (the version comes from the
|
||||
# commit, so an unchanged commit is an existing tag).
|
||||
#
|
||||
# Mondays 09:17 UTC, the same odd minute as before. Run it by hand from the
|
||||
# Actions tab (workflow_dispatch); dry_run defaults to true, so a manual run
|
||||
# shows the decision and stops unless you untick it.
|
||||
#
|
||||
# SIDE-BY-SIDE PERIOD: until the GitLab cutover, GitLab's own schedule is
|
||||
# still live and still cuts the real release, and its tags reach this copy
|
||||
# through the sync. Two releasers would race to create the same tag, so this
|
||||
# workflow only ever dry-runs unless the variable RELEASE_LIVE is '1'. Set
|
||||
# RELEASE_LIVE=1 (repo or org Actions variable) at cutover, when GitLab's
|
||||
# schedule is switched off -- not before.
|
||||
#
|
||||
# Reads use the job's own token. The release -- and with it the tag -- is
|
||||
# created with RELEASE_TOKEN (jcoffey-dev, write:repository), because a tag
|
||||
# Gitea creates for the job token raises no event (checked 2026-09-22), and
|
||||
# the tag has to start ci.yml's version and publish jobs.
|
||||
name: weekly-release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 9 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Show the decision and stop
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# One at a time: two overlapping runs would race to create the same tag.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
weekly-release:
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
env:
|
||||
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
|
||||
# with dry_run unticked.
|
||||
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends curl jq >/dev/null
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Internal address, as for everything else CI does: never through the proxy.
|
||||
API="${CI_SERVER_INTERNAL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
# The newest published release, or empty on a project that has never
|
||||
# had one -- in which case everything counts as new.
|
||||
previous="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "${API}/releases?draft=false&pre-release=false&limit=1" | jq -r '.[0].tag_name // ""')"
|
||||
# A release can outlive its tag. Falling back to the whole history
|
||||
# over-counts, which cuts a release that was due anyway;
|
||||
# under-counting would skip one that was.
|
||||
# Tag lookups use show-ref, which matches an exact ref and nothing
|
||||
# else. `rev-parse --verify refs/tags/<name>` does not: on the git in
|
||||
# this image (2.39) a name ending in -g<hex> falls back to being read
|
||||
# as git-describe output, resolves to that commit, and so "exists"
|
||||
# whether or not the tag does. Every commit not merged through a pull
|
||||
# request has a -g<hex> version, so that check reported every such
|
||||
# week as already released.
|
||||
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||
fi
|
||||
version="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain '+', and neither should the git tag,
|
||||
# so the two always agree about what to call a build.
|
||||
tag="v${version/+/-}"
|
||||
title="v${version%%+*}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||
fi
|
||||
if git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||
echo "Nothing to release: tag ${tag} already exists."; exit 0
|
||||
fi
|
||||
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, at ${sha}."
|
||||
if [ "$DRY_RUN" = "1" ]; then echo "Dry run (RELEASE_LIVE='${{ vars.RELEASE_LIVE }}'): stopping here."; exit 0; fi
|
||||
# Notes bounded to what is new, from the first-parent history of
|
||||
# main -- one line per merge, which is what GitHub's generated notes
|
||||
# listed.
|
||||
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||
jq -n --arg tag "$tag" --arg ref "$sha" --arg name "$title" \
|
||||
--arg body "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||
'{tag_name:$tag, target_commitish:$ref, name:$name, body:$body}' > release.json
|
||||
curl -fsS -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" \
|
||||
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Thanks for contributing to ihasmail. CONTRIBUTING.md has the full guide;
|
||||
this is the short version. Delete any section that does not apply.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changes, and why. -->
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- e.g. Closes #123. Leave blank if there are none. -->
|
||||
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
- Adds or alters user-visible strings: how many keys, and the fallback
|
||||
count before and after.
|
||||
- Adds none.
|
||||
|
||||
"Adds none" is an answer. Saying nothing is not -- it leaves it to be
|
||||
inferred. See CONTRIBUTING.md -> Translations.
|
||||
-->
|
||||
|
||||
## Testing
|
||||
|
||||
<!--
|
||||
What you ran, and what you saw. `npm run typecheck`, `npm test` and
|
||||
`npm run build` all run in CI, so the useful thing here is what CI cannot
|
||||
do: which flows you exercised by hand, and against what -- a real Stalwart
|
||||
instance, or `npm run dev:mock`.
|
||||
|
||||
If the change is visible on screen, drive the built app, not just the
|
||||
store. See CONTRIBUTING.md -> Verifying UI work.
|
||||
-->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For UI changes. Before/after, or a GIF for anything with motion. -->
|
||||
@@ -15,8 +15,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||
# the release it belongs to in the trailing comment, and the repository
|
||||
# requires it -- an unpinned ref fails the run rather than quietly
|
||||
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
|
||||
# last moved it to, so trusting one is trusting every future version of
|
||||
# that action, including the one pushed by whoever compromises the
|
||||
# account. Read the comment for the version; the SHA is what runs.
|
||||
#
|
||||
# Dependabot updates both halves together on its weekly github-actions
|
||||
# run, so this costs nothing to keep current -- do not "simplify" a pin
|
||||
# back to a tag.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -39,11 +39,12 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
# Pinned to a commit rather than a moving major tag. This action is
|
||||
# handed `packages: write` and its whole job is deletion, so a tag
|
||||
# repointed at something else -- by a compromise or a mistake upstream --
|
||||
# is a bad day. v1.2.2.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
|
||||
# The only third-party action here that is not published by GitHub or
|
||||
# Docker, and the one with the most to lose: it is handed
|
||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||
# day. It was pinned to a commit long before the rest of them were.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||
with:
|
||||
owner: Coffey-Labs
|
||||
package: ihasmail
|
||||
|
||||
@@ -76,11 +76,11 @@ jobs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: v
|
||||
@@ -108,18 +108,18 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -54,11 +54,11 @@ jobs:
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: decide
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
server/data/
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# CI for the self-hosted GitLab that replaced GitHub Actions when the account
|
||||
# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml and
|
||||
# publish.yml, which are kept in the tree for reference and for the day the
|
||||
# appeal succeeds.
|
||||
#
|
||||
# Every `image:` here is pinned to a digest, with the tag it belonged to in the
|
||||
# trailing comment. That is the direct replacement for the SHA-pinned `uses:`
|
||||
# in the Actions workflows: GitLab has no equivalent of an action allowlist, so
|
||||
# the only thing standing between this pipeline and whatever the publisher
|
||||
# pushes to a tag next is the digest. Read the comment for the version; the
|
||||
# digest is what runs. Do not "simplify" one back to a bare tag.
|
||||
#
|
||||
# The runner is a group runner on Web_Host with the host docker socket bound
|
||||
# in, reached over the internal container network rather than
|
||||
# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free
|
||||
# plan, which caps request bodies at 100 MB and would break artifact uploads.
|
||||
|
||||
stages: [test, build, publish, release]
|
||||
|
||||
variables:
|
||||
# Jobs talk to the registry directly on its DNS-only name, never through the
|
||||
# proxy, for the same 100 MB reason.
|
||||
IMAGE: $CI_REGISTRY_IMAGE
|
||||
GIT_DEPTH: "0"
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
||||
# ---------------------------------------------------------------- test ------
|
||||
node:
|
||||
stage: test
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
variables:
|
||||
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
|
||||
cache:
|
||||
key:
|
||||
files: [package-lock.json]
|
||||
paths: [.npm/]
|
||||
before_script:
|
||||
# version.test.ts shells out to git to resolve a build version, and the
|
||||
# slim image ships without it. The clone is done by the runner's helper
|
||||
# image, so nothing else here needs git and its absence is easy to miss.
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
|
||||
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||
# refused. Root ignores the permission bits, so as root that assertion can
|
||||
# never hold. The tests run as the image's unprivileged `node` user for
|
||||
# that reason; -p keeps the environment.
|
||||
#
|
||||
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to
|
||||
# IPv4 only, and the test's control case connects to a port nothing is
|
||||
# listening on. That is a runner property, so it cannot be fixed from this
|
||||
# file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1,
|
||||
# check that the runner still puts jobs on an IPv6-enabled network.
|
||||
- chown -R node:node "$CI_PROJECT_DIR"
|
||||
script:
|
||||
- su node -p -c "npm ci --ignore-scripts"
|
||||
- su node -p -c "npm run typecheck"
|
||||
- su node -p -c "npm test"
|
||||
- su node -p -c "npm run build"
|
||||
artifacts:
|
||||
paths: [dist/]
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $RELEASE_WEEKLY == "1"
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
# --------------------------------------------------------------- build ------
|
||||
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step.
|
||||
#
|
||||
# Not called `image`: that is a reserved keyword, and a job by that name is
|
||||
# silently read as the global image: setting instead ("image name should be a
|
||||
# string"). Same trap for `stages`, `cache`, `services` and `variables`.
|
||||
docker-build:
|
||||
stage: build
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
needs: [node]
|
||||
script:
|
||||
- docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA .
|
||||
- docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA
|
||||
rules:
|
||||
- if: $RELEASE_WEEKLY == "1"
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ------------------------------------------------------------- publish ------
|
||||
# Tag-driven, replacing the release -> publish workflow_call chain. GitHub
|
||||
# needed that dance because a release cut with GITHUB_TOKEN raises no event;
|
||||
# GitLab has no such rule, so a tag pipeline is enough.
|
||||
#
|
||||
# arm64 is built under QEMU on this amd64 host, not on a native runner as
|
||||
# GitHub's free `ubuntu-24.04-arm` did. It is slow -- tens of minutes for the
|
||||
# npm install and Vite build through instruction translation -- which is
|
||||
# tolerable for a weekly tag and would not be for every push. That is why this
|
||||
# job is tag-only. If arm64 ever starts timing out, the fix is an arm64 runner,
|
||||
# not dropping the platform: TrueNAS and Unraid users pull it.
|
||||
# The version the image is built with, computed the way publish.yml did it:
|
||||
# scripts/version.mjs, which needs node and the full history. The build is
|
||||
# *told* the real form (IHASMAIL_VERSION, what About and /api/health report);
|
||||
# the Docker tag gets the same string with '+' turned into '-', because a tag
|
||||
# may not contain '+'. The first port of this job left the build arg out, so
|
||||
# a tag would have shipped an image reporting itself unversioned -- which is
|
||||
# exactly what version.test.ts calls looking wrong.
|
||||
version:
|
||||
stage: build
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
variables:
|
||||
GIT_DEPTH: "0"
|
||||
before_script:
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git >/dev/null
|
||||
# The build directory is reused between jobs, and the node job chowns it to
|
||||
# the unprivileged `node` user so its tests can run. A later job running
|
||||
# git as root then finds the checkout owned by somebody else, and git
|
||||
# refuses with "detected dubious ownership" (exit 128). Whether it happens
|
||||
# depends on which cached directory a job lands on, so it comes and goes.
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
script:
|
||||
- V="$(node scripts/version.mjs)"
|
||||
- echo "VERSION=$V" > version.env
|
||||
- echo "DOCKER_TAG=${V/+/-}" >> version.env
|
||||
- cat version.env
|
||||
artifacts:
|
||||
reports:
|
||||
dotenv: version.env
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
publish:
|
||||
stage: publish
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
needs: [node, version]
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
- docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
# The registry hands out push tokens from https://git.coffeylabs.org/jwt/auth,
|
||||
# and buildx fetches them here, in the job, not in its builder. On ci-net
|
||||
# that name is the gitlab container itself (172.30.0.2), which serves
|
||||
# plain HTTP to the runner and nothing on 443, so every push failed at the
|
||||
# last step with "connection refused". The login above works because the
|
||||
# host's daemon does it, and the host resolves the name publicly. So, for
|
||||
# this job only, point the name at its public address the same way. Only
|
||||
# the token request uses it; layers go to the registry's own DNS-only name.
|
||||
- |
|
||||
public="$(nslookup "$CI_SERVER_HOST" 1.1.1.1 2>/dev/null | awk '/^Address: / && $2 !~ /:/ { print $2; exit }')"
|
||||
if [ -z "$public" ]; then echo "Could not resolve $CI_SERVER_HOST publicly" >&2; exit 1; fi
|
||||
echo "$public $CI_SERVER_HOST" >> /etc/hosts
|
||||
echo "$CI_SERVER_HOST -> $public for the registry token"
|
||||
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
|
||||
script:
|
||||
- |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||
--provenance=false --sbom=false \
|
||||
--tag "$IMAGE:$DOCKER_TAG" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--push .
|
||||
after_script:
|
||||
- docker logout "$CI_REGISTRY" || true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
# ----------------------------------------------------------- weekly release --
|
||||
# Port of .github/workflows/release.yml: cut a release once a week, but only
|
||||
# when there is something in it. The decision is the workflow's, unchanged --
|
||||
# count the commits on main since the newest published release, and skip the
|
||||
# week if there are none or if the tag already exists (the version comes from
|
||||
# the commit, so an unchanged commit is an existing tag).
|
||||
#
|
||||
# It runs from a pipeline schedule (Mondays 09:17 UTC, the same odd minute as
|
||||
# before) that sets RELEASE_WEEKLY=1. GitLab keeps schedules on the project,
|
||||
# not in this file, so the schedule and this job only work as a pair. Run it by
|
||||
# hand with RELEASE_WEEKLY=1, adding DRY_RUN=1 to see the decision and stop.
|
||||
#
|
||||
# The release -- and with it the tag -- is created with RELEASE_TOKEN, a
|
||||
# project access token (protected, masked), not CI_JOB_TOKEN. A tag pushed that
|
||||
# way is an ordinary push, so it starts the tag pipeline, and the version and
|
||||
# publish jobs above build the image from it. That replaces release.yml's
|
||||
# direct call of publish.yml, which only existed because a tag created with
|
||||
# GITHUB_TOKEN raises no event. The token expires; when it does this job fails
|
||||
# at the API call, loudly, and a new one goes in the same variable.
|
||||
weekly-release:
|
||||
stage: release
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
# One at a time: two overlapping runs would race to create the same tag.
|
||||
resource_group: weekly-release
|
||||
variables:
|
||||
GIT_DEPTH: "0"
|
||||
before_script:
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl jq >/dev/null
|
||||
# See the version job: same shared directory, same root, same refusal.
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
script:
|
||||
- |
|
||||
set -euo pipefail
|
||||
# Internal address, as for everything else CI does: never through the proxy.
|
||||
API="http://gitlab/api/v4/projects/${CI_PROJECT_ID}"
|
||||
auth=(--header "PRIVATE-TOKEN: ${RELEASE_TOKEN}")
|
||||
# The newest published release, or empty on a project that has never had
|
||||
# one -- in which case everything counts as new.
|
||||
previous="$(curl -fsS "${auth[@]}" "${API}/releases?order_by=released_at&sort=desc&per_page=1" | jq -r '.[0].tag_name // ""')"
|
||||
# A release can outlive its tag. Falling back to the whole history
|
||||
# over-counts, which cuts a release that was due anyway; under-counting
|
||||
# would skip one that was.
|
||||
# Tag lookups use show-ref, which matches an exact ref and nothing else.
|
||||
# `rev-parse --verify refs/tags/<name>` does not: on the git in this image
|
||||
# (2.39) a name ending in -g<hex> falls back to being read as
|
||||
# git-describe output, resolves to that commit, and so "exists" whether
|
||||
# or not the tag does. Every commit not merged through a pull request has
|
||||
# a -g<hex> version, so that check reported every such week as already
|
||||
# released. Newer git (and GitHub's runners) do not fall back, which is
|
||||
# why release.yml never showed it.
|
||||
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||
fi
|
||||
version="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain '+', and neither should the git tag, so
|
||||
# the two always agree about what to call a build.
|
||||
tag="v${version/+/-}"
|
||||
title="v${version%%+*}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||
fi
|
||||
if git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||
echo "Nothing to release: tag ${tag} already exists."; exit 0
|
||||
fi
|
||||
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, at ${sha}."
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN=1: stopping here."; exit 0; fi
|
||||
# Notes bounded to what is new, from the first-parent history of main --
|
||||
# one line per merge, which is what GitHub's generated notes listed.
|
||||
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||
jq -n --arg tag "$tag" --arg ref "$sha" --arg name "$title" \
|
||||
--arg desc "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||
'{tag_name:$tag, ref:$ref, name:$name, description:$desc}' > release.json
|
||||
curl -fsS "${auth[@]}" --header "Content-Type: application/json" \
|
||||
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||
rules:
|
||||
- if: $RELEASE_WEEKLY == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
@@ -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://git.coffeylabs.org/coffey-labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
@@ -58,6 +58,12 @@ check has passed — not afterwards — and the branch cannot be force-pushed or
|
||||
deleted. No approving review is required, so a PR of your own is not blocked
|
||||
waiting for one.
|
||||
|
||||
**CI on a PR from a fork waits to be approved.** Every workflow run on an
|
||||
outside contributor's branch sits at *awaiting approval* until a maintainer
|
||||
starts it by hand, so the **build** check will not appear the moment you open
|
||||
the PR — that is the gate working, not a broken run. Pushing again will not
|
||||
start it, and neither will closing and reopening.
|
||||
|
||||
### Code Style
|
||||
|
||||
- Match the existing formatting and naming conventions used elsewhere in the codebase.
|
||||
|
||||
@@ -239,9 +239,16 @@ for that one, and the dialog says so.
|
||||
Real JMAP mailboxes, with the server's roles honored.
|
||||
|
||||
- Create, rename, create a subfolder, delete (with or without its mail).
|
||||
- **Drag a folder onto another** to reparent it. Folders with a server role
|
||||
(Inbox, Sent, Drafts, Trash, Junk, Archive) are structural and are not
|
||||
offered the drag, because the server refuses to move them anyway.
|
||||
- **Order:** Inbox first, then the other special folders (Drafts, Sent,
|
||||
Archive, Junk, Trash), then everything else A–Z, at every level.
|
||||
- **Drag a folder between two others** to put it there. The line shows where
|
||||
it will land. The order is saved on the server as the folders' JMAP
|
||||
`sortOrder`, so it follows you to every device, and other clients that
|
||||
honor `sortOrder` show it too. *Move up* and *Move down* in the folder menu
|
||||
do the same from the keyboard or on touch. Inbox always stays first.
|
||||
- **Drag a folder onto the middle of another** to reparent it. Folders with a
|
||||
server role (Sent, Drafts, Trash, Junk, Archive) can be reordered but not
|
||||
nested, because the server refuses to move them to another parent.
|
||||
- **Subscribe / unsubscribe** — *Show in list* / *Hide from list*. An
|
||||
unsubscribed folder still exists and still receives; it is just out of the
|
||||
way. Inbox cannot be hidden.
|
||||
@@ -434,6 +441,21 @@ minimizable and maximizable; full-screen on mobile.
|
||||
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
|
||||
formatting. Tab and Shift+Tab indent inside the body.
|
||||
- **Plain text** as a per-message or default format.
|
||||
- **Quoting follows the message's own image decision.** A quote renders the
|
||||
message again, so the reply blocks its remote images unless that message was
|
||||
allowed them — by policy, by a trusted sender, by the sender being a
|
||||
contact, or by *Show images* having been pressed on it. Blocked images keep
|
||||
their address and get it back when the reply is sent, so the recipient's
|
||||
copy is the quote as its sender wrote it. Allowed ones are fetched through
|
||||
the server's image proxy, the same as when the message was read, and the
|
||||
sent copy points at their own addresses rather than at this server.
|
||||
- **Answering in the format the message was written in.** Replying in plain
|
||||
text to a rich text message, or the reverse, loses either the formatting or
|
||||
the plain text somebody chose to write in. The composer opens in the default
|
||||
format and offers the other one for that message, above the editor; the
|
||||
offer is dismissible and changes no setting. Forwards too. What counts as
|
||||
rich text is the body part's own type, not the presence of `htmlBody`, which
|
||||
RFC 8621 derives for plain-text mail as well.
|
||||
- **Recipient chips** with autocomplete from contacts, shared address books you
|
||||
have added, the server directory and recent recipients; your own cards win a
|
||||
tie against a colleague's copy of the same person. Free-form addresses parse
|
||||
@@ -482,6 +504,8 @@ minimizable and maximizable; full-screen on mobile.
|
||||
uploaded, which it was not being.
|
||||
- **Attachment reminder** when the text mentions an attachment and none is there.
|
||||
- **Spell check** toggle.
|
||||
- **Open the composer full screen**, as a setting, for anyone whose first
|
||||
move is always Maximize. Off by default; on a phone it changes nothing.
|
||||
- **Drafts** save as you type and on close, with the save state shown.
|
||||
- **Quoting** on reply, with the signature placed above or below it, and
|
||||
reply-all as an optional default.
|
||||
@@ -921,7 +945,7 @@ not reach another that already has ihasmail open until it signs in again.
|
||||
|
||||
| Section | Holds |
|
||||
| --- | --- |
|
||||
| **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
|
||||
| **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check, full-screen composer; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
|
||||
| **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered; the three warnings and the domains they measure against; undo-send window, attachment reminder, confirm-before-delete |
|
||||
| **Appearance** | Theme, accent color, density, font size, sidebar, swipe actions, interface language |
|
||||
| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker |
|
||||
@@ -1414,8 +1438,13 @@ server settings is deliberately out of scope.
|
||||
# Platform
|
||||
|
||||
- **Installable PWA** with a service worker: the app shell is cached for
|
||||
installability and fast loads, API requests never are, and navigations are
|
||||
network-first with the shell as fallback.
|
||||
installability and fast loads, API requests never are. An app route is
|
||||
answered from the kept shell at once while a fresh copy is fetched behind it;
|
||||
a shell a build behind is caught by the version check at start and reloaded.
|
||||
After a new version is seen, the rest of its code (composer, settings,
|
||||
viewers) is fetched in the background, so opening them later does not wait on
|
||||
the server; language catalogs are cached when first used, and nothing is
|
||||
fetched ahead when the browser is set to save data.
|
||||
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
||||
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
||||
notification opened while ihasmail is already running arrives in the copy
|
||||
@@ -1545,15 +1574,23 @@ costs something to get wrong is the one that assumes the machine is yours.
|
||||
| --- | --- | --- |
|
||||
| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) |
|
||||
| Idle sign-out | after 5 minutes | none |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) |
|
||||
| Background notifications | refused | available |
|
||||
| Administration | unavailable | available, if the role allows it |
|
||||
|
||||
Local storage is gated on that answer for **reads** as well as writes — a
|
||||
machine trusted once still has residue, and honoring it would let a previous
|
||||
session's data surface in a later untrusted one. Signing out clears the settings
|
||||
cache and recent addresses and tears down the push subscription, whichever
|
||||
answer was given.
|
||||
cache, recent addresses and kept folder list, and tears down the push
|
||||
subscription, whichever answer was given.
|
||||
|
||||
What a ticked device keeps is what makes it **start quickly on a distant
|
||||
link**: once the server has confirmed the session, the folders and the inbox
|
||||
paint from the kept copy straight away, and the request for the open folder
|
||||
goes out without waiting on the folder list first. The server's answers
|
||||
replace the copy a round trip later. **Nothing kept is shown before the session
|
||||
is confirmed** — until then the app shows a spinner, so a session that has
|
||||
ended goes from the spinner to the sign-in form and never past a mailbox.
|
||||
|
||||
The idle timer exists because the alternative does not work: `beforeunload` text
|
||||
was removed from browsers years ago, and **no event fires at all** for walking
|
||||
|
||||
@@ -46,9 +46,15 @@ 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).
|
||||
[`stalwart-0.15-support`](https://git.coffeylabs.org/coffey-labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
|
||||
|
||||
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/375)).
|
||||
|
||||
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/376)).
|
||||
|
||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||
|
||||
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
||||
- **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved.
|
||||
@@ -112,14 +118,14 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
|
||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalog key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
- **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://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/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 omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. **0.16.21 fixed this for calendars and address books**: with `properties` omitted, `Calendar/get` and `AddressBook/get` now return every property, `shareWith` included — **confirmed live on 0.16.21 (2026-09-06)**. `Mailbox/get` on the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere.
|
||||
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
|
||||
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/67), closed 2026-08-26, and [PR #285](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
|
||||
|
||||
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
|
||||
- **It takes S/MIME certificates as well as OpenPGP keys, and parses both.** A self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"*. Worth checking rather than assuming, because every *other* message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not.
|
||||
@@ -134,11 +140,11 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/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 honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
||||
- **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://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/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://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
||||
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
@@ -36,9 +36,9 @@ settings included, belongs to Stalwart, so the container is disposable.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
|
||||
Taken against the built-in mock with sample data. More, including the phone
|
||||
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
@@ -65,8 +65,8 @@ The long version is [FEATURES.md](FEATURES.md) and
|
||||
against 0.16.22; what changed in each release is in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
||||
|
||||
- **No Stalwart yet?** [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command.
|
||||
- **On Stalwart 0.15?** [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support) release.
|
||||
- **No Stalwart yet?** [ihasmail-oneshot](https://git.coffeylabs.org/coffey-labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command.
|
||||
- **On Stalwart 0.15?** [stalwart-migrator](https://git.coffeylabs.org/coffey-labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://git.coffeylabs.org/coffey-labs/ihasmail/releases/tag/stalwart-0.15-support) release.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
@@ -77,7 +77,7 @@ docker compose up --build -d
|
||||
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
||||
```
|
||||
|
||||
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are
|
||||
Or pull the published image, `registry.coffeylabs.org/coffey-labs/ihasmail`. Releases are
|
||||
weekly, so it is usually a few days behind `main`.
|
||||
|
||||
People sign in with their Stalwart mailbox credentials. **An account with
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
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);
|
||||
is genuinely open lives in [the issue tracker](https://git.coffeylabs.org/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.
|
||||
|
||||
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
||||
- **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://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/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 catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
- **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://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
|
||||
|
||||
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
|
||||
|
||||
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
|
||||
|
||||
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
|
||||
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/67), [PR #285](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
|
||||
|
||||
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
|
||||
|
||||
|
||||
@@ -9,14 +9,27 @@ services:
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
# Loopback only: ihasmail expects a TLS reverse proxy in front of it. On
|
||||
# every interface the app is reachable over plain HTTP, passwords and all,
|
||||
# and with TRUST_PROXY any machine on a private network can set its own
|
||||
# X-Forwarded-For. A proxy running in Docker can reach the service by name
|
||||
# on the compose network and needs no published port at all.
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# The app needs no privileges and writes only to /data and /tmp.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
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://git.coffeylabs.org/coffey-labs/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Write a Brotli and a gzip copy beside every compressible file in a web build.
|
||||
*
|
||||
* The server used to gzip the bundle again on every request that asked for it,
|
||||
* at a level chosen for speed. These are made once, at the level chosen for
|
||||
* size, and `server/src/static.ts` hands one out when the browser accepts it.
|
||||
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
|
||||
* do per request, which is why it was never offered.
|
||||
*
|
||||
* node scripts/precompress.mjs web/dist
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
|
||||
|
||||
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
|
||||
// Below this, the encoding costs more than it saves.
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
function* files(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* files(p);
|
||||
else yield p;
|
||||
}
|
||||
}
|
||||
|
||||
const root = process.argv[2];
|
||||
if (!root) {
|
||||
console.error("usage: precompress.mjs <dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
let count = 0;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const p of files(root)) {
|
||||
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
|
||||
const data = readFileSync(p);
|
||||
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
|
||||
writeFileSync(`${p}.br`, br);
|
||||
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
|
||||
count++;
|
||||
before += data.length;
|
||||
after += br.length;
|
||||
}
|
||||
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
|
||||
@@ -5,8 +5,8 @@
|
||||
* images already use rather than whatever a window happens to be.
|
||||
*
|
||||
* npm run dev:mock # in another terminal
|
||||
* node docs/screenshots.mjs docs/screenshots
|
||||
* node docs/screenshots-light.mjs docs/screenshots
|
||||
* node scripts/screenshots.mjs screenshots
|
||||
* node scripts/screenshots-light.mjs screenshots
|
||||
*
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
@@ -30,7 +30,7 @@
|
||||
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
||||
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
||||
* at --window-size and never calling setDeviceMetricsOverride renders it
|
||||
* correctly, which is what docs/screenshots-light.mjs does.
|
||||
* correctly, which is what scripts/screenshots-light.mjs does.
|
||||
*
|
||||
* assertTheme() stays either way: without it this script wrote a dark
|
||||
* screenshot under a light caption and reported success, and that is how the
|
||||
@@ -226,7 +226,7 @@ try {
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
// (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
|
||||
// (inbox-light is captured by scripts/screenshots-light.mjs -- see the header)
|
||||
|
||||
|
||||
// --- calendar ---
|
||||
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||
assert.ok(created.body.id);
|
||||
@@ -84,8 +84,86 @@ test("app passwords are created, listed once with their secret, and revoked", as
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("an app password needs the account password", async () => {
|
||||
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.error, "missing_fields");
|
||||
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.equal(wrong.body.error, "invalid_credentials");
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
|
||||
});
|
||||
|
||||
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
|
||||
// Signed in without "my own device", so the proxy reads every request.
|
||||
const res = await call("/api/jmap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
|
||||
assert.equal(up.status, 200);
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
|
||||
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store");
|
||||
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
test("a download passes a byte range through, for viewers that read in pieces", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
|
||||
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
|
||||
assert.equal(part.status, 206);
|
||||
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
|
||||
assert.equal(part.headers.get("accept-ranges"), "bytes");
|
||||
assert.equal(await part.text(), "hello");
|
||||
const whole = await app.request(url, { headers: { cookie } });
|
||||
assert.equal(whole.status, 200);
|
||||
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
|
||||
assert.equal(await whole.text(), "hello world");
|
||||
// Past the end, Stalwart sends the whole file rather than a 416.
|
||||
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
|
||||
assert.equal(beyond.status, 200);
|
||||
assert.equal(await beyond.text(), "hello world");
|
||||
// Anything that is not a plain byte range is not passed on.
|
||||
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
|
||||
assert.equal(odd.status, 200);
|
||||
await odd.arrayBuffer();
|
||||
});
|
||||
|
||||
test("upstream caches let go of sessions that have aged out", async () => {
|
||||
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
|
||||
// Signed in above, so this session has an entry.
|
||||
assert.ok(upstreamCacheSizes().sessions >= 1);
|
||||
sweepUpstreamCaches(Date.now() + 60 * 60_000);
|
||||
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
||||
});
|
||||
|
||||
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
|
||||
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
|
||||
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
|
||||
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
|
||||
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
|
||||
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
|
||||
} }, "s"]]);
|
||||
assert.equal(res.status, 200);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
|
||||
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
|
||||
assert.ok(set.created.inline.id, "a data URI is accepted");
|
||||
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
|
||||
});
|
||||
|
||||
test("an app password needs a name", async () => {
|
||||
const res = await post("/api/account/app-passwords", { description: " " });
|
||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error, "missing_fields");
|
||||
});
|
||||
|
||||
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test("the account's own registry objects pass", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||
test("the account's own registry objects can be read", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
|
||||
});
|
||||
|
||||
test("but not written: a credential minted here would outlive a borrowed session", () => {
|
||||
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
|
||||
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
|
||||
}
|
||||
});
|
||||
|
||||
test("API keys are not the account's to reach from here at all", () => {
|
||||
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
|
||||
});
|
||||
|
||||
test("directory and server objects are refused, and named", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||
* touched: they act on what the account can already reach.
|
||||
*/
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
|
||||
|
||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||
|
||||
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
|
||||
const name = Array.isArray(call) ? call[0] : undefined;
|
||||
if (typeof name !== "string") return { ok: false, method: null };
|
||||
if (!name.startsWith("x:")) continue;
|
||||
const object = name.slice(2).split("/")[0] ?? "";
|
||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||
const [object = "", op = ""] = name.slice(2).split("/");
|
||||
/*
|
||||
* Read, never write. The browser sends none of these itself -- password,
|
||||
* app-password and 2FA changes go through /api/account, which checks the
|
||||
* account password first -- so a write here could only come from
|
||||
* somebody working the console of a session on a borrowed machine, and
|
||||
* `x:AppPassword/set` would hand them a credential that outlives it.
|
||||
*/
|
||||
if (!SELF_SERVICE.has(object) || op === "set") return { ok: false, method: name };
|
||||
}
|
||||
return { ok: true, body: JSON.stringify(parsed) };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { compress } from "hono/compress";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { fetchPermissions } from "./permissionSchema.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||
import { safeEqual } from "./crypto.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
@@ -209,6 +211,22 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* The largest body an API route that reads JSON will take.
|
||||
*
|
||||
* Hono reads a JSON body whole, and before this nothing bounded it: a few
|
||||
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
|
||||
* run the process out of memory, and a restart signs everybody out. What
|
||||
* these routes actually receive is a username and password, or a code.
|
||||
*
|
||||
* JMAP and uploads carry real payloads and bound themselves as they stream;
|
||||
* the push callback has its own limit ahead of this one.
|
||||
*/
|
||||
const MAX_SMALL_BODY = 64 * 1024;
|
||||
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
|
||||
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
|
||||
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
|
||||
|
||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
@@ -269,6 +287,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
const api = new Hono<Env>();
|
||||
api.use("*", csrfGuard);
|
||||
api.use("*", smallBodies);
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
||||
|
||||
@@ -303,6 +322,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// ---------- Auth ----------
|
||||
api.post("/auth/login", async (c) => {
|
||||
const ip = clientIp(c);
|
||||
// What the limits count under: the address, or its /64 for IPv6.
|
||||
const rateIp = rateLimitKey(ip);
|
||||
// The flood ceiling needs nothing from the body, so it goes before reading one.
|
||||
if (!loginFloodLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
@@ -318,7 +344,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
/*
|
||||
* Three checks, answering different questions.
|
||||
*
|
||||
* `limitKey` is this username from this address, and `ip` is any username
|
||||
* `limitKey` is this username from this address, and `rateIp` is any username
|
||||
* from it -- both guard guessing, and both are given back when the upstream
|
||||
* never got as far as judging the password. Refunding only the first would
|
||||
* not fix #239: ten retries through an outage would still spend the address
|
||||
@@ -328,12 +354,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
* The flood ceiling is the one that is never refunded, and it is the reason
|
||||
* the other two safely can be.
|
||||
*/
|
||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
||||
if (!loginFloodLimiter.check(ip)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
||||
const limitKey = `${rateIp}|${username.toLowerCase()}`;
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
@@ -351,7 +373,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// The credentials were accepted; only the server is too old. Not an
|
||||
// attempt worth counting against them.
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
@@ -364,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||
password: effectivePassword,
|
||||
remember: Boolean(body.remember),
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
@@ -411,7 +434,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
if (!(err instanceof UpstreamError && err.status === 401)) {
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -445,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
api.get("/auth/sessions", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
|
||||
});
|
||||
|
||||
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
const n = sessions.destroyAllForUser(session.username, session.id);
|
||||
const n = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ revoked: n });
|
||||
});
|
||||
|
||||
@@ -478,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||
@@ -517,7 +540,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const otpCode = body.otpCode?.trim();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -531,12 +554,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* An app password is a credential that outlives this session, a password
|
||||
* change and a sign-out -- so minting one asks for the account password, as
|
||||
* changing the password does. Otherwise a session left open on somebody
|
||||
* else's machine is enough to take a permanent key away from it.
|
||||
*/
|
||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
|
||||
const limited = guarded(c, "app-password");
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ description?: string }>(c);
|
||||
const body = await readJson<{ description?: string; current?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
const current = body.current ?? "";
|
||||
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
|
||||
if (!(await confirmsPassword(session, current))) {
|
||||
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
|
||||
}
|
||||
try {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
@@ -611,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// Other sessions still hold the bare password and will be refused.
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -648,12 +685,27 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
const held = gatedReads.get(session.id) ?? 0;
|
||||
if (held >= MAX_GATED_PER_SESSION) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "rate_limited" }, 429);
|
||||
}
|
||||
gatedReads.set(session.id, held + 1);
|
||||
let raw: string;
|
||||
try {
|
||||
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||
} catch {
|
||||
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
|
||||
} catch (err) {
|
||||
if (err instanceof GatedBudgetError) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "busy" }, 503);
|
||||
}
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
} finally {
|
||||
const left = (gatedReads.get(session.id) ?? 1) - 1;
|
||||
if (left > 0) gatedReads.set(session.id, left);
|
||||
else gatedReads.delete(session.id);
|
||||
}
|
||||
const gate = gateAdministration(raw);
|
||||
if (!gate.ok) {
|
||||
@@ -751,23 +803,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||
// A PDF viewer or a video element asks for pieces; pass that on. A server
|
||||
// that ignores it answers with the whole file, as it did before.
|
||||
const range = c.req.header("range");
|
||||
const res = await fetch(url, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (res.status === 416) return c.body(null, 416);
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const partial = res.status === 206 && res.headers.get("content-range");
|
||||
if (partial) headers.set("Content-Range", partial);
|
||||
/*
|
||||
* Said here because Stalwart does not say it. It honors a single byte
|
||||
* range but sends no `Accept-Ranges` (0.16.22, checked live on
|
||||
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
|
||||
* the first response advertises it. A server that ignores a range sends
|
||||
* the whole file, which the browser takes just as well.
|
||||
*/
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||
@@ -786,8 +852,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} else {
|
||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||
}
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
// Kept out of the browser's disk cache on a device that is not the
|
||||
// person's own: signing out wipes what the app stores, not that.
|
||||
/*
|
||||
* A blob id names its content -- the same id is the same bytes for good
|
||||
* -- so on the reader's own device there is nothing to revalidate. On
|
||||
* anyone else's, nothing is left in the disk cache at all.
|
||||
*/
|
||||
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
|
||||
return new Response(res.body, { status: partial ? 206 : 200, headers });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -872,6 +945,43 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `candidate` the password of the account this session is signed in to?
|
||||
*
|
||||
* Compared with the credential the session holds first, which costs nothing
|
||||
* and tells Stalwart nothing -- its auto-ban counts failures against the
|
||||
* proxy's address, which every user shares. That credential is the password,
|
||||
* with a TOTP code after a `$` when one was given at sign-in. A session that
|
||||
* turning on 2FA moved onto an app password (Stalwart's secrets start
|
||||
* `$app$`) holds something else, and only then is the candidate put to the
|
||||
* server.
|
||||
*/
|
||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
||||
const held = decoded.slice(decoded.indexOf(":") + 1);
|
||||
if (safeEqual(held, candidate)) return true;
|
||||
const withoutCode = held.replace(/\$\d{6,8}$/, "");
|
||||
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
|
||||
// Holding the password, the comparison above is the answer, and a wrong
|
||||
// guess never reaches the server's auto-ban.
|
||||
if (!held.startsWith("$app$")) return false;
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
||||
* read as a PDF in the downloads list. A filename has no use for them.
|
||||
*/
|
||||
function withoutBidiControls(name: string): string {
|
||||
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
|
||||
}
|
||||
|
||||
/** Name the app password after the browser it will live in. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
@@ -931,9 +1041,50 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
*/
|
||||
/**
|
||||
* The largest JMAP request read into memory for the administration check.
|
||||
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||
*
|
||||
* Only sessions that may not administer come this way, and what the client
|
||||
* sends is small: attachments and pasted images go through `/upload`, and the
|
||||
* composer turns inline images into uploads before a draft is saved. Stalwart
|
||||
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
|
||||
* held here as a string, parsed and serialized again, so each one costs
|
||||
* several times its size; 4 MB is far past anything the client sends.
|
||||
*/
|
||||
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||
const MAX_GATED_REQUEST = 4 * 1024 * 1024;
|
||||
/**
|
||||
* How many checked requests one session may have in flight at once. Matches
|
||||
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
|
||||
* already stays within.
|
||||
*/
|
||||
const MAX_GATED_PER_SESSION = 4;
|
||||
/**
|
||||
* The bytes all checked requests together may hold at once. Counted as they
|
||||
* arrive rather than reserved up front, so a slow body that has sent little
|
||||
* holds little, and a burst of large ones is turned away with a 503 instead of
|
||||
* taking the process down.
|
||||
*/
|
||||
const GATED_BUDGET = 32 * 1024 * 1024;
|
||||
const gatedReads = new Map<string, number>();
|
||||
let gatedBytes = 0;
|
||||
|
||||
class GatedBudgetError extends Error {}
|
||||
|
||||
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
let mine = 0;
|
||||
const counted = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
mine += chunk.byteLength;
|
||||
gatedBytes += chunk.byteLength;
|
||||
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
|
||||
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await new Response(stream.pipeThrough(counted)).text();
|
||||
} finally {
|
||||
gatedBytes -= mine;
|
||||
}
|
||||
}
|
||||
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
|
||||
@@ -97,3 +97,21 @@ export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: Trus
|
||||
const real = headers.realIp?.trim();
|
||||
return real && isIP(real) !== 0 ? real : peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a rate limit counts an address under.
|
||||
*
|
||||
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
|
||||
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
|
||||
* address holds 2^64 of them, and a limit keyed on the full address is no
|
||||
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
|
||||
* IPv4 NAT already makes.
|
||||
*/
|
||||
export function rateLimitKey(ip: string): string {
|
||||
if (isIP(ip) !== 6) return ip;
|
||||
const bits = toBits(ip);
|
||||
if (!bits) return ip;
|
||||
const prefix = bits.value >> 64n;
|
||||
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
|
||||
return `${groups.join(":")}::/64`;
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ 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://git.coffeylabs.org/coffey-labs/ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
/**
|
||||
|
||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
// As for attachments: nothing left in the disk cache of a device that is not the person's own.
|
||||
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { account } from "./config.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
|
||||
/* Shared by the HTTP layer and by the handlers that re-check a code. */
|
||||
export function checkOtp(code: string | undefined): boolean {
|
||||
if (!account.otpUrl) return true;
|
||||
const params = parseOtpauthUrl(account.otpUrl);
|
||||
return Boolean(code && params && verifyTotp(params, code));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
|
||||
export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions;
|
||||
|
||||
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
*/
|
||||
export const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
/**
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honors it when
|
||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||
* off, in which case the hold is dropped without a word and the message goes
|
||||
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
||||
*/
|
||||
export const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||
export const MAX_DELAYED_SEND = 86400 * 30;
|
||||
export const ACCOUNT = "a1";
|
||||
/** How long a push subscription lives before the server drops it. */
|
||||
export const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** An account somebody has shared with the demo user. See the session below. */
|
||||
export const SHARED_ACCOUNT = "a2";
|
||||
export const SHARED_CAPS: Obj = {
|
||||
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||
};
|
||||
export const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
export const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
|
||||
export const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
|
||||
export const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
/**
|
||||
* Credential state, mutable so the self-service flows can be exercised against
|
||||
* the mock the way they run against a real 0.16 server: the password changes,
|
||||
* 2FA starts demanding a code on every request, and app passwords keep working
|
||||
* without one.
|
||||
*/
|
||||
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
|
||||
export const MASKED = "[********]";
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
export const state = { n: 1 };
|
||||
export const nextState = () => String(state.n++);
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||
import { Obj, SHARED_ACCOUNT, USER, account } from "./config.js";
|
||||
|
||||
/* ---------- data ---------- */
|
||||
/*
|
||||
* The names are Stalwart's own defaults, which follow the Exchange convention:
|
||||
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
|
||||
* short forms, so anything built from a folder's name read differently here
|
||||
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
|
||||
* against a real server -- and every screenshot in the README showed a folder
|
||||
* list no user has. The role is what the client branches on; the name is only
|
||||
* ever displayed, which is exactly why it has to look right.
|
||||
*/
|
||||
/** Push subscriptions, as a fresh account has none. */
|
||||
export const pushSubscriptions: Obj[] = [];
|
||||
|
||||
export const mailboxes: Obj[] = [
|
||||
mb("inbox", "Inbox", "inbox"),
|
||||
mb("drafts", "Drafts", "drafts"),
|
||||
mb("sent", "Sent Items", "sent"),
|
||||
mb("junk", "Junk Mail", "junk"),
|
||||
mb("trash", "Deleted Items", "trash"),
|
||||
mb("archive", "Archive", "archive"),
|
||||
mb("work", "Work", null),
|
||||
mb("work-inv", "Invoices", null, "work"),
|
||||
mb("news", "Newsletters", null),
|
||||
];
|
||||
export function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj {
|
||||
return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } };
|
||||
}
|
||||
|
||||
export const blobs = new Map<string, { type: string; data: Buffer }>();
|
||||
export function putBlob(data: Buffer | string, type: string): string {
|
||||
const id = `b${randomUUID().slice(0, 8)}`;
|
||||
blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const people = [
|
||||
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
];
|
||||
export const subjects = [
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
|
||||
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
|
||||
];
|
||||
export const emails: Obj[] = [];
|
||||
export const seq = { counter: 1 };
|
||||
/**
|
||||
* A real TNEF blob, built to the format description, so the winmail.dat
|
||||
* decoder has something to open that is not a hand-made fixture in its own
|
||||
* test file. Two files inside, one of them carrying a long name in the MAPI
|
||||
* stream behind an 8.3 title -- which is the case the decoder exists for.
|
||||
*/
|
||||
export function winmailDat(): Buffer {
|
||||
const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]);
|
||||
const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]);
|
||||
const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; };
|
||||
const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]);
|
||||
const asciiProp = (id: number, value: string) => {
|
||||
const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]);
|
||||
const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4);
|
||||
return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]);
|
||||
};
|
||||
const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]);
|
||||
|
||||
const renddata = Buffer.alloc(14);
|
||||
const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]);
|
||||
const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1");
|
||||
const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1");
|
||||
|
||||
return Buffer.concat([
|
||||
u32(0x223e9f78), u16(0x1234),
|
||||
attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("QUARTE~1.CSV")),
|
||||
attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])),
|
||||
attr(2, 0x0006800f, csv),
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("notes.txt")),
|
||||
attr(2, 0x0006800f, notes),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A really signed message, served as the raw blob a client verifies against.
|
||||
*
|
||||
* The signature is over exact bytes, so this deliberately does not go through
|
||||
* addEmail: that builds a message out of parts and would hand back a body it
|
||||
* had assembled rather than the one that was signed. Here the blob *is* the
|
||||
* fixture, byte for byte, and the JMAP metadata is arranged around it.
|
||||
*
|
||||
* `bodyStructure` says multipart/signed because that is what the client checks
|
||||
* before deciding to download anything -- a mock that omitted it would leave
|
||||
* the whole path unreachable while every stored byte was still correct.
|
||||
*/
|
||||
export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const raw = signedMessage(o.which);
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
|
||||
const textBlob = putBlob(body, "text/plain");
|
||||
const e: Obj = {
|
||||
id,
|
||||
blobId: putBlob(raw, "message/rfc822"),
|
||||
threadId: `t${id}`,
|
||||
mailboxIds: { [o.mailbox]: true },
|
||||
keywords: o.unread ? {} : { $seen: true },
|
||||
size: raw.length,
|
||||
receivedAt: received,
|
||||
sentAt: received,
|
||||
messageId: [`${id}@mock`],
|
||||
inReplyTo: null,
|
||||
references: null,
|
||||
from: [{ name: o.from[0], email: o.from[1] }],
|
||||
to: [{ name: "Demo User", email: USER }],
|
||||
cc: null, bcc: null, replyTo: null, sender: null,
|
||||
subject: o.subject,
|
||||
hasAttachment: false,
|
||||
preview: body.slice(0, 120),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
// `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML
|
||||
// alternative still gets one, holding the text/plain part. Checked against
|
||||
// Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client,
|
||||
// which reads the part's type rather than trusting this list to be empty.
|
||||
htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
attachments: [],
|
||||
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
|
||||
bodyStructure: {
|
||||
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
|
||||
subParts: [
|
||||
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
|
||||
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
emails.push(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/*
|
||||
* A marketing template of the shape #290 was reported against.
|
||||
*
|
||||
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
|
||||
* `<style>` block, a colored call to action, a gray footer — and that is the
|
||||
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
||||
* mock without one could not show what "apply the theme to messages too" does
|
||||
* to the mail people actually receive: nothing at all.
|
||||
*/
|
||||
export const STYLED_MARKETING_HTML = `<html><head><style>
|
||||
a { color:#1155CC; text-decoration:underline }
|
||||
.h { font-size:20px; color:#111111 }
|
||||
</style></head><body style="margin:0;background-color:#f4f4f4">
|
||||
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
|
||||
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
|
||||
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
|
||||
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
|
||||
<table cellpadding="0" cellspacing="0"><tr>
|
||||
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
|
||||
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
|
||||
</td></tr></table>
|
||||
<p style="color:#666666;font-size:12px">Order #4471 · placed 2 September</p>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
|
||||
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table></body></html>`;
|
||||
|
||||
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const textBlob = putBlob(text, "text/plain");
|
||||
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
|
||||
const attachments: Obj[] = [];
|
||||
if (o.attach) {
|
||||
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
||||
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.winmail) {
|
||||
const dat = winmailDat();
|
||||
attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
|
||||
const e: Obj = {
|
||||
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
|
||||
threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true },
|
||||
keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) },
|
||||
size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received,
|
||||
messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null,
|
||||
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
||||
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
// No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail.
|
||||
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
attachments,
|
||||
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
||||
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
||||
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
||||
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
|
||||
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
|
||||
// delivered mail carries it and mail this account wrote does not.
|
||||
"header:X-Spam-Status:asText":
|
||||
o.mailbox === "junk"
|
||||
? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no"
|
||||
: o.mailbox === "inbox"
|
||||
? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]"
|
||||
: null,
|
||||
};
|
||||
emails.push(e);
|
||||
return e;
|
||||
}
|
||||
// Seed
|
||||
for (let i = 0; i < 45; i++) {
|
||||
const p = people[i % people.length]!;
|
||||
const subj = subjects[i % subjects.length]!;
|
||||
const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 });
|
||||
if (i % 4 === 0) {
|
||||
// thread replies
|
||||
addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true });
|
||||
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
|
||||
}
|
||||
}
|
||||
addEmail({ from: ["Shop Updates", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
|
||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||
|
||||
/*
|
||||
* Three signed messages, so every branch of the signature banner can be seen
|
||||
* without staging a certificate authority. Read "A note" first: that pins Ada's
|
||||
* certificate, after which the other two have something to disagree with.
|
||||
*/
|
||||
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||
// A thread whose unread message is not the last one: someone's server queued
|
||||
// their reply for hours, so it landed after messages that answer it and sits in
|
||||
// the middle of the conversation. Opening this thread at the newest message
|
||||
// left that reply above the fold until the mark-read timer swept it (#87).
|
||||
{
|
||||
const subj = "Compiler timings for the release";
|
||||
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
|
||||
const tid = t.threadId as string;
|
||||
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
|
||||
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
|
||||
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
|
||||
// Long enough after the unread one that the thread scrolls: opening at the
|
||||
// bottom put four messages between the reader and the mail they had not read.
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
|
||||
}
|
||||
// Invitation email
|
||||
{
|
||||
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
const e = addEmail({ from: ["Ada Lovelace", "[email protected]"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||
const b = putBlob(ics, "text/calendar");
|
||||
(e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }];
|
||||
(e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null });
|
||||
e.hasAttachment = true;
|
||||
}
|
||||
|
||||
export const identities: Obj[] = [
|
||||
{ id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
|
||||
{ id: "i2", name: "Demo (alias)", email: "[email protected]", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true },
|
||||
];
|
||||
export const vacationBox: { current: Obj } = { current: { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null } };
|
||||
export const sieveScripts: Obj[] = [];
|
||||
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||
export const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||
export const sharedEvents: Obj[] = [];
|
||||
export const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||
export const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||
export const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||
export function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||
export const events: Obj[] = [];
|
||||
{
|
||||
const now = new Date();
|
||||
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
|
||||
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
|
||||
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
|
||||
/*
|
||||
* One event in a zone that is not the reader's, because every other fixture
|
||||
* here uses the machine's own and so cannot tell a correct conversion from
|
||||
* no conversion at all. Dragging this one is what proves a move keeps the
|
||||
* time the event says it happens at.
|
||||
*/
|
||||
events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" });
|
||||
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
|
||||
// Two in the shared account, so a colleague's calendar has something in it.
|
||||
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||
}
|
||||
export const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
export const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||
export const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
|
||||
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||
what a share usually is. */
|
||||
export const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||
export const sharedCards: Obj[] = [
|
||||
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
];
|
||||
/**
|
||||
* One sort property, as Email/query defines them. `hasKeyword` sorts a
|
||||
* boolean, and false comes before true -- which is what makes "unread first"
|
||||
* an *ascending* sort on $seen.
|
||||
*/
|
||||
export function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
|
||||
const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? "");
|
||||
switch (property) {
|
||||
case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt));
|
||||
case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt));
|
||||
case "size": return Number(x.size ?? 0) - Number(y.size ?? 0);
|
||||
case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? ""));
|
||||
case "from": return addr(x.from).localeCompare(addr(y.from));
|
||||
case "to": return addr(x.to).localeCompare(addr(y.to));
|
||||
case "hasKeyword": {
|
||||
const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0);
|
||||
return has(x) - has(y);
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
||||
export const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||
|
||||
/** The floor Stalwart puts under a requested EventSource ping interval. */
|
||||
export const PING_FLOOR_SECONDS = 30;
|
||||
|
||||
/*
|
||||
* An account that may not send calendar invitations.
|
||||
*
|
||||
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
|
||||
* the account lacks the `calendarSchedulingSend` permission, rather than
|
||||
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
|
||||
* (2026-09-06)** against an account holding a role with that permission
|
||||
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
|
||||
* `forbidden` and the text below, while the identical request with the flag
|
||||
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
|
||||
* that account.
|
||||
*/
|
||||
export const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
|
||||
export const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
|
||||
|
||||
export const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||
/** One per contact, by index; a gap means that card has no birthday. */
|
||||
export const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
||||
{ year: 1815, month: 12, day: 10 },
|
||||
{ month: 6, day: 9 }, // no year: the common case
|
||||
{ year: 1912, month: 6, day: 23 },
|
||||
null,
|
||||
{ year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year
|
||||
{ year: 1918, month: 8, day: 26 },
|
||||
];
|
||||
|
||||
export const cards: Obj[] = people.slice(0, 6).map((p, i) => {
|
||||
const [given, surname] = p[0]!.split(" ");
|
||||
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined,
|
||||
/*
|
||||
* Birthdays on most but not all of them, and one with no year, because a
|
||||
* card that records only a day and month is the common case rather than
|
||||
* the exceptional one.
|
||||
*/
|
||||
anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined };
|
||||
});
|
||||
export const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
|
||||
export const fileNodes: Obj[] = [
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
|
||||
/* What the shared account holds. Its own nodes, so opening the share in Files
|
||||
shows something different from the reader's own folders rather than the same
|
||||
list under another name. */
|
||||
export const sharedFileNodes: Obj[] = [
|
||||
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
/** The node list an account owns. */
|
||||
export const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
|
||||
|
||||
export function fr() {
|
||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
}
|
||||
|
||||
export function recount() {
|
||||
for (const m of mailboxes) {
|
||||
const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]);
|
||||
m.totalEmails = inBox.length;
|
||||
m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length;
|
||||
const threads = new Set(inBox.map((e) => e.threadId));
|
||||
m.totalThreads = threads.size;
|
||||
m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size;
|
||||
}
|
||||
}
|
||||
recount();
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { createDirectory, mockRole } from "./directory.js";
|
||||
import { ACCOUNT, MOCK_LOCALE, Obj, USER, account, nextState, state } from "./config.js";
|
||||
import { NO_SCHEDULING_SEND, SCHEDULING_FORBIDDEN, blobs, events, mailboxes } from "./data.js";
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
export function pick(o: Obj, props?: string[] | null): Obj {
|
||||
if (!props) return o;
|
||||
const out: Obj = { id: o.id };
|
||||
for (const p of props) if (p in o) out[p] = o[p];
|
||||
else if (p.startsWith("header:")) out[p] = null;
|
||||
return out;
|
||||
}
|
||||
export function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (k.startsWith("#")) {
|
||||
const r = v as { resultOf: string; name: string; path: string };
|
||||
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
||||
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
||||
} else out[k] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
||||
* go, pointing at something created earlier in the same request. Sending a
|
||||
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
||||
* without this the mock quietly declines to create any submission at all.
|
||||
*
|
||||
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
||||
* the method that receives them resolves them itself.
|
||||
*/
|
||||
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
||||
if (key === "onSuccessUpdateEmail") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(value as Obj)) {
|
||||
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
||||
out[nk] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export function jsonPointer(obj: unknown, path: string): unknown {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let cur: unknown = obj;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]!;
|
||||
if (p === "*") {
|
||||
const rest = parts.slice(i + 1).join("/");
|
||||
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
|
||||
return arr;
|
||||
}
|
||||
cur = (cur as Obj)?.[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.operator) {
|
||||
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
|
||||
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
|
||||
}
|
||||
const kw = e.keywords as Obj;
|
||||
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
|
||||
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
|
||||
if (f.notKeyword && kw[f.notKeyword as string]) return false;
|
||||
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
|
||||
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
|
||||
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
|
||||
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
|
||||
if (f.after && String(e.receivedAt) < String(f.after)) return false;
|
||||
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
|
||||
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
|
||||
return true;
|
||||
}
|
||||
export function applyPatch(obj: Obj, patch: Obj) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k.includes("/")) {
|
||||
const [root, ...rest] = k.split("/");
|
||||
const key = rest.join("/");
|
||||
const target = (obj[root!] as Obj) ?? {};
|
||||
if (v === null) delete target[key];
|
||||
else target[key] = v;
|
||||
obj[root!] = target;
|
||||
} else obj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- method handlers ---------- */
|
||||
export type Handler = (args: Obj) => Obj | [string, Obj][];
|
||||
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
|
||||
export class MethodError extends Error {
|
||||
constructor(
|
||||
public readonly type: string,
|
||||
description?: string,
|
||||
) {
|
||||
super(description ?? type);
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_OBJECTS = 500;
|
||||
|
||||
/**
|
||||
* Stalwart refuses a whole method call that carries more objects than it will
|
||||
* process at once - it does not quietly handle the first 500. Enforce the same
|
||||
* ceiling the session advertises, so an unbatched client fails here too.
|
||||
*/
|
||||
export function enforceLimits(name: string, args: Obj): void {
|
||||
const tooLarge = () => {
|
||||
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
|
||||
};
|
||||
if (name.endsWith("/get")) {
|
||||
const ids = args.ids as unknown[] | null | undefined;
|
||||
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
if (name.endsWith("/set")) {
|
||||
const n =
|
||||
Object.keys((args.create as Obj) ?? {}).length +
|
||||
Object.keys((args.update as Obj) ?? {}).length +
|
||||
((args.destroy as unknown[] | undefined)?.length ?? 0);
|
||||
if (n > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
}
|
||||
|
||||
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||
|
||||
/*
|
||||
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
||||
* name: a `/get` with no `properties` comes back without the field at all.
|
||||
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
||||
* The mock handing it over unasked meant a client that never asked still saw
|
||||
* every share, and the one place that did not -- the real server -- showed
|
||||
* nothing shared at all.
|
||||
*
|
||||
* Calendars and address books used to behave the same way and no longer do.
|
||||
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
||||
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
||||
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
||||
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
||||
* stays applied to mailboxes alone.
|
||||
*/
|
||||
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||
if (a.properties) return res;
|
||||
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||
}
|
||||
|
||||
export function genericGet(list: Obj[]) {
|
||||
return (a: Obj) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An id, as either a stored event or one occurrence of one.
|
||||
*
|
||||
* A synthetic id whose base is gone, or whose date the rule no longer
|
||||
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
||||
* the way the server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||
const direct = list.find((x) => x.id === id);
|
||||
if (direct) return { base: direct };
|
||||
const parsed = parseSyntheticId(id);
|
||||
if (!parsed) return null;
|
||||
const base = list.find((x) => x.id === parsed.baseId);
|
||||
if (!base) return null;
|
||||
const occ = occurrenceAt(base, parsed.recurrenceId);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
export class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
|
||||
}
|
||||
|
||||
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
return (a: Obj) => {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `${prefix}${randomUUID().slice(0, 6)}`;
|
||||
const o = { ...(obj as Obj), id };
|
||||
try {
|
||||
onCreate?.(o);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetError)) throw err;
|
||||
notCreated[cid] = err.toJSON();
|
||||
continue;
|
||||
}
|
||||
list.push(o);
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const o = list.find((x) => x.id === id);
|
||||
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = list.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- calendar events ---------- */
|
||||
|
||||
/**
|
||||
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||
*
|
||||
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||
*
|
||||
* The refusals are the point of reproducing this at all:
|
||||
*
|
||||
* - a base event and one of its instances in the same request is refused, both
|
||||
* ids at once, because the server cannot apply them in a defined order;
|
||||
* - the same id twice is "Duplicate event id.";
|
||||
* - the ten event-level properties are refused with `invalidProperties`;
|
||||
* - and the twelve inherited ones are dropped in silence, with the response
|
||||
* still saying the update succeeded. A mock that applied them would let a
|
||||
* client that sends them look correct everywhere except a real server.
|
||||
*/
|
||||
/**
|
||||
* Enough of an iCalendar reader to stand in for Stalwart's.
|
||||
*
|
||||
* It reads per VEVENT rather than across the whole file, because a file is the
|
||||
* case an emailed invitation never was: an export carries a year of them, and a
|
||||
* regex over the whole text would find the first DTSTART and call that the
|
||||
* answer. One event still comes back as a bare object, the shape this returned
|
||||
* when an invitation was all it had to handle.
|
||||
*
|
||||
* The synthetic organizer and attendee only go on events that arrived with a
|
||||
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
||||
* are; a plain export is not addressed to anyone, and inventing participants
|
||||
* for it would make imported events look like invitations nobody sent.
|
||||
*/
|
||||
export function calendarEventParse(a: Obj) {
|
||||
const parsed: Obj = {};
|
||||
const notParsable: string[] = [];
|
||||
for (const b of a.blobIds as string[]) {
|
||||
const blob = blobs.get(b);
|
||||
if (!blob) { notParsable.push(b); continue; }
|
||||
const text = blob.data.toString();
|
||||
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
|
||||
const method = field(text, "METHOD");
|
||||
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
|
||||
const events = bodies.map((body) => {
|
||||
const g = (k: string) => field(body, k);
|
||||
const ds = g("DTSTART") ?? "20260101T000000Z";
|
||||
const de = g("DTEND") ?? ds;
|
||||
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
|
||||
const start = new Date(`${toLocal(ds)}Z`);
|
||||
const end = new Date(`${toLocal(de)}Z`);
|
||||
return {
|
||||
"@type": "Event",
|
||||
uid: g("UID"),
|
||||
title: g("SUMMARY"),
|
||||
start: toLocal(ds),
|
||||
timeZone: "Etc/UTC",
|
||||
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
|
||||
method,
|
||||
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
|
||||
participants: method
|
||||
? {
|
||||
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
|
||||
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
if (!events.length) { notParsable.push(b); continue; }
|
||||
parsed[b] = events.length === 1 ? events[0] : events;
|
||||
}
|
||||
return { accountId: ACCOUNT, parsed, notParsable };
|
||||
}
|
||||
|
||||
export function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
/*
|
||||
* An account that may not send invitations refuses the whole request the
|
||||
* moment it asks for them, and refuses it per object rather than as a method
|
||||
* error. Confirmed live on 0.16.21 for all three of create, update and
|
||||
* destroy; the same requests with the flag absent or false went through.
|
||||
* The flag alone decides it — the server does not first check whether the
|
||||
* event has anyone to notify.
|
||||
*/
|
||||
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
||||
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
||||
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
||||
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
||||
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is
|
||||
// how #26 and #30 reached a live server unnoticed — so it does both.
|
||||
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
o.uid = o.uid ?? randomUUID();
|
||||
events.push(o);
|
||||
created[cid] = { id: o.id };
|
||||
}
|
||||
|
||||
const updates = Object.entries((a.update as Obj) ?? {});
|
||||
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||
const seen = new Set<string>();
|
||||
|
||||
/* A base and one of its instances cannot be settled in the same request. */
|
||||
const baseOf = (id: string): string | null => {
|
||||
const r = resolveEvent(events, id);
|
||||
return r ? (r.base.id as string) : null;
|
||||
};
|
||||
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||
const b = baseOf(id);
|
||||
if (!b) continue;
|
||||
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||
touched.set(b, entry);
|
||||
}
|
||||
const conflicted = new Set<string>();
|
||||
for (const [, e] of touched) {
|
||||
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||
}
|
||||
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||
|
||||
for (const [id, patch] of updates) {
|
||||
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||
seen.add(id);
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||
writeOverride(resolved.base, resolved.occ, applied);
|
||||
updated[id] = null;
|
||||
}
|
||||
|
||||
for (const id of destroys) {
|
||||
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||
if (resolved.occ) {
|
||||
// One date off a series, which is an override rather than a deletion.
|
||||
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||
destroyed.push(id);
|
||||
continue;
|
||||
}
|
||||
const i = events.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into the override for one date.
|
||||
*
|
||||
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||
* an override always carries its own timing; the mock does the same, or a
|
||||
* client could depend on inheriting them and be right only here.
|
||||
*/
|
||||
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||
const next: Obj = { ...existing };
|
||||
if (!replace) {
|
||||
if (!("start" in next)) next.start = occ.start;
|
||||
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||
}
|
||||
applyPatch(next, patch);
|
||||
overrides[occ.recurrenceId] = next;
|
||||
base.recurrenceOverrides = overrides;
|
||||
}
|
||||
|
||||
/* ---------- submissions ---------- */
|
||||
/**
|
||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||
* envelope's FUTURERELEASE parameter rather than set by the client, and
|
||||
* `undoStatus` reports whether the message is still in the queue.
|
||||
*/
|
||||
export const submissions: Obj[] = [];
|
||||
|
||||
export function submissionView(sub: Obj): Obj {
|
||||
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
|
||||
}
|
||||
|
||||
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
|
||||
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
|
||||
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||
export const directory = createDirectory({
|
||||
accountId: ACCOUNT,
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
metricsOff: process.env.MOCK_METRICS === "off",
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { ACCOUNT, state } from "./config.js";
|
||||
|
||||
/*
|
||||
* The server-sent-events fan-out and the Email/changes ring buffer.
|
||||
*
|
||||
* Separate from index.ts because the JMAP handlers raise these events and
|
||||
* index.ts imports the handlers -- leaving them in index.ts makes that a
|
||||
* cycle. Separate from data.ts because a live HTTP response is not fixture
|
||||
* data.
|
||||
*/
|
||||
export const sseClients = new Set<ServerResponse>();
|
||||
/** What changed and when, so `Email/changes` can answer honestly. */
|
||||
export const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
export function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
// A window is plenty; the client refetches from scratch if it falls behind.
|
||||
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
||||
}
|
||||
|
||||
/** The same for contact cards, so `ContactCard/changes` can answer too. */
|
||||
export const cardChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
/** Changes at or below this state have been dropped from the log, so a client that far behind cannot be answered. */
|
||||
export const cardLog = { floor: 0 };
|
||||
export function recordCardChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
cardChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
if (cardChanges.length > 200) {
|
||||
const dropped = cardChanges.splice(0, cardChanges.length - 200);
|
||||
cardLog.floor = dropped[dropped.length - 1]!.state;
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcast(types: string[]) {
|
||||
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
|
||||
for (const c of sseClients) c.write(payload);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { checkOtp } from "./auth.js";
|
||||
import { cardChanges, cardLog, emailChanges, recordCardChange, recordEmailChange, broadcast } from "./events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj, PUSH_TTL_MS, SHARED_ACCOUNT, account, nextState, state } from "./config.js";
|
||||
import { NO_KEYWORD_SORT, abRights, blobs, booksFor, calendarsFor, cards, compareBy, emails, eventsFor, fileNodes, fr, identities, mailboxes, mb, nodesFor, participantIdentities, principals, pushSubscriptions, putBlob, recount, rightsCal, seq, sharedCards, sieveScripts, vacationBox } from "./data.js";
|
||||
import { Handler, MethodError, applyPatch, calendarEventParse, calendarEventSet, directory, genericGet, genericSet, hideShareWithUnlessAsked, matchFilter, matchSubmissionFilter, pick, resolveEvent, setResp, submissionView, submissions } from "./engine.js";
|
||||
|
||||
/** Stalwart's limit per account (0.16.22). */
|
||||
const MAX_PUSH_SUBSCRIPTIONS = 15;
|
||||
/** What an empty or missing `types` list is taken to mean: everything. */
|
||||
const ALL_PUSH_TYPES = ["Email", "EmailDelivery", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse", "CalendarEvent", "Calendar", "ContactCard", "AddressBook", "FileNode", "Quota", "SieveScript", "PushSubscription"];
|
||||
|
||||
export const handlers: Record<string, Handler> = {
|
||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||
"x:AccountSettings/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? ["singleton"];
|
||||
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
// Stalwart's directory registry: accounts, domains and roles, behind the
|
||||
// same permissions as the real thing. The locale fallback reads x:Account
|
||||
// too, and is refused here exactly when a real server would refuse it.
|
||||
...directory.handlers,
|
||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
"Email/query": (a) => {
|
||||
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||
/*
|
||||
* Honor the sort rather than always answering newest-first. This used to
|
||||
* ignore it entirely, which reproduced a server that silently returns a
|
||||
* different order from the one asked for -- the one shape of wrongness a
|
||||
* client cannot detect.
|
||||
*/
|
||||
const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }];
|
||||
if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) {
|
||||
// A method-level failure, the way a real server refuses an optional sort:
|
||||
// the whole call fails rather than the sort being quietly dropped.
|
||||
throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported.");
|
||||
}
|
||||
list.sort((x, y) => {
|
||||
for (const c of sort) {
|
||||
const asc = c.isAscending !== false;
|
||||
const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined);
|
||||
if (cmp !== 0) return asc ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
if (a.collapseThreads) {
|
||||
const seen = new Set<string>();
|
||||
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
}
|
||||
const pos = Number(a.position ?? 0);
|
||||
const limit = Number(a.limit ?? 50);
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
|
||||
},
|
||||
"Email/get": (a) => genericGet(emails)(a),
|
||||
/*
|
||||
* Real changes, not an empty answer.
|
||||
*
|
||||
* This used to return three empty arrays whatever had happened, so the
|
||||
* client's whole reconciliation path -- `Email/changes`, then deciding what
|
||||
* to do with what came back -- never ran against the mock. A bug living in
|
||||
* that path could not be reproduced here at all, which is how one reached
|
||||
* production and survived being "fixed" once (#100). The log below is what
|
||||
* the real server can answer from.
|
||||
*/
|
||||
"Email/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
const relevant = emailChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"Email/set": (a) => {
|
||||
const r = genericSet(emails, "e", (o) => {
|
||||
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
|
||||
const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); };
|
||||
const parts: Obj[] = [];
|
||||
walk(o.bodyStructure as Obj, parts);
|
||||
o.textBody = parts.filter((p) => p.type === "text/plain");
|
||||
o.htmlBody = parts.filter((p) => p.type === "text/html");
|
||||
o.attachments = [];
|
||||
const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); };
|
||||
collect(o.bodyStructure as Obj);
|
||||
o.hasAttachment = (o.attachments as Obj[]).length > 0;
|
||||
o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`;
|
||||
o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
o.size = 2000;
|
||||
o.preview = (bv.text?.value ?? "").slice(0, 100);
|
||||
o.messageId = [`${o.id}@mock`];
|
||||
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
|
||||
})(a);
|
||||
recount();
|
||||
nextState();
|
||||
recordEmailChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((a.update as Obj) ?? {}),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
/* A real server pushes a state change after a set, and the client acts on
|
||||
it -- `Email/changes` runs and the store reconciles what came back. The
|
||||
mock stayed silent, so that whole path never ran here and a bug living
|
||||
in it could not be reproduced: marking a message read went round the
|
||||
server and back on the live instance, and did nothing at all on the mock
|
||||
(#100). Announced now, the way Stalwart does. */
|
||||
broadcast(["Email", "Mailbox", "Thread"]);
|
||||
return r;
|
||||
},
|
||||
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${seq.counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
|
||||
// Stalwart 0.16 registry objects backing self-service credentials.
|
||||
"x:AccountPassword/get": () => ({
|
||||
accountId: ACCOUNT,
|
||||
state: String(state.n),
|
||||
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
|
||||
notFound: [],
|
||||
}),
|
||||
"x:AccountPassword/set": (a) => {
|
||||
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
|
||||
if (!patch) return setResp({ updated: {} });
|
||||
const current = patch.currentSecret as string | undefined;
|
||||
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
|
||||
if (!current) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
|
||||
}
|
||||
if (current !== account.password) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
if (account.otpUrl && !code) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
|
||||
}
|
||||
if (account.otpUrl && !checkOtp(code!)) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
const secret = patch.secret as string | undefined;
|
||||
if (secret !== undefined && secret !== MASKED) {
|
||||
if (secret.length < 8) {
|
||||
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
|
||||
}
|
||||
account.password = secret;
|
||||
}
|
||||
if ("otpAuth/otpUrl" in patch) {
|
||||
const url = patch["otpAuth/otpUrl"] as string | null;
|
||||
if (url !== MASKED) account.otpUrl = url;
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ updated: { singleton: null } });
|
||||
},
|
||||
/*
|
||||
* Push subscriptions. The JMAP half can be modeled; delivery cannot -- that
|
||||
* runs through the browser vendor's real push service, so nothing local will
|
||||
* ever make a notification appear.
|
||||
*
|
||||
* What is worth reproducing is the handshake, because it is the part that
|
||||
* fails quietly: a subscription is created unverified and stays silent until
|
||||
* the client echoes back a code the server pushed. A mock that marked one
|
||||
* verified on creation would let a client ship without ever implementing
|
||||
* that, and the symptom in production is "registered, and no notifications".
|
||||
*/
|
||||
"PushSubscription/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
||||
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
||||
// `keys` is write-only in JMAP: the server never hands it back.
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
||||
},
|
||||
"PushSubscription/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o = obj as Obj;
|
||||
const keys = (o.keys ?? {}) as Obj;
|
||||
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
||||
// API produces; padding it would be the client inventing a shape.
|
||||
for (const k of ["p256dh", "auth"]) {
|
||||
const v = String(keys[k] ?? "");
|
||||
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
||||
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (notCreated[cid]) continue;
|
||||
if (!String(o.url ?? "").startsWith("https://")) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
||||
continue;
|
||||
}
|
||||
// A filter condition with a null value is not a filter -- the real server
|
||||
// answers "Invalid filter" and refuses the whole subscription. ihasmail
|
||||
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
|
||||
// all here, and the mock accepted it happily. It does not any more.
|
||||
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
|
||||
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
|
||||
return Object.values(f).some((v) => v === null || v === undefined);
|
||||
});
|
||||
if (badFilter) {
|
||||
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* As Stalwart does (checked live on 0.16.22, 2026-09-16): a repeated
|
||||
* deviceClientId is a second subscription, not a replacement -- this mock
|
||||
* used to replace, which is how the client's pile-up never showed here
|
||||
* (#375) -- and an account holds at most fifteen.
|
||||
*/
|
||||
const deviceId = String(o.deviceClientId ?? "");
|
||||
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
|
||||
notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." };
|
||||
continue;
|
||||
}
|
||||
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||
/*
|
||||
* A subscription expires, and this used to hand back `expires: null`.
|
||||
* That is the one shape that makes the client's real problem invisible in
|
||||
* development: JMAP puts a ceiling of seven days on a push subscription
|
||||
* and expects the client to re-register before it lapses, so a client
|
||||
* that never renews works perfectly against a mock that never expires
|
||||
* anything and goes silent a week after being deployed. Seven days here,
|
||||
* so "does this client renew?" is a question the mock can answer.
|
||||
*/
|
||||
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
|
||||
// An empty or missing list means every type, not none.
|
||||
const types = Array.isArray(o.types) && o.types.length ? o.types : ALL_PUSH_TYPES;
|
||||
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
||||
created[cid] = { id, expires };
|
||||
state.n++;
|
||||
}
|
||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const s = pushSubscriptions.find((x) => x.id === id);
|
||||
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
const code = (patch as Obj).verificationCode;
|
||||
if (code !== undefined) {
|
||||
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||
s.verified = true;
|
||||
}
|
||||
// An expiry can be extended, up to the same seven days a new one gets.
|
||||
const wanted = (patch as Obj).expires;
|
||||
if (typeof wanted === "string") {
|
||||
const at = Math.min(Date.parse(wanted), Date.now() + PUSH_TTL_MS);
|
||||
if (Number.isNaN(at)) { notUpdated[id] = { type: "invalidProperties", properties: ["expires"] }; continue; }
|
||||
s.expires = new Date(at).toISOString();
|
||||
}
|
||||
updated[id] = null;
|
||||
state.n++;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
||||
}
|
||||
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
||||
},
|
||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||
"x:AppPassword/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `ap${randomUUID().slice(0, 6)}`;
|
||||
// Real app passwords carry their credential id, so the server can spot
|
||||
// one by its shape alone. Mirror that.
|
||||
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
||||
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
|
||||
account.appPasswords.push(row);
|
||||
created[cid] = { id, secret, createdAt: row.createdAt };
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = account.appPasswords.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ created, destroyed });
|
||||
},
|
||||
"Identity/get": genericGet(identities),
|
||||
"Identity/set": (a) => {
|
||||
// Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of
|
||||
// UTF-8, not characters. Anything longer is refused by name.
|
||||
for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) {
|
||||
for (const [key, obj] of Object.entries(entries)) {
|
||||
const over = ["htmlSignature", "textSignature"].find((prop) => {
|
||||
const v = (obj as Obj)[prop];
|
||||
return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047;
|
||||
});
|
||||
if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } });
|
||||
}
|
||||
}
|
||||
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
|
||||
},
|
||||
"EmailSubmission/get": (a) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
|
||||
},
|
||||
"EmailSubmission/query": (a) => {
|
||||
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
|
||||
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
|
||||
const pos = Number(a.position ?? 0);
|
||||
const limit = Number(a.limit ?? 50);
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
|
||||
},
|
||||
"EmailSubmission/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const sub = raw as Obj;
|
||||
const emailId = sub.emailId as string;
|
||||
const e = emails.find((x) => x.id === emailId);
|
||||
if (!e) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
|
||||
continue;
|
||||
}
|
||||
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
|
||||
if (Number.isNaN(hold)) {
|
||||
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
|
||||
continue;
|
||||
}
|
||||
// Stalwart rejects MAIL FROM outright past its own limit.
|
||||
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
|
||||
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
|
||||
continue;
|
||||
}
|
||||
// With the MTA extension off, the hold is dropped in silence.
|
||||
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
|
||||
const rec: Obj = {
|
||||
id: `s${randomUUID().slice(0, 6)}`,
|
||||
identityId: sub.identityId ?? null,
|
||||
emailId,
|
||||
threadId: e.threadId ?? null,
|
||||
envelope: sub.envelope ?? null,
|
||||
sendAt: new Date(sendAt).toISOString(),
|
||||
undoStatus: null,
|
||||
deliveryStatus: null,
|
||||
};
|
||||
submissions.push(rec);
|
||||
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
|
||||
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
|
||||
if (patch) applyPatch(e, patch);
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const patch = raw as Obj;
|
||||
const sub = submissions.find((x) => x.id === id);
|
||||
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (patch.undoStatus !== "canceled") {
|
||||
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
|
||||
continue;
|
||||
}
|
||||
const status = undoStatusOf(sub, Date.now());
|
||||
if (status !== "pending") {
|
||||
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." };
|
||||
continue;
|
||||
}
|
||||
sub.undoStatus = "canceled";
|
||||
updated[id] = null;
|
||||
}
|
||||
recount();
|
||||
return setResp({
|
||||
created,
|
||||
updated,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
});
|
||||
},
|
||||
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacationBox.current], notFound: [] }),
|
||||
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacationBox.current = { ...vacationBox.current, ...p }; return setResp({ updated: { singleton: null } }); },
|
||||
"Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }),
|
||||
"SieveScript/get": genericGet(sieveScripts),
|
||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||
/*
|
||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||
* included, which is what a live 0.16.19 does and what makes `baseEventId`
|
||||
* useless as a test for a series. Without it (the `findByUid` path) the
|
||||
* stored ids come back untouched, because callers hand those straight to a
|
||||
* destroy and mean the whole event.
|
||||
*/
|
||||
"CalendarEvent/query": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const filter = (a.filter as Obj) ?? {};
|
||||
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||
if (!a.expandRecurrences) {
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||
}
|
||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||
const ids: string[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||
},
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const properties = a.properties as string[] | null | undefined;
|
||||
// With no ids every event comes back under its stored id, none synthetic.
|
||||
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
|
||||
},
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||
"CalendarEvent/parse": (a) => calendarEventParse(a),
|
||||
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||
"Principal/get": genericGet(principals),
|
||||
// One busy block a day across whatever range was asked for. It used to answer
|
||||
// with a single block on the first day whatever the range, which was all an
|
||||
// availability bar a day wide could show -- and left a bar covering several
|
||||
// days looking as though everyone were free for all but the first of them.
|
||||
"Principal/getAvailability": (a) => {
|
||||
const from = new Date(String(a.utcStart));
|
||||
const to = new Date(String(a.utcEnd));
|
||||
const list: Obj[] = [];
|
||||
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
|
||||
const date = day.toISOString().slice(0, 11);
|
||||
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
|
||||
}
|
||||
return { accountId: ACCOUNT, list };
|
||||
},
|
||||
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
||||
"AddressBook/set": (a) => {
|
||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||
included -- "You are not allowed to modify this address book", confirmed
|
||||
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
|
||||
that accepted it would have agreed that subscribing works, which is
|
||||
exactly the belief that shipped. Calendars accept the same write; the
|
||||
difference is the server's, not ours. */
|
||||
if (a.accountId === SHARED_ACCOUNT && a.update) {
|
||||
const notUpdated: Obj = {};
|
||||
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
|
||||
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
|
||||
}
|
||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||
},
|
||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||
// An empty `properties` list returns `id` alone, which `pick` already does.
|
||||
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
|
||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||
/*
|
||||
* Recorded and announced like Email/set, so the client's incremental sync
|
||||
* (`ContactCard/changes`, then fetching what it names) runs here too. A
|
||||
* state older than the log's window cannot be answered, as on a real server.
|
||||
*/
|
||||
"ContactCard/set": (a) => {
|
||||
/*
|
||||
* Stalwart refuses a `blobId` inside `media` (0.16.22, checked live on
|
||||
* 2026-09-16), and takes the whole call down for it. The mock took
|
||||
* anything, which is how ihasmail shipped a photo upload that never
|
||||
* worked against the real server (#376).
|
||||
*/
|
||||
const withBlobMedia = (o: unknown) => Object.values(((o as Obj)?.media as Record<string, Obj> | null) ?? {}).some((m) => m && "blobId" in m);
|
||||
const refuse = { type: "invalidProperties", description: "blobIds in media is not supported.", properties: ["media"] };
|
||||
const create = { ...((a.create as Obj) ?? {}) };
|
||||
const update = { ...((a.update as Obj) ?? {}) };
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
for (const [k, v] of Object.entries(create)) if (withBlobMedia(v)) { notCreated[k] = refuse; delete create[k]; }
|
||||
for (const [k, v] of Object.entries(update)) if (withBlobMedia(v)) { notUpdated[k] = refuse; delete update[k]; }
|
||||
const r = genericSet(cards, "cc")({ ...a, create, update });
|
||||
if (Object.keys(notCreated).length) r.notCreated = { ...((r.notCreated as Obj) ?? {}), ...notCreated };
|
||||
if (Object.keys(notUpdated).length) r.notUpdated = notUpdated;
|
||||
nextState();
|
||||
recordCardChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((r.updated ?? {}) as Obj),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
broadcast(["ContactCard"]);
|
||||
return r;
|
||||
},
|
||||
"ContactCard/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
if (since < cardLog.floor) throw new MethodError("cannotCalculateChanges", "That state is too old to answer from.");
|
||||
const relevant = cardChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: a.accountId ?? ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const fileNodes = nodesFor(a.accountId);
|
||||
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||
// directories and was handed files, which it then drew as folders.
|
||||
const list = fileNodes.filter((n) => {
|
||||
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||
return true;
|
||||
});
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
|
||||
"FileNode/set": (a) => {
|
||||
return genericSet(nodesFor(a.accountId), "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
// file properties. Keep it internally so query and get stay consistent.
|
||||
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
|
||||
})(a);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -133,3 +133,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
|
||||
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("a new subscription clears what this installation left behind, and only that", async () => {
|
||||
// What a restart finds: its own subscription from the last process, another
|
||||
// installation's on the same server, a browser's, and the old id format.
|
||||
const calls: Array<[string, Record<string, unknown>]> = [];
|
||||
let ownPrefix = "";
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const [name, args, id] = methodCalls[0]!;
|
||||
calls.push([name, args]);
|
||||
let result: Record<string, unknown> = {};
|
||||
if (name === "PushSubscription/get") {
|
||||
result = { list: [
|
||||
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
|
||||
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
|
||||
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
|
||||
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
|
||||
] };
|
||||
} else if (name === "PushSubscription/set" && args.create) {
|
||||
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
|
||||
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
|
||||
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
|
||||
} else {
|
||||
result = { destroyed: args.destroy };
|
||||
}
|
||||
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||
push.prepare("[email protected]", "a", "Basic p");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
|
||||
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||
|
||||
calls.length = 0;
|
||||
push.prepare("[email protected]", "a", "Basic r");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* transition loses no events, because a tab opened before verification keeps
|
||||
* its own relay for its whole life.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
||||
@@ -71,10 +71,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
||||
}
|
||||
|
||||
/*
|
||||
* Whose subscriptions are whose.
|
||||
*
|
||||
* Each process used to register a subscription per account and forget it when
|
||||
* it stopped -- state here is in memory, and an immutable deployment restarts
|
||||
* on every deploy -- so each restart left one more behind, receiving 404s until
|
||||
* it expired. Stalwart keeps them all and allows fifteen per account (checked
|
||||
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
|
||||
* too (#375).
|
||||
*
|
||||
* So the device id names the installation -- a hash of the address Stalwart
|
||||
* posts to, stable across restarts and different for another installation on
|
||||
* the same server -- and a new subscription first removes the ones this
|
||||
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
|
||||
* the browsers' own, which the web client may clear to make room.
|
||||
*/
|
||||
function installationId(): string {
|
||||
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
|
||||
}
|
||||
|
||||
function deviceIdFor(entry: AccountPush): string {
|
||||
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
async function removeLeftovers(entry: AccountPush) {
|
||||
const mine = `ihasmail-proxy-${installationId()}-`;
|
||||
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
|
||||
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
|
||||
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
|
||||
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
|
||||
}
|
||||
|
||||
/** Give the live subscription another week, rather than registering a second one. */
|
||||
async function renew(entry: AccountPush) {
|
||||
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
|
||||
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
|
||||
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
|
||||
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
|
||||
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
|
||||
entry.expires = after ? Date.parse(after) : Date.parse(expires);
|
||||
}
|
||||
|
||||
async function subscribe(entry: AccountPush) {
|
||||
try {
|
||||
await removeLeftovers(entry);
|
||||
} catch (err) {
|
||||
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
|
||||
}
|
||||
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||
const r = await jmap(entry, [["PushSubscription/set", {
|
||||
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
|
||||
create: { s: { deviceClientId: deviceIdFor(entry), url,
|
||||
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||
}, "0"]]);
|
||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
||||
@@ -184,8 +232,13 @@ function startSweeper() {
|
||||
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
||||
}
|
||||
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||
entry.state = "pending"; entry.since = now;
|
||||
// Extended in place, which keeps it verified. Only if the server will
|
||||
// not is a new one registered, and that one has to verify again.
|
||||
entry.expires = now + RENEW_BEFORE_MS;
|
||||
renew(entry).catch(() => {
|
||||
entry.state = "pending"; entry.since = Date.now();
|
||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||
});
|
||||
}
|
||||
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||
void unsubscribe(entry);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* How much a request may make the proxy hold in memory.
|
||||
*
|
||||
* Routes that read JSON take a small body and no more, whether or not anyone
|
||||
* is signed in. The JMAP route streams straight through for a session that may
|
||||
* administer; for one that may not, it reads the body to check it, and that
|
||||
* read is capped in size, in how many one session runs at once, and in bytes
|
||||
* across everyone.
|
||||
*/
|
||||
|
||||
const PORT = 18813;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-request-limits";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { rateLimitKey } = await import("./clientip.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
let cookie = "";
|
||||
|
||||
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
|
||||
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent >= size) return controller.close();
|
||||
const n = Math.min(chunk, size - sent);
|
||||
controller.enqueue(new Uint8Array(n).fill(0x20));
|
||||
sent += n;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const jmap = (body: BodyInit) =>
|
||||
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
|
||||
|
||||
before(async () => {
|
||||
// Not remembered: a device that is not the person's own, so JMAP is checked.
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||
assert.equal(res.status, 200, "login should succeed against the mock");
|
||||
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("sign-in refuses a large body by its length, before reading it", async () => {
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("sign-in refuses a large chunked body without holding all of it", async () => {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("other JSON routes are limited too", async () => {
|
||||
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("an ordinary checked JMAP request still goes through", async () => {
|
||||
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than the check allows is refused", async () => {
|
||||
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
|
||||
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
|
||||
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
|
||||
assert.equal((await jmap(body)).status, 200);
|
||||
});
|
||||
|
||||
test("one session cannot hold more than a few checked reads at once", async () => {
|
||||
// Bodies that never finish: each holds its slot until its stream fails.
|
||||
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
||||
const pending: Promise<Response>[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
|
||||
pending.push(jmap(s));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const fifth = await jmap("{}");
|
||||
assert.equal(fifth.status, 429);
|
||||
assert.ok(fifth.headers.get("retry-after"));
|
||||
for (const c of controllers) c.error(new Error("client went away"));
|
||||
await Promise.allSettled(pending);
|
||||
// The slots are given back once those requests end.
|
||||
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
|
||||
assert.equal(again.status, 200);
|
||||
});
|
||||
|
||||
test("IPv6 addresses share a rate-limit key across their /64", () => {
|
||||
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
|
||||
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
|
||||
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
|
||||
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
|
||||
assert.equal(rateLimitKey("unknown"), "unknown");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { SessionStore, accountKey } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
|
||||
assert.equal(store.resolve(cookie), null);
|
||||
});
|
||||
|
||||
test("sessions group by the account, however its name was typed", () => {
|
||||
const store = new SessionStore("");
|
||||
const key = accountKey("https://mail.example.com", "[email protected]");
|
||||
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
// The same name on another configured server is another account.
|
||||
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
assert.equal(a.session.account, b.session.account);
|
||||
assert.equal(store.listForUser(a.session.account).length, 2);
|
||||
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
|
||||
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
|
||||
assert.ok(store.resolve(a.cookie), "this session was kept");
|
||||
});
|
||||
|
||||
test("persisted session data does not contain the password", () => {
|
||||
const store = new SessionStore("");
|
||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface StoredSession {
|
||||
/** sealed JSON {username, password} */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
account?: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
@@ -24,6 +26,8 @@ export interface StoredSession {
|
||||
export interface LiveSession {
|
||||
id: string;
|
||||
username: string;
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
authorization: string;
|
||||
remember: boolean;
|
||||
@@ -46,8 +50,27 @@ export interface SessionSummary {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key sessions are grouped by for "sign out everywhere else".
|
||||
*
|
||||
* Not the username as typed: Stalwart takes `[email protected]` and a bare
|
||||
* `alice` as the same account, and a session opened either way was missing
|
||||
* from the list and survived the sign-out. The server's own name for the
|
||||
* account, lower-cased, and the server it lives on -- the same name on two
|
||||
* configured servers is two accounts.
|
||||
*/
|
||||
export function accountKey(upstream: string, canonicalUsername: string): string {
|
||||
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function accountOf(s: StoredSession): string {
|
||||
return s.account ?? s.username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
@@ -85,8 +108,9 @@ export interface SessionBackend {
|
||||
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[];
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
listForUser(account: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: now + ttl,
|
||||
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
destroyAllForUser(username: string, exceptId?: string): number {
|
||||
destroyAllForUser(account: string, exceptId?: string): number {
|
||||
let n = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.username === username && id !== exceptId) {
|
||||
if (accountOf(s) === account && id !== exceptId) {
|
||||
this.sessions.delete(id);
|
||||
n++;
|
||||
}
|
||||
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
listForUser(account: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
||||
if (accountOf(s) !== account) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
account: accountOf(s),
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
/*
|
||||
* The bundle goes out compressed once, at build time, and anything revalidated
|
||||
* can be answered with a 304.
|
||||
*
|
||||
* Before this the server gzipped the bundle again for every request that asked,
|
||||
* never offered Brotli, and sent no validator for the shell -- so each reload's
|
||||
* revalidation of index.html and sw.js downloaded them in full.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
|
||||
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
|
||||
// A copy left over from an older build of the same name must not be served.
|
||||
writeFileSync(join(root, "assets", "stale-000000.js"), js);
|
||||
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
|
||||
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
const app = createApp();
|
||||
|
||||
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
|
||||
|
||||
test("Brotli is served where the browser takes it", async () => {
|
||||
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "br");
|
||||
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
||||
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
|
||||
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
|
||||
assert.equal(gz.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
|
||||
const plain = await get("/assets/app-a1b2c3.js");
|
||||
assert.equal(plain.headers.get("content-encoding"), null);
|
||||
assert.equal(await plain.text(), js);
|
||||
});
|
||||
|
||||
test("a file without a copy is compressed as before", async () => {
|
||||
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("a copy older than its file is ignored", async () => {
|
||||
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
|
||||
assert.notEqual(res.headers.get("content-encoding"), "br");
|
||||
});
|
||||
|
||||
test("the shell and the worker answer a revalidation with 304", async () => {
|
||||
for (const path of ["/", "/sw.js"]) {
|
||||
const first = await get(path);
|
||||
const etag = first.headers.get("etag");
|
||||
assert.ok(etag, `${path} carries a validator`);
|
||||
await first.arrayBuffer();
|
||||
const again = await get(path, { "if-none-match": etag! });
|
||||
assert.equal(again.status, 304, `${path} is not sent again`);
|
||||
assert.equal(await again.text(), "");
|
||||
const changed = await get(path, { "if-none-match": `"something-else"` });
|
||||
assert.equal(changed.status, 200);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
/*
|
||||
* What a file is, for the purpose of "has it changed". The shell and the
|
||||
* never-stale files are revalidated on every load; with no validator to send
|
||||
* back, every revalidation downloaded the whole file again.
|
||||
*/
|
||||
function etagOf(size: number, mtimeMs: number): string {
|
||||
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
|
||||
}
|
||||
|
||||
function notModified(c: Context, etag: string): boolean {
|
||||
const sent = c.req.header("if-none-match");
|
||||
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
|
||||
}
|
||||
|
||||
/*
|
||||
* The encodings a build can carry beside a file, best first. See
|
||||
* scripts/precompress.mjs, which writes them.
|
||||
*/
|
||||
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
|
||||
{ token: "br", suffix: ".br", encoding: "br" },
|
||||
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
|
||||
];
|
||||
|
||||
function accepts(c: Context, token: string): boolean {
|
||||
const header = c.req.header("accept-encoding") ?? "";
|
||||
return header.split(",").some((part) => {
|
||||
const [name, ...params] = part.trim().split(";");
|
||||
if (name?.trim().toLowerCase() !== token) return false;
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
||||
return !q || Number(q.slice(2)) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
let indexCache: { body: string; mtime: number; etag: string } | null = null;
|
||||
/** Which precompressed copies exist, per file and modification time. */
|
||||
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
|
||||
|
||||
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
|
||||
const known = variants.get(filePath);
|
||||
if (known && known.mtime === mtime) return known.found;
|
||||
const found = new Map<string, number>();
|
||||
for (const v of PRECOMPRESSED) {
|
||||
try {
|
||||
const st = await stat(filePath + v.suffix);
|
||||
// A copy older than the file it came from describes something else.
|
||||
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
variants.set(filePath, { mtime, found });
|
||||
return found;
|
||||
}
|
||||
let mismatchWarned = false;
|
||||
|
||||
/**
|
||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const p = join(absRoot, "index.html");
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
const body = await readFile(p, "utf8");
|
||||
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
|
||||
mismatchWarned = false;
|
||||
}
|
||||
warnOnBaseMismatch(indexCache.body);
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
c.header("ETag", indexCache.etag);
|
||||
if (notModified(c, indexCache.etag)) return c.body(null, 304);
|
||||
return c.body(indexCache.body);
|
||||
} catch {
|
||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
if (!st.isFile()) return serveIndex(c);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||
c.header("Content-Length", String(st.size));
|
||||
const etag = etagOf(st.size, st.mtimeMs);
|
||||
c.header("ETag", etag);
|
||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
} else {
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
if (notModified(c, etag)) return c.body(null, 304);
|
||||
// Serve a copy made at build time where the browser takes one.
|
||||
let servePath = filePath;
|
||||
let size = st.size;
|
||||
const found = await variantsOf(filePath, st.mtimeMs);
|
||||
if (found.size) {
|
||||
c.header("Vary", "Accept-Encoding");
|
||||
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
|
||||
if (pick) {
|
||||
servePath = filePath + pick.suffix;
|
||||
size = found.get(pick.suffix)!;
|
||||
c.header("Content-Encoding", pick.encoding);
|
||||
}
|
||||
}
|
||||
c.header("Content-Length", String(size));
|
||||
if (c.req.method === "HEAD") return c.body(null);
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
||||
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||
return c.body(stream);
|
||||
} catch {
|
||||
// SPA fallback for client-side routes (no file extension) only.
|
||||
|
||||
@@ -233,6 +233,23 @@ export interface AccountInfo {
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
|
||||
/*
|
||||
* Both caches are keyed by session, and used to lose an entry only when that
|
||||
* session signed out or was refused -- not when it simply expired, which is how
|
||||
* most sessions end. An entry past its age is never used again, so dropping
|
||||
* those on a timer is all it takes to stop them accumulating.
|
||||
*/
|
||||
export function sweepUpstreamCaches(now = Date.now()): void {
|
||||
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
|
||||
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
|
||||
}
|
||||
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
|
||||
|
||||
/** How many sessions the caches hold; for tests. */
|
||||
export function upstreamCacheSizes(): { sessions: number; info: number } {
|
||||
return { sessions: sessionCache.size, info: infoCache.size };
|
||||
}
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -18,18 +18,169 @@ const VERSION = "ihasmail-v2";
|
||||
* 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 = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
||||
|
||||
/*
|
||||
* Only the app page may be kept as the app page.
|
||||
*
|
||||
* The mount's root is not always the app: demo.ihasmail.com puts its landing
|
||||
* page there, and a front door of any kind can. The worker used to cache
|
||||
* whatever `/` returned at install and whatever HTML a navigation returned,
|
||||
* and since app routes are answered from that copy first, a demo visitor who
|
||||
* came back got the landing page on every route, for good. The app page is
|
||||
* recognised by the asset list the build writes into it.
|
||||
*/
|
||||
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
|
||||
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
|
||||
|
||||
/*
|
||||
* The routes the app itself owns (App.tsx). Only these are answered from the
|
||||
* kept page; anything else under the mount -- the root, a landing or farewell
|
||||
* page in front of the app, a file -- goes to the network as it always did.
|
||||
*/
|
||||
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((c) => c.addAll(SHELL))
|
||||
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
||||
.then(() => dropForeignShell())
|
||||
.then(() => tidy())
|
||||
.catch(() => {})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* Keeping the cache to what the current build uses.
|
||||
*
|
||||
* Build assets are cached on first use and their names change with every
|
||||
* build, and nothing used to take them out again: every deploy's chunks stayed
|
||||
* in the browser for good. Worse, whatever the server answered was kept -- a
|
||||
* 404 for a chunk asked for while a deploy was changing over became that
|
||||
* chunk, from then on, in that browser.
|
||||
*
|
||||
* The rule now: only a successful response is cached, and whenever the app
|
||||
* page changes, the assets it no longer names are dropped. A lazily loaded
|
||||
* chunk the page does not name is dropped too, and fetched again the next time
|
||||
* it is wanted -- a hash that did not change is still on the server.
|
||||
*
|
||||
* The cache name stays as it is. The same cache carries what the worker leaves
|
||||
* for a tab to collect -- a push verification, a share, the facts it notifies
|
||||
* from -- and a new name would throw those away along with the rubbish.
|
||||
*/
|
||||
const ASSETS = `${BASE}/assets/`;
|
||||
const SHELL_KEY = `${BASE}/`;
|
||||
|
||||
function assetsNamedIn(html) {
|
||||
const out = new Set();
|
||||
for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
|
||||
try {
|
||||
out.add(new URL(m[1], self.location).pathname);
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop failed responses, and assets the cached app page does not name. `also`
|
||||
* is a page whose assets are kept as well: the one just replaced, which a tab
|
||||
* opened from the kept copy may still be running.
|
||||
*/
|
||||
async function tidy(also = "") {
|
||||
const cache = await caches.open(VERSION);
|
||||
const shell = await cache.match(SHELL_KEY);
|
||||
// Without a page to go by, which assets are current is unknown; keep them.
|
||||
const keep = shell ? assetsNamedIn(await shell.text()) : null;
|
||||
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
|
||||
for (const req of await cache.keys()) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path.startsWith(ASSETS)) {
|
||||
if (keep && !keep.has(path)) {
|
||||
await cache.delete(req);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const res = await cache.match(req);
|
||||
if (res && !res.ok) await cache.delete(req);
|
||||
}
|
||||
}
|
||||
|
||||
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
|
||||
async function dropForeignShell() {
|
||||
const cache = await caches.open(VERSION);
|
||||
const kept = await cache.match(SHELL_KEY);
|
||||
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
|
||||
}
|
||||
|
||||
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
|
||||
async function refreshShell(res) {
|
||||
const html = await res.text();
|
||||
if (!isAppPage(html)) return;
|
||||
const cache = await caches.open(VERSION);
|
||||
const prev = await cache.match(SHELL_KEY);
|
||||
const prevHtml = prev ? await prev.text() : "";
|
||||
if (prevHtml !== html) {
|
||||
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
||||
await tidy(prevHtml);
|
||||
}
|
||||
await precache(html);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fetching the rest of the build before it is asked for.
|
||||
*
|
||||
* The app page lists every file of its build (see the asset-list plugin in
|
||||
* vite.config.ts). Without this, the first time after a deploy that a reader
|
||||
* opened the composer, settings or a viewer, it waited on the server for the
|
||||
* code -- on a distant link, a visible pause. Now those files are fetched
|
||||
* quietly once a page names them, a few at a time, and only those not held
|
||||
* already; a load cut short is carried on at the next navigation, which calls
|
||||
* this again. Language catalogs are left to be cached when used, and nothing
|
||||
* is fetched ahead when the reader has asked the browser to save data.
|
||||
*/
|
||||
const PRECACHE_PARALLEL = 3;
|
||||
|
||||
function precacheList(html) {
|
||||
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
|
||||
if (!m) return [];
|
||||
try {
|
||||
const list = JSON.parse(m[1]).precache;
|
||||
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function precache(html) {
|
||||
if (self.navigator.connection && self.navigator.connection.saveData) return;
|
||||
const cache = await caches.open(VERSION);
|
||||
const wanted = [];
|
||||
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
|
||||
const next = async () => {
|
||||
for (let path = wanted.shift(); path; path = wanted.shift()) {
|
||||
try {
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
if (res.ok) await cache.put(path, res);
|
||||
} catch {
|
||||
/* offline, or a deploy changing over; the next navigation tries again */
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
|
||||
}
|
||||
|
||||
/*
|
||||
* Where a share from the operating system is left for a tab to collect.
|
||||
*
|
||||
@@ -103,21 +254,52 @@ self.addEventListener("fetch", (event) => {
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
||||
// Hashed build assets: cache-first, and only what actually arrived.
|
||||
if (url.pathname.startsWith(ASSETS)) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
if (res.ok && res.type === "basic") {
|
||||
const copy = res.clone();
|
||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations & everything else: network-first, fall back to cached shell.
|
||||
/*
|
||||
* Navigations: the kept app page at once, and the network's behind it.
|
||||
*
|
||||
* Every route in the app is the same page, and waiting on the server for it
|
||||
* cost a full round trip before anything could start -- the longest single
|
||||
* wait on a distant link. So a route is answered from the kept copy when
|
||||
* there is one, and the fresh page is fetched alongside to replace it for
|
||||
* next time. A page that is a build behind is caught the way it always was:
|
||||
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
|
||||
* names are kept for one more build so it can run until then.
|
||||
*
|
||||
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
|
||||
* app, and a file opened in a tab of its own go to the network as before. So
|
||||
* does the first visit, which has no copy yet.
|
||||
*/
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
|
||||
const network = fetch(req).then((res) => {
|
||||
// Every route is the same app page; a fresh one replaces the offline copy.
|
||||
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
|
||||
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
|
||||
event.respondWith((async () => {
|
||||
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
|
||||
if (kept) {
|
||||
event.waitUntil(network.catch(() => {}));
|
||||
return kept;
|
||||
}
|
||||
return network.catch(() => caches.match(SHELL_KEY));
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
@@ -272,6 +454,14 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
* top of it is a second telling of the same thing (#375). Chrome does not
|
||||
* require one while the site is in the foreground.
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
@@ -287,8 +477,10 @@ self.addEventListener("push", (event) => {
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
|
||||
if (!emails.length) {
|
||||
// A StateChange, or a payload too large to carry the message. Say
|
||||
// something true rather than inventing a sender.
|
||||
// A delivery from a server that sends StateChange rather than EmailPush
|
||||
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
|
||||
// or a payload too large to carry the message. Say something true
|
||||
// rather than inventing a sender.
|
||||
await self.registration.showNotification(strings.newMail, {
|
||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
@@ -308,7 +500,8 @@ self.addEventListener("push", (event) => {
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
|
||||
@@ -16,12 +16,12 @@ import { LoginPage } from "@/views/Login";
|
||||
import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { publishWorkerFacts } from "@/lib/swFacts";
|
||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||
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 { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
@@ -260,10 +260,8 @@ function AuthedApp() {
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
});
|
||||
}, [inboxUnread, appName]);
|
||||
|
||||
/*
|
||||
@@ -284,7 +282,7 @@ function AuthedApp() {
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
useEffect(() => {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
if (notif) void requestNotificationPermission();
|
||||
}, [notif]);
|
||||
|
||||
// Nothing worth painting until the account's settings are in force; see the
|
||||
|
||||
@@ -104,6 +104,8 @@ export class JmapClient {
|
||||
private callCounter = 0;
|
||||
private unauthHandlers = new Set<() => void>();
|
||||
private stateHandlers = new Set<(sessionState: string) => void>();
|
||||
/** The last session state announced, so a burst of replies announces it once. */
|
||||
private announcedState: string | null = null;
|
||||
|
||||
get maxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
||||
@@ -255,7 +257,8 @@ export class JmapClient {
|
||||
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state) {
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state && res.sessionState !== this.announcedState) {
|
||||
this.announcedState = res.sessionState;
|
||||
for (const fn of this.stateHandlers) fn(res.sessionState);
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||
import { displayName, formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||
|
||||
describe("address parsing", () => {
|
||||
it("parses mixed lists", () => {
|
||||
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
|
||||
expect(m.to).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("names that reorder themselves", () => {
|
||||
const spoof = { name: "[email protected]\u202E", email: "[email protected]" };
|
||||
it("lose their direction controls when displayed", () => {
|
||||
expect(displayName({ name: "\u202Egnp.exe\u202C Ann", email: "[email protected]" })).toBe("gnp.exe Ann");
|
||||
expect(formatAddress(spoof)).toBe("[email protected] <[email protected]>");
|
||||
});
|
||||
it("fall back to the address when nothing else is left", () => {
|
||||
expect(displayName({ name: "\u200F\u202E", email: "[email protected]" })).toBe("[email protected]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* An instance renamed with APP_NAME should be called by its name everywhere,
|
||||
* not only on the sign-in page and in the title bar. So no sentence shown to
|
||||
* a person may write "ihasmail" into itself: it takes the name as {app}.
|
||||
*
|
||||
* The exceptions are the places where "ihasmail" is not the app's name but a
|
||||
* literal a person could go and look at: the Files folder, the Sieve script
|
||||
* and the project's own address. Renaming those would rename real data.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
/** Strings that name a stored thing, not the app. */
|
||||
const LITERALS = [
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.",
|
||||
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.",
|
||||
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.",
|
||||
"ihasmail.org",
|
||||
"ihasmail",
|
||||
];
|
||||
|
||||
function sources(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name === "locales" || name === "__tests__") continue;
|
||||
sources(path, out);
|
||||
} else if (/\.tsx?$/.test(name)) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every translated string in a file, however `t` was imported. */
|
||||
function translatedStrings(code: string): string[] {
|
||||
return [...code.matchAll(/\b(?:t|tNode|translate)\(\s*"((?:[^"\\]|\\.)*)"/g)].map((m) =>
|
||||
JSON.parse(`"${m[1]}"`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("text that names the app", () => {
|
||||
it("takes the name as {app} instead of writing ihasmail into the sentence", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sources(SRC)) {
|
||||
for (const s of translatedStrings(readFileSync(file, "utf8"))) {
|
||||
if (s.includes("ihasmail") && !LITERALS.includes(s)) {
|
||||
offenders.push(`${file.slice(SRC.length)}: ${s.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a placeholder in every translation of those strings", () => {
|
||||
const catalogs = readdirSync(join(SRC, "locales")).filter((f) => f.endsWith(".ts") && f !== "index.ts");
|
||||
const wrong: string[] = [];
|
||||
for (const name of catalogs) {
|
||||
const code = readFileSync(join(SRC, "locales", name), "utf8");
|
||||
for (const m of code.matchAll(/^\s*"((?:[^"\\]|\\.)*)": "((?:[^"\\]|\\.)*)",$/gm)) {
|
||||
const key = JSON.parse(`"${m[1]}"`);
|
||||
const value = JSON.parse(`"${m[2]}"`);
|
||||
// A key that takes the name must not hard-code it in the translation.
|
||||
if (key.includes("{app}") && value.includes("ihasmail")) wrong.push(`${name}: ${key.slice(0, 50)}`);
|
||||
}
|
||||
}
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contactFromAddress, nameParts } from "../contacts";
|
||||
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const parts = (name: string | null, email = "[email protected]") =>
|
||||
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
|
||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #376: a photo saved as a `blobId` was refused by Stalwart, which only takes
|
||||
* the `uri` form. Saving one must also leave a card's other media alone.
|
||||
*/
|
||||
describe("withPhoto", () => {
|
||||
const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" };
|
||||
|
||||
it("puts the photo in as a data URI, never a blob id", () => {
|
||||
const media = withPhoto(undefined, photo)!;
|
||||
const [m] = Object.values(media);
|
||||
expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" });
|
||||
expect(m).not.toHaveProperty("blobId");
|
||||
});
|
||||
|
||||
it("replaces an existing photo and keeps a logo", () => {
|
||||
const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!;
|
||||
expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1);
|
||||
expect(media.old).toBeUndefined();
|
||||
expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" });
|
||||
});
|
||||
|
||||
it("removes only the photo, and clears media when nothing is left", () => {
|
||||
expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } });
|
||||
expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("is read back by contactPhoto", () => {
|
||||
const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard;
|
||||
expect(contactPhoto(card, "a1")).toBe(photo.dataUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeRule as describeSieve } from "../sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
|
||||
import { describeRule as describeSieve } from "../sieve/sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
||||
import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasHtmlAlternative } from "../html";
|
||||
import { hasHtmlAlternative } from "../text/html";
|
||||
|
||||
/*
|
||||
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
import { isTextEntry, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Shortcuts after a click on a checkbox (#260).
|
||||
@@ -11,8 +11,8 @@ import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
* swallows a keystroke in the first place.
|
||||
*/
|
||||
|
||||
const pressFrom = (el: Element, key: string) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
const pressFrom = (el: EventTarget, key: string, init?: KeyboardEventInit) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
||||
el.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
@@ -92,3 +92,22 @@ describe("shortcuts with a checkbox focused", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shifted letter shortcuts", () => {
|
||||
it("matches Shift+I and Shift+U bindings", () => {
|
||||
const read = vi.fn();
|
||||
const unread = vi.fn();
|
||||
pop = keyboard.pushScope("test", [
|
||||
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: read },
|
||||
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: unread },
|
||||
]);
|
||||
|
||||
const readEvent = pressFrom(window, "I", { shiftKey: true });
|
||||
const unreadEvent = pressFrom(window, "U", { shiftKey: true });
|
||||
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(unread).toHaveBeenCalledOnce();
|
||||
expect(readEvent.defaultPrevented).toBe(true);
|
||||
expect(unreadEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comboOf, keyboard } from "@/lib/keyboard";
|
||||
import { comboOf, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* A "keydown" that carries no key. Chrome's password autofill dispatches one
|
||||
@@ -36,7 +36,7 @@ describe("a keydown with no key", () => {
|
||||
|
||||
it("leaves real keys alone", () => {
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("E");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("shift+e");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toMatch(/enter$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||
|
||||
/**
|
||||
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
||||
@@ -112,3 +112,19 @@ describe("the body a share turns into", () => {
|
||||
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareSummary", () => {
|
||||
const file = (name: string) => new File(["x"], name);
|
||||
it("gives the title, the text and link together, and the file names", () => {
|
||||
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
|
||||
title: "Trip",
|
||||
preview: "See this https://example.com",
|
||||
files: ["a.jpg"],
|
||||
});
|
||||
});
|
||||
it("shortens a long text rather than showing all of it", () => {
|
||||
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
|
||||
expect(preview.length).toBeLessThanOrEqual(160);
|
||||
expect(preview.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
|
||||
@@ -79,6 +79,12 @@ describe("isTnef", () => {
|
||||
});
|
||||
|
||||
describe("parseTnef", () => {
|
||||
it("takes the direction overrides out of a name", () => {
|
||||
const out = parseTnef(tnef(file("x.bin", "MZ", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "Invoice_\u202Efdp.exe" }]) },
|
||||
])));
|
||||
expect(out[0]!.name).toBe("Invoice_fdp.exe");
|
||||
});
|
||||
it("pulls one attachment out, with its name and bytes", () => {
|
||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||
expect(out).toHaveLength(1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { withoutBidiControls } from "@/lib/text/text";
|
||||
|
||||
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
||||
|
||||
@@ -48,9 +49,10 @@ export function parseOne(raw: string): EmailAddress | null {
|
||||
|
||||
export function formatAddress(a: EmailAddress | null | undefined): string {
|
||||
if (!a) return "";
|
||||
if (!a.name) return a.email;
|
||||
const needsQuote = /[,;<>"()\\]/.test(a.name);
|
||||
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
|
||||
const clean = a.name ? withoutBidiControls(a.name) : "";
|
||||
if (!clean) return a.email;
|
||||
const needsQuote = /[,;<>"()\\]/.test(clean);
|
||||
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
|
||||
return `${name} <${a.email}>`;
|
||||
}
|
||||
|
||||
@@ -60,7 +62,8 @@ export function formatAddressList(list: EmailAddress[] | null | undefined): stri
|
||||
|
||||
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
||||
if (!a) return fallback;
|
||||
if (a.name?.trim()) return a.name.trim();
|
||||
const name = a.name ? withoutBidiControls(a.name).trim() : "";
|
||||
if (name) return name;
|
||||
return a.email || fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/admin/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/admin/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/admin/adminDirectory";
|
||||
|
||||
describe("setting a password", () => {
|
||||
it("writes into the existing password credential, keeping its place", () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/admin/adminDomains";
|
||||
|
||||
/**
|
||||
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/admin/adminGroups";
|
||||
|
||||
describe("group membership", () => {
|
||||
it("is a patch to each member, one pointer each, so no other membership moves", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/admin/adminLists";
|
||||
|
||||
describe("a mailing list's recipients", () => {
|
||||
it("are saved as what was added and removed, one pointer each", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { permissionSet } from "@/lib/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
||||
import { permissionSet } from "@/lib/admin/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
|
||||
|
||||
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
||||
const roles = new Map<string, DirectoryRole>([
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
|
||||
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/admin/adminTenants";
|
||||
|
||||
describe("a tenant's limits", () => {
|
||||
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/admin/adminAccess";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/admin/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Groups, from Stalwart 0.16's directory.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Mailing lists, from Stalwart 0.16's directory.
|
||||
@@ -1,8 +1,8 @@
|
||||
import { apiFetch, client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { Permissions, RoleDef } from "@/lib/adminAccess";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import type { Permissions, RoleDef } from "@/lib/admin/adminAccess";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
import { DomainError } from "@/lib/admin/adminDomains";
|
||||
import type { PermissionInfo } from "@/lib/permissionLabels";
|
||||
|
||||
/**
|
||||
@@ -1,7 +1,7 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
import { DomainError } from "@/lib/admin/adminDomains";
|
||||
|
||||
/**
|
||||
* Tenants, from Stalwart 0.16's directory.
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
/**
|
||||
* What this instance calls itself, when nothing has said otherwise yet.
|
||||
*
|
||||
@@ -11,3 +13,25 @@
|
||||
* three copies of a default is how two of them end up stale.
|
||||
*/
|
||||
export const DEFAULT_APP_NAME = "ihasmail";
|
||||
|
||||
/**
|
||||
* What this instance calls itself, right now.
|
||||
*
|
||||
* Text that names the app reads it from here rather than writing "ihasmail"
|
||||
* into the sentence, so an instance renamed with `APP_NAME` is called by its
|
||||
* name everywhere, not only on the sign-in page and in the title bar. The
|
||||
* name goes into the sentence as the `{app}` placeholder, which also lets a
|
||||
* translator put it where their language wants it.
|
||||
*
|
||||
* Two shapes for the same fact: the hook for components, and the plain
|
||||
* function for the few places that build strings outside React (the service
|
||||
* worker's facts, for one). Both fall back to the default until the session
|
||||
* arrives.
|
||||
*/
|
||||
export function useAppName(): string {
|
||||
return useSession((s) => s.session?.ihasmail?.appName)?.trim() || DEFAULT_APP_NAME;
|
||||
}
|
||||
|
||||
export function currentAppName(): string {
|
||||
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/calendar/appointment";
|
||||
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { availabilityWindow } from "@/lib/availabilityWindow";
|
||||
import { availabilityWindow } from "@/lib/calendar/availabilityWindow";
|
||||
|
||||
const at = (s: string) => new Date(s);
|
||||
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
snap,
|
||||
movePatch,
|
||||
moveByDaysPatch,
|
||||
moveAcrossPatch,
|
||||
columnsMoved,
|
||||
dayDelta,
|
||||
resizePatch,
|
||||
SNAP_MINUTES,
|
||||
} from "@/lib/eventDrag";
|
||||
} from "@/lib/calendar/eventDrag";
|
||||
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
@@ -180,10 +182,22 @@ describe("the patch a drag sends, computed in the event's own frame", () => {
|
||||
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
||||
});
|
||||
|
||||
it("moves by days and minutes together, as a week-grid drag does", () => {
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", 2, 90)).toEqual({ start: "2026-09-06T15:30:00" });
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", -1, 0)).toEqual({ start: "2026-09-03T14:00:00" });
|
||||
expect(moveAcrossPatch("2026-09-04T14:00:00", 0, -30)).toEqual({ start: "2026-09-04T13:30:00" });
|
||||
});
|
||||
|
||||
it("adds the days as days, so a clock change does not move the hour", () => {
|
||||
// US clocks go back on 1 November 2026; 14:00 stays 14:00 across it.
|
||||
expect(moveAcrossPatch("2026-10-31T14:00:00", 2, 0)).toEqual({ start: "2026-11-02T14:00:00" });
|
||||
});
|
||||
|
||||
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({});
|
||||
expect(moveAcrossPatch("not a date", 1, 30)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,3 +242,23 @@ describe("pixelsToMinutes", () => {
|
||||
expect(SNAP_MINUTES).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnsMoved", () => {
|
||||
it("counts whole columns, to the nearest", () => {
|
||||
expect(columnsMoved(100, 100, 2, 7)).toBe(1);
|
||||
expect(columnsMoved(140, 100, 2, 7)).toBe(1);
|
||||
expect(columnsMoved(160, 100, 2, 7)).toBe(2);
|
||||
expect(columnsMoved(-40, 100, 2, 7)).toBe(0);
|
||||
expect(columnsMoved(-160, 100, 3, 7)).toBe(-2);
|
||||
});
|
||||
|
||||
it("stops at the edges of the week instead of wrapping", () => {
|
||||
expect(columnsMoved(-900, 100, 2, 7)).toBe(-2);
|
||||
expect(columnsMoved(900, 100, 2, 7)).toBe(4);
|
||||
});
|
||||
|
||||
it("never moves sideways in a one-day grid or before it is measured", () => {
|
||||
expect(columnsMoved(500, 100, 0, 1)).toBe(0);
|
||||
expect(columnsMoved(500, 0, 0, 7)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
|
||||
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/calendar/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`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toIcs, parseIcs } from "@/lib/ics";
|
||||
import { toIcs, parseIcs } from "@/lib/calendar/ics";
|
||||
import type { JSCalendarEvent } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Email, EmailAddress } from "@/jmap/types";
|
||||
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { uniqueAddresses } from "./address";
|
||||
import { toLocalDateOnly } from "./dates";
|
||||
import { htmlToText } from "./text";
|
||||
import { uniqueAddresses } from "../address";
|
||||
import { toLocalDateOnly } from "../dates";
|
||||
import { htmlToText } from "../text/text";
|
||||
|
||||
/**
|
||||
* How much of a message body is copied into an event description.
|
||||
@@ -10,8 +10,8 @@
|
||||
* question — given an event and a gesture, what are the new start and end —
|
||||
* and the caller decides whether it is allowed to save that.
|
||||
*/
|
||||
import { addMinutes } from "./dates";
|
||||
import { isBirthdayEvent } from "./birthdays";
|
||||
import { addMinutes } from "../dates";
|
||||
import { isBirthdayEvent } from "../birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
@@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
|
||||
return { start: formatStored(moved) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved by whole days and by minutes at once -- the week grid, where a drag
|
||||
* goes sideways to another day and up or down to another hour in the same
|
||||
* gesture.
|
||||
*
|
||||
* The days go first and as days, for the reason moveByDaysPatch gives: a day
|
||||
* added to a wall clock keeps its time of day across a clock change, where
|
||||
* 1440 minutes would not.
|
||||
*/
|
||||
export function moveAcrossPatch(storedStart: string, days: number, deltaMinutes: number): DragPatch {
|
||||
const byDays = days ? moveByDaysPatch(storedStart, days).start : storedStart;
|
||||
if (!byDays) return {};
|
||||
return snap(deltaMinutes) ? movePatch(byDays, deltaMinutes) : { start: byDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns sideways the pointer has gone, kept inside the grid.
|
||||
*
|
||||
* Counted from the column the drag began in, so an event that crosses
|
||||
* midnight moves by the same amount whichever of its two halves was picked
|
||||
* up. Past the first or last column it stops at the edge rather than
|
||||
* wrapping: the week on screen is the only week a drag can reach.
|
||||
*/
|
||||
export function columnsMoved(deltaPixels: number, columnWidth: number, fromIndex: number, columnCount: number): number {
|
||||
if (!columnWidth || columnCount < 2) return 0;
|
||||
const moved = Math.round(deltaPixels / columnWidth) || 0; // never -0
|
||||
return Math.max(-fromIndex, Math.min(columnCount - 1 - fromIndex, moved));
|
||||
}
|
||||
|
||||
/** Whole days between two local dates, ignoring the time of day on each. */
|
||||
export function dayDelta(from: Date, to: Date): number {
|
||||
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { formatList, weekdayName, weekdayNames } from "./datetime";
|
||||
import { formatList, weekdayName, weekdayNames } from "../datetime";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
||||
import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/** Best display name for a card. */
|
||||
@@ -57,6 +57,22 @@ export function contactEmails(c: ContactCard): EmailAddress[] {
|
||||
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's `media` with its photo replaced by `photo`, or removed when that is
|
||||
* null, and everything else in it -- a logo, a sound -- left as it was.
|
||||
*
|
||||
* The photo goes in as a `data:` URI. Stalwart (0.16.22, checked live on
|
||||
* 2026-09-16) refuses a `blobId` in `media` outright -- "blobIds in media is
|
||||
* not supported" -- which is RFC 9610's JMAP extension to JSContact, and
|
||||
* accepts the plain RFC 9553 `uri` form, returning it unchanged (#376). The
|
||||
* editor's photo is a 256px JPEG, tens of kilobytes; 134 KB was accepted.
|
||||
*/
|
||||
export function withPhoto(media: Record<string, JSContactMedia> | undefined | null, photo: { dataUrl: string; type: string } | null): Record<string, JSContactMedia> | null {
|
||||
const rest: Record<string, JSContactMedia> = Object.fromEntries(Object.entries(media ?? {}).filter(([, m]) => m.kind !== "photo"));
|
||||
if (photo) rest[newKey("p")] = { "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: photo.type };
|
||||
return Object.keys(rest).length ? rest : null;
|
||||
}
|
||||
|
||||
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||
if (!m) return null;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hand the browser a file the app made, to save.
|
||||
*
|
||||
* The object URL is released as soon as the download has been started: a
|
||||
* click on the link starts it synchronously, and an unreleased URL keeps the
|
||||
* whole file in memory for as long as the tab is open -- an address book's
|
||||
* worth of vCards, per export.
|
||||
*/
|
||||
export function downloadFile(content: BlobPart, type: string, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { descendantIds } from "./folderMove";
|
||||
import { descendantIds } from "./mailbox/folderMove";
|
||||
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
|
||||
*
|
||||
* So the sentence stays whole and the elements are placeholders in it:
|
||||
*
|
||||
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
|
||||
* tNode("Open {scheme} links in {app}.", { scheme: <code>mailto:</code> }, { app: "ihasmail" })
|
||||
*
|
||||
* A translator sees one sentence with a named hole and can put the hole
|
||||
* wherever their language wants it.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||