Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
**/dist
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
|
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
server/data
|
server/data
|
||||||
|
|||||||
@@ -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:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||||
- uses: actions/setup-node@v7
|
# 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:
|
with:
|
||||||
node-version: 26
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
# Pinned to a commit rather than a moving major tag. This action is
|
# The only third-party action here that is not published by GitHub or
|
||||||
# handed `packages: write` and its whole job is deletion, so a tag
|
# Docker, and the one with the most to lose: it is handed
|
||||||
# repointed at something else -- by a compromise or a mistake upstream --
|
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||||
# is a bad day. v1.2.2.
|
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
|
# day. It was pinned to a commit long before the rest of them were.
|
||||||
|
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||||
with:
|
with:
|
||||||
owner: Coffey-Labs
|
owner: Coffey-Labs
|
||||||
package: ihasmail
|
package: ihasmail
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ jobs:
|
|||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 26
|
||||||
- id: v
|
- id: v
|
||||||
@@ -108,18 +108,18 @@ jobs:
|
|||||||
- platform: linux/arm64
|
- platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
- uses: docker/login-action@v4
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@@ -140,7 +140,7 @@ jobs:
|
|||||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||||
digest="${{ steps.push.outputs.digest }}"
|
digest="${{ steps.push.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
# One artifact per platform; the merge job globs them back together.
|
# One artifact per platform; the merge job globs them back together.
|
||||||
name: digest-${{ strategy.job-index }}
|
name: digest-${{ strategy.job-index }}
|
||||||
@@ -157,13 +157,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
- uses: docker/login-action@v4
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ jobs:
|
|||||||
previous: ${{ steps.decide.outputs.previous }}
|
previous: ${{ steps.decide.outputs.previous }}
|
||||||
count: ${{ steps.decide.outputs.count }}
|
count: ${{ steps.decide.outputs.count }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 26
|
||||||
- id: decide
|
- id: decide
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/data/
|
server/data/
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# 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
|
||||||
|
- 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
|
||||||
@@ -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
|
deleted. No approving review is required, so a PR of your own is not blocked
|
||||||
waiting for one.
|
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
|
### Code Style
|
||||||
|
|
||||||
- Match the existing formatting and naming conventions used elsewhere in the codebase.
|
- 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.
|
Real JMAP mailboxes, with the server's roles honored.
|
||||||
|
|
||||||
- Create, rename, create a subfolder, delete (with or without its mail).
|
- Create, rename, create a subfolder, delete (with or without its mail).
|
||||||
- **Drag a folder onto another** to reparent it. Folders with a server role
|
- **Order:** Inbox first, then the other special folders (Drafts, Sent,
|
||||||
(Inbox, Sent, Drafts, Trash, Junk, Archive) are structural and are not
|
Archive, Junk, Trash), then everything else A–Z, at every level.
|
||||||
offered the drag, because the server refuses to move them anyway.
|
- **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
|
- **Subscribe / unsubscribe** — *Show in list* / *Hide from list*. An
|
||||||
unsubscribed folder still exists and still receives; it is just out of the
|
unsubscribed folder still exists and still receives; it is just out of the
|
||||||
way. Inbox cannot be hidden.
|
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
|
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
|
||||||
formatting. Tab and Shift+Tab indent inside the body.
|
formatting. Tab and Shift+Tab indent inside the body.
|
||||||
- **Plain text** as a per-message or default format.
|
- **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
|
- **Recipient chips** with autocomplete from contacts, shared address books you
|
||||||
have added, the server directory and recent recipients; your own cards win a
|
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
|
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.
|
uploaded, which it was not being.
|
||||||
- **Attachment reminder** when the text mentions an attachment and none is there.
|
- **Attachment reminder** when the text mentions an attachment and none is there.
|
||||||
- **Spell check** toggle.
|
- **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.
|
- **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
|
- **Quoting** on reply, with the signature placed above or below it, and
|
||||||
reply-all as an optional default.
|
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 |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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
|
# Platform
|
||||||
|
|
||||||
- **Installable PWA** with a service worker: the app shell is cached for
|
- **Installable PWA** with a service worker: the app shell is cached for
|
||||||
installability and fast loads, API requests never are, and navigations are
|
installability and fast loads, API requests never are. An app route is
|
||||||
network-first with the shell as fallback.
|
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.
|
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
||||||
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
||||||
notification opened while ihasmail is already running arrives in the copy
|
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`) |
|
| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) |
|
||||||
| Idle sign-out | after 5 minutes | none |
|
| 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 |
|
| Background notifications | refused | available |
|
||||||
| Administration | unavailable | available, if the role allows it |
|
| Administration | unavailable | available, if the role allows it |
|
||||||
|
|
||||||
Local storage is gated on that answer for **reads** as well as writes — a
|
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
|
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
|
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
|
cache, recent addresses and kept folder list, and tears down the push
|
||||||
answer was given.
|
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
|
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
|
was removed from browsers years ago, and **no event fires at all** for walking
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ 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
|
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://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **`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://github.com/Coffey-Labs/ihasmail/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://github.com/Coffey-Labs/ihasmail/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://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:
|
- **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:
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ settings included, belongs to Stalwart, so the container is disposable.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||||
| **Composer**  | **Calendar**  |
|
| **Composer**  | **Calendar**  |
|
||||||
| **Contacts**  | **Sieve filter builder**  |
|
| **Contacts**  | **Sieve filter builder**  |
|
||||||
|
|
||||||
Taken against the built-in mock with sample data. More, including the phone
|
Taken against the built-in mock with sample data. More, including the phone
|
||||||
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||||
@@ -77,7 +77,7 @@ docker compose up --build -d
|
|||||||
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
# → 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`.
|
weekly, so it is usually a few days behind `main`.
|
||||||
|
|
||||||
People sign in with their Stalwart mailbox credentials. **An account with
|
People sign in with their Stalwart mailbox credentials. **An account with
|
||||||
|
|||||||
@@ -9,8 +9,21 @@ services:
|
|||||||
BASE_PATH: ${BASE_PATH:-}
|
BASE_PATH: ${BASE_PATH:-}
|
||||||
image: ihasmail:2
|
image: ihasmail:2
|
||||||
restart: unless-stopped
|
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:
|
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:
|
environment:
|
||||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||||
|
|||||||
|
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.
|
* images already use rather than whatever a window happens to be.
|
||||||
*
|
*
|
||||||
* npm run dev:mock # in another terminal
|
* npm run dev:mock # in another terminal
|
||||||
* node docs/screenshots.mjs docs/screenshots
|
* node scripts/screenshots.mjs screenshots
|
||||||
* node docs/screenshots-light.mjs docs/screenshots
|
* node scripts/screenshots-light.mjs screenshots
|
||||||
*
|
*
|
||||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||||
* run against the same mock shows them twice.
|
* run against the same mock shows them twice.
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
||||||
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
||||||
* at --window-size and never calling setDeviceMetricsOverride renders it
|
* 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
|
* assertTheme() stays either way: without it this script wrote a dark
|
||||||
* screenshot under a light caption and reported success, and that is how the
|
* 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 evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||||
await sleep(800);
|
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 ---
|
// --- 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 () => {
|
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.equal(created.status, 200);
|
||||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||||
assert.ok(created.body.id);
|
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, []);
|
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 () => {
|
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.status, 400);
|
||||||
assert.equal(res.body.error, "missing_fields");
|
assert.equal(res.body.error, "missing_fields");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
|
|||||||
assert.equal(r.ok, true);
|
assert.equal(r.ok, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the account's own registry objects pass", () => {
|
test("the account's own registry objects can be read", () => {
|
||||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
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", () => {
|
test("directory and server objects are refused, and named", () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||||
* touched: they act on what the account can already reach.
|
* 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 };
|
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;
|
const name = Array.isArray(call) ? call[0] : undefined;
|
||||||
if (typeof name !== "string") return { ok: false, method: null };
|
if (typeof name !== "string") return { ok: false, method: null };
|
||||||
if (!name.startsWith("x:")) continue;
|
if (!name.startsWith("x:")) continue;
|
||||||
const object = name.slice(2).split("/")[0] ?? "";
|
const [object = "", op = ""] = name.slice(2).split("/");
|
||||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
/*
|
||||||
|
* 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) };
|
return { ok: true, body: JSON.stringify(parsed) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { compress } from "hono/compress";
|
import { compress } from "hono/compress";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import { request as httpsRequest } from "node:https";
|
import { request as httpsRequest } from "node:https";
|
||||||
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { fetchPermissions } from "./permissionSchema.js";
|
import { fetchPermissions } from "./permissionSchema.js";
|
||||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.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 { RateLimiter } from "./ratelimit.js";
|
||||||
import { resolveClientIp } from "./clientip.js";
|
import { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||||
|
import { safeEqual } from "./crypto.js";
|
||||||
import {
|
import {
|
||||||
type AccountInfo,
|
type AccountInfo,
|
||||||
UpstreamError,
|
UpstreamError,
|
||||||
@@ -209,6 +211,22 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
|
|||||||
await 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 requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
const cookie = getCookie(c, config.cookieName);
|
const cookie = getCookie(c, config.cookieName);
|
||||||
const session = sessions.resolve(cookie);
|
const session = sessions.resolve(cookie);
|
||||||
@@ -269,6 +287,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
|
|
||||||
const api = new Hono<Env>();
|
const api = new Hono<Env>();
|
||||||
api.use("*", csrfGuard);
|
api.use("*", csrfGuard);
|
||||||
|
api.use("*", smallBodies);
|
||||||
|
|
||||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
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 ----------
|
// ---------- Auth ----------
|
||||||
api.post("/auth/login", async (c) => {
|
api.post("/auth/login", async (c) => {
|
||||||
const ip = clientIp(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 };
|
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||||
try {
|
try {
|
||||||
body = await c.req.json();
|
body = await c.req.json();
|
||||||
@@ -318,7 +344,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
/*
|
/*
|
||||||
* Three checks, answering different questions.
|
* 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
|
* 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
|
* 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
|
* 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 flood ceiling is the one that is never refunded, and it is the reason
|
||||||
* the other two safely can be.
|
* the other two safely can be.
|
||||||
*/
|
*/
|
||||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
const limitKey = `${rateIp}|${username.toLowerCase()}`;
|
||||||
if (!loginFloodLimiter.check(ip)) {
|
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
|
||||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
|
|
||||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
|
||||||
}
|
|
||||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
|
||||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
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);
|
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
|
// The credentials were accepted; only the server is too old. Not an
|
||||||
// attempt worth counting against them.
|
// attempt worth counting against them.
|
||||||
loginLimiter.refund(limitKey);
|
loginLimiter.refund(limitKey);
|
||||||
loginLimiter.refund(ip);
|
loginLimiter.refund(rateIp);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: "unsupported_server",
|
error: "unsupported_server",
|
||||||
@@ -364,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
loginLimiter.reset(limitKey);
|
loginLimiter.reset(limitKey);
|
||||||
const { cookie, session } = sessions.create({
|
const { cookie, session } = sessions.create({
|
||||||
username,
|
username,
|
||||||
|
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||||
password: effectivePassword,
|
password: effectivePassword,
|
||||||
remember: Boolean(body.remember),
|
remember: Boolean(body.remember),
|
||||||
userAgent: c.req.header("user-agent") ?? "",
|
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)) {
|
if (!(err instanceof UpstreamError && err.status === 401)) {
|
||||||
loginLimiter.refund(limitKey);
|
loginLimiter.refund(limitKey);
|
||||||
loginLimiter.refund(ip);
|
loginLimiter.refund(rateIp);
|
||||||
}
|
}
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -445,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
|
|
||||||
api.get("/auth/sessions", requireSession, (c) => {
|
api.get("/auth/sessions", requireSession, (c) => {
|
||||||
const session = c.get("session");
|
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) => {
|
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||||
const session = c.get("session");
|
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 });
|
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. */
|
/** Guard the endpoints that check a password against brute-forcing. */
|
||||||
const guarded = (c: Context<Env>): Response | null => {
|
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||||
if (accountLimiter.check(key)) return null;
|
if (accountLimiter.check(key)) return null;
|
||||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
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();
|
const otpCode = body.otpCode?.trim();
|
||||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||||
forgetUpstreamSession(session.id);
|
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 });
|
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) => {
|
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 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);
|
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||||
const description = (body.description ?? "").trim().slice(0, 120);
|
const description = (body.description ?? "").trim().slice(0, 120);
|
||||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
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 {
|
try {
|
||||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -611,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
if (sessionKept) forgetUpstreamSession(session.id);
|
if (sessionKept) forgetUpstreamSession(session.id);
|
||||||
}
|
}
|
||||||
// Other sessions still hold the bare password and will be refused.
|
// 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 });
|
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;
|
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||||
if (!administrationAllowed(config.administration, session.remember)) {
|
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;
|
let raw: string;
|
||||||
try {
|
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.
|
// 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() : "";
|
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof GatedBudgetError) {
|
||||||
|
c.header("Retry-After", "1");
|
||||||
|
return c.json({ error: "busy" }, 503);
|
||||||
|
}
|
||||||
return c.json({ error: "too_large" }, 413);
|
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);
|
const gate = gateAdministration(raw);
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
@@ -751,23 +803,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
const 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, {
|
const res = await fetch(url, {
|
||||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||||
// on our behalf and hand back a decompressed body whose content-length
|
// on our behalf and hand back a decompressed body whose content-length
|
||||||
// header still describes the compressed one -- see forwardedContentLength.
|
// 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)),
|
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);
|
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||||
headers.set("Content-Type", type);
|
headers.set("Content-Type", type);
|
||||||
const cl = forwardedContentLength(res.headers);
|
const cl = forwardedContentLength(res.headers);
|
||||||
if (cl) headers.set("Content-Length", cl);
|
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);
|
const safeInline = inline && isInlineSafe(type);
|
||||||
headers.set(
|
headers.set(
|
||||||
"Content-Disposition",
|
"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");
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
// 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 {
|
} else {
|
||||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||||
}
|
}
|
||||||
headers.set("Cache-Control", "private, max-age=3600");
|
// Kept out of the browser's disk cache on a device that is not the
|
||||||
return new Response(res.body, { status: 200, headers });
|
// 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) {
|
} catch (err) {
|
||||||
return upstreamFailure(c, 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. */
|
/** Name the app password after the browser it will live in. */
|
||||||
function appPasswordName(c: Context): string {
|
function appPasswordName(c: Context): string {
|
||||||
const ua = c.req.header("user-agent") ?? "";
|
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.
|
* 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"]);
|
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();
|
const real = headers.realIp?.trim();
|
||||||
return real && isIP(real) !== 0 ? real : peer;
|
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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
|||||||
res.on("close", done);
|
res.on("close", done);
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
"Content-Type": type,
|
"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",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||||
"Cross-Origin-Resource-Policy": "same-origin",
|
"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");
|
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||||
} finally { restore(); }
|
} 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
|
* transition loses no events, because a tab opened before verification keeps
|
||||||
* its own relay for its whole life.
|
* 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 type { ServerResponse } from "node:http";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.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][] };
|
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) {
|
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 url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||||
const r = await jmap(entry, [["PushSubscription/set", {
|
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"] } },
|
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||||
}, "0"]]);
|
}, "0"]]);
|
||||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
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`);
|
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) {
|
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
|
||||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
// 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)) {
|
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||||
void unsubscribe(entry);
|
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 { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { SessionStore } from "./sessions.js";
|
import { SessionStore, accountKey } from "./sessions.js";
|
||||||
import { normalizeLocale } from "./upstream.js";
|
import { normalizeLocale } from "./upstream.js";
|
||||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||||
import { RateLimiter } from "./ratelimit.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);
|
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", () => {
|
test("persisted session data does not contain the password", () => {
|
||||||
const store = new SessionStore("");
|
const store = new SessionStore("");
|
||||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface StoredSession {
|
|||||||
/** sealed JSON {username, password} */
|
/** sealed JSON {username, password} */
|
||||||
sealedCredentials: string;
|
sealedCredentials: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||||
|
account?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeenAt: number;
|
lastSeenAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -24,6 +26,8 @@ export interface StoredSession {
|
|||||||
export interface LiveSession {
|
export interface LiveSession {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** See `accountKey`. */
|
||||||
|
account: string;
|
||||||
/** Basic Authorization header value for upstream calls. */
|
/** Basic Authorization header value for upstream calls. */
|
||||||
authorization: string;
|
authorization: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
@@ -46,8 +50,27 @@ export interface SessionSummary {
|
|||||||
ip: string;
|
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 {
|
export interface CreateSessionParams {
|
||||||
username: string;
|
username: string;
|
||||||
|
/** From `accountKey`; defaults to the lower-cased username. */
|
||||||
|
account?: string;
|
||||||
password: string;
|
password: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
@@ -85,8 +108,9 @@ export interface SessionBackend {
|
|||||||
resolve(cookie: string | undefined): LiveSession | null;
|
resolve(cookie: string | undefined): LiveSession | null;
|
||||||
reseal(cookie: string | undefined, password: string): boolean;
|
reseal(cookie: string | undefined, password: string): boolean;
|
||||||
destroy(id: string): void;
|
destroy(id: string): void;
|
||||||
destroyAllForUser(username: string, exceptId?: string): number;
|
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||||
listForUser(username: string): SessionSummary[];
|
destroyAllForUser(account: string, exceptId?: string): number;
|
||||||
|
listForUser(account: string): SessionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COOKIE_SEP = ".";
|
const COOKIE_SEP = ".";
|
||||||
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
salt: salt.toString("base64"),
|
salt: salt.toString("base64"),
|
||||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||||
username: params.username,
|
username: params.username,
|
||||||
|
account: params.account ?? params.username.trim().toLowerCase(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
expiresAt: now + ttl,
|
expiresAt: now + ttl,
|
||||||
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
|
|||||||
if (this.sessions.delete(id)) this.scheduleSave();
|
if (this.sessions.delete(id)) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyAllForUser(username: string, exceptId?: string): number {
|
destroyAllForUser(account: string, exceptId?: string): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const [id, s] of this.sessions) {
|
for (const [id, s] of this.sessions) {
|
||||||
if (s.username === username && id !== exceptId) {
|
if (accountOf(s) === account && id !== exceptId) {
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
listForUser(username: string): SessionSummary[] {
|
listForUser(account: string): SessionSummary[] {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const s of this.sessions.values()) {
|
for (const s of this.sessions.values()) {
|
||||||
if (s.username !== username) continue;
|
if (accountOf(s) !== account) continue;
|
||||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||||
out.push(rest);
|
out.push(rest);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
username,
|
username,
|
||||||
|
account: accountOf(s),
|
||||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||||
remember: s.remember,
|
remember: s.remember,
|
||||||
createdAt: s.createdAt,
|
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 { createReadStream } from "node:fs";
|
||||||
import { stat, readFile } from "node:fs/promises";
|
import { stat, readFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
|||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
].join("; ");
|
].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 {
|
export function staticHandler(root: string, basePath = ""): Handler {
|
||||||
const absRoot = resolve(root);
|
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;
|
let mismatchWarned = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
const p = join(absRoot, "index.html");
|
const p = join(absRoot, "index.html");
|
||||||
const st = await stat(p);
|
const st = await stat(p);
|
||||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
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;
|
mismatchWarned = false;
|
||||||
}
|
}
|
||||||
warnOnBaseMismatch(indexCache.body);
|
warnOnBaseMismatch(indexCache.body);
|
||||||
c.header("Content-Type", "text/html; charset=utf-8");
|
c.header("Content-Type", "text/html; charset=utf-8");
|
||||||
c.header("Cache-Control", "no-cache");
|
c.header("Cache-Control", "no-cache");
|
||||||
c.header("Content-Security-Policy", APP_CSP);
|
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);
|
return c.body(indexCache.body);
|
||||||
} catch {
|
} catch {
|
||||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
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);
|
if (!st.isFile()) return serveIndex(c);
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
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/")) {
|
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
} else {
|
} else {
|
||||||
c.header("Cache-Control", "public, max-age=3600");
|
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);
|
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);
|
return c.body(stream);
|
||||||
} catch {
|
} catch {
|
||||||
// SPA fallback for client-side routes (no file extension) only.
|
// 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 infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
const INFO_CACHE_MS = 30 * 60_000;
|
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: [] };
|
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
@@ -18,18 +18,169 @@ const VERSION = "ihasmail-v2";
|
|||||||
* eventually would.
|
* eventually would.
|
||||||
*/
|
*/
|
||||||
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
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) => {
|
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) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
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.
|
* 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.origin !== self.location.origin) return;
|
||||||
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
||||||
|
|
||||||
// Hashed build assets: cache-first.
|
// Hashed build assets: cache-first, and only what actually arrived.
|
||||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
if (url.pathname.startsWith(ASSETS)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||||
const copy = res.clone();
|
if (res.ok && res.type === "basic") {
|
||||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
const copy = res.clone();
|
||||||
|
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
return;
|
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") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
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 : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
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 facts = await readFacts();
|
||||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
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 ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||||
|
|
||||||
if (!emails.length) {
|
if (!emails.length) {
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
// A delivery from a server that sends StateChange rather than EmailPush
|
||||||
// something true rather than inventing a sender.
|
// -- 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, {
|
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` },
|
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.
|
// be drawn.
|
||||||
actions: email.id ? actionsFor(facts) : [],
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
data: {
|
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,
|
id: email.id || null,
|
||||||
title,
|
title,
|
||||||
accountId: facts?.accountId ?? null,
|
accountId: facts?.accountId ?? null,
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import { LoginPage } from "@/views/Login";
|
|||||||
import { AppShell } from "@/views/AppShell";
|
import { AppShell } from "@/views/AppShell";
|
||||||
import { MailView } from "@/views/mail/MailView";
|
import { MailView } from "@/views/mail/MailView";
|
||||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||||
import { setUnreadBadge } from "@/lib/notify";
|
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||||
import { publishWorkerFacts } from "@/lib/swFacts";
|
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
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 { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||||
@@ -260,10 +260,8 @@ function AuthedApp() {
|
|||||||
});
|
});
|
||||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("@/lib/notify").then((m) => {
|
setBaseTitle(appName);
|
||||||
m.setBaseTitle(appName);
|
setUnreadBadge(inboxUnread);
|
||||||
setUnreadBadge(inboxUnread);
|
|
||||||
});
|
|
||||||
}, [inboxUnread, appName]);
|
}, [inboxUnread, appName]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -284,7 +282,7 @@ function AuthedApp() {
|
|||||||
// Request notification permission lazily when enabled
|
// Request notification permission lazily when enabled
|
||||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
if (notif) void requestNotificationPermission();
|
||||||
}, [notif]);
|
}, [notif]);
|
||||||
|
|
||||||
// Nothing worth painting until the account's settings are in force; see the
|
// Nothing worth painting until the account's settings are in force; see the
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export class JmapClient {
|
|||||||
private callCounter = 0;
|
private callCounter = 0;
|
||||||
private unauthHandlers = new Set<() => void>();
|
private unauthHandlers = new Set<() => void>();
|
||||||
private stateHandlers = new Set<(sessionState: string) => 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 {
|
get maxCallsInRequest(): number {
|
||||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
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 };
|
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
|
||||||
if (createdIds) body.createdIds = createdIds;
|
if (createdIds) body.createdIds = createdIds;
|
||||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
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);
|
for (const fn of this.stateHandlers) fn(res.sessionState);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("address parsing", () => {
|
||||||
it("parses mixed lists", () => {
|
it("parses mixed lists", () => {
|
||||||
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
|
|||||||
expect(m.to).toHaveLength(1);
|
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 { describe, expect, it } from "vitest";
|
||||||
import { contactFromAddress, nameParts } from "../contacts";
|
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
|
||||||
import type { ContactCard } from "@/jmap/types";
|
import type { ContactCard } from "@/jmap/types";
|
||||||
|
|
||||||
const parts = (name: string | null, email = "[email protected]") =>
|
const parts = (name: string | null, email = "[email protected]") =>
|
||||||
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
|
|||||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
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 ".
|
* the joining is Intl's rather than a hardcoded " and ".
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describeRule as describeSieve } from "../sieve";
|
import { describeRule as describeSieve } from "../sieve/sieve";
|
||||||
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
|
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
||||||
import { setUiLanguageForFormatting } from "../datetime";
|
import { setUiLanguageForFormatting } from "../datetime";
|
||||||
import { setCatalog } from "../i18n";
|
import { setCatalog } from "../i18n";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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
|
* 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 { 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).
|
* 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.
|
* swallows a keystroke in the first place.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const pressFrom = (el: Element, key: string) => {
|
const pressFrom = (el: EventTarget, key: string, init?: KeyboardEventInit) => {
|
||||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
||||||
el.dispatchEvent(e);
|
el.dispatchEvent(e);
|
||||||
return e;
|
return e;
|
||||||
};
|
};
|
||||||
@@ -92,3 +92,22 @@ describe("shortcuts with a checkbox focused", () => {
|
|||||||
expect(handler).not.toHaveBeenCalled();
|
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 { 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
|
* 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", () => {
|
it("leaves real keys alone", () => {
|
||||||
expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e");
|
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$/);
|
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 { 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.
|
* Two-key sequences against the single keys they start with.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
* 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");
|
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 { describe, expect, it } from "vitest";
|
||||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
|
||||||
|
|
||||||
describe("navSwipeThreshold", () => {
|
describe("navSwipeThreshold", () => {
|
||||||
it("asks for more travel than a row swipe does, at every width", () => {
|
it("asks for more travel than a row swipe does, at every width", () => {
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ describe("isTnef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("parseTnef", () => {
|
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", () => {
|
it("pulls one attachment out, with its name and bytes", () => {
|
||||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||||
expect(out).toHaveLength(1);
|
expect(out).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EmailAddress } from "@/jmap/types";
|
import type { EmailAddress } from "@/jmap/types";
|
||||||
|
import { withoutBidiControls } from "@/lib/text/text";
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
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 {
|
export function formatAddress(a: EmailAddress | null | undefined): string {
|
||||||
if (!a) return "";
|
if (!a) return "";
|
||||||
if (!a.name) return a.email;
|
const clean = a.name ? withoutBidiControls(a.name) : "";
|
||||||
const needsQuote = /[,;<>"()\\]/.test(a.name);
|
if (!clean) return a.email;
|
||||||
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
|
const needsQuote = /[,;<>"()\\]/.test(clean);
|
||||||
|
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
|
||||||
return `${name} <${a.email}>`;
|
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 {
|
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
||||||
if (!a) return fallback;
|
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;
|
return a.email || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 set = (...p: string[]) => permissionSet(p);
|
||||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { client, JmapMethodError } from "@/jmap/client";
|
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 });
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { client } from "@/jmap/client";
|
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", () => {
|
describe("setting a password", () => {
|
||||||
it("writes into the existing password credential, keeping its place", () => {
|
it("writes into the existing password credential, keeping its place", () => {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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
|
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { client } from "@/jmap/client";
|
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", () => {
|
describe("group membership", () => {
|
||||||
it("is a patch to each member, one pointer each, so no other membership moves", () => {
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { client } from "@/jmap/client";
|
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", () => {
|
describe("a mailing list's recipients", () => {
|
||||||
it("are saved as what was added and removed, one pointer each", () => {
|
it("are saved as what was added and removed, one pointer each", () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { permissionSet } from "@/lib/adminAccess";
|
import { permissionSet } from "@/lib/admin/adminAccess";
|
||||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
|
||||||
|
|
||||||
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
||||||
const roles = new Map<string, DirectoryRole>([
|
const roles = new Map<string, DirectoryRole>([
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { client } from "@/jmap/client";
|
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", () => {
|
describe("a tenant's limits", () => {
|
||||||
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { t } from "@/lib/i18n";
|
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.
|
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { plural, t } from "@/lib/i18n";
|
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.
|
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import type { PermissionsMode, UserRoles } from "@/lib/adminAccess";
|
import type { PermissionsMode, UserRoles } from "@/lib/admin/adminAccess";
|
||||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/adminDirectory";
|
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/admin/adminDirectory";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups, from Stalwart 0.16's directory.
|
* Groups, from Stalwart 0.16's directory.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { t } from "@/lib/i18n";
|
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.
|
* Mailing lists, from Stalwart 0.16's directory.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { apiFetch, client } from "@/jmap/client";
|
import { apiFetch, client } from "@/jmap/client";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import type { Permissions, RoleDef } from "@/lib/adminAccess";
|
import type { Permissions, RoleDef } from "@/lib/admin/adminAccess";
|
||||||
import { DirectoryError } from "@/lib/adminDirectory";
|
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||||
import { DomainError } from "@/lib/adminDomains";
|
import { DomainError } from "@/lib/admin/adminDomains";
|
||||||
import type { PermissionInfo } from "@/lib/permissionLabels";
|
import type { PermissionInfo } from "@/lib/permissionLabels";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { DirectoryError } from "@/lib/adminDirectory";
|
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||||
import { DomainError } from "@/lib/adminDomains";
|
import { DomainError } from "@/lib/admin/adminDomains";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenants, from Stalwart 0.16's directory.
|
* 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.
|
* 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.
|
* three copies of a default is how two of them end up stale.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_APP_NAME = "ihasmail";
|
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 { 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";
|
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 at = (s: string) => new Date(s);
|
||||||
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
|
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
|
||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
snap,
|
snap,
|
||||||
movePatch,
|
movePatch,
|
||||||
moveByDaysPatch,
|
moveByDaysPatch,
|
||||||
|
moveAcrossPatch,
|
||||||
|
columnsMoved,
|
||||||
dayDelta,
|
dayDelta,
|
||||||
resizePatch,
|
resizePatch,
|
||||||
SNAP_MINUTES,
|
SNAP_MINUTES,
|
||||||
} from "@/lib/eventDrag";
|
} from "@/lib/calendar/eventDrag";
|
||||||
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||||
import type { CalendarEvent } from "@/jmap/types";
|
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" });
|
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", () => {
|
it("says nothing at all about a start it cannot read", () => {
|
||||||
expect(movePatch("not a date", 30)).toEqual({});
|
expect(movePatch("not a date", 30)).toEqual({});
|
||||||
expect(moveByDaysPatch("", 3)).toEqual({});
|
expect(moveByDaysPatch("", 3)).toEqual({});
|
||||||
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).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);
|
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 { 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 cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
|
||||||
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
|
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { JSCalendarEvent } from "@/jmap/types";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Email, EmailAddress } from "@/jmap/types";
|
import type { Email, EmailAddress } from "@/jmap/types";
|
||||||
import { useCalendar, type EventDraft } from "@/store/calendar";
|
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { uniqueAddresses } from "./address";
|
import { uniqueAddresses } from "../address";
|
||||||
import { toLocalDateOnly } from "./dates";
|
import { toLocalDateOnly } from "../dates";
|
||||||
import { htmlToText } from "./text";
|
import { htmlToText } from "../text/text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much of a message body is copied into an event description.
|
* 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 —
|
* 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.
|
* and the caller decides whether it is allowed to save that.
|
||||||
*/
|
*/
|
||||||
import { addMinutes } from "./dates";
|
import { addMinutes } from "../dates";
|
||||||
import { isBirthdayEvent } from "./birthdays";
|
import { isBirthdayEvent } from "../birthdays";
|
||||||
import type { CalendarEvent } from "@/jmap/types";
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
|
|||||||
return { start: formatStored(moved) };
|
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. */
|
/** Whole days between two local dates, ignoring the time of day on each. */
|
||||||
export function dayDelta(from: Date, to: Date): number {
|
export function dayDelta(from: Date, to: Date): number {
|
||||||
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
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";
|
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";
|
import { withBase } from "@/lib/basePath";
|
||||||
|
|
||||||
/** Best display name for a card. */
|
/** 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 }));
|
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 {
|
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||||
if (!m) return null;
|
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.
|
* so a node has one shape and there is nothing left to detect.
|
||||||
*/
|
*/
|
||||||
import type { FileNode, Id } from "@/jmap/types";
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
import { descendantIds } from "./folderMove";
|
import { descendantIds } from "./mailbox/folderMove";
|
||||||
|
|
||||||
/** Properties to request for a node. */
|
/** Properties to request for a node. */
|
||||||
export function fileNodeProps(): string[] {
|
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:
|
* 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
|
* A translator sees one sentence with a named hole and can put the hole
|
||||||
* wherever their language wants it.
|
* wherever their language wants it.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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
|
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { rowClick, type RowClick } from "@/lib/listSelection";
|
import { rowClick, type RowClick } from "@/lib/input/listSelection";
|
||||||
|
|
||||||
const IDS = ["a", "b", "c", "d", "e"];
|
const IDS = ["a", "b", "c", "d", "e"];
|
||||||
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||||
@@ -145,13 +145,14 @@ export function comboOf(e: KeyboardEvent): string | null {
|
|||||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||||
if (mod) parts.push("mod");
|
if (mod) parts.push("mod");
|
||||||
if (e.altKey) parts.push("alt");
|
if (e.altKey) parts.push("alt");
|
||||||
if (e.shiftKey && key.length > 1) parts.push("shift");
|
const shiftedLetter = e.shiftKey && /^[a-z]$/i.test(key);
|
||||||
|
if (e.shiftKey && (key.length > 1 || shiftedLetter)) parts.push("shift");
|
||||||
let k = key;
|
let k = key;
|
||||||
if (k === " ") k = "space";
|
if (k === " ") k = "space";
|
||||||
else if (k === "Escape") k = "esc";
|
else if (k === "Escape") k = "esc";
|
||||||
else if (k.length === 1) {
|
else if (k.length === 1) {
|
||||||
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
|
// Symbols encode Shift in the character itself (e.g. "#", "!").
|
||||||
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
|
k = shiftedLetter || !e.shiftKey ? k.toLowerCase() : k;
|
||||||
} else k = k.toLowerCase();
|
} else k = k.toLowerCase();
|
||||||
parts.push(k);
|
parts.push(k);
|
||||||
return parts.join("+");
|
return parts.join("+");
|
||||||