Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -3,4 +3,7 @@ node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
server/data
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Thanks for contributing to ihasmail. CONTRIBUTING.md has the full guide;
|
||||
this is the short version. Delete any section that does not apply.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changes, and why. -->
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- e.g. Closes #123. Leave blank if there are none. -->
|
||||
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
- Adds or alters user-visible strings: how many keys, and the fallback
|
||||
count before and after.
|
||||
- Adds none.
|
||||
|
||||
"Adds none" is an answer. Saying nothing is not -- it leaves it to be
|
||||
inferred. See CONTRIBUTING.md -> Translations.
|
||||
-->
|
||||
|
||||
## Testing
|
||||
|
||||
<!--
|
||||
What you ran, and what you saw. `npm run typecheck`, `npm test` and
|
||||
`npm run build` all run in CI, so the useful thing here is what CI cannot
|
||||
do: which flows you exercised by hand, and against what -- a real Stalwart
|
||||
instance, or `npm run dev:mock`.
|
||||
|
||||
If the change is visible on screen, drive the built app, not just the
|
||||
store. See CONTRIBUTING.md -> Verifying UI work.
|
||||
-->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For UI changes. Before/after, or a GIF for anything with motion. -->
|
||||
@@ -15,8 +15,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||
# the release it belongs to in the trailing comment, and the repository
|
||||
# requires it -- an unpinned ref fails the run rather than quietly
|
||||
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
|
||||
# last moved it to, so trusting one is trusting every future version of
|
||||
# that action, including the one pushed by whoever compromises the
|
||||
# account. Read the comment for the version; the SHA is what runs.
|
||||
#
|
||||
# Dependabot updates both halves together on its weekly github-actions
|
||||
# run, so this costs nothing to keep current -- do not "simplify" a pin
|
||||
# back to a tag.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -39,11 +39,12 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
# Pinned to a commit rather than a moving major tag. This action is
|
||||
# handed `packages: write` and its whole job is deletion, so a tag
|
||||
# repointed at something else -- by a compromise or a mistake upstream --
|
||||
# is a bad day. v1.2.2.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
|
||||
# The only third-party action here that is not published by GitHub or
|
||||
# Docker, and the one with the most to lose: it is handed
|
||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||
# day. It was pinned to a commit long before the rest of them were.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||
with:
|
||||
owner: Coffey-Labs
|
||||
package: ihasmail
|
||||
|
||||
@@ -76,11 +76,11 @@ jobs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: v
|
||||
@@ -108,18 +108,18 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -54,11 +54,11 @@ jobs:
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: decide
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
server/data/
|
||||
|
||||
@@ -58,6 +58,12 @@ check has passed — not afterwards — and the branch cannot be force-pushed or
|
||||
deleted. No approving review is required, so a PR of your own is not blocked
|
||||
waiting for one.
|
||||
|
||||
**CI on a PR from a fork waits to be approved.** Every workflow run on an
|
||||
outside contributor's branch sits at *awaiting approval* until a maintainer
|
||||
starts it by hand, so the **build** check will not appear the moment you open
|
||||
the PR — that is the gate working, not a broken run. Pushing again will not
|
||||
start it, and neither will closing and reopening.
|
||||
|
||||
### Code Style
|
||||
|
||||
- Match the existing formatting and naming conventions used elsewhere in the codebase.
|
||||
|
||||
+18
-5
@@ -1414,8 +1414,13 @@ server settings is deliberately out of scope.
|
||||
# Platform
|
||||
|
||||
- **Installable PWA** with a service worker: the app shell is cached for
|
||||
installability and fast loads, API requests never are, and navigations are
|
||||
network-first with the shell as fallback.
|
||||
installability and fast loads, API requests never are. An app route is
|
||||
answered from the kept shell at once while a fresh copy is fetched behind it;
|
||||
a shell a build behind is caught by the version check at start and reloaded.
|
||||
After a new version is seen, the rest of its code (composer, settings,
|
||||
viewers) is fetched in the background, so opening them later does not wait on
|
||||
the server; language catalogs are cached when first used, and nothing is
|
||||
fetched ahead when the browser is set to save data.
|
||||
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
||||
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
||||
notification opened while ihasmail is already running arrives in the copy
|
||||
@@ -1545,15 +1550,23 @@ costs something to get wrong is the one that assumes the machine is yours.
|
||||
| --- | --- | --- |
|
||||
| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) |
|
||||
| Idle sign-out | after 5 minutes | none |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) |
|
||||
| Background notifications | refused | available |
|
||||
| Administration | unavailable | available, if the role allows it |
|
||||
|
||||
Local storage is gated on that answer for **reads** as well as writes — a
|
||||
machine trusted once still has residue, and honoring it would let a previous
|
||||
session's data surface in a later untrusted one. Signing out clears the settings
|
||||
cache and recent addresses and tears down the push subscription, whichever
|
||||
answer was given.
|
||||
cache, recent addresses and kept folder list, and tears down the push
|
||||
subscription, whichever answer was given.
|
||||
|
||||
What a ticked device keeps is what makes it **start quickly on a distant
|
||||
link**: once the server has confirmed the session, the folders and the inbox
|
||||
paint from the kept copy straight away, and the request for the open folder
|
||||
goes out without waiting on the folder list first. The server's answers
|
||||
replace the copy a round trip later. **Nothing kept is shown before the session
|
||||
is confirmed** — until then the app shows a spinner, so a session that has
|
||||
ended goes from the spinner to the sign-in form and never past a mailbox.
|
||||
|
||||
The idle timer exists because the alternative does not work: `beforeunload` text
|
||||
was removed from browsers years ago, and **no event fires at all** for walking
|
||||
|
||||
@@ -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
|
||||
[`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:
|
||||
|
||||
- **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)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
|
||||
Taken against the built-in mock with sample data. More, including the phone
|
||||
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
|
||||
+14
-1
@@ -9,8 +9,21 @@ services:
|
||||
BASE_PATH: ${BASE_PATH:-}
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
# Loopback only: ihasmail expects a TLS reverse proxy in front of it. On
|
||||
# every interface the app is reachable over plain HTTP, passwords and all,
|
||||
# and with TRUST_PROXY any machine on a private network can set its own
|
||||
# X-Forwarded-For. A proxy running in Docker can reach the service by name
|
||||
# on the compose network and needs no published port at all.
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# The app needs no privileges and writes only to /data and /tmp.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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`);
|
||||
File renamed without changes.
@@ -5,8 +5,8 @@
|
||||
* images already use rather than whatever a window happens to be.
|
||||
*
|
||||
* npm run dev:mock # in another terminal
|
||||
* node docs/screenshots.mjs docs/screenshots
|
||||
* node docs/screenshots-light.mjs docs/screenshots
|
||||
* node scripts/screenshots.mjs screenshots
|
||||
* node scripts/screenshots-light.mjs screenshots
|
||||
*
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
@@ -30,7 +30,7 @@
|
||||
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
||||
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
||||
* at --window-size and never calling setDeviceMetricsOverride renders it
|
||||
* correctly, which is what docs/screenshots-light.mjs does.
|
||||
* correctly, which is what scripts/screenshots-light.mjs does.
|
||||
*
|
||||
* assertTheme() stays either way: without it this script wrote a dark
|
||||
* screenshot under a light caption and reported success, and that is how the
|
||||
@@ -226,7 +226,7 @@ try {
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
// (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
|
||||
// (inbox-light is captured by scripts/screenshots-light.mjs -- see the header)
|
||||
|
||||
|
||||
// --- calendar ---
|
||||
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||
assert.ok(created.body.id);
|
||||
@@ -84,8 +84,86 @@ test("app passwords are created, listed once with their secret, and revoked", as
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("an app password needs the account password", async () => {
|
||||
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.error, "missing_fields");
|
||||
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.equal(wrong.body.error, "invalid_credentials");
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
|
||||
});
|
||||
|
||||
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
|
||||
// Signed in without "my own device", so the proxy reads every request.
|
||||
const res = await call("/api/jmap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
|
||||
assert.equal(up.status, 200);
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
|
||||
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store");
|
||||
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
test("a download passes a byte range through, for viewers that read in pieces", async () => {
|
||||
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
|
||||
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
|
||||
assert.equal(part.status, 206);
|
||||
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
|
||||
assert.equal(part.headers.get("accept-ranges"), "bytes");
|
||||
assert.equal(await part.text(), "hello");
|
||||
const whole = await app.request(url, { headers: { cookie } });
|
||||
assert.equal(whole.status, 200);
|
||||
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
|
||||
assert.equal(await whole.text(), "hello world");
|
||||
// Past the end, Stalwart sends the whole file rather than a 416.
|
||||
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
|
||||
assert.equal(beyond.status, 200);
|
||||
assert.equal(await beyond.text(), "hello world");
|
||||
// Anything that is not a plain byte range is not passed on.
|
||||
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
|
||||
assert.equal(odd.status, 200);
|
||||
await odd.arrayBuffer();
|
||||
});
|
||||
|
||||
test("upstream caches let go of sessions that have aged out", async () => {
|
||||
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
|
||||
// Signed in above, so this session has an entry.
|
||||
assert.ok(upstreamCacheSizes().sessions >= 1);
|
||||
sweepUpstreamCaches(Date.now() + 60 * 60_000);
|
||||
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
||||
});
|
||||
|
||||
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
|
||||
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
|
||||
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
|
||||
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
|
||||
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
|
||||
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
|
||||
} }, "s"]]);
|
||||
assert.equal(res.status, 200);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
|
||||
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
|
||||
assert.ok(set.created.inline.id, "a data URI is accepted");
|
||||
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
|
||||
});
|
||||
|
||||
test("an app password needs a name", async () => {
|
||||
const res = await post("/api/account/app-passwords", { description: " " });
|
||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error, "missing_fields");
|
||||
});
|
||||
|
||||
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test("the account's own registry objects pass", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||
test("the account's own registry objects can be read", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
|
||||
});
|
||||
|
||||
test("but not written: a credential minted here would outlive a borrowed session", () => {
|
||||
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
|
||||
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
|
||||
}
|
||||
});
|
||||
|
||||
test("API keys are not the account's to reach from here at all", () => {
|
||||
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
|
||||
});
|
||||
|
||||
test("directory and server objects are refused, and named", () => {
|
||||
|
||||
+10
-3
@@ -18,7 +18,7 @@
|
||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||
* touched: they act on what the account can already reach.
|
||||
*/
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
|
||||
|
||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||
|
||||
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
|
||||
const name = Array.isArray(call) ? call[0] : undefined;
|
||||
if (typeof name !== "string") return { ok: false, method: null };
|
||||
if (!name.startsWith("x:")) continue;
|
||||
const object = name.slice(2).split("/")[0] ?? "";
|
||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||
const [object = "", op = ""] = name.slice(2).split("/");
|
||||
/*
|
||||
* Read, never write. The browser sends none of these itself -- password,
|
||||
* app-password and 2FA changes go through /api/account, which checks the
|
||||
* account password first -- so a write here could only come from
|
||||
* somebody working the console of a session on a borrowed machine, and
|
||||
* `x:AppPassword/set` would hand them a credential that outlives it.
|
||||
*/
|
||||
if (!SELF_SERVICE.has(object) || op === "set") return { ok: false, method: name };
|
||||
}
|
||||
return { ok: true, body: JSON.stringify(parsed) };
|
||||
}
|
||||
+177
-26
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { compress } from "hono/compress";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { fetchPermissions } from "./permissionSchema.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||
import { safeEqual } from "./crypto.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
@@ -209,6 +211,22 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* The largest body an API route that reads JSON will take.
|
||||
*
|
||||
* Hono reads a JSON body whole, and before this nothing bounded it: a few
|
||||
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
|
||||
* run the process out of memory, and a restart signs everybody out. What
|
||||
* these routes actually receive is a username and password, or a code.
|
||||
*
|
||||
* JMAP and uploads carry real payloads and bound themselves as they stream;
|
||||
* the push callback has its own limit ahead of this one.
|
||||
*/
|
||||
const MAX_SMALL_BODY = 64 * 1024;
|
||||
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
|
||||
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
|
||||
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
|
||||
|
||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
@@ -269,6 +287,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
const api = new Hono<Env>();
|
||||
api.use("*", csrfGuard);
|
||||
api.use("*", smallBodies);
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
||||
|
||||
@@ -303,6 +322,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// ---------- Auth ----------
|
||||
api.post("/auth/login", async (c) => {
|
||||
const ip = clientIp(c);
|
||||
// What the limits count under: the address, or its /64 for IPv6.
|
||||
const rateIp = rateLimitKey(ip);
|
||||
// The flood ceiling needs nothing from the body, so it goes before reading one.
|
||||
if (!loginFloodLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
@@ -318,7 +344,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
/*
|
||||
* Three checks, answering different questions.
|
||||
*
|
||||
* `limitKey` is this username from this address, and `ip` is any username
|
||||
* `limitKey` is this username from this address, and `rateIp` is any username
|
||||
* from it -- both guard guessing, and both are given back when the upstream
|
||||
* never got as far as judging the password. Refunding only the first would
|
||||
* not fix #239: ten retries through an outage would still spend the address
|
||||
@@ -328,12 +354,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
* The flood ceiling is the one that is never refunded, and it is the reason
|
||||
* the other two safely can be.
|
||||
*/
|
||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
||||
if (!loginFloodLimiter.check(ip)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
||||
const limitKey = `${rateIp}|${username.toLowerCase()}`;
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
@@ -351,7 +373,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// The credentials were accepted; only the server is too old. Not an
|
||||
// attempt worth counting against them.
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
@@ -364,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||
password: effectivePassword,
|
||||
remember: Boolean(body.remember),
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
@@ -411,7 +434,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
if (!(err instanceof UpstreamError && err.status === 401)) {
|
||||
loginLimiter.refund(limitKey);
|
||||
loginLimiter.refund(ip);
|
||||
loginLimiter.refund(rateIp);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -445,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
api.get("/auth/sessions", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
|
||||
});
|
||||
|
||||
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
const n = sessions.destroyAllForUser(session.username, session.id);
|
||||
const n = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ revoked: n });
|
||||
});
|
||||
|
||||
@@ -478,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||
@@ -517,7 +540,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const otpCode = body.otpCode?.trim();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -531,12 +554,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* An app password is a credential that outlives this session, a password
|
||||
* change and a sign-out -- so minting one asks for the account password, as
|
||||
* changing the password does. Otherwise a session left open on somebody
|
||||
* else's machine is enough to take a permanent key away from it.
|
||||
*/
|
||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
|
||||
const limited = guarded(c, "app-password");
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ description?: string }>(c);
|
||||
const body = await readJson<{ description?: string; current?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
const current = body.current ?? "";
|
||||
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
|
||||
if (!(await confirmsPassword(session, current))) {
|
||||
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
|
||||
}
|
||||
try {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
@@ -611,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// Other sessions still hold the bare password and will be refused.
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
@@ -648,12 +685,27 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
*/
|
||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
const held = gatedReads.get(session.id) ?? 0;
|
||||
if (held >= MAX_GATED_PER_SESSION) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "rate_limited" }, 429);
|
||||
}
|
||||
gatedReads.set(session.id, held + 1);
|
||||
let raw: string;
|
||||
try {
|
||||
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||
} catch {
|
||||
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
|
||||
} catch (err) {
|
||||
if (err instanceof GatedBudgetError) {
|
||||
c.header("Retry-After", "1");
|
||||
return c.json({ error: "busy" }, 503);
|
||||
}
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
} finally {
|
||||
const left = (gatedReads.get(session.id) ?? 1) - 1;
|
||||
if (left > 0) gatedReads.set(session.id, left);
|
||||
else gatedReads.delete(session.id);
|
||||
}
|
||||
const gate = gateAdministration(raw);
|
||||
if (!gate.ok) {
|
||||
@@ -751,23 +803,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||
// A PDF viewer or a video element asks for pieces; pass that on. A server
|
||||
// that ignores it answers with the whole file, as it did before.
|
||||
const range = c.req.header("range");
|
||||
const res = await fetch(url, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (res.status === 416) return c.body(null, 416);
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const partial = res.status === 206 && res.headers.get("content-range");
|
||||
if (partial) headers.set("Content-Range", partial);
|
||||
/*
|
||||
* Said here because Stalwart does not say it. It honors a single byte
|
||||
* range but sends no `Accept-Ranges` (0.16.22, checked live on
|
||||
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
|
||||
* the first response advertises it. A server that ignores a range sends
|
||||
* the whole file, which the browser takes just as well.
|
||||
*/
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||
@@ -786,8 +852,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} else {
|
||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||
}
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
// Kept out of the browser's disk cache on a device that is not the
|
||||
// person's own: signing out wipes what the app stores, not that.
|
||||
/*
|
||||
* A blob id names its content -- the same id is the same bytes for good
|
||||
* -- so on the reader's own device there is nothing to revalidate. On
|
||||
* anyone else's, nothing is left in the disk cache at all.
|
||||
*/
|
||||
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
|
||||
return new Response(res.body, { status: partial ? 206 : 200, headers });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -872,6 +945,43 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `candidate` the password of the account this session is signed in to?
|
||||
*
|
||||
* Compared with the credential the session holds first, which costs nothing
|
||||
* and tells Stalwart nothing -- its auto-ban counts failures against the
|
||||
* proxy's address, which every user shares. That credential is the password,
|
||||
* with a TOTP code after a `$` when one was given at sign-in. A session that
|
||||
* turning on 2FA moved onto an app password (Stalwart's secrets start
|
||||
* `$app$`) holds something else, and only then is the candidate put to the
|
||||
* server.
|
||||
*/
|
||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
||||
const held = decoded.slice(decoded.indexOf(":") + 1);
|
||||
if (safeEqual(held, candidate)) return true;
|
||||
const withoutCode = held.replace(/\$\d{6,8}$/, "");
|
||||
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
|
||||
// Holding the password, the comparison above is the answer, and a wrong
|
||||
// guess never reaches the server's auto-ban.
|
||||
if (!held.startsWith("$app$")) return false;
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
||||
* read as a PDF in the downloads list. A filename has no use for them.
|
||||
*/
|
||||
function withoutBidiControls(name: string): string {
|
||||
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
|
||||
}
|
||||
|
||||
/** Name the app password after the browser it will live in. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
@@ -931,9 +1041,50 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
*/
|
||||
/**
|
||||
* The largest JMAP request read into memory for the administration check.
|
||||
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||
*
|
||||
* Only sessions that may not administer come this way, and what the client
|
||||
* sends is small: attachments and pasted images go through `/upload`, and the
|
||||
* composer turns inline images into uploads before a draft is saved. Stalwart
|
||||
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
|
||||
* held here as a string, parsed and serialized again, so each one costs
|
||||
* several times its size; 4 MB is far past anything the client sends.
|
||||
*/
|
||||
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||
const MAX_GATED_REQUEST = 4 * 1024 * 1024;
|
||||
/**
|
||||
* How many checked requests one session may have in flight at once. Matches
|
||||
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
|
||||
* already stays within.
|
||||
*/
|
||||
const MAX_GATED_PER_SESSION = 4;
|
||||
/**
|
||||
* The bytes all checked requests together may hold at once. Counted as they
|
||||
* arrive rather than reserved up front, so a slow body that has sent little
|
||||
* holds little, and a burst of large ones is turned away with a 503 instead of
|
||||
* taking the process down.
|
||||
*/
|
||||
const GATED_BUDGET = 32 * 1024 * 1024;
|
||||
const gatedReads = new Map<string, number>();
|
||||
let gatedBytes = 0;
|
||||
|
||||
class GatedBudgetError extends Error {}
|
||||
|
||||
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
let mine = 0;
|
||||
const counted = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
mine += chunk.byteLength;
|
||||
gatedBytes += chunk.byteLength;
|
||||
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
|
||||
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await new Response(stream.pipeThrough(counted)).text();
|
||||
} finally {
|
||||
gatedBytes -= mine;
|
||||
}
|
||||
}
|
||||
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
|
||||
@@ -97,3 +97,21 @@ export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: Trus
|
||||
const real = headers.realIp?.trim();
|
||||
return real && isIP(real) !== 0 ? real : peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a rate limit counts an address under.
|
||||
*
|
||||
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
|
||||
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
|
||||
* address holds 2^64 of them, and a limit keyed on the full address is no
|
||||
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
|
||||
* IPv4 NAT already makes.
|
||||
*/
|
||||
export function rateLimitKey(ip: string): string {
|
||||
if (isIP(ip) !== 6) return ip;
|
||||
const bits = toBits(ip);
|
||||
if (!bits) return ip;
|
||||
const prefix = bits.value >> 64n;
|
||||
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
|
||||
return `${groups.join(":")}::/64`;
|
||||
}
|
||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
// As for attachments: nothing left in the disk cache of a device that is not the person's own.
|
||||
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { account } from "./config.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
|
||||
/* Shared by the HTTP layer and by the handlers that re-check a code. */
|
||||
export function checkOtp(code: string | undefined): boolean {
|
||||
if (!account.otpUrl) return true;
|
||||
const params = parseOtpauthUrl(account.otpUrl);
|
||||
return Boolean(code && params && verifyTotp(params, code));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
|
||||
export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions;
|
||||
|
||||
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
*/
|
||||
export const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
/**
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honors it when
|
||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||
* off, in which case the hold is dropped without a word and the message goes
|
||||
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
||||
*/
|
||||
export const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||
export const MAX_DELAYED_SEND = 86400 * 30;
|
||||
export const ACCOUNT = "a1";
|
||||
/** How long a push subscription lives before the server drops it. */
|
||||
export const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** An account somebody has shared with the demo user. See the session below. */
|
||||
export const SHARED_ACCOUNT = "a2";
|
||||
export const SHARED_CAPS: Obj = {
|
||||
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||
};
|
||||
export const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
export const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
|
||||
export const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
|
||||
export const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
/**
|
||||
* Credential state, mutable so the self-service flows can be exercised against
|
||||
* the mock the way they run against a real 0.16 server: the password changes,
|
||||
* 2FA starts demanding a code on every request, and app passwords keep working
|
||||
* without one.
|
||||
*/
|
||||
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
|
||||
export const MASKED = "[********]";
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
export const state = { n: 1 };
|
||||
export const nextState = () => String(state.n++);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
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: [],
|
||||
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 }],
|
||||
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 }] : [],
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
+34
-1363
File diff suppressed because it is too large.
Load diff
@@ -133,3 +133,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
|
||||
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("a new subscription clears what this installation left behind, and only that", async () => {
|
||||
// What a restart finds: its own subscription from the last process, another
|
||||
// installation's on the same server, a browser's, and the old id format.
|
||||
const calls: Array<[string, Record<string, unknown>]> = [];
|
||||
let ownPrefix = "";
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const [name, args, id] = methodCalls[0]!;
|
||||
calls.push([name, args]);
|
||||
let result: Record<string, unknown> = {};
|
||||
if (name === "PushSubscription/get") {
|
||||
result = { list: [
|
||||
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
|
||||
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
|
||||
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
|
||||
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
|
||||
] };
|
||||
} else if (name === "PushSubscription/set" && args.create) {
|
||||
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
|
||||
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
|
||||
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
|
||||
} else {
|
||||
result = { destroyed: args.destroy };
|
||||
}
|
||||
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||
push.prepare("[email protected]", "a", "Basic p");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
|
||||
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||
|
||||
calls.length = 0;
|
||||
push.prepare("[email protected]", "a", "Basic r");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
});
|
||||
+57
-4
@@ -23,7 +23,7 @@
|
||||
* transition loses no events, because a tab opened before verification keeps
|
||||
* its own relay for its whole life.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
||||
@@ -71,10 +71,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
||||
}
|
||||
|
||||
/*
|
||||
* Whose subscriptions are whose.
|
||||
*
|
||||
* Each process used to register a subscription per account and forget it when
|
||||
* it stopped -- state here is in memory, and an immutable deployment restarts
|
||||
* on every deploy -- so each restart left one more behind, receiving 404s until
|
||||
* it expired. Stalwart keeps them all and allows fifteen per account (checked
|
||||
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
|
||||
* too (#375).
|
||||
*
|
||||
* So the device id names the installation -- a hash of the address Stalwart
|
||||
* posts to, stable across restarts and different for another installation on
|
||||
* the same server -- and a new subscription first removes the ones this
|
||||
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
|
||||
* the browsers' own, which the web client may clear to make room.
|
||||
*/
|
||||
function installationId(): string {
|
||||
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
|
||||
}
|
||||
|
||||
function deviceIdFor(entry: AccountPush): string {
|
||||
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
async function removeLeftovers(entry: AccountPush) {
|
||||
const mine = `ihasmail-proxy-${installationId()}-`;
|
||||
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
|
||||
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
|
||||
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
|
||||
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
|
||||
}
|
||||
|
||||
/** Give the live subscription another week, rather than registering a second one. */
|
||||
async function renew(entry: AccountPush) {
|
||||
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
|
||||
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
|
||||
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
|
||||
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
|
||||
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
|
||||
entry.expires = after ? Date.parse(after) : Date.parse(expires);
|
||||
}
|
||||
|
||||
async function subscribe(entry: AccountPush) {
|
||||
try {
|
||||
await removeLeftovers(entry);
|
||||
} catch (err) {
|
||||
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
|
||||
}
|
||||
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||
const r = await jmap(entry, [["PushSubscription/set", {
|
||||
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
|
||||
create: { s: { deviceClientId: deviceIdFor(entry), url,
|
||||
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||
}, "0"]]);
|
||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
||||
@@ -184,8 +232,13 @@ function startSweeper() {
|
||||
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
||||
}
|
||||
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||
entry.state = "pending"; entry.since = now;
|
||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||
// Extended in place, which keeps it verified. Only if the server will
|
||||
// not is a new one registered, and that one has to verify again.
|
||||
entry.expires = now + RENEW_BEFORE_MS;
|
||||
renew(entry).catch(() => {
|
||||
entry.state = "pending"; entry.since = Date.now();
|
||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||
});
|
||||
}
|
||||
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||
void unsubscribe(entry);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* How much a request may make the proxy hold in memory.
|
||||
*
|
||||
* Routes that read JSON take a small body and no more, whether or not anyone
|
||||
* is signed in. The JMAP route streams straight through for a session that may
|
||||
* administer; for one that may not, it reads the body to check it, and that
|
||||
* read is capped in size, in how many one session runs at once, and in bytes
|
||||
* across everyone.
|
||||
*/
|
||||
|
||||
const PORT = 18813;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-request-limits";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { rateLimitKey } = await import("./clientip.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
let cookie = "";
|
||||
|
||||
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
|
||||
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent >= size) return controller.close();
|
||||
const n = Math.min(chunk, size - sent);
|
||||
controller.enqueue(new Uint8Array(n).fill(0x20));
|
||||
sent += n;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const jmap = (body: BodyInit) =>
|
||||
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
|
||||
|
||||
before(async () => {
|
||||
// Not remembered: a device that is not the person's own, so JMAP is checked.
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||
assert.equal(res.status, 200, "login should succeed against the mock");
|
||||
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("sign-in refuses a large body by its length, before reading it", async () => {
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("sign-in refuses a large chunked body without holding all of it", async () => {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("other JSON routes are limited too", async () => {
|
||||
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test("an ordinary checked JMAP request still goes through", async () => {
|
||||
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than the check allows is refused", async () => {
|
||||
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
|
||||
});
|
||||
|
||||
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
|
||||
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
|
||||
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
|
||||
assert.equal((await jmap(body)).status, 200);
|
||||
});
|
||||
|
||||
test("one session cannot hold more than a few checked reads at once", async () => {
|
||||
// Bodies that never finish: each holds its slot until its stream fails.
|
||||
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
||||
const pending: Promise<Response>[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
|
||||
pending.push(jmap(s));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const fifth = await jmap("{}");
|
||||
assert.equal(fifth.status, 429);
|
||||
assert.ok(fifth.headers.get("retry-after"));
|
||||
for (const c of controllers) c.error(new Error("client went away"));
|
||||
await Promise.allSettled(pending);
|
||||
// The slots are given back once those requests end.
|
||||
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
|
||||
assert.equal(again.status, 200);
|
||||
});
|
||||
|
||||
test("IPv6 addresses share a rate-limit key across their /64", () => {
|
||||
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
|
||||
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
|
||||
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
|
||||
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
|
||||
assert.equal(rateLimitKey("unknown"), "unknown");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { SessionStore, accountKey } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
|
||||
assert.equal(store.resolve(cookie), null);
|
||||
});
|
||||
|
||||
test("sessions group by the account, however its name was typed", () => {
|
||||
const store = new SessionStore("");
|
||||
const key = accountKey("https://mail.example.com", "[email protected]");
|
||||
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
// The same name on another configured server is another account.
|
||||
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||
assert.equal(a.session.account, b.session.account);
|
||||
assert.equal(store.listForUser(a.session.account).length, 2);
|
||||
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
|
||||
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
|
||||
assert.ok(store.resolve(a.cookie), "this session was kept");
|
||||
});
|
||||
|
||||
test("persisted session data does not contain the password", () => {
|
||||
const store = new SessionStore("");
|
||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||
|
||||
+33
-7
@@ -13,6 +13,8 @@ export interface StoredSession {
|
||||
/** sealed JSON {username, password} */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
account?: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
@@ -24,6 +26,8 @@ export interface StoredSession {
|
||||
export interface LiveSession {
|
||||
id: string;
|
||||
username: string;
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
authorization: string;
|
||||
remember: boolean;
|
||||
@@ -46,8 +50,27 @@ export interface SessionSummary {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key sessions are grouped by for "sign out everywhere else".
|
||||
*
|
||||
* Not the username as typed: Stalwart takes `[email protected]` and a bare
|
||||
* `alice` as the same account, and a session opened either way was missing
|
||||
* from the list and survived the sign-out. The server's own name for the
|
||||
* account, lower-cased, and the server it lives on -- the same name on two
|
||||
* configured servers is two accounts.
|
||||
*/
|
||||
export function accountKey(upstream: string, canonicalUsername: string): string {
|
||||
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function accountOf(s: StoredSession): string {
|
||||
return s.account ?? s.username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
@@ -85,8 +108,9 @@ export interface SessionBackend {
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
listForUser(account: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: now + ttl,
|
||||
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
destroyAllForUser(username: string, exceptId?: string): number {
|
||||
destroyAllForUser(account: string, exceptId?: string): number {
|
||||
let n = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.username === username && id !== exceptId) {
|
||||
if (accountOf(s) === account && id !== exceptId) {
|
||||
this.sessions.delete(id);
|
||||
n++;
|
||||
}
|
||||
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
listForUser(account: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
||||
if (accountOf(s) !== account) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
account: accountOf(s),
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
/*
|
||||
* The bundle goes out compressed once, at build time, and anything revalidated
|
||||
* can be answered with a 304.
|
||||
*
|
||||
* Before this the server gzipped the bundle again for every request that asked,
|
||||
* never offered Brotli, and sent no validator for the shell -- so each reload's
|
||||
* revalidation of index.html and sw.js downloaded them in full.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
|
||||
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
|
||||
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
|
||||
// A copy left over from an older build of the same name must not be served.
|
||||
writeFileSync(join(root, "assets", "stale-000000.js"), js);
|
||||
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
|
||||
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
const app = createApp();
|
||||
|
||||
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
|
||||
|
||||
test("Brotli is served where the browser takes it", async () => {
|
||||
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "br");
|
||||
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
||||
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
|
||||
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
|
||||
assert.equal(gz.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
|
||||
const plain = await get("/assets/app-a1b2c3.js");
|
||||
assert.equal(plain.headers.get("content-encoding"), null);
|
||||
assert.equal(await plain.text(), js);
|
||||
});
|
||||
|
||||
test("a file without a copy is compressed as before", async () => {
|
||||
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||
});
|
||||
|
||||
test("a copy older than its file is ignored", async () => {
|
||||
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
|
||||
assert.notEqual(res.headers.get("content-encoding"), "br");
|
||||
});
|
||||
|
||||
test("the shell and the worker answer a revalidation with 304", async () => {
|
||||
for (const path of ["/", "/sw.js"]) {
|
||||
const first = await get(path);
|
||||
const etag = first.headers.get("etag");
|
||||
assert.ok(etag, `${path} carries a validator`);
|
||||
await first.arrayBuffer();
|
||||
const again = await get(path, { "if-none-match": etag! });
|
||||
assert.equal(again.status, 304, `${path} is not sent again`);
|
||||
assert.equal(await again.text(), "");
|
||||
const changed = await get(path, { "if-none-match": `"something-else"` });
|
||||
assert.equal(changed.status, 200);
|
||||
}
|
||||
});
|
||||
+76
-4
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
/*
|
||||
* What a file is, for the purpose of "has it changed". The shell and the
|
||||
* never-stale files are revalidated on every load; with no validator to send
|
||||
* back, every revalidation downloaded the whole file again.
|
||||
*/
|
||||
function etagOf(size: number, mtimeMs: number): string {
|
||||
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
|
||||
}
|
||||
|
||||
function notModified(c: Context, etag: string): boolean {
|
||||
const sent = c.req.header("if-none-match");
|
||||
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
|
||||
}
|
||||
|
||||
/*
|
||||
* The encodings a build can carry beside a file, best first. See
|
||||
* scripts/precompress.mjs, which writes them.
|
||||
*/
|
||||
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
|
||||
{ token: "br", suffix: ".br", encoding: "br" },
|
||||
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
|
||||
];
|
||||
|
||||
function accepts(c: Context, token: string): boolean {
|
||||
const header = c.req.header("accept-encoding") ?? "";
|
||||
return header.split(",").some((part) => {
|
||||
const [name, ...params] = part.trim().split(";");
|
||||
if (name?.trim().toLowerCase() !== token) return false;
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
||||
return !q || Number(q.slice(2)) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
let indexCache: { body: string; mtime: number; etag: string } | null = null;
|
||||
/** Which precompressed copies exist, per file and modification time. */
|
||||
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
|
||||
|
||||
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
|
||||
const known = variants.get(filePath);
|
||||
if (known && known.mtime === mtime) return known.found;
|
||||
const found = new Map<string, number>();
|
||||
for (const v of PRECOMPRESSED) {
|
||||
try {
|
||||
const st = await stat(filePath + v.suffix);
|
||||
// A copy older than the file it came from describes something else.
|
||||
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
variants.set(filePath, { mtime, found });
|
||||
return found;
|
||||
}
|
||||
let mismatchWarned = false;
|
||||
|
||||
/**
|
||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const p = join(absRoot, "index.html");
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
const body = await readFile(p, "utf8");
|
||||
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
|
||||
mismatchWarned = false;
|
||||
}
|
||||
warnOnBaseMismatch(indexCache.body);
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
c.header("ETag", indexCache.etag);
|
||||
if (notModified(c, indexCache.etag)) return c.body(null, 304);
|
||||
return c.body(indexCache.body);
|
||||
} catch {
|
||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
if (!st.isFile()) return serveIndex(c);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||
c.header("Content-Length", String(st.size));
|
||||
const etag = etagOf(st.size, st.mtimeMs);
|
||||
c.header("ETag", etag);
|
||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
} else {
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
if (notModified(c, etag)) return c.body(null, 304);
|
||||
// Serve a copy made at build time where the browser takes one.
|
||||
let servePath = filePath;
|
||||
let size = st.size;
|
||||
const found = await variantsOf(filePath, st.mtimeMs);
|
||||
if (found.size) {
|
||||
c.header("Vary", "Accept-Encoding");
|
||||
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
|
||||
if (pick) {
|
||||
servePath = filePath + pick.suffix;
|
||||
size = found.get(pick.suffix)!;
|
||||
c.header("Content-Encoding", pick.encoding);
|
||||
}
|
||||
}
|
||||
c.header("Content-Length", String(size));
|
||||
if (c.req.method === "HEAD") return c.body(null);
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
||||
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||
return c.body(stream);
|
||||
} catch {
|
||||
// SPA fallback for client-side routes (no file extension) only.
|
||||
|
||||
@@ -233,6 +233,23 @@ export interface AccountInfo {
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
|
||||
/*
|
||||
* Both caches are keyed by session, and used to lose an entry only when that
|
||||
* session signed out or was refused -- not when it simply expired, which is how
|
||||
* most sessions end. An entry past its age is never used again, so dropping
|
||||
* those on a timer is all it takes to stop them accumulating.
|
||||
*/
|
||||
export function sweepUpstreamCaches(now = Date.now()): void {
|
||||
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
|
||||
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
|
||||
}
|
||||
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
|
||||
|
||||
/** How many sessions the caches hold; for tests. */
|
||||
export function upstreamCacheSizes(): { sessions: number; info: number } {
|
||||
return { sessions: sessionCache.size, info: infoCache.size };
|
||||
}
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
|
||||
+205
-12
@@ -18,18 +18,169 @@ const VERSION = "ihasmail-v2";
|
||||
* eventually would.
|
||||
*/
|
||||
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
||||
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
||||
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
||||
|
||||
/*
|
||||
* Only the app page may be kept as the app page.
|
||||
*
|
||||
* The mount's root is not always the app: demo.ihasmail.com puts its landing
|
||||
* page there, and a front door of any kind can. The worker used to cache
|
||||
* whatever `/` returned at install and whatever HTML a navigation returned,
|
||||
* and since app routes are answered from that copy first, a demo visitor who
|
||||
* came back got the landing page on every route, for good. The app page is
|
||||
* recognised by the asset list the build writes into it.
|
||||
*/
|
||||
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
|
||||
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
|
||||
|
||||
/*
|
||||
* The routes the app itself owns (App.tsx). Only these are answered from the
|
||||
* kept page; anything else under the mount -- the root, a landing or farewell
|
||||
* page in front of the app, a file -- goes to the network as it always did.
|
||||
*/
|
||||
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((c) => c.addAll(SHELL))
|
||||
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
||||
.then(() => dropForeignShell())
|
||||
.then(() => tidy())
|
||||
.catch(() => {})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* Keeping the cache to what the current build uses.
|
||||
*
|
||||
* Build assets are cached on first use and their names change with every
|
||||
* build, and nothing used to take them out again: every deploy's chunks stayed
|
||||
* in the browser for good. Worse, whatever the server answered was kept -- a
|
||||
* 404 for a chunk asked for while a deploy was changing over became that
|
||||
* chunk, from then on, in that browser.
|
||||
*
|
||||
* The rule now: only a successful response is cached, and whenever the app
|
||||
* page changes, the assets it no longer names are dropped. A lazily loaded
|
||||
* chunk the page does not name is dropped too, and fetched again the next time
|
||||
* it is wanted -- a hash that did not change is still on the server.
|
||||
*
|
||||
* The cache name stays as it is. The same cache carries what the worker leaves
|
||||
* for a tab to collect -- a push verification, a share, the facts it notifies
|
||||
* from -- and a new name would throw those away along with the rubbish.
|
||||
*/
|
||||
const ASSETS = `${BASE}/assets/`;
|
||||
const SHELL_KEY = `${BASE}/`;
|
||||
|
||||
function assetsNamedIn(html) {
|
||||
const out = new Set();
|
||||
for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
|
||||
try {
|
||||
out.add(new URL(m[1], self.location).pathname);
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop failed responses, and assets the cached app page does not name. `also`
|
||||
* is a page whose assets are kept as well: the one just replaced, which a tab
|
||||
* opened from the kept copy may still be running.
|
||||
*/
|
||||
async function tidy(also = "") {
|
||||
const cache = await caches.open(VERSION);
|
||||
const shell = await cache.match(SHELL_KEY);
|
||||
// Without a page to go by, which assets are current is unknown; keep them.
|
||||
const keep = shell ? assetsNamedIn(await shell.text()) : null;
|
||||
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
|
||||
for (const req of await cache.keys()) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path.startsWith(ASSETS)) {
|
||||
if (keep && !keep.has(path)) {
|
||||
await cache.delete(req);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const res = await cache.match(req);
|
||||
if (res && !res.ok) await cache.delete(req);
|
||||
}
|
||||
}
|
||||
|
||||
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
|
||||
async function dropForeignShell() {
|
||||
const cache = await caches.open(VERSION);
|
||||
const kept = await cache.match(SHELL_KEY);
|
||||
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
|
||||
}
|
||||
|
||||
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
|
||||
async function refreshShell(res) {
|
||||
const html = await res.text();
|
||||
if (!isAppPage(html)) return;
|
||||
const cache = await caches.open(VERSION);
|
||||
const prev = await cache.match(SHELL_KEY);
|
||||
const prevHtml = prev ? await prev.text() : "";
|
||||
if (prevHtml !== html) {
|
||||
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
||||
await tidy(prevHtml);
|
||||
}
|
||||
await precache(html);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fetching the rest of the build before it is asked for.
|
||||
*
|
||||
* The app page lists every file of its build (see the asset-list plugin in
|
||||
* vite.config.ts). Without this, the first time after a deploy that a reader
|
||||
* opened the composer, settings or a viewer, it waited on the server for the
|
||||
* code -- on a distant link, a visible pause. Now those files are fetched
|
||||
* quietly once a page names them, a few at a time, and only those not held
|
||||
* already; a load cut short is carried on at the next navigation, which calls
|
||||
* this again. Language catalogs are left to be cached when used, and nothing
|
||||
* is fetched ahead when the reader has asked the browser to save data.
|
||||
*/
|
||||
const PRECACHE_PARALLEL = 3;
|
||||
|
||||
function precacheList(html) {
|
||||
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
|
||||
if (!m) return [];
|
||||
try {
|
||||
const list = JSON.parse(m[1]).precache;
|
||||
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function precache(html) {
|
||||
if (self.navigator.connection && self.navigator.connection.saveData) return;
|
||||
const cache = await caches.open(VERSION);
|
||||
const wanted = [];
|
||||
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
|
||||
const next = async () => {
|
||||
for (let path = wanted.shift(); path; path = wanted.shift()) {
|
||||
try {
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
if (res.ok) await cache.put(path, res);
|
||||
} catch {
|
||||
/* offline, or a deploy changing over; the next navigation tries again */
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
|
||||
}
|
||||
|
||||
/*
|
||||
* Where a share from the operating system is left for a tab to collect.
|
||||
*
|
||||
@@ -103,21 +254,52 @@ self.addEventListener("fetch", (event) => {
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
||||
// Hashed build assets: cache-first, and only what actually arrived.
|
||||
if (url.pathname.startsWith(ASSETS)) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
||||
if (res.ok && res.type === "basic") {
|
||||
const copy = res.clone();
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations & everything else: network-first, fall back to cached shell.
|
||||
/*
|
||||
* Navigations: the kept app page at once, and the network's behind it.
|
||||
*
|
||||
* Every route in the app is the same page, and waiting on the server for it
|
||||
* cost a full round trip before anything could start -- the longest single
|
||||
* wait on a distant link. So a route is answered from the kept copy when
|
||||
* there is one, and the fresh page is fetched alongside to replace it for
|
||||
* next time. A page that is a build behind is caught the way it always was:
|
||||
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
|
||||
* names are kept for one more build so it can run until then.
|
||||
*
|
||||
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
|
||||
* app, and a file opened in a tab of its own go to the network as before. So
|
||||
* does the first visit, which has no copy yet.
|
||||
*/
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
|
||||
const network = fetch(req).then((res) => {
|
||||
// Every route is the same app page; a fresh one replaces the offline copy.
|
||||
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
|
||||
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
|
||||
event.respondWith((async () => {
|
||||
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
|
||||
if (kept) {
|
||||
event.waitUntil(network.catch(() => {}));
|
||||
return kept;
|
||||
}
|
||||
return network.catch(() => caches.match(SHELL_KEY));
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
@@ -272,6 +454,14 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
* top of it is a second telling of the same thing (#375). Chrome does not
|
||||
* require one while the site is in the foreground.
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
@@ -287,8 +477,10 @@ self.addEventListener("push", (event) => {
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
|
||||
if (!emails.length) {
|
||||
// A StateChange, or a payload too large to carry the message. Say
|
||||
// something true rather than inventing a sender.
|
||||
// A delivery from a server that sends StateChange rather than EmailPush
|
||||
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
|
||||
// or a payload too large to carry the message. Say something true
|
||||
// rather than inventing a sender.
|
||||
await self.registration.showNotification(strings.newMail, {
|
||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
@@ -308,7 +500,8 @@ self.addEventListener("push", (event) => {
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
|
||||
+6
-8
@@ -16,12 +16,12 @@ import { LoginPage } from "@/views/Login";
|
||||
import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { publishWorkerFacts } from "@/lib/swFacts";
|
||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
@@ -260,10 +260,8 @@ function AuthedApp() {
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
});
|
||||
setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
}, [inboxUnread, appName]);
|
||||
|
||||
/*
|
||||
@@ -284,7 +282,7 @@ function AuthedApp() {
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
useEffect(() => {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
if (notif) void requestNotificationPermission();
|
||||
}, [notif]);
|
||||
|
||||
// Nothing worth painting until the account's settings are in force; see the
|
||||
|
||||
@@ -104,6 +104,8 @@ export class JmapClient {
|
||||
private callCounter = 0;
|
||||
private unauthHandlers = new Set<() => void>();
|
||||
private stateHandlers = new Set<(sessionState: string) => void>();
|
||||
/** The last session state announced, so a burst of replies announces it once. */
|
||||
private announcedState: string | null = null;
|
||||
|
||||
get maxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
||||
@@ -255,7 +257,8 @@ export class JmapClient {
|
||||
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state) {
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state && res.sessionState !== this.announcedState) {
|
||||
this.announcedState = res.sessionState;
|
||||
for (const fn of this.stateHandlers) fn(res.sessionState);
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||
import { displayName, formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||
|
||||
describe("address parsing", () => {
|
||||
it("parses mixed lists", () => {
|
||||
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
|
||||
expect(m.to).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("names that reorder themselves", () => {
|
||||
const spoof = { name: "[email protected]\u202E", email: "[email protected]" };
|
||||
it("lose their direction controls when displayed", () => {
|
||||
expect(displayName({ name: "\u202Egnp.exe\u202C Ann", email: "[email protected]" })).toBe("gnp.exe Ann");
|
||||
expect(formatAddress(spoof)).toBe("[email protected] <[email protected]>");
|
||||
});
|
||||
it("fall back to the address when nothing else is left", () => {
|
||||
expect(displayName({ name: "\u200F\u202E", email: "[email protected]" })).toBe("[email protected]");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contactFromAddress, nameParts } from "../contacts";
|
||||
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const parts = (name: string | null, email = "[email protected]") =>
|
||||
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
|
||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #376: a photo saved as a `blobId` was refused by Stalwart, which only takes
|
||||
* the `uri` form. Saving one must also leave a card's other media alone.
|
||||
*/
|
||||
describe("withPhoto", () => {
|
||||
const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" };
|
||||
|
||||
it("puts the photo in as a data URI, never a blob id", () => {
|
||||
const media = withPhoto(undefined, photo)!;
|
||||
const [m] = Object.values(media);
|
||||
expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" });
|
||||
expect(m).not.toHaveProperty("blobId");
|
||||
});
|
||||
|
||||
it("replaces an existing photo and keeps a logo", () => {
|
||||
const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!;
|
||||
expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1);
|
||||
expect(media.old).toBeUndefined();
|
||||
expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" });
|
||||
});
|
||||
|
||||
it("removes only the photo, and clears media when nothing is left", () => {
|
||||
expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } });
|
||||
expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("is read back by contactPhoto", () => {
|
||||
const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard;
|
||||
expect(contactPhoto(card, "a1")).toBe(photo.dataUrl);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeRule as describeSieve } from "../sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
|
||||
import { describeRule as describeSieve } from "../sieve/sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
||||
import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasHtmlAlternative } from "../html";
|
||||
import { hasHtmlAlternative } from "../text/html";
|
||||
|
||||
/*
|
||||
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
import { isTextEntry, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Shortcuts after a click on a checkbox (#260).
|
||||
@@ -11,8 +11,8 @@ import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
* swallows a keystroke in the first place.
|
||||
*/
|
||||
|
||||
const pressFrom = (el: Element, key: string) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
const pressFrom = (el: EventTarget, key: string, init?: KeyboardEventInit) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
||||
el.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
@@ -92,3 +92,22 @@ describe("shortcuts with a checkbox focused", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shifted letter shortcuts", () => {
|
||||
it("matches Shift+I and Shift+U bindings", () => {
|
||||
const read = vi.fn();
|
||||
const unread = vi.fn();
|
||||
pop = keyboard.pushScope("test", [
|
||||
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: read },
|
||||
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: unread },
|
||||
]);
|
||||
|
||||
const readEvent = pressFrom(window, "I", { shiftKey: true });
|
||||
const unreadEvent = pressFrom(window, "U", { shiftKey: true });
|
||||
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(unread).toHaveBeenCalledOnce();
|
||||
expect(readEvent.defaultPrevented).toBe(true);
|
||||
expect(unreadEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comboOf, keyboard } from "@/lib/keyboard";
|
||||
import { comboOf, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* A "keydown" that carries no key. Chrome's password autofill dispatches one
|
||||
@@ -36,7 +36,7 @@ describe("a keydown with no key", () => {
|
||||
|
||||
it("leaves real keys alone", () => {
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("E");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("shift+e");
|
||||
expect(comboOf(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toMatch(/enter$/);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||
|
||||
/**
|
||||
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
||||
@@ -112,3 +112,19 @@ describe("the body a share turns into", () => {
|
||||
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareSummary", () => {
|
||||
const file = (name: string) => new File(["x"], name);
|
||||
it("gives the title, the text and link together, and the file names", () => {
|
||||
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
|
||||
title: "Trip",
|
||||
preview: "See this https://example.com",
|
||||
files: ["a.jpg"],
|
||||
});
|
||||
});
|
||||
it("shortens a long text rather than showing all of it", () => {
|
||||
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
|
||||
expect(preview.length).toBeLessThanOrEqual(160);
|
||||
expect(preview.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
|
||||
@@ -79,6 +79,12 @@ describe("isTnef", () => {
|
||||
});
|
||||
|
||||
describe("parseTnef", () => {
|
||||
it("takes the direction overrides out of a name", () => {
|
||||
const out = parseTnef(tnef(file("x.bin", "MZ", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "Invoice_\u202Efdp.exe" }]) },
|
||||
])));
|
||||
expect(out[0]!.name).toBe("Invoice_fdp.exe");
|
||||
});
|
||||
it("pulls one attachment out, with its name and bytes", () => {
|
||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||
expect(out).toHaveLength(1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { withoutBidiControls } from "@/lib/text/text";
|
||||
|
||||
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
||||
|
||||
@@ -48,9 +49,10 @@ export function parseOne(raw: string): EmailAddress | null {
|
||||
|
||||
export function formatAddress(a: EmailAddress | null | undefined): string {
|
||||
if (!a) return "";
|
||||
if (!a.name) return a.email;
|
||||
const needsQuote = /[,;<>"()\\]/.test(a.name);
|
||||
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
|
||||
const clean = a.name ? withoutBidiControls(a.name) : "";
|
||||
if (!clean) return a.email;
|
||||
const needsQuote = /[,;<>"()\\]/.test(clean);
|
||||
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
|
||||
return `${name} <${a.email}>`;
|
||||
}
|
||||
|
||||
@@ -60,7 +62,8 @@ export function formatAddressList(list: EmailAddress[] | null | undefined): stri
|
||||
|
||||
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
||||
if (!a) return fallback;
|
||||
if (a.name?.trim()) return a.name.trim();
|
||||
const name = a.name ? withoutBidiControls(a.name).trim() : "";
|
||||
if (name) return name;
|
||||
return a.email || fallback;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/admin/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/admin/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/admin/adminDirectory";
|
||||
|
||||
describe("setting a password", () => {
|
||||
it("writes into the existing password credential, keeping its place", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/admin/adminDomains";
|
||||
|
||||
/**
|
||||
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/admin/adminGroups";
|
||||
|
||||
describe("group membership", () => {
|
||||
it("is a patch to each member, one pointer each, so no other membership moves", () => {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
|
||||
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/admin/adminLists";
|
||||
|
||||
describe("a mailing list's recipients", () => {
|
||||
it("are saved as what was added and removed, one pointer each", () => {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { permissionSet } from "@/lib/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
||||
import { permissionSet } from "@/lib/admin/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/admin/adminRoles";
|
||||
|
||||
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
||||
const roles = new Map<string, DirectoryRole>([
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
|
||||
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/admin/adminTenants";
|
||||
|
||||
describe("a tenant's limits", () => {
|
||||
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/admin/adminAccess";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/admin/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Groups, from Stalwart 0.16's directory.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import { DirectoryError, type EmailAlias } from "@/lib/admin/adminDirectory";
|
||||
|
||||
/**
|
||||
* Mailing lists, from Stalwart 0.16's directory.
|
||||
@@ -1,8 +1,8 @@
|
||||
import { apiFetch, client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { Permissions, RoleDef } from "@/lib/adminAccess";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import type { Permissions, RoleDef } from "@/lib/admin/adminAccess";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
import { DomainError } from "@/lib/admin/adminDomains";
|
||||
import type { PermissionInfo } from "@/lib/permissionLabels";
|
||||
|
||||
/**
|
||||
@@ -1,7 +1,7 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { DirectoryError } from "@/lib/adminDirectory";
|
||||
import { DomainError } from "@/lib/adminDomains";
|
||||
import { DirectoryError } from "@/lib/admin/adminDirectory";
|
||||
import { DomainError } from "@/lib/admin/adminDomains";
|
||||
|
||||
/**
|
||||
* Tenants, from Stalwart 0.16's directory.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/calendar/appointment";
|
||||
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { availabilityWindow } from "@/lib/availabilityWindow";
|
||||
import { availabilityWindow } from "@/lib/calendar/availabilityWindow";
|
||||
|
||||
const at = (s: string) => new Date(s);
|
||||
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
dayDelta,
|
||||
resizePatch,
|
||||
SNAP_MINUTES,
|
||||
} from "@/lib/eventDrag";
|
||||
} from "@/lib/calendar/eventDrag";
|
||||
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
|
||||
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/calendar/ics";
|
||||
|
||||
const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
|
||||
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toIcs, parseIcs } from "@/lib/ics";
|
||||
import { toIcs, parseIcs } from "@/lib/calendar/ics";
|
||||
import type { JSCalendarEvent } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Email, EmailAddress } from "@/jmap/types";
|
||||
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { uniqueAddresses } from "./address";
|
||||
import { toLocalDateOnly } from "./dates";
|
||||
import { htmlToText } from "./text";
|
||||
import { uniqueAddresses } from "../address";
|
||||
import { toLocalDateOnly } from "../dates";
|
||||
import { htmlToText } from "../text/text";
|
||||
|
||||
/**
|
||||
* How much of a message body is copied into an event description.
|
||||
File renamed without changes.
@@ -10,8 +10,8 @@
|
||||
* question — given an event and a gesture, what are the new start and end —
|
||||
* and the caller decides whether it is allowed to save that.
|
||||
*/
|
||||
import { addMinutes } from "./dates";
|
||||
import { isBirthdayEvent } from "./birthdays";
|
||||
import { addMinutes } from "../dates";
|
||||
import { isBirthdayEvent } from "../birthdays";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { formatList, weekdayName, weekdayNames } from "./datetime";
|
||||
import { formatList, weekdayName, weekdayNames } from "../datetime";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
+17
-1
@@ -1,4 +1,4 @@
|
||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
||||
import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/** Best display name for a card. */
|
||||
@@ -57,6 +57,22 @@ export function contactEmails(c: ContactCard): EmailAddress[] {
|
||||
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's `media` with its photo replaced by `photo`, or removed when that is
|
||||
* null, and everything else in it -- a logo, a sound -- left as it was.
|
||||
*
|
||||
* The photo goes in as a `data:` URI. Stalwart (0.16.22, checked live on
|
||||
* 2026-09-16) refuses a `blobId` in `media` outright -- "blobIds in media is
|
||||
* not supported" -- which is RFC 9610's JMAP extension to JSContact, and
|
||||
* accepts the plain RFC 9553 `uri` form, returning it unchanged (#376). The
|
||||
* editor's photo is a 256px JPEG, tens of kilobytes; 134 KB was accepted.
|
||||
*/
|
||||
export function withPhoto(media: Record<string, JSContactMedia> | undefined | null, photo: { dataUrl: string; type: string } | null): Record<string, JSContactMedia> | null {
|
||||
const rest: Record<string, JSContactMedia> = Object.fromEntries(Object.entries(media ?? {}).filter(([, m]) => m.kind !== "photo"));
|
||||
if (photo) rest[newKey("p")] = { "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: photo.type };
|
||||
return Object.keys(rest).length ? rest : null;
|
||||
}
|
||||
|
||||
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||
if (!m) return null;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hand the browser a file the app made, to save.
|
||||
*
|
||||
* The object URL is released as soon as the download has been started: a
|
||||
* click on the link starts it synchronously, and an unreleased URL keeps the
|
||||
* whole file in memory for as long as the tab is open -- an address book's
|
||||
* worth of vCards, per export.
|
||||
*/
|
||||
export function downloadFile(content: BlobPart, type: string, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { descendantIds } from "./folderMove";
|
||||
import { descendantIds } from "./mailbox/folderMove";
|
||||
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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 click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -145,13 +145,14 @@ export function comboOf(e: KeyboardEvent): string | null {
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (mod) parts.push("mod");
|
||||
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;
|
||||
if (k === " ") k = "space";
|
||||
else if (k === "Escape") k = "esc";
|
||||
else if (k.length === 1) {
|
||||
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
|
||||
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
|
||||
// Symbols encode Shift in the character itself (e.g. "#", "!").
|
||||
k = shiftedLetter || !e.shiftKey ? k.toLowerCase() : k;
|
||||
} else k = k.toLowerCase();
|
||||
parts.push(k);
|
||||
return parts.join("+");
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/mailbox/archiveDate";
|
||||
|
||||
/**
|
||||
* The dates below are written as local-time strings on purpose. The segments
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user