Compare commits

...
Author SHA1 Message Date
jcoffey ebf678be73 Merge pull request #389 from Coffey-Labs/fix/push-subscriptions
Stop duplicate push notifications and piling up subscriptions
2026-09-16 11:41:37 -07:00
jcoffey-dev 37eb145652 Merge main into fix/push-subscriptions
# Conflicts:
#	KNOWN-ISSUES.md
2026-09-16 11:39:35 -07:00
jcoffey 3d7602ce74 Merge pull request #388 from Coffey-Labs/fix/contact-photos
Save contact photos inline, and load cards so avatars show
2026-09-16 11:38:22 -07:00
jcoffey-dev 4054f82c37 Stop duplicate push notifications and piling up subscriptions
Browsers subscribed to Email changes, so every read or move on any
client arrived as a push the worker could only show as "New mail". They
now subscribe to EmailDelivery, which changes only on delivery; Stalwart
sends a delivery to a subscription with an emailPush filter as an
EmailPush alone. The payload now names id and threadId, which Stalwart
sends only when asked, so notifications carry their actions and open the
message. The worker stays quiet while a focused window is open, and the
page leaves notifications to the worker where push is on.

Every renewal registered a new subscription, on the belief that a
repeated deviceClientId replaces the old one. Stalwart keeps both and
allows fifteen per account, which filled up. A browser now extends its
subscription, clears its own duplicates, replaces them only when its
endpoint changed, and on overQuota makes room among other browsers'
subscriptions. The server names its subscriptions by installation and
removes what its previous process registered, and extends rather than
re-creates.

Checked live on 0.16.22; the mock now keeps duplicates, enforces the
limit and accepts an expiry update.

Fixes #375.
2026-09-16 11:36:07 -07:00
jcoffey-dev d38dee7eb9 Save contact photos inline, and load cards so avatars show
Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.

Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.

Fixes #376.
2026-09-16 11:27:47 -07:00
jcoffey aa9bf1b9b2 Merge pull request #387 from Coffey-Labs/perf/client-memory
Let go of old message bodies and of exported files
2026-09-16 11:02:42 -07:00
jcoffey c63fd0dfe0 Merge pull request #386 from Coffey-Labs/perf/server-static-and-caches
Precompress the bundle, validate the shell, and pass byte ranges on
2026-09-16 11:02:37 -07:00
jcoffey-dev f123467897 Let go of old message bodies and of exported files
Every message opened kept its full copy for as long as the tab was open.
The store now holds bodies for the 40 messages most recently wanted; older
ones go back to the list properties and are fetched in full again if
opened. The open conversation is never released.

Contact, settings and calendar exports go through downloadFile, which
releases the object URL once the download has started; three of them
never released it.
2026-09-16 10:59:57 -07:00
jcoffey-dev 71d211a13f Precompress the bundle, validate the shell, and pass byte ranges on
The web build now writes a Brotli and a gzip copy of each compressible
file, and the static handler serves the best one the browser accepts.
The bundle was gzipped again for every request and Brotli was never
offered; the main chunk is 122 KB with Brotli against 144 KB gzipped.

index.html and every static file carry an ETag, and a matching
If-None-Match gets a 304. The shell and the worker are revalidated on
every load and were downloaded whole each time.

Attachment downloads pass a plain byte Range to Stalwart and relay a 206,
so a PDF viewer or a video element can read in pieces where the server
allows it. On the reader's own device a blob is cached as immutable,
since its id names its content.

The upstream session and account-info caches drop entries past their age
on a timer; they lost an entry only on sign-out or refusal, not when a
session expired. The mock answers byte ranges.
2026-09-16 10:54:14 -07:00
jcoffey 56bd48e891 Merge pull request #385 from mbjboon-netizen/mbjboon-netizen-patch-1
Mbjboon netizen patch 1
2026-09-16 10:50:14 -07:00
jcoffey da87925b9c Merge pull request #384 from Coffey-Labs/perf/contacts-calendar-changes
Sync contacts by what changed, and hold fewer calendar windows
2026-09-16 10:45:12 -07:00
jcoffey-dev 360420402d Sync contacts by what changed, and hold fewer calendar windows
A pushed contact change, and every edit or import made here, reloaded the
whole address book. The store now keeps the state its cards were read at
and asks ContactCard/changes what changed since, fetching only those cards,
split to maxObjectsInGet. A server that cannot say falls back to the full
load.

The calendar held every week or month the reader had visited, queried each
of them again on any event change, and walked them all on every render. It
now holds the four most recently shown; a change reloads those in place,
without emptying the view first, and a window dropped is loaded again when
it is next shown. Shared calendars' events are fetched from every account
at once, and instancesIn builds the added-shares set once.

The mock keeps a ContactCard change log, answers ContactCard/changes, and
announces a ContactCard/set, as Stalwart does.
2026-09-16 10:41:21 -07:00
mbjboon-netizen 8bd7904a21 Update nl.ts
Signed-off-by: mbjboon-netizen <[email protected]>
2026-09-16 19:07:54 +02:00
jcoffey 6090442058 Merge pull request #383 from Coffey-Labs/perf/lazy-store-init
Ask shared accounts together, and about files only when Files opens
2026-09-16 10:06:53 -07:00
jcoffey-dev 4c7b2ec370 Ask shared accounts together, and about files only when Files opens
At sign-in the files, contacts and calendar stores each asked every shared
account a question, one account after another: a request apiece before the
reader had opened any of those views.

Files now only works out at sign-in whether it is available. Which shared
accounts hold files is asked when the Files view or the file picker opens,
which the Files view already did on every visit. Shared address books and
calendars are asked for in one request, and the calendar store loads its
calendars, identities and shared calendars side by side.
2026-09-16 10:04:24 -07:00
jcoffey 9560ad06f4 Merge pull request #382 from Coffey-Labs/perf/lazy-chunks
Load the composer, previews, dialogs and other sidebars on demand
2026-09-16 09:50:14 -07:00
jcoffey-dev 6139031689 Load the composer, previews, dialogs and other sidebars on demand
The main chunk carried everything the mail view might open: the file
preview and its Markdown renderer, the composer and its editor, the contact
editor, the filter and share dialogs, and the calendar, contacts and files
sidebars. Each is now loaded when first shown. The composer is also
fetched when the browser is idle after startup, so the first Compose does
not wait on the network.

Import the notification helpers statically where they already were: the
dynamic imports beside those static ones split nothing.
2026-09-16 09:48:04 -07:00
jcoffey e3cd56314b Merge pull request #381 from Coffey-Labs/perf/message-list-rows
Render only the message rows that changed
2026-09-16 09:22:08 -07:00
jcoffey-dev c6dbcaef63 Render only the message rows that changed
The rows were memoized, but nothing they were given kept its identity: the
list built each row's thread messages afresh, passed inline handlers and the
whole selection, and the click and context-menu handlers changed with the
selection and the menu. Every visible row rendered on every store write.

Rows now select their own message and conversation from the store, take a
plain selected flag, and get handlers whose identity never changes. The
conversation summary is memoized.

Refreshes also keep the object for a message whose fetched properties did
not change, so a refresh that changed one message renders one row.
2026-09-16 09:10:41 -07:00
jcoffey 460760ba12 Merge pull request #380 from Coffey-Labs/fix/sw-cache
Keep the service worker's cache to the current build
2026-09-16 09:01:20 -07:00
jcoffey-dev a607450aaa Keep the service worker's cache to the current build
Cache a build asset only when it arrived: a 404 for a chunk asked for while
a deploy was changing over used to be kept as that chunk in that browser.

Refresh the offline copy of the app page after every successful page load,
and when it changes, drop the assets it no longer names along with any
failed response. The same tidy runs when this worker activates, which
clears what earlier workers left. Every deploy's chunks used to stay in
the browser for good.

The cache keeps its name: it also holds what the worker leaves for a tab
to collect.
2026-09-16 08:59:01 -07:00
jcoffey a9302075e7 Merge pull request #379 from Coffey-Labs/fix/security-followups
Close the smaller gaps from the security review
2026-09-16 08:50:31 -07:00
jcoffey-dev dfe885a921 Close the smaller gaps from the security review
Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.

Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.

On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.

Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
2026-09-16 08:47:23 -07:00
jcoffey 9691a7bbf5 Merge pull request #378 from Coffey-Labs/fix/push-refresh
Keep list refreshes within the server's limits and stop repeating them
2026-09-16 08:33:01 -07:00
jcoffey-dev e2b4cc18db Keep list refreshes within the server's limits and stop repeating them
Refresh the list in pages of at most maxObjectsInGet. A list scrolled past
500 rows used to send all of its ids to one Email/get, which the server
refuses whole, and the refresh failed without a word.

Fetch the other messages of listed threads in their own capped requests,
and only those not already held. They used to be back-referenced from
Thread/get with no bound.

Have loadThread fetch bodies only for messages not held in full. Every push
refetched the whole open thread's bodies, and the new attachment objects
made the reading pane redo work it had already done.

Build the list query from the folder names, roles and tree rather than the
mailbox map, which every reload replaces. Each reload used to build a new
query, which query() answered with another full refresh.
2026-09-16 08:25:57 -07:00
jcoffey 47a2477d9f Merge pull request #377 from Coffey-Labs/fix/server-hardening
Bound what a request can make the server hold
2026-09-16 07:58:47 -07:00
jcoffey-dev 98e105efd6 Bound what a request can make the server hold
Cap JSON bodies at 64 KB on every API route except JMAP and uploads, which
bound themselves. Sign-in used to read a body of any size before its rate
limits ran; the flood ceiling now also runs before the body is read.

For sessions whose JMAP requests are checked, lower the read cap from 16 MB
to 4 MB, allow four such reads per session at once, and turn requests away
with a 503 once 32 MB is held across everyone.

Count sign-in limits per /64 for IPv6, since one host holds a whole /64.

Bind the compose example to loopback, and run it read-only with no
capabilities and no-new-privileges. Keep .env.* out of git and the image
build context.
2026-09-16 07:54:20 -07:00
mbjboon-netizen 4cb1945eb5 Update nl.ts
Signed-off-by: mbjboon-netizen <[email protected]>
2026-09-16 04:32:50 +02:00
68 changed files with 2765 additions and 378 deletions
+3
View File
@@ -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
+3
View File
@@ -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/
+4
View File
@@ -48,6 +48,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **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.
+14 -1
View File
@@ -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)}
+47
View File
@@ -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`);
+77 -2
View File
@@ -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,83 @@ 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(await whole.text(), "hello world");
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 416);
// 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");
});
+12 -2
View File
@@ -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
View File
@@ -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) };
}
+170 -26
View File
@@ -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,30 @@ 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);
if (res.headers.get("accept-ranges") === "bytes") 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 +845,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 +938,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 +1034,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"]);
+18
View File
@@ -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`;
}
+2 -1
View File
@@ -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",
+12
View File
@@ -18,6 +18,18 @@ export function recordEmailChange(change: { created?: string[]; updated?: string
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);
+65 -6
View File
@@ -1,5 +1,5 @@
import { checkOtp } from "./auth.js";
import { emailChanges, recordEmailChange, broadcast } from "./events.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";
@@ -7,6 +7,11 @@ import { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj,
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).
@@ -197,10 +202,17 @@ export const handlers: Record<string, Handler> = {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
// One per device: re-subscribing replaces rather than accumulates.
/*
* 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 ?? "");
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1);
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`.
@@ -212,7 +224,9 @@ export const handlers: Record<string, Handler> = {
* so "does this client renew?" is a question the mock can answer.
*/
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
// 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++;
}
@@ -224,6 +238,13 @@ export const handlers: Record<string, Handler> = {
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++;
}
@@ -428,7 +449,45 @@ export const handlers: Record<string, Handler> = {
// 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),
"ContactCard/set": genericSet(cards, "cc"),
/*
* 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) ?? {};
+15 -1
View File
@@ -138,7 +138,21 @@ export const server = createServer(async (req, res) => {
const [, , , , blobId] = url.pathname.split("/");
const b = blobs.get(blobId ?? "");
if (!b) { res.writeHead(404); return res.end(); }
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
const type = url.searchParams.get("accept") ?? b.type;
// One byte range, the way a PDF viewer or a video element asks for one.
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
if (m && (m[1] || m[2])) {
const size = b.data.length;
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start >= size || start > end) {
res.writeHead(416, { "content-range": `bytes */${size}` });
return res.end();
}
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" });
return res.end(b.data.subarray(start, end + 1));
}
res.writeHead(200, { "content-type": type, "content-length": b.data.length, "accept-ranges": "bytes" });
return res.end(b.data);
}
/*
+52
View File
@@ -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
View File
@@ -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);
+115
View File
@@ -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");
});
+15 -1
View File
@@ -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
View File
@@ -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,
+79
View File
@@ -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
View File
@@ -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.
+17
View File
@@ -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
View File
@@ -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"
+94 -9
View File
@@ -26,10 +26,76 @@ self.addEventListener("install", (event) => {
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(() => 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. */
async function tidy() {
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;
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);
}
}
/** Keep the offline copy of the app page current, and tidy when it changes. */
async function refreshShell(res) {
const html = await res.text();
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
if (prev && (await prev.text()) === html) return;
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy();
}
/*
* Where a share from the operating system is left for a tab to collect.
*
@@ -103,12 +169,14 @@ 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;
}))
);
@@ -117,7 +185,13 @@ self.addEventListener("fetch", (event) => {
// Navigations & everything else: network-first, fall back to cached shell.
if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
event.respondWith(fetch(req).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;
}).catch(() => caches.match(SHELL_KEY)));
return;
}
event.respondWith(fetch(req).catch(() => caches.match(req)));
@@ -272,6 +346,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 +369,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 +392,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,
+4 -6
View File
@@ -16,7 +16,7 @@ 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/notify";
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";
@@ -260,10 +260,8 @@ function AuthedApp() {
});
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
useEffect(() => {
void import("@/lib/notify/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/notify").then((m) => m.requestNotificationPermission());
if (notif) void requestNotificationPermission();
}, [notif]);
// Nothing worth painting until the account's settings are in force; see the
+12 -1
View File
@@ -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]");
});
});
+33 -1
View File
@@ -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
View File
@@ -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);
+7 -4
View File
@@ -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;
}
+17 -1
View File
@@ -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;
+16
View File
@@ -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);
}
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { setDeviceTrusted } from "@/lib/storage";
import { deviceClientId, isBrowserSubscription, rememberEndpoint, roomToMake, setPushEnabledHere, type JmapPushSubscription } from "@/lib/notify/webpush";
import { renewWebPush } from "@/lib/notify/webpushEnable";
/**
* #375: every renewal registered another subscription, on the belief that a
* repeated deviceClientId replaces the old one. Stalwart keeps both and allows
* fifteen per account, so accounts filled up with "too many subscriptions".
*
* The server below behaves as a live 0.16.22 was seen to: duplicates are kept,
* the sixteenth is refused with overQuota, and an expiry can be extended.
*/
const KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
const DAY = 24 * 60 * 60 * 1000;
const OTHER = (n: number) => `ihasmail-00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
let server: Array<JmapPushSubscription & { types?: string[] }>;
let writes: Array<[string, Record<string, unknown>]>;
let seq: number;
const fakeSub = (endpoint: string) => ({
endpoint,
toJSON: () => ({ endpoint, keys: { p256dh: "BPub", auth: "auth" } }),
getKey: () => null,
});
let browserSub: ReturnType<typeof fakeSub> | null;
function install() {
client.session = { capabilities: { "urn:ietf:params:jmap:core": { maxCallsInRequest: 16 }, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: KEY } }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
vi.stubGlobal("PushManager", function PushManager() {});
vi.stubGlobal("Notification", { permission: "granted" });
const reg = {
pushManager: {
getSubscription: async () => browserSub,
subscribe: async () => (browserSub = fakeSub("https://push.example/new-endpoint")),
},
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: { ready: Promise.resolve(reg), getRegistration: async () => reg, addEventListener: () => {} },
});
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, args, id]) => {
if (name === "PushSubscription/get") return [name, { list: server.map((s) => ({ ...s })), notFound: [] }, id];
writes.push([name, args]);
const created: Record<string, unknown> = {};
const notCreated: Record<string, unknown> = {};
const updated: Record<string, null> = {};
for (const [cid, body] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
if (server.length >= 15) { notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." }; continue; }
const sub = { id: `p${seq++}`, deviceClientId: String(body.deviceClientId), expires: new Date(Date.now() + 7 * DAY).toISOString(), verificationCode: null, types: body.types as string[] };
server.push(sub);
created[cid] = { id: sub.id, expires: sub.expires };
}
for (const [sid, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const s = server.find((x) => x.id === sid);
if (s && typeof patch.expires === "string") { s.expires = patch.expires; updated[sid] = null; }
}
const destroy = (args.destroy ?? []) as string[];
server = server.filter((s) => !destroy.includes(s.id));
return [name, { created, notCreated, updated, destroyed: destroy }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
}
beforeEach(() => {
localStorage.clear();
server = [];
writes = [];
seq = 1;
browserSub = fakeSub("https://push.example/endpoint-a");
setDeviceTrusted(true);
setPushEnabledHere(true);
install();
});
afterEach(() => {
client.session = null;
vi.unstubAllGlobals();
});
const mine = () => server.filter((s) => s.deviceClientId === deviceClientId());
const ours = (expiresIn: number, id = `m${seq++}`) => ({ id, deviceClientId: deviceClientId(), expires: new Date(Date.now() + expiresIn).toISOString(), verificationCode: "done" });
describe("keeping this browser registered", () => {
it("registers once, for new mail only, and remembers the endpoint", async () => {
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(mine()[0]!.types).toEqual(["EmailDelivery"]);
// Started again straight away: nothing more to do.
writes = [];
await renewWebPush();
expect(writes).toEqual([]);
expect(mine()).toHaveLength(1);
});
it("leaves a subscription with time on it alone", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(6 * DAY));
await renewWebPush();
expect(writes).toEqual([]);
});
it("extends one that is close to expiring instead of adding another", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(1 * DAY, "keep"));
await renewWebPush();
expect(writes.map(([n, a]) => `${n} ${Object.keys(a).join(",")}`)).toEqual(["PushSubscription/set update"]);
expect(mine()).toHaveLength(1);
expect(Date.parse(mine()[0]!.expires!) - Date.now()).toBeGreaterThan(6 * DAY);
});
it("clears the copies earlier versions left, keeping the newest", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(1 * DAY), ours(3 * DAY), ours(6 * DAY, "newest"));
await renewWebPush();
expect(mine().map((s) => s.id)).toEqual(["newest"]);
});
it("replaces its registrations when the browser's endpoint has changed", async () => {
rememberEndpoint("https://push.example/an-old-endpoint");
server.push(ours(6 * DAY, "old1"), ours(6 * DAY, "old2"));
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(mine()[0]!.id).not.toMatch(/^old/);
expect(localStorage.getItem("ihasmail:pushEndpoint")).toBe(browserSub!.endpoint);
});
it("makes room when the account is full, taking another browser's never-verified one first", async () => {
for (let i = 0; i < 13; i++) server.push({ id: `o${i}`, deviceClientId: OTHER(i), expires: new Date(Date.now() + (i + 1) * DAY / 4).toISOString(), verificationCode: "done" });
server.push({ id: "unverified", deviceClientId: OTHER(99), expires: new Date(Date.now() + 6 * DAY).toISOString(), verificationCode: null });
server.push({ id: "proxy", deviceClientId: "ihasmail-proxy-abcdefghij-12345678", expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "done" });
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(server.find((s) => s.id === "unverified")).toBeUndefined();
expect(server.find((s) => s.id === "proxy")).toBeDefined();
expect(server).toHaveLength(15);
});
});
describe("telling subscriptions apart", () => {
it("recognizes a browser's id, and not the server's or another client's", () => {
const sub = (deviceClientId: string) => ({ id: "x", deviceClientId, expires: null }) as JmapPushSubscription;
expect(isBrowserSubscription(sub(OTHER(1)))).toBe(true);
expect(isBrowserSubscription(sub("ihasmail-proxy-abcdefghij-12345678"))).toBe(false);
expect(isBrowserSubscription(sub("ihasmail-Ab3_x9Qz"))).toBe(false);
expect(isBrowserSubscription(sub("some-other-client"))).toBe(false);
});
it("chooses the soonest to expire when every candidate is verified", () => {
const subs = [
{ id: "later", deviceClientId: OTHER(1), expires: new Date(Date.now() + 5 * DAY).toISOString(), verificationCode: "v" },
{ id: "sooner", deviceClientId: OTHER(2), expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "v" },
{ id: "me", deviceClientId: OTHER(3), expires: new Date(Date.now()).toISOString(), verificationCode: "v" },
];
expect(roomToMake(subs, OTHER(3))).toEqual(["sooner"]);
});
});
+8 -2
View File
@@ -125,9 +125,15 @@ describe("what gets registered", () => {
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
});
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
it("subscribes to deliveries only, so reading or moving mail elsewhere sends nothing", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["EmailDelivery"]);
});
it("asks for the message and conversation ids, which Stalwart only sends when named", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY }, "urn:ietf:params:jmap:emailpush": {} });
const payload = subscriptionPayload(fakeSub, "a1") as { emailPush: Record<string, { properties: string[] }> };
expect(payload.emailPush.a1!.properties).toEqual(expect.arrayContaining(["id", "threadId"]));
});
});
+18 -2
View File
@@ -82,14 +82,30 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
}
}
/**
* Show a notification from the page, for a tab that is open but not in front.
*
* Through the service worker's registration where there is one: Android's
* Chrome refuses `new Notification()` outright, so notifications from an open
* tab never appeared there at all. The tag is the one the service worker uses
* for the same message (`ihasmail-<id>`), so if both ever show it, the second
* replaces the first instead of stacking beside it.
*/
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
const { onClick, ...options } = opts;
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
if (viaWorker) {
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
return;
}
try {
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
const n = new Notification(title, full);
n.onclick = () => {
window.focus();
opts.onClick?.();
onClick?.();
n.close();
};
setTimeout(() => n.close(), 8000);
+126 -10
View File
@@ -24,17 +24,36 @@ import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
/** Which Email properties to put in the payload, best first. */
const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"];
/**
* Which Email properties to put in the payload, best first.
*
* `id` and `threadId` have to be asked for: Stalwart sends only what is named
* (0.16.22 source). Without them a notification could not be tagged by
* message, carried no Archive or Mark-read button, and opened the inbox rather
* than the message.
*/
const PAYLOAD_PROPS = ["id", "threadId", "from", "subject", "preview", "receivedAt"];
export interface JmapPushSubscription {
id: Id;
deviceClientId: string;
url: string;
/** Write-only: Stalwart never returns it, so a subscription cannot be matched by endpoint. */
url?: string;
expires: string | null;
verificationCode?: string | null;
}
/** A `PushSubscription/set` refusal, with the server's type kept for deciding what to do. */
export class PushSetError extends Error {
constructor(
readonly type: string,
message: string,
) {
super(message);
this.name = "PushSetError";
}
}
/** The VAPID key this server signs with, or null if it does not do Web Push. */
export function applicationServerKey(): string | null {
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
@@ -126,9 +145,19 @@ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null,
deviceClientId: deviceClientId(),
url: sub.endpoint,
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
// StateChange notifications are not wanted: the app already has EventSource
// while it is open, and this channel exists for when it is not.
types: ["Email"],
/*
* New mail, and nothing else.
*
* `EmailDelivery` changes only when a message is delivered; `Email` changes
* on every read, flag and move, from any client, and each of those arrived
* here as a push the worker could only show as "New mail" (#375). With an
* `emailPush` filter, Stalwart sends a delivery as an EmailPush alone; a
* server without emailpush turns it into a StateChange naming
* `EmailDelivery`, which is then a true "New mail". An empty or null list
* is not "none": Stalwart takes it as every type there is (checked live on
* 0.16.22, 2026-09-16).
*/
types: ["EmailDelivery"],
};
if (accountId && supportsEmailPush()) {
body.emailPush = {
@@ -185,9 +214,51 @@ export function setPushEnabledHere(on: boolean): void {
*/
export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000;
/**
* This browser's registered subscriptions, the one with the most time left
* first.
*
* Plural because Stalwart keeps every create: a second subscription with the
* same `deviceClientId` sits beside the first rather than replacing it
* (checked live on 0.16.22, 2026-09-16), so an account holds as many as were
* ever registered until each one expires.
*/
export function mySubscriptions(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription[] {
const left = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER);
return subs.filter((s) => s.deviceClientId === deviceId).sort((a, b) => left(b) - left(a));
}
/** This browser's registered subscription, out of everything the account has. */
export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null {
return subs.find((s) => s.deviceClientId === deviceId) ?? null;
return mySubscriptions(subs, deviceId)[0] ?? null;
}
/**
* Whether a subscription was registered by a browser running ihasmail, rather
* than by the ihasmail server (`ihasmail-proxy-`, or the older eight-character
* form) or by another client altogether.
*/
export function isBrowserSubscription(s: JmapPushSubscription): boolean {
return /^ihasmail-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.deviceClientId);
}
/**
* Which subscriptions to let go of when the account is at its limit.
*
* Stalwart allows fifteen per account and refuses the sixteenth with
* `overQuota` (checked live on 0.16.22, 2026-09-16). Only browser
* subscriptions are candidates, never this browser's and never the server's:
* one that never verified first, then the one closest to expiring. A device
* that loses its subscription this way registers again the next time the app
* is opened there, because it no longer finds its own.
*/
export function roomToMake(subs: JmapPushSubscription[], deviceId: string, count = 1): Id[] {
const expiry = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER);
return subs
.filter((s) => s.deviceClientId !== deviceId && isBrowserSubscription(s))
.sort((a, b) => Number(Boolean(a.verificationCode)) - Number(Boolean(b.verificationCode)) || expiry(a) - expiry(b))
.slice(0, count)
.map((s) => s.id);
}
/**
@@ -225,10 +296,55 @@ export async function createSubscription(body: Record<string, unknown>): Promise
{ create: { s: body } },
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
);
if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type));
const refused = res.notCreated?.s;
if (refused) throw new PushSetError(String(refused.type), String(refused.description ?? refused.type));
return (res.created?.s as { id?: Id } | undefined)?.id ?? null;
}
/**
* Give a registered subscription more time, rather than registering another.
*
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
* may shorten what is asked for, and whatever it keeps is what counts.
*/
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const err = res.notUpdated?.[id];
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
}
export async function destroySubscriptions(ids: Id[]): Promise<void> {
if (!ids.length) return;
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
}
/**
* The push endpoint this browser last registered with the server.
*
* The server never returns a subscription's URL, so this is the only way to
* tell a subscription that still points at this browser's endpoint from one
* made for an endpoint the browser has since replaced.
*/
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
export function registeredEndpoint(): string | null {
try {
return localStorage.getItem(ENDPOINT_KEY);
} catch {
return null;
}
}
export function rememberEndpoint(endpoint: string | null): void {
try {
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
else localStorage.removeItem(ENDPOINT_KEY);
} catch {
/* private mode: every start is then a fresh registration, which still works */
}
}
/**
* Hand back the code the server pushed.
*
@@ -262,10 +378,10 @@ export async function unsubscribeThisDevice(): Promise<void> {
/* the browser end is gone or was never there; still clear the server end */
}
try {
const subs = await listSubscriptions();
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id));
} catch {
/* signing out must not fail over this */
}
rememberEndpoint(null);
setPushEnabledHere(false);
}
+56 -11
View File
@@ -15,11 +15,18 @@ import {
applicationServerKey,
createSubscription,
decodeApplicationServerKey,
destroySubscriptions,
deviceClientId,
extendSubscription,
findSubscription,
listSubscriptions,
needsRenewal,
mySubscriptions,
PushSetError,
pushEnabledHere,
registeredEndpoint,
rememberEndpoint,
RENEW_WITHIN_MS,
roomToMake,
setPushEnabledHere,
subscriptionPayload,
unsubscribeThisDevice,
@@ -64,8 +71,7 @@ async function collectStoredVerification(): Promise<void> {
}
/**
* Subscribe this browser. Safe to call again — the deviceClientId makes a
* repeat replace rather than accumulate.
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
*
* Returns why it could not, rather than throwing, because every reason is
* something to tell the user plainly: an old server, a browser without push, a
@@ -98,11 +104,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
}
/**
* Get this browser subscribed at the push service and registered at Stalwart.
* Get this browser subscribed at the push service and registered at Stalwart,
* with exactly one subscription there, and that one current.
*
* Shared by turning push on and by renewing it, because they are the same
* call: `deviceClientId` makes a repeat registration replace rather than
* accumulate, so there is no separate "update" path to get wrong.
* Shared by turning push on and by renewing it. It used to create a new
* subscription every time, on the belief that a repeated `deviceClientId`
* replaces the old one. Stalwart keeps both (checked live on 0.16.22), so each
* renewal added one, every start inside the renewal window added another, and
* the account reached its limit of fifteen -- "too many subscriptions" (#375).
* Now:
*
* - the same endpoint as last time, already registered: extend the newest one
* when it is close to expiring, and remove any extra copies;
* - anything else -- a new endpoint, nothing registered, an extension the
* server refused: remove this browser's old ones and register afresh.
*
* A registration refused for `overQuota` makes room among other browsers'
* subscriptions (`roomToMake`) and is tried once more.
*
* The local subscription is created when it is missing rather than only reused.
* A browser may drop or rotate one on its own -- a `pushsubscriptionchange`
@@ -117,9 +135,35 @@ async function registerThisBrowser(key: string): Promise<void> {
userVisibleOnly: true,
applicationServerKey: decodeApplicationServerKey(key),
}));
const accountId = useSession.getState().ownAccountFor(CAP.mail);
const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
const deviceId = deviceClientId();
const subs = await listSubscriptions();
const mine = mySubscriptions(subs, deviceId);
const [newest, ...extra] = mine;
if (newest && registeredEndpoint() === sub.endpoint) {
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
try {
await extendSubscription(newest.id);
return;
} catch {
/* not extendable: replaced below */
}
}
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox"));
try {
await createSubscription(payload);
} catch (err) {
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
if (!room.length) throw err;
await destroySubscriptions(room);
await createSubscription(payload);
}
rememberEndpoint(sub.endpoint);
}
/**
@@ -141,7 +185,8 @@ export async function renewWebPush(): Promise<void> {
const key = applicationServerKey();
if (!key) return;
try {
if (!needsRenewal(await listSubscriptions(), deviceClientId())) return;
// Cheap when nothing is due: one read, and a write only when a
// subscription is close to expiring, missing, or duplicated.
await registerThisBrowser(key);
listenForVerification();
} catch {
+9
View File
@@ -1,3 +1,12 @@
/**
* Remove the characters that reorder text around them: direction overrides,
* embeddings, isolates and marks. A sender-supplied name has no honest use for
* them, and `Invoice_\u202Efdp.exe` displays as "Invoice_exe.pdf".
*/
export function withoutBidiControls(s: string): string {
return s.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { withoutBidiControls } from "@/lib/text/text";
/**
* `winmail.dat`, opened.
*
@@ -210,7 +211,7 @@ export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] {
current = null;
return;
}
const name = (current.mapiName || current.title || "attachment").trim() || "attachment";
const name = withoutBidiControls(current.mapiName || current.title || "attachment").trim() || "attachment";
out.push({
name,
type: current.mapiType || guessType(name),
+65 -63
View File
@@ -10,7 +10,7 @@ import type { Catalog } from "@/lib/i18n";
* deleting a bad entry is a valid fix.
*
* ── Decisions this file is consistent about ──────────────────────────────
*
* --- Native speaker note: i would keep the more formal "u" and "uw" ---
* Register: **u**, throughout, following "Sie" and "vous" for the same reason
* — ihasmail is as often a company's mail as somebody's own. Dutch leans
* informal further and faster than German or French, and "je" is what most
@@ -22,15 +22,15 @@ import type { Catalog } from "@/lib/i18n";
* which is ordinary good Dutch UI and sidesteps it entirely.
*
* Terminology, fixed once so it cannot drift:
*
* Inbox Postvak IN Archive (verb) archiveren
* Drafts Concepten Delete verwijderen
* Sent Verzonden Move to verplaatsen naar
* Deleted Items Prullenbak Reply beantwoorden
* Junk / Spam Spam Reply all allen beantwoorden
* Folder Map Forward doorsturen
* Label Label Star ster
* Conversation Gesprek Read / unread gelezen / ongelezen
* --- Used capitals on all words for unity ---
* Inbox Postvak IN Archive (verb) Archiveren
* Drafts Concepten Delete Verwijderen
* Sent Verzonden Move to Verplaatsen naar
* Deleted Items Prullenbak Reply Beantwoorden
* Junk / Spam Spam Reply all Allen beantwoorden
* Folder Map Forward Doorsturen
* Label Label Star Ster
* Conversation Gesprek Read / unread Gelezen / ongelezen
* Message Bericht Settings Instellingen
* Attachment Bijlage Signature Handtekening
* Contact Contact Identity Identiteit
@@ -46,8 +46,8 @@ export const catalog: Catalog = {
// ── Administration: domains ────────────────────────────────────
"Domains": "Domeinen",
"By hand": "Handmatig",
"Signing": "Ondertekent",
"Published, not signing yet": "Gepubliceerd, ondertekent nog niet",
"Signing": "Ondertekenen",
"Published, not signing yet": "Gepubliceerd, nog niet ondertekend",
"Retiring": "Wordt uitgefaseerd",
"Retired": "Uitgefaseerd",
"This domain no longer exists. Someone may have removed it.": "Dit domein bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
@@ -55,13 +55,13 @@ export const catalog: Catalog = {
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.",
"Saved {name}": "{name} opgeslagen",
"Add domain": "Domein toevoegen",
"Added {date}": "Toegevoegd op {date}",
"Added {date}": "{date} toegevoegd",
"This domain is disabled on the server.": "Dit domein is op de server uitgeschakeld.",
"Your role lets you view domains but not change them.": "Met uw rol kunt u domeinen bekijken, maar niet wijzigen.",
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Nieuwe domeinen ondertekenen hun e-mail met DKIM-sleutels die de server aanmaakt en vervangt. De DNS-records verschijnen hier zodra het domein is toegevoegd.",
"Other names": "Andere namen",
"Delivery": "Bezorging",
"Catch-all address": "Catch-alladres",
"Catch-all address": "Catch-all adres",
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mail aan een adres dat niemand op dit domein heeft, wordt hier bezorgd. Laat leeg om die e-mail te weigeren.",
"Plus addressing": "Plus-adressering",
"Set by a custom rule on the server.": "Ingesteld door een aangepaste regel op de server.",
@@ -69,29 +69,29 @@ export const catalog: Catalog = {
"DNS records": "DNS-records",
"Published automatically through {provider}.": "Automatisch gepubliceerd via {provider}.",
"Published automatically by the server.": "Automatisch gepubliceerd door de server.",
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Voeg deze toe waar de DNS van dit domein wordt beheerd. Tot die tijd wordt e-mail niet bezorgd of vertrouwd.",
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Voeg deze toe waar de DNS van dit domein wordt beheerd. Tot die tijd worden e-mails niet bezorgd of vertrouwd.",
"Copy {type} record for {name}": "{type}-record voor {name} kopiëren",
"Copy value": "Waarde kopiëren",
"Copied the zone file": "Zonebestand gekopieerd",
"Copy all as a zone file": "Alles als zonebestand kopiëren",
"The server returned no records for this domain.": "De server gaf geen records voor dit domein.",
"The server returned no records for this domain.": "De server gaf geen records terug voor dit domein.",
"DKIM keys": "DKIM-sleutels",
"The server creates and rotates these keys itself.": "De server maakt en vervangt deze sleutels zelf.",
"These keys are managed by hand on the server.": "Deze sleutels worden op de server handmatig beheerd.",
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Geen DKIM-sleutels: e-mail van dit domein wordt niet ondertekend en komt eerder in de spam terecht.",
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Geen DKIM-sleutels: e-mails van dit domein worden niet ondertekend en worden eerder als spam aangemerkt.",
"Managed by the server": "Beheerd door de server",
"Certificate": "Certificaat",
"Another name for this domain": "Andere naam voor dit domein",
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mail aan hetzelfde adres onder een van deze namen komt in hetzelfde account. Wijzigingen gelden na opslaan.",
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mails aan hetzelfde adres onder een van deze namen komen in hetzelfde account. Wijzigingen worden toegepast na opslaan.",
"Its DKIM keys have to be removed first, and your role can't remove them.": "De DKIM-sleutels moeten eerst worden verwijderd, en uw rol mag dat niet.",
"The server stops accepting mail for this domain.": "De server accepteert geen e-mail meer voor dit domein.",
"The server stops accepting mail for this domain.": "De server accepteert geen e-mails meer voor dit domein.",
"Remove domain…": "Domein verwijderen…",
"Remove {name}?": "{name} verwijderen?",
"Removed {name}": "{name} verwijderd",
"The server kept the domain: it is still used by {things}.": "De server heeft het domein behouden: het wordt nog gebruikt door {things}.",
"Remove domain": "Domein verwijderen",
"The server stops accepting mail for this domain. This can't be undone.": "De server accepteert geen e-mail meer voor dit domein. Dit kan niet ongedaan worden gemaakt.",
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Waar uw adressen wonen, en de DNS-records waardoor e-mail aankomt en wordt vertrouwd.",
"The server stops accepting mail for this domain. This can't be undone.": "De server accepteert geen e-mails meer voor dit domein. Dit kan niet ongedaan worden gemaakt.",
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Waar je e-mail adressen staan en de DNS-records die ervoor zorgen dat e-mail aankomt en als betrouwbaar wordt herkend.",
"Search domains": "Domeinen zoeken",
"No domains match": "Geen domeinen gevonden",
"No domains yet": "Nog geen domeinen",
@@ -101,17 +101,17 @@ export const catalog: Catalog = {
"also {names}": "ook {names}",
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
// ── Administration: refusals ───────────────────────────────────
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Dat is geen geldige domeinnaam. Gebruik een naam zoals example.com, met een echt topleveldomein.",
"That isn't a valid email address. Use a full address, such as [email protected].": "Dat is geen geldig e-mailadres. Gebruik een volledig adres, zoals na[email protected].",
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Dat is geen geldig adres. Gebruik letters, cijfers, punten, koppeltekens of underscores vóór de @.",
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Dat is geen geldige domeinnaam. Gebruik een naam zoals voorbeeld.nl, met een echt topleveldomein.",
"That isn't a valid email address. Use a full address, such as [email protected].": "Dat is geen geldig e-mailadres. Gebruik een volledig adres, zoals na[email protected].",
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Dat is geen geldig adres. Gebruik letters, cijfers, punten, koppeltekens of underscores voor de @.",
"That isn't a valid host name or IP address.": "Dat is geen geldige hostnaam of IP-adres.",
"A required value was left empty.": "Een verplichte waarde is leeg gelaten.",
"A required value was left empty.": "Een verplichte waarde is niet ingevuld.",
"Administration is turned off on this installation.": "Beheer is uitgeschakeld in deze installatie.",
"The mail server could not carry out the request ({code}).": "De mailserver kon het verzoek niet uitvoeren ({code}).",
"You can't give an account permissions your own role doesn't have.": "U kunt een account geen rechten geven die uw eigen rol niet heeft.",
"This account signs in through an external directory, so its password can't be set here.": "Dit account logt in via een externe adreslijst, dus het wachtwoord kan hier niet worden ingesteld.",
"The server's license allows no more accounts.": "De licentie van de server staat geen extra accounts toe.",
"That domain name is already in use on this server, as a domain or another domain's other name.": "Die domeinnaam is op deze server al in gebruik, als domein of als andere naam van een ander domein.",
"The server's license allows no more accounts.": "De licentie van de server staat geen extra accounts meer toe.",
"That domain name is already in use on this server, as a domain or another domain's other name.": "Die domeinnaam is op deze server al in gebruik, als domein of als een andere naam van een ander domein.",
"Your organization has reached the number of domains it is allowed.": "Uw organisatie heeft het toegestane aantal domeinen bereikt.",
"That is more than the mail server accepts in one change.": "Dat is meer dan de mailserver in één wijziging accepteert.",
"The mail server rejected one of the values. Check what you entered and try again.": "De mailserver heeft een van de waarden geweigerd. Controleer wat u hebt ingevuld en probeer het opnieuw.",
@@ -135,14 +135,14 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
"Nothing to show": "Niets om te tonen",
"Could not be loaded": "Kon niet worden geladen",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in het eigen beheer van Stalwart.",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in de beheeromgeving van Stalwart.",
"Open Stalwart admin": "Stalwart-beheer openen",
"Default group role": "Standaardrol voor groepen",
"A group needs an address.": "Een groep heeft een adres nodig.",
"New group": "Nieuwe groep",
"Your role lets you view groups but not change them.": "Met uw rol kunt u groepen bekijken, maar niet wijzigen.",
"No domains are available to create a group on.": "Er zijn geen domeinen waarop een groep kan worden aangemaakt.",
"Mail to these addresses is delivered to this group. Changes apply when you save.": "E-mail aan deze adressen wordt bij deze groep bezorgd. Wijzigingen gelden zodra u opslaat.",
"Mail to these addresses is delivered to this group. Changes apply when you save.": "E-mails aan deze adressen worden bij deze groep bezorgd. Wijzigingen worden toegepast na opslaan.",
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Wat de groep zelf mag doen. Leden houden hun eigen rollen: een groep geeft hen wat ermee gedeeld is, niet haar rechten. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
"Loading the group's members…": "Leden van de groep laden…",
"This group has more members than can be taken out at once.": "Deze groep heeft meer leden dan er in één keer uit kunnen worden gehaald.",
@@ -155,7 +155,7 @@ export const catalog: Catalog = {
"Remove from group": "Uit de groep halen",
"No members yet": "Nog geen leden",
"Showing {shown} of {total} members.": "{shown} van {total} leden getoond.",
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Leden krijgen wat met de groep gedeeld is, zoals haar postvak. Wijzigingen gelden meteen.",
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Leden krijgen wat met de groep gedeeld is, zoals haar postvak. Wijzigingen worden meteen toegepast.",
"Add a member by name or address": "Lid toevoegen op naam of adres",
"Add a member": "Lid toevoegen",
"No one else matches": "Verder komt niemand overeen",
@@ -167,7 +167,7 @@ export const catalog: Catalog = {
"Search groups": "Groepen zoeken",
"No groups match": "Geen groepen gevonden",
"No groups yet": "Nog geen groepen",
"Your organization has reached the number of groups it is allowed.": "Uw organisatie heeft het toegestane aantal groepen bereikt.",
"Your organization has reached the number of groups it is allowed.": "Uw organisatie heeft het maximale aantal groepen bereikt.",
"This group no longer exists. Someone may have deleted it.": "Deze groep bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
"The server did not say whether the group was created.": "De server heeft niet gemeld of de groep is aangemaakt.",
"Mailing lists": "Mailinglijsten",
@@ -176,19 +176,19 @@ export const catalog: Catalog = {
"New mailing list": "Nieuwe mailinglijst",
"Your role lets you view mailing lists but not change them.": "Met uw rol kunt u mailinglijsten bekijken, maar niet wijzigen.",
"No domains are available to create a list on.": "Er zijn geen domeinen waarop een lijst kan worden aangemaakt.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "E-mail aan deze adressen gaat ook naar de lijst. Wijzigingen gelden zodra u opslaat.",
"Mail to these addresses goes to the list too. Changes apply when you save.": "E-mails aan deze adressen gaan ook naar de lijst. Wijzigingen worden toegepast na opslaan.",
"Create list": "Lijst aanmaken",
"Filter recipients": "Ontvangers filteren",
"No recipients match": "Geen ontvangers gevonden",
"Add recipients": "Ontvangers toevoegen",
"Addresses, separated by commas": "Adressen, gescheiden door komma's",
"Not added, as they aren't addresses: {items}": "Niet toegevoegd, want dit zijn geen adressen: {items}",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "E-mail aan de lijst wordt doorgestuurd naar elke ontvanger, op deze server of elders. U kunt er meerdere tegelijk plakken. Wijzigingen gelden zodra u opslaat.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "E-mail aan dit adres wordt niet meer doorgestuurd. De eigen e-mail van de ontvangers blijft ongemoeid.",
"Mail to the list is passed on to every recipient, on this server or anywhere else. Paste several at once if you like. Changes apply when you save.": "E-mails naar de lijst worden doorgestuurd naar elke ontvanger op deze server of elders. U kunt er meerdere tegelijk plakken. Wijzigingen worden toegepast na opslaan.",
"Mail to this address stops being passed on. The recipients' own mail is untouched.": "E-mails aan dit adres worden niet meer doorgestuurd. De eigen e-mails van de ontvangers blijven ongemoeid.",
"Delete list…": "Lijst verwijderen…",
"Delete list": "Lijst verwijderen",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "E-mail aan dit adres wordt aan niemand meer doorgestuurd. Dit kan niet ongedaan worden gemaakt.",
"Addresses that pass mail on to everyone on them.": "Adressen die e-mail doorsturen naar iedereen die erop staat.",
"Mail to this address is no longer passed on to anyone. It can't be undone.": "E-mails aan dit adres worden aan niemand meer doorgestuurd. Dit kan niet ongedaan worden gemaakt.",
"Addresses that pass mail on to everyone on them.": "Adressen die e-mails doorsturen naar iedereen die erop staat.",
"Search mailing lists": "Mailinglijsten zoeken",
"No mailing lists match": "Geen mailinglijsten gevonden",
"No mailing lists yet": "Nog geen mailinglijsten",
@@ -203,16 +203,16 @@ export const catalog: Catalog = {
"A role needs a name.": "Een rol heeft een naam nodig.",
"Created {name}": "{name} aangemaakt",
"New role": "Nieuwe rol",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die de uwe niet heeft, dus u kunt hem bekijken maar niet wijzigen.",
"This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die uw rol niet heeft, dus u kunt hem bekijken maar niet wijzigen.",
"Your role lets you view roles but not change them.": "Met uw rol kunt u rollen bekijken, maar niet wijzigen.",
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
"Builds on": "Bouwt voort op",
"Permissions": "Rechten",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart geeft deze rol standaard, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in het eigen beheer van Stalwart.",
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die de uwe niet heeft.",
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in de beheeromgeving van Stalwart.",
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
"Create role": "Rol aanmaken",
"builds on this one": "bouwt voort op deze",
"has permissions yours doesn't": "heeft rechten die de uwe niet heeft",
"has permissions yours doesn't": "heeft rechten die u niet heeft",
"No other roles": "Geen andere rollen",
"A role has every permission of the roles it builds on, apart from any it or they deny.": "Een rol heeft alle rechten van de rollen waarop hij voortbouwt, behalve die hij of zij weigeren.",
"Search permissions": "Rechten zoeken",
@@ -231,6 +231,7 @@ export const catalog: Catalog = {
"Accounts, groups and other roles that use it must be moved off it first.": "Accounts, groepen en andere rollen die hem gebruiken, moeten er eerst vanaf.",
"Delete role…": "Rol verwijderen…",
"Deleted {name}": "{name} verwijderd",
// this translation says: "Still used by {things}. Give them another role first". But if you truly mean "Move them to another role first" the translation should be: "Nog in gebruik bij {things}. Verplaats deze eerst naar een andere rol."
"Still used by {things}. Move them to another role first.": "Nog in gebruik bij {things}. Geef ze eerst een andere rol.",
"Delete role": "Rol verwijderen",
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
@@ -295,7 +296,7 @@ export const catalog: Catalog = {
"Search accounts": "Accounts zoeken",
"No accounts match": "Geen accounts gevonden",
"No accounts yet": "Nog geen accounts",
"Nothing on your domains matches “{query}”.": "Niets op uw domeinen komt overeen met {query}.",
"Nothing on your domains matches “{query}”.": "Niets in uw domeinen komt overeen met {query}.",
"Open {address}": "{address} openen",
"{from}{to} of {total}": "{from}{to} van {total}",
"Previous page": "Vorige pagina",
@@ -305,7 +306,7 @@ export const catalog: Catalog = {
"{used} · no limit": "{used} · geen limiet",
"Profile": "Profiel",
"Domain": "Domein",
"No domains are available to create an account on.": "Er is geen domein beschikbaar om een account op aan te maken.",
"No domains are available to create an account on.": "Er zijn geen domeinen beschikbaar om een account op aan te maken.",
"Sign-in": "Inloggen",
"Other addresses": "Andere adressen",
"Not in any group": "Geen lid van een groep",
@@ -313,9 +314,9 @@ export const catalog: Catalog = {
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Alleen rollen waarvan u de rechten zelf hebt, worden aangeboden. Bij een account binnen een tenant betekent Beheerder: beheerder van die tenant.",
"Limit in GB": "Limiet in GB",
"No limit": "Geen limiet",
"This account has permissions yours doesn't, so you can view it but not change it.": "Dit account heeft rechten die het uwe niet heeft. U kunt het bekijken, maar niet wijzigen.",
"This account has permissions yours doesn't, so you can view it but not change it.": "Dit account heeft rechten die uw account niet heeft. U kunt hem bekijken, maar niet wijzigen.",
"Your role lets you view accounts but not change them.": "Met uw rol kunt u accounts bekijken, maar niet wijzigen.",
"This account has permissions yours doesn't.": "Dit account heeft rechten die het uwe niet heeft.",
"This account has permissions yours doesn't.": "Dit account heeft rechten die uw account niet heeft.",
"You can't delete the account you're signed in with.": "U kunt het account waarmee u bent ingelogd niet verwijderen.",
"Create account": "Account aanmaken",
"An account needs an address.": "Een account heeft een adres nodig.",
@@ -331,11 +332,11 @@ export const catalog: Catalog = {
"Remove {address}": "{address} verwijderen",
"New address": "Nieuw adres",
"another name": "andere naam",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-mail aan deze adressen wordt in dit account afgeleverd. Wijzigingen gelden na opslaan.",
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles erin.",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-mail aan deze adressen wordt in dit account afgeleverd. Wijzigingen worden toegepast na opslaan.",
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles wat erin zit.",
"Delete account…": "Account verwijderen…",
"Delete {address}?": "{address} verwijderen?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mail, agendas, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mails, agendas, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
"Type {address} to confirm": "Typ {address} om te bevestigen",
"Delete account": "Account verwijderen",
"Deleted {address}": "{address} verwijderd",
@@ -344,12 +345,12 @@ export const catalog: Catalog = {
"That address is already in use on this server, as an account, a list or an alias.": "Dat adres is op deze server al in gebruik, als account, lijst of alias.",
"One of the chosen domain, role or group can't be used for this account.": "Het gekozen domein, de rol of de groep kan niet voor dit account worden gebruikt.",
"Your organization has reached the number of accounts it is allowed.": "Uw organisatie heeft het toegestane aantal accounts bereikt.",
"Something still depends on this, so the server kept it.": "Er hangt nog iets van af, dus de server heeft het behouden.",
"Something still depends on this, so the server kept it.": "Er is nog iets dat hiervan afhankelijk is, dus de server heeft het behouden.",
"This account no longer exists. Someone may have deleted it.": "Dit account bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
"The password was not accepted: {reason}": "Het wachtwoord is niet geaccepteerd: {reason}",
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
"Go to folder…": "Ga naar map…",
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
"Set for everyone here. You cannot change this.": "Voor iedereen hier ingesteld. U kunt dit niet wijzigen.",
"Export iCAL file": "iCAL-bestand exporteren",
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
// ── Actions ────────────────────────────────────────────────────────
@@ -418,7 +419,7 @@ export const catalog: Catalog = {
"Validate": "Controleren",
"Revoke": "Intrekken",
"Turn off": "Uitschakelen",
"Clear": "Legen",
"Clear": "Wissen",
"Clear selection": "Selectie opheffen",
"Clear custom color": "Eigen kleur wissen",
"Select": "Selecteren",
@@ -472,7 +473,7 @@ export const catalog: Catalog = {
"Body": "Tekst",
"Body text": "Bodytekst",
"Attach files": "Bestanden bijvoegen",
"Attach from Files": "Bijvoegen uit Bestanden",
"Attach from Files": "Bijvoegen uit bestanden",
"Remove attachment": "Bijlage verwijderen",
"Has attachment": "Heeft bijlage",
"Has the words": "Bevat de woorden",
@@ -503,7 +504,7 @@ export const catalog: Catalog = {
"Low": "Laag",
"to {recipients}": "aan {recipients}",
"From: {sender}": "Van: {sender}",
"Waiting on the server — goes out {when}.": "Wacht op de server — gaat {when} de deur uit.",
"Waiting on the server — goes out {when}.": "Wacht op de server — wordt {when} verzonden.",
"Scheduled — click to clear the schedule": "Gepland — klik om de planning te wissen",
"Nothing scheduled": "Niets gepland",
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Het bericht wacht op de server en wordt verzonden, of ihasmail nu open is of niet.",
@@ -563,28 +564,29 @@ export const catalog: Catalog = {
"Day": "Dag",
"Week": "Week",
"Month": "Maand",
"Agenda": "Agendaoverzicht",
"Agenda": "Agenda overzicht",
"Today": "Vandaag",
"Go to day": "Naar dag",
"Go to week": "Naar week",
"Go to day": "Ga naar dag",
"Go to week": "Ga naar week",
"Previous month": "Vorige maand",
"Next month": "Volgende maand",
"Does not repeat": "Herhaalt niet",
"Daily": "Dagelijks",
"Every weekday": "Elke werkdag",
"Every weekday": "Elke doordeweekse dag",
"Yearly": "Jaarlijks",
"Custom…": "Aangepast…",
"Weekly on {weekday}": "Wekelijks op {weekday}",
"Monthly on day {day}": "Maandelijks op dag {day}",
"Repeat every": "Herhalen elke",
"Repeat every": "Herhaal elke",
"Repeat until": "Herhalen tot",
"after N times": "na N keer",
"on date": "op datum",
"never": "nooit",
// consistency, but i would suggest "dag/dagen", "week/weken", "maand/maanden", "jaar/jaren"
"day(s)": "dag(en)",
"week(s)": "we(e)k(en)",
"month(s)": "maand(en)",
"year(s)": "jaar/jaren",
"year(s)": "ja(a)r(en)",
"Reminders": "Herinneringen",
"Add reminder": "Herinnering toevoegen",
"Remove reminder": "Herinnering verwijderen",
@@ -606,7 +608,7 @@ export const catalog: Catalog = {
"Default view": "Standaardweergave",
"Guests": "Genodigden",
"Add guests by name or email": "Genodigden toevoegen op naam of e-mail",
"Send invitation emails to guests": "Uitnodigingen per e-mail versturen",
"Send invitation emails to guests": "Uitnodigingen per e-mail versturen naar genodigden",
"Going?": "Bent u erbij?",
"Yes": "Ja",
"No": "Nee",
@@ -1021,7 +1023,7 @@ export const catalog: Catalog = {
"New label": "Nieuw label",
"Delete label": "Label verwijderen",
"Large attachments may be rejected by some servers": "Grote bijlagen worden door sommige servers geweigerd",
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw Bestanden (map “ihasmail”) en bij verzending ingesloten.",
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw bestanden (map “ihasmail”) en bij verzending ingesloten.",
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
"Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch antwoorden aan mensen die u mailen terwijl u weg bent. Elke afzender krijgt hoogstens één antwoord.",
"Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Inkomende post automatisch sorteren. De regels draaien op de server (Sieve) en gelden dus voor elke client die u gebruikt.",
@@ -1503,7 +1505,7 @@ export const catalog: Catalog = {
"Show birthdays from your contacts": "Verjaardagen van uw contacten tonen",
"Show in the sidebar": "In de zijbalk tonen",
"Show keyboard shortcuts": "Sneltoetsen tonen",
"Somebody else saved this file while it was open. Copy your changes, close it, and start again.": "Iemand anders heeft dit bestand opgeslagen terwijl het open stond. Kopieer uw wijzigingen, sluit het en begin opnieuw.",
"Somebody else saved this file while it was open. Copy your changes, close it, and start again.": "Iemand anders heeft dit bestand opgeslagen terwijl het open stond. Kopieer uw wijzigingen, sluit het bestand en begin opnieuw.",
"Sort by, in order": "Sorteren op, in deze volgorde",
"Source": "Bron",
"Spam filter": "Spamfilter",
@@ -1545,7 +1547,7 @@ export const catalog: Catalog = {
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"View as": "Weergeven als",
"Warnings": "Waarschuwingen",
"Week view": "Weekweergave",
"Week view": "Week weergave",
"What is it called?": "Hoe heet het?",
"What reaches a sender, and what asks before it happens.": "Wat een afzender bereikt, en wat het vraagt voordat het gebeurt.",
"What you changed here will be lost.": "Wat u hier hebt gewijzigd gaat verloren.",
@@ -1554,7 +1556,7 @@ export const catalog: Catalog = {
"You": "U",
"Your Sieve script has changes that have not been saved.": "Uw Sieve-script bevat wijzigingen die niet zijn opgeslagen.",
"Your filter rules have changes that have not been saved.": "Uw filterregels bevatten wijzigingen die niet zijn opgeslagen.",
"Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook de subdomeinen.",
"Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook zijn subdomeinen.",
"Your own:": "Uw eigen:",
"dark mode": "de donkere modus",
"file": "bestand",
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { BODIES_KEPT, resetBodyOrder, useMail } from "@/store/mail";
/**
* A long session used to keep the full copy of every message it opened --
* bodies, headers, attachment lists -- until the tab closed.
*/
const full = (id: string, threadId = `t-${id}`) => ({
id,
threadId,
mailboxIds: { in: true },
keywords: {},
subject: `Subject ${id}`,
receivedAt: "2026-09-16T00:00:00Z",
preview: "p",
htmlBody: [{ partId: "1", type: "text/html" }],
bodyValues: { "1": { value: "<p>".padEnd(10_000, "x") } },
attachments: [],
});
beforeEach(() => {
resetBodyOrder();
client.session = { capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
useMail.setState({ accountId: "a1", emails: {}, fullIds: {}, threads: {}, openThreadId: null, emailState: "1" });
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, args, id]) => [name, { state: "1", list: (args.ids as string[]).map((x) => full(x)), notFound: [] }, id]);
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const open = async (id: string) => {
await useMail.getState().getEmails([id], true);
};
describe("message bodies", () => {
it("are let go past the limit, oldest first, back to what the list shows", async () => {
for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`);
const { emails, fullIds } = useMail.getState();
expect(Object.keys(fullIds)).toHaveLength(BODIES_KEPT);
for (let i = 0; i < 5; i++) {
expect(fullIds[`m${i}`]).toBeUndefined();
expect(emails[`m${i}`]).toMatchObject({ id: `m${i}`, subject: `Subject m${i}`, preview: "p" });
expect(emails[`m${i}`]).not.toHaveProperty("bodyValues");
expect(emails[`m${i}`]).not.toHaveProperty("htmlBody");
}
expect(emails[`m${BODIES_KEPT + 4}`]).toHaveProperty("bodyValues");
});
it("count a message opened again as recent", async () => {
for (let i = 0; i < BODIES_KEPT; i++) await open(`m${i}`);
await open("m0");
await open("extra");
const { fullIds } = useMail.getState();
expect(fullIds.m0).toBe(true);
expect(fullIds.m1).toBeUndefined();
});
it("are never taken from the conversation that is open", async () => {
await open("keep");
useMail.setState((s) => ({ openThreadId: "t-keep", threads: { ...s.threads, "t-keep": { id: "t-keep", emailIds: ["keep"] } } }));
for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`);
expect(useMail.getState().fullIds.keep).toBe(true);
expect(useMail.getState().emails.keep).toHaveProperty("bodyValues");
});
it("are fetched again when a released message is opened", async () => {
for (let i = 0; i < BODIES_KEPT + 1; i++) await open(`m${i}`);
expect(useMail.getState().fullIds.m0).toBeUndefined();
const [again] = await useMail.getState().getEmails(["m0"], true);
expect(again).toHaveProperty("bodyValues");
expect(useMail.getState().fullIds.m0).toBe(true);
});
});
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCompose } from "@/store/compose";
import { useSession } from "@/store/session";
/**
* A message being written belongs to the session it was written in. On a
* shared machine the next person to sign in -- after an idle sign-out, with no
* reload in between -- used to find the last one's composer still open.
*/
beforeEach(() => {
vi.useFakeTimers();
useSession.setState({ status: "authenticated" });
});
afterEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
vi.useRealTimers();
});
describe("signing out", () => {
it("closes every composer and stops sends that are still waiting", () => {
const run = vi.fn(async () => {});
const timer = window.setTimeout(() => void run(), 5000);
useCompose.setState({
drafts: [{ key: "d1", subject: "Half written" } as never],
activeKey: "d1",
pendingSends: { d2: { timer, toastId: 1, draft: { key: "d2" } as never, run } },
});
useSession.setState({ status: "anonymous" });
expect(useCompose.getState().drafts).toEqual([]);
expect(useCompose.getState().activeKey).toBeNull();
expect(useCompose.getState().pendingSends).toEqual({});
vi.advanceTimersByTime(10_000);
expect(run).not.toHaveBeenCalled();
});
it("leaves the composer alone while still signed in", () => {
useCompose.setState({ drafts: [{ key: "d1" } as never], activeKey: "d1" });
useSession.setState({ pushConnected: true });
expect(useCompose.getState().drafts).toHaveLength(1);
});
it("sends what is inside its undo window before the session goes", async () => {
const run = vi.fn(async () => {});
const timer = window.setTimeout(() => void run(), 5000);
useCompose.setState({ pendingSends: { d2: { timer, toastId: 1, draft: { key: "d2" } as never, run } } });
await useCompose.getState().flushPendingSends();
expect(run).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(10_000);
expect(run).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { keepRecent, RANGES_KEPT, useCalendar } from "@/store/calendar";
/**
* What a change to one contact or one event costs.
*
* A pushed ContactCard change used to reload the whole address book, and a
* CalendarEvent change queried every window the reader had ever visited again.
*/
type Call = [string, Record<string, unknown>, string];
let calls: Call[];
let reply: (name: string, args: Record<string, unknown>) => unknown;
beforeEach(() => {
calls = [];
client.session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 2 }, [CAP.contacts]: {}, [CAP.calendars]: {} },
accounts: {},
primaryAccounts: {},
state: "s",
} as unknown as JmapSession;
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
const methodResponses = methodCalls.map(([name, args, id]) => {
calls.push([name, args, id]);
const out = reply(name, args);
return out instanceof Error ? ["error", { type: out.message }, id] : [name, out, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const card = (id: string, name: string) => ({ id, addressBookIds: { b1: true }, name: { full: name } });
describe("syncCards", () => {
beforeEach(() => {
useContacts.setState({
accountId: "a1",
available: true,
loaded: true,
loading: false,
cardState: "10",
cards: { c1: card("c1", "Ann"), c2: card("c2", "Bob"), c3: card("c3", "Cy") } as never,
});
});
it("fetches only what changed, in batches the server takes", async () => {
reply = (name, args) => {
if (name === "ContactCard/changes") return { oldState: "10", newState: "12", hasMoreChanges: false, created: ["c4", "c5", "c6"], updated: ["c1"], destroyed: ["c2"] };
if (name === "ContactCard/get") return { state: "12", list: (args.ids as string[]).map((id) => card(id, `new ${id}`)), notFound: [] };
throw new Error(`unexpected ${name}`);
};
await useContacts.getState().syncCards();
const names = calls.map(([n]) => n);
expect(names).not.toContain("ContactCard/query");
const gets = calls.filter(([n]) => n === "ContactCard/get").map(([, a]) => a.ids as string[]);
expect(gets.every((ids) => ids.length <= 2)).toBe(true);
expect(gets.flat().sort()).toEqual(["c1", "c4", "c5", "c6"]);
const s = useContacts.getState();
expect(Object.keys(s.cards).sort()).toEqual(["c1", "c3", "c4", "c5", "c6"]);
expect(s.cards.c1!.name!.full).toBe("new c1");
expect(s.cards.c3!.name!.full).toBe("Cy");
expect(s.cardState).toBe("12");
});
it("follows changes across pages", async () => {
reply = (name, args) => {
if (name === "ContactCard/changes") {
return args.sinceState === "10"
? { oldState: "10", newState: "11", hasMoreChanges: true, created: [], updated: ["c1"], destroyed: [] }
: { oldState: "11", newState: "13", hasMoreChanges: false, created: [], updated: [], destroyed: ["c1"] };
}
return { state: "13", list: [], notFound: [] };
};
await useContacts.getState().syncCards();
// Updated on the first page and destroyed on the second: gone, and not fetched.
expect(useContacts.getState().cards.c1).toBeUndefined();
expect(calls.filter(([n]) => n === "ContactCard/get")).toHaveLength(0);
expect(useContacts.getState().cardState).toBe("13");
});
it("reloads everything when the server cannot say what changed", async () => {
reply = (name) => {
if (name === "ContactCard/changes") return new Error("cannotCalculateChanges");
if (name === "ContactCard/query") return { ids: ["c9"], total: 1, position: 0, queryState: "q" };
if (name === "ContactCard/get") return { state: "20", list: [card("c9", "Zed")], notFound: [] };
throw new Error(`unexpected ${name}`);
};
await useContacts.getState().syncCards();
const s = useContacts.getState();
expect(Object.keys(s.cards)).toEqual(["c9"]);
expect(s.cardState).toBe("20");
});
it("records the state a full load was read at", async () => {
useContacts.setState({ cardState: null, loaded: false });
reply = (name) => {
if (name === "ContactCard/query") return { ids: ["c1"], total: 1, position: 0, queryState: "q" };
return { state: "30", list: [card("c1", "Ann")], notFound: [] };
};
await useContacts.getState().loadAll();
expect(useContacts.getState().cardState).toBe("30");
});
});
describe("calendar windows", () => {
const day = 86_400_000;
const windowAt = (n: number) => [new Date(n * 7 * day), new Date((n + 1) * 7 * day)] as const;
beforeEach(() => {
useCalendar.setState({ accountId: "a1", available: true, ranges: {}, sharedRanges: {}, events: {}, sharedCalendars: [] });
reply = (name) => (name === "CalendarEvent/query" ? { ids: [], total: 0, position: 0, queryState: "q" } : { state: "1", list: [], notFound: [] });
});
it("keeps only the most recent few", () => {
let ranges: Record<string, string[]> = {};
for (let i = 0; i < RANGES_KEPT + 3; i++) ranges = keepRecent(ranges, `k${i}`, []);
expect(Object.keys(ranges)).toEqual(Array.from({ length: RANGES_KEPT }, (_, i) => `k${i + 3}`));
// Seeing one again moves it to the back of the queue.
ranges = keepRecent(ranges, "k3", ["e"]);
expect(Object.keys(ranges).at(-1)).toBe("k3");
expect(ranges.k3).toEqual(["e"]);
});
it("queries only the windows it holds when an event changes", async () => {
const store = useCalendar.getState();
for (let i = 0; i < 10; i++) {
const [a, b] = windowAt(i);
await store.loadRange(a, b);
}
expect(Object.keys(useCalendar.getState().ranges)).toHaveLength(RANGES_KEPT);
calls = [];
useCalendar.getState().applyChanges(new Set(["CalendarEvent"]));
await vi.waitFor(() => expect(calls.filter(([n]) => n === "CalendarEvent/query")).toHaveLength(RANGES_KEPT));
});
it("does not empty the windows while they reload", () => {
useCalendar.setState({ ranges: { [`${7 * day}|${14 * day}`]: ["e1"] } });
useCalendar.getState().invalidate();
expect(Object.values(useCalendar.getState().ranges)).toEqual([["e1"]]);
});
});
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
/**
* #376: avatars in the mail list come from the address book's cards, and
* nothing loaded those at sign-in -- so a contact's photo showed once Contacts
* had been opened and was gone after the next reload.
*/
const session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.contacts]: {} },
accounts: { own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.contacts]: {} } } },
primaryAccounts: { [CAP.contacts]: "own" },
state: "s",
} as unknown as JmapSession;
beforeEach(() => {
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own" });
useContacts.setState({ accountId: null, loaded: false, loading: false, cards: {}, cardState: null });
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, , id]) => {
if (name === "ContactCard/query") return [name, { ids: ["c1"], total: 1, position: 0, queryState: "q" }, id];
if (name === "ContactCard/get") return [name, { state: "5", list: [{ id: "c1", emails: { e: { address: "[email protected]" } }, media: { p: { kind: "photo", uri: "data:image/jpeg;base64,AA" } } }], notFound: [] }, id];
return [name, { state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("contacts at sign-in", () => {
it("loads the cards, so an avatar can be found without opening Contacts", async () => {
await useContacts.getState().init();
await vi.waitFor(() => expect(useContacts.getState().loaded).toBe(true));
const card = useContacts.getState().lookupByEmail("[email protected]");
expect(card?.id).toBe("c1");
});
});
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useMail } from "@/store/mail";
import type { JmapSession } from "@/jmap/types";
/**
* What a list refresh and an open thread cost the server.
*
* A refresh fetches everything already on screen, and in conversation mode
* every message of every listed thread. Both used to go to Email/get in one
* call whatever their number, and Stalwart refuses a whole call over
* `maxObjectsInGet` -- so past a few pages, or with long threads, the refresh
* failed and the list silently stopped updating.
*/
const MAX = 500;
const INBOX = "mbInbox";
type Call = [string, Record<string, unknown>, string];
/**
* A mailbox of `count` messages. With `threadSize` above 1, each listed
* message leads a thread of that many, whose other members are not in the
* list. Enforces MAX on every /get, back-referenced ids included, as the mock
* server and Stalwart do.
*/
function server(count: number, threadSize = 1) {
const listed = Array.from({ length: count }, (_, i) => `e${i}`);
const members = (lead: string) => [lead, ...Array.from({ length: threadSize - 1 }, (_, j) => `${lead}m${j}`)];
const calls: Call[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: Call[] };
const responses: Call[] = [];
const resolve = (args: Record<string, unknown>): Record<string, unknown> => {
const ref = args["#ids"] as { resultOf: string; path: string } | undefined;
if (!ref) return args;
const from = responses.find((r) => r[2] === ref.resultOf)![1];
const ids =
ref.path === "/ids" ? (from.ids as string[])
: ref.path === "/list/*/threadId" ? (from.list as { threadId: string }[]).map((e) => e.threadId)
: (from.list as { emailIds: string[] }[]).flatMap((t) => t.emailIds);
const { "#ids": _drop, ...rest } = args;
return { ...rest, ids };
};
for (const [name, raw, id] of body.methodCalls) {
const args = resolve(raw);
calls.push([name, args, id]);
const ids = args.ids as string[] | undefined;
if (name.endsWith("/get") && ids && ids.length > MAX) {
responses.push(["error", { type: "requestTooLarge" }, id]);
continue;
}
if (name === "Email/query") {
const position = args.position as number;
const limit = args.limit as number;
responses.push([name, { accountId: "a1", queryState: "q1", canCalculateChanges: false, position, ids: listed.slice(position, position + limit), total: listed.length }, id]);
} else if (name === "Email/get") {
const list = ids!.map((e) => ({ id: e, threadId: `t${e.replace(/m\d+$/, "")}`, mailboxIds: { [INBOX]: true }, keywords: {}, receivedAt: "2026-09-16T00:00:00Z" }));
responses.push([name, { accountId: "a1", state: "s1", list, notFound: [] }, id]);
} else if (name === "Thread/get") {
const list = ids!.map((t) => ({ id: t, emailIds: members(t.slice(1)) }));
responses.push([name, { accountId: "a1", state: "s1", list, notFound: [] }, id]);
} else {
responses.push([name, { accountId: "a1", state: "s1", list: [], notFound: [] }, id]);
}
}
return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { calls, listed };
}
const getSizes = (calls: Call[]) => calls.filter(([n]) => n === "Email/get").map(([, a]) => (a.ids as string[]).length);
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.mail]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
list: null,
emails: {},
fullIds: {},
threads: {},
emailState: "s1",
loadingThreads: {},
openThreadId: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const listOf = (ids: string[], collapseThreads = false) => ({
key: "k",
filter: { inMailbox: INBOX },
sort: [],
collapseThreads,
mailboxId: INBOX,
ids,
total: ids.length,
queryState: "q0",
loading: false,
loadingMore: false,
error: null,
exhausted: false,
});
describe("refreshList", () => {
it("refreshes more rows than one Email/get may carry, in pages the server takes", async () => {
const { calls, listed } = server(1300);
useMail.setState({ list: listOf(listed.slice(0, 1200)) as never });
await useMail.getState().refreshList();
const sizes = getSizes(calls);
expect(sizes.every((n) => n <= MAX)).toBe(true);
expect(calls.some(([n]) => n === "error")).toBe(false);
const list = useMail.getState().list!;
expect(list.ids).toEqual(listed.slice(0, 1200));
expect(list.total).toBe(1300);
expect(list.error).toBeNull();
});
it("stops at the end of a folder that shrank", async () => {
const { listed } = server(700);
useMail.setState({ list: listOf([...listed, ...Array.from({ length: 300 }, (_, i) => `gone${i}`)]) as never });
await useMail.getState().refreshList();
const list = useMail.getState().list!;
expect(list.ids).toEqual(listed);
expect(list.exhausted).toBe(true);
});
it("fetches long threads' other messages within the limit", async () => {
// 50 listed threads of 20 messages: 950 members that are not in the list.
const { calls, listed } = server(50, 20);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: true, mailboxId: INBOX });
const list = useMail.getState().list!;
expect(list.error).toBeNull();
expect(list.ids).toEqual(listed);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
const emails = useMail.getState().emails;
expect(Object.keys(emails)).toHaveLength(50 * 20);
// A second refresh does not fetch the members it already holds.
calls.length = 0;
await useMail.getState().refreshList();
expect(getSizes(calls)).toEqual([50]);
});
});
describe("merging a refresh", () => {
it("keeps the object for a message that did not change, so its row need not render", async () => {
const { listed } = server(3);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX });
const before = { ...useMail.getState().emails };
await useMail.getState().refreshList();
const after = useMail.getState().emails;
for (const id of listed) expect(after[id]).toBe(before[id]);
});
it("replaces the object for a message that did change", async () => {
const { listed } = server(2);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX });
// Held as starred; the server says it is not.
useMail.setState((s) => ({ emails: { ...s.emails, [listed[0]!]: { ...s.emails[listed[0]!]!, keywords: { $flagged: true } } } }));
const held = useMail.getState().emails;
await useMail.getState().refreshList();
const after = useMail.getState().emails;
expect(after[listed[0]!]).not.toBe(held[listed[0]!]);
expect(after[listed[0]!]!.keywords).toEqual({});
expect(after[listed[1]!]).toBe(held[listed[1]!]);
});
});
describe("loadThread", () => {
it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3);
useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true, e0m1: true },
});
const before = useMail.getState().emails.e0;
const got = await useMail.getState().loadThread("te0");
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0", "e0m1"]);
expect(calls.map(([n]) => n)).toEqual(["Thread/get"]);
// The same object, so nothing derived from it has to be rebuilt.
expect(useMail.getState().emails.e0).toBe(before);
});
it("fetches in full only the message it does not have", async () => {
const { calls } = server(1, 3);
useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true },
});
await useMail.getState().loadThread("te0");
const gets = calls.filter(([n]) => n === "Email/get");
expect(gets).toHaveLength(1);
expect(gets[0]![1].ids).toEqual(["e0m1"]);
expect(gets[0]![1].fetchHTMLBodyValues).toBe(true);
expect(useMail.getState().fullIds.e0m1).toBe(true);
expect(useMail.getState().loadingThreads).toEqual({});
});
it("splits a thread longer than one Email/get may carry", async () => {
const { calls } = server(1, 1200);
const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
});
});
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useSession } from "@/store/session";
import { useFiles } from "@/store/files";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
/**
* What signing in costs for each account somebody has shared with the reader.
*
* The files, contacts and calendar stores each asked every shared account a
* question at sign-in, one account after another -- a request apiece, before
* the reader had opened any of those views. The questions now go out together,
* and Files does not ask at all until it is opened.
*/
const SHARED = ["s1", "s2", "s3"];
const session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} },
accounts: {
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} } },
...Object.fromEntries(SHARED.map((id) => [id, { name: `${id}@example.com`, isPersonal: false, accountCapabilities: {} }])),
},
primaryAccounts: { [CAP.filenode]: "own", [CAP.contacts]: "own", [CAP.calendars]: "own" },
state: "s",
} as unknown as JmapSession;
type Call = [string, Record<string, unknown>, string];
let requests: Call[][];
beforeEach(() => {
requests = [];
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
requests.push(methodCalls);
const methodResponses = methodCalls.map(([name, args, id]) => {
const accountId = args.accountId as string;
if (name === "FileNode/query") return [name, { accountId, ids: accountId === "s2" ? ["f1"] : [], total: 0, position: 0, queryState: "q" }, id];
if (name === "Calendar/get") return [name, { accountId, state: "1", list: [{ id: `cal-${accountId}`, name: `Calendar of ${accountId}` }], notFound: [] }, id];
return [name, { accountId, state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shared accounts", () => {
it("are not asked about files at sign-in", async () => {
await useFiles.getState().init();
expect(requests).toHaveLength(0);
expect(useFiles.getState().available).toBe(true);
expect(useFiles.getState().ownAccountId).toBe("own");
});
it("are asked about files together when Files wants to know", async () => {
await useFiles.getState().discoverShared();
expect(requests).toHaveLength(1);
expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "[email protected]" }]);
});
it("are asked about address books in one request", async () => {
await useContacts.getState().loadShared();
expect(requests).toHaveLength(1);
expect(requests[0]!.filter(([n]) => n === "AddressBook/get")).toHaveLength(3);
});
it("are asked about calendars in one request, and listed in the session's order", async () => {
await useCalendar.getState().loadSharedCalendars();
expect(requests).toHaveLength(1);
expect(useCalendar.getState().sharedCalendars.map((c) => c.accountId)).toEqual(SHARED);
});
});
+59 -27
View File
@@ -409,14 +409,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available });
if (!available) return;
await get().loadCalendars();
// Side by side: none of the three waits on another, and together they share a request.
const identities = client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null }).then(
(res) => set({ identities: res.list }),
() => set({ identities: [] }),
);
void get().loadSharedCalendars();
try {
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
} catch {
set({ identities: [] });
}
await Promise.all([get().loadCalendars(), identities]);
},
/*
@@ -434,15 +433,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
const found: SharedCalendar[] = [];
for (const [accountId, account] of accounts) {
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
} catch {
continue;
}
}
// Every account at once, in one request, and listed in the session's order.
const answers = await Promise.all(
accounts.map(([accountId, account]) =>
client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }).then(
(res) => res.list.map((calendar): SharedCalendar => ({ accountId, accountName: account.name, calendar })),
(): SharedCalendar[] => [],
),
),
);
const found = answers.flat();
set({ sharedCalendars: found });
// Fill in whatever windows are already on screen.
for (const key of Object.keys(get().ranges)) {
@@ -498,7 +498,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accounts = [...new Set(shared.map((c) => c.accountId))];
const ids: string[] = [];
const events: Record<string, CalendarEvent> = {};
for (const accountId of accounts) {
// Every account at once, rather than one waiting on the last.
await Promise.all(accounts.map(async (accountId) => {
try {
const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
@@ -512,10 +513,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}
} catch {
// One account refusing must not empty the calendar of the others.
continue;
}
}
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } }));
}));
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: key in s.ranges ? { ...s.sharedRanges, [key]: ids } : s.sharedRanges }));
},
async loadCalendars() {
@@ -535,7 +535,12 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId;
if (!accountId) return;
const key = `${start.getTime()}|${end.getTime()}`;
if (!force && get().ranges[key]) return;
const held = get().ranges[key];
if (!force && held) {
// Shown again, so the last to be dropped.
set((s) => ({ ranges: keepRecent(s.ranges, key, held) }));
return;
}
set({ loading: true });
const tz = settings().timeZone ?? browserTimeZone;
try {
@@ -560,7 +565,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
set((s) => {
const events = { ...s.events };
for (const e of g.list) events[e.id] = e;
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
const ranges = keepRecent(s.ranges, key, q.ids);
return { events, ranges, sharedRanges: onlyKeys(s.sharedRanges, ranges), loading: false, error: null };
});
void get().loadSharedRange(start, end);
} catch (err) {
@@ -665,6 +671,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
hiding one is remembered under the same account-qualified key. */
const sharedKeys = new Set<string>();
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
const added = new Set(settings().addedShares);
for (const k of sharedKeys) {
const e = sharedEvents[k];
if (!e) continue;
@@ -676,7 +683,6 @@ export const useCalendar = create<CalendarState>((set, get) => ({
an account linked for its files offered its calendar too. `isSubscribed`
is the only thing separating "shared with me" from "reachable", so
nothing unsubscribed is drawn. */
const added = new Set(settings().addedShares);
const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) {
if (c.accountId !== accountId) continue;
@@ -1000,11 +1006,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (types.has("CalendarEvent")) get().invalidate();
},
/*
* Load the windows held again, after something changed.
*
* Only the few most recently shown are held (see `keepRecent`), so this is a
* handful of queries rather than one for every month ever looked at. They
* are replaced where they are, not emptied first: clearing them made the
* calendar go blank until the answers came back.
*/
invalidate() {
// Force reload of all ranges currently cached.
const keys = Object.keys(get().ranges);
set({ ranges: {} });
for (const k of keys) {
for (const k of Object.keys(get().ranges)) {
const [s, e] = k.split("|").map(Number) as [number, number];
void get().loadRange(new Date(s), new Date(e), true);
}
@@ -1015,6 +1026,27 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
}));
/**
* How many loaded windows are held.
*
* Every week or month the reader visited used to stay, and each was queried
* again whenever any event changed, and walked by every render. A window
* dropped here is simply loaded again if the reader goes back to it.
*/
export const RANGES_KEPT = 4;
/** `ranges` with `key` set and moved to the end, trimmed to the most recent `RANGES_KEPT`. */
export function keepRecent(ranges: Record<string, Id[]>, key: string, ids: Id[]): Record<string, Id[]> {
const { [key]: _old, ...rest } = ranges;
const entries = [...Object.entries(rest), [key, ids] as [string, Id[]]];
return Object.fromEntries(entries.slice(-RANGES_KEPT));
}
/** `map` restricted to the windows still held. */
function onlyKeys<T>(map: Record<string, T>, held: Record<string, unknown>): Record<string, T> {
return Object.fromEntries(Object.entries(map).filter(([k]) => k in held));
}
/**
* Whether an event is part of a series.
*
+39 -2
View File
@@ -7,6 +7,7 @@ import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/l
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
@@ -82,7 +83,9 @@ export interface Draft {
interface ComposeState {
drafts: Draft[];
activeKey: string | null;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft; run: () => Promise<void> }>;
/** Send everything still inside its undo window now. For signing out, while the session can still send. */
flushPendingSends(): Promise<void>;
open(init?: Partial<Draft>): string;
/** Open a draft holding what the operating system's share sheet sent us. */
openFromShare(share: SharedContent): string;
@@ -632,7 +635,16 @@ export const useCompose = create<ComposeState>((set, get) => ({
}
const toastId = toast.show(translate("Sending…"), { duration: delay * 1000, progress: true, action: { label: translate("Undo"), onClick: () => get().undoSend(key) } });
const timer = window.setTimeout(() => void doSend(), delay * 1000);
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } }));
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d, run: doSend } } }));
},
async flushPendingSends() {
const pending = Object.values(get().pendingSends);
for (const p of pending) {
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
}
await Promise.all(pending.map((p) => p.run()));
},
undoSend(key) {
@@ -999,3 +1011,28 @@ export function draftFromMailto(url: string): Partial<Draft> {
...(body ? { html: body, text: m.body } : {}),
};
}
/*
* Nothing written in one session is left for the next.
*
* The other stores let go of their data when the session ends; this one used
* to keep its open composers, so on a shared machine the next person to sign
* in -- without a reload, after an idle sign-out, say -- found the last one's
* draft open and could send it. A draft that was saved is still in Drafts on
* the server. A send still in its undo window was sent on the way out if the
* sign-out was a deliberate one (see `logout`); if the session had already
* ended there is nothing left to send it with, so its timer is stopped rather
* than let it fire under whoever signs in next.
*/
useSession.subscribe((s) => {
if (s.status !== "anonymous") return;
const { drafts, pendingSends } = useCompose.getState();
if (!drafts.length && !Object.keys(pendingSends).length) return;
for (const t of autosaveTimers.values()) window.clearTimeout(t);
autosaveTimers.clear();
for (const p of Object.values(pendingSends)) {
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
}
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
});
+81 -13
View File
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
@@ -173,6 +173,8 @@ interface ContactsState {
available: boolean;
books: Record<Id, AddressBook>;
cards: Record<Id, ContactCard>;
/** The server's ContactCard state `cards` was read at, for asking what changed since. */
cardState: string | null;
loaded: boolean;
loading: boolean;
error: string | null;
@@ -189,6 +191,11 @@ interface ContactsState {
init(): Promise<void>;
loadBooks(): Promise<void>;
loadAll(): Promise<void>;
/**
* Bring `cards` up to date with what changed on the server, or load them all
* when that cannot be worked out.
*/
syncCards(): Promise<void>;
/** Books and cards from accounts that shared with the reader. */
loadShared(): Promise<void>;
select(selection: BookSelection): void;
@@ -247,6 +254,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
available: false,
books: {},
cards: {},
cardState: null,
loaded: false,
loading: false,
error: null,
@@ -264,11 +272,18 @@ export const useContacts = create<ContactsState>((set, get) => ({
// should move when the switcher does.
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false, selection: { accountId: null, bookId: "all" } });
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available });
if (!available) return;
await get().loadBooks();
void get().loadShared();
/*
* The cards too, in the background. The avatars in the mail list come from
* them, and nothing else loaded them until Contacts was opened or an
* address was typed -- so a photo appeared once somebody did either, and
* was gone again after the next reload (#376).
*/
if (!get().loaded) void get().loadAll();
},
/*
@@ -294,7 +309,9 @@ export const useContacts = create<ContactsState>((set, get) => ({
}
const books: SharedBook[] = [];
const cards: Record<string, ContactCard> = {};
for (const [accountId, account] of accounts) {
// Every account at once: calls made in one tick share a request, where a
// loop sent one after another for each account shared with the reader.
await Promise.all(accounts.map(async ([accountId, account]) => {
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
@@ -310,7 +327,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
*/
const added = new Set(useSettings.getState().settings.addedShares);
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
if (!wanted.size) continue;
if (!wanted.size) return;
// One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it.
const cardsRes = await client.chain([
@@ -325,9 +342,11 @@ export const useContacts = create<ContactsState>((set, get) => ({
} catch {
// An account that refuses is one that shared nothing here. Not an
// error to show: the reader did not ask for it and cannot act on it.
continue;
}
}
}));
// Answers arrive in any order; list the books in the session's.
const order = new Map(accounts.map(([id], i) => [id, i]));
books.sort((a, b) => (order.get(a.accountId) ?? 0) - (order.get(b.accountId) ?? 0));
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
},
@@ -404,6 +423,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const cards: Record<Id, ContactCard> = {};
let position = 0;
const limit = 500;
let cardState: string | null = null;
for (let guard = 0; guard < 50; guard++) {
const res = await client.chain([
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
@@ -412,15 +432,63 @@ export const useContacts = create<ContactsState>((set, get) => ({
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) cards[c.id] = c;
// The first page's: a change made while the rest were being read is
// then reported again by the next sync, rather than missed.
cardState ??= g.state;
position += q.ids.length;
if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
}
set({ cards, loaded: true, loading: false, error: null });
set({ cards, cardState, loaded: true, loading: false, error: null });
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
/*
* What changed, rather than everything again.
*
* Every push that touched a card, and every edit made here, used to reload
* the whole address book -- up to fifty pages of five hundred cards with all
* their properties -- to pick up one change. ContactCard/changes names what
* changed since the state the cards were read at, and only those are fetched.
* A server that cannot say (`cannotCalculateChanges`), or any other failure,
* falls back to the full load, which is what happened before.
*/
async syncCards() {
const { accountId, cardState, loaded } = get();
if (!accountId || !loaded || !cardState) return get().loadAll();
try {
const changed = new Set<Id>();
const destroyed = new Set<Id>();
let since = cardState;
for (let guard = 0; guard < 50; guard++) {
const ch = await client.call<ChangesResponse>("ContactCard/changes", { accountId, sinceState: since, maxChanges: 500 });
for (const id of [...ch.created, ...ch.updated]) { changed.add(id); destroyed.delete(id); }
for (const id of ch.destroyed) { destroyed.add(id); changed.delete(id); }
since = ch.newState;
if (!ch.hasMoreChanges) break;
}
const fetched = await Promise.all(
chunk([...changed], client.maxObjectsInGet).map((part) => client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: part })),
);
if (get().accountId !== accountId) return;
set((s) => {
const cards = { ...s.cards };
for (const id of destroyed) delete cards[id];
for (const r of fetched) {
for (const c of r.list) cards[c.id] = c;
// An id listed as changed but gone by the time it was asked for.
for (const id of r.notFound ?? []) delete cards[id];
}
return { cards, cardState: since, error: null };
});
} catch (err) {
if (!(err instanceof JmapMethodError) || err.type !== "cannotCalculateChanges") console.warn("[ihasmail] contact sync failed, reloading:", err);
set({ cardState: null });
await get().loadAll();
}
},
async getCard(id) {
const accountId = get().accountId;
if (!accountId) return null;
@@ -542,7 +610,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
} finally {
await get().loadAll();
await get().syncCards();
}
return { destroyed: gone.length, unfiled, refused };
},
@@ -570,7 +638,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
await get().loadAll();
await get().syncCards();
},
async importVCard(text, addressBookId) {
@@ -619,7 +687,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
matched on it rather than guessed at. */
return { created, updated, alike: 0 };
} finally {
await get().loadAll();
await get().syncCards();
}
},
@@ -687,7 +755,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
return { created, updated, alike };
} finally {
await get().loadAll();
await get().syncCards();
}
},
@@ -782,7 +850,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
applyChanges(types) {
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
if (types.has("ContactCard") && get().loaded) void get().loadAll();
if (types.has("ContactCard") && get().loaded) void get().syncCards();
},
}));
+31 -11
View File
@@ -50,7 +50,10 @@ interface FilesState {
*/
draggingIds: Id[];
/** Whether Files is available and which account is the reader's. No round trip. */
init(): Promise<void>;
/** Ask each shared account whether it holds files; see the note on it. */
discoverShared(): Promise<void>;
/** Browse an account: the reader's own, or one shared with them. */
openAccount(accountId: Id | null): void;
loadChildren(parentId: Id | null): Promise<void>;
@@ -138,6 +141,17 @@ export const useFiles = create<FilesState>((set, get) => ({
const session = useSession.getState();
const ownAccountId = session.ownAccountFor(CAP.filenode);
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
// Stay where the reader is if the session still offers that account;
// whether it still holds files is `discoverShared`'s to say.
const browsing = get().accountId;
const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false);
if (!(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId });
},
async discoverShared() {
const session = useSession.getState();
const ownAccountId = get().ownAccountId;
/*
* Which accounts hold shared files cannot be worked out from capabilities:
* Stalwart advertises the whole set on a shared account -- mail, calendars,
@@ -151,23 +165,29 @@ export const useFiles = create<FilesState>((set, get) => ({
* account whose calendar or contacts were the thing actually shared. An
* account that shares no files does not belong in a list of shared files.
*/
/*
* Not at sign-in: only the Files view and the file picker list shared
* accounts, and each opening asks afresh. The questions go out together --
* calls made in one tick share a request -- rather than one account after
* another.
*/
const s = session.session;
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
const sharedAccounts: SharedAccount[] = [];
for (const [id, a] of candidates) {
try {
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
} catch {
// Refused means nothing here is ours to see, which is the same answer.
continue;
}
}
const answers = await Promise.all(
candidates.map(([id, a]) =>
client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }).then(
(res): SharedAccount | null => (res.ids.length ? { id, name: a.name } : null),
// Refused means nothing here is ours to see, which is the same answer.
() => null,
),
),
);
const sharedAccounts = answers.filter((a): a is SharedAccount => a !== null);
// Stay where the reader is if they are reading a share that still exists.
const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
if (!keep) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId, sharedAccounts });
set({ sharedAccounts });
},
openAccount(accountId) {
+155 -39
View File
@@ -28,6 +28,8 @@ import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath";
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
import { type ListQuery, type MailState } from "./types";
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
import { pushEnabledHere } from "@/lib/notify/webpush";
/*
* `@/store/mail` stays the one public entry. The split below is about file
@@ -98,6 +100,7 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) {
if (accountId === get().accountId) return;
resetBodyOrder();
set({
accountId,
mailboxes: {},
@@ -206,8 +209,24 @@ export const useMail = create<MailState>((set, get) => ({
const l = get().list;
if (!accountId || !l) return;
try {
const limit = Math.max(settings().pageSize, l.ids.length);
const { ids, total, queryState } = await runQuery(accountId, l, 0, limit);
/*
* Everything already on screen is fetched again, which past a few pages
* is more than one Email/get may carry: Stalwart refuses the whole call
* over `maxObjectsInGet`, and a refused refresh left the list silently
* stale. So it goes in pages the server will take.
*/
const want = Math.max(settings().pageSize, l.ids.length);
const ids: Id[] = [];
const seen = new Set<Id>();
let total = 0;
let queryState = "";
while (ids.length < want) {
const page = await runQuery(accountId, l, ids.length, want - ids.length);
total = page.total;
queryState ||= page.queryState;
for (const id of page.ids) if (!seen.has(id)) { seen.add(id); ids.push(id); }
if (page.ids.length < page.limit || ids.length >= total) break;
}
const cur = get().list;
if (!cur || cur.key !== l.key) return;
set({ list: { ...cur, ids, total, queryState, loading: false, error: null, exhausted: ids.length >= total } });
@@ -239,13 +258,17 @@ export const useMail = create<MailState>((set, get) => ({
for (const r of results) {
state = r.state;
for (const e of r.list) {
next[e.id] = { ...next[e.id], ...e };
next[e.id] = mergeEmail(next[e.id], e);
if (full) nextFull[e.id] = true;
}
}
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
});
}
if (full) {
touchBodies(ids);
set((s) => releaseBodies(s));
}
const now = get().emails;
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e));
},
@@ -255,34 +278,31 @@ export const useMail = create<MailState>((set, get) => ({
if (!accountId) return [];
set((s) => ({ loadingThreads: { ...s.loadingThreads, [threadId]: true } }));
try {
const res = await client.chain([
["Thread/get", { accountId, ids: [threadId] }, "t"],
[
"Email/get",
{
accountId,
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
properties: FULL_PROPS,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: 2 * 1024 * 1024,
bodyProperties: BODY_PROPS,
},
"e",
],
]);
const thread = (res.get("t")?.[0] as unknown as GetResponse<Thread>).list[0];
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
if (!thread) return [];
/*
* Bodies are fetched only for messages not already held in full.
*
* This runs on every push that touches mail, the open thread's own
* mark-as-read included, and it used to fetch every message in the
* thread in full each time -- up to 2 MB of body apiece, and new
* attachment objects that made the reading pane rebuild what it had
* already rendered. A body cannot change under an id (RFC 8621), and
* keywords and mailboxes come in with the list refresh, so a message
* held in full needs nothing more. getEmails also splits the fetch to
* `maxObjectsInGet`, which a long thread could exceed.
*/
const res = await client.call<GetResponse<Thread>>("Thread/get", { accountId, ids: [threadId] });
const thread = res.list[0];
if (!thread) {
set((s) => {
const { [threadId]: _drop, ...rest } = s.loadingThreads;
return { loadingThreads: rest };
});
return [];
}
await get().getEmails(thread.emailIds, true);
set((s) => {
const next = { ...s.emails };
const nextFull = { ...s.fullIds };
for (const e of emailsRes.list) {
next[e.id] = { ...next[e.id], ...e };
nextFull[e.id] = true;
}
const { [threadId]: _drop, ...rest } = s.loadingThreads;
return { emails: next, fullIds: nextFull, threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
return { threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
});
return get().threadEmails(threadId);
} catch (err) {
@@ -977,7 +997,7 @@ export const useMail = create<MailState>((set, get) => ({
);
set((s) => {
const next = { ...s.emails };
for (const r of results) for (const e of r.list) next[e.id] = { ...next[e.id], ...e };
for (const r of results) for (const e of r.list) next[e.id] = mergeEmail(next[e.id], e);
return { emails: next };
});
}
@@ -1025,6 +1045,75 @@ function sortIdentities(list: Identity[], accountId: Id): Identity[] {
*
* Keyed by nothing: a refusal is about the server, and there is only one.
*/
/**
* Fold freshly fetched properties into the copy already held.
*
* Returns the held object itself when nothing in `next` differs from it. A
* refresh fetches every listed message again, and a new object for each one
* -- the same data, a new identity -- made every row of the list render
* again after any change at all.
*/
function mergeEmail(prev: Email | undefined, next: Email): Email {
if (!prev) return next;
for (const key of Object.keys(next) as (keyof Email)[]) {
const a = prev[key];
const b = next[key];
if (a === b) continue;
if (a && b && typeof a === "object" && JSON.stringify(a) === JSON.stringify(b)) continue;
return { ...prev, ...next };
}
return prev;
}
/*
* How many messages are held with their bodies.
*
* Every message opened kept its full copy -- bodies of up to 2 MB each, parsed
* headers, the attachment list -- for as long as the tab was open, so a long
* session's memory grew with every message read. Past this many, the ones read
* longest ago go back to what the list needs, and are fetched in full again if
* they are opened again. The open conversation is never touched.
*/
export const BODIES_KEPT = 40;
/** Messages held in full, least recently wanted first. */
const bodyOrder: Id[] = [];
const LIST_KEYS = new Set<string>(LIST_PROPS);
function touchBodies(ids: Id[]): void {
for (const id of ids) {
const at = bodyOrder.indexOf(id);
if (at >= 0) bodyOrder.splice(at, 1);
bodyOrder.push(id);
}
}
/** The state with bodies past `BODIES_KEPT` let go; the same state when there is nothing to do. */
export function releaseBodies(s: MailState): MailState | Partial<MailState> {
if (bodyOrder.length <= BODIES_KEPT) return s;
const open = new Set(s.openThreadId ? (s.threads[s.openThreadId]?.emailIds ?? []) : []);
const emails = { ...s.emails };
const fullIds = { ...s.fullIds };
let over = bodyOrder.length - BODIES_KEPT;
for (let i = 0; i < bodyOrder.length && over > 0; ) {
const id = bodyOrder[i]!;
if (open.has(id) || s.emails[id]?.threadId === s.openThreadId) {
i++;
continue;
}
bodyOrder.splice(i, 1);
over--;
delete fullIds[id];
const e = emails[id];
if (e) emails[id] = Object.fromEntries(Object.entries(e).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email;
}
return { emails, fullIds };
}
/** Forget what is held; for tests, and for an account switch. */
export function resetBodyOrder(): void {
bodyOrder.length = 0;
}
let sortRefused = false;
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
@@ -1057,29 +1146,54 @@ function isUnsupportedSort(err: unknown): boolean {
return type === "unsupportedSort" || /unsupportedSort/i.test(message);
}
async function runQueryOnce(accountId: Id, q: ListQuery, position: number, limit: number) {
async function runQueryOnce(accountId: Id, q: ListQuery, position: number, requested: number) {
// The ids are back-referenced into Email/get, which may carry no more than this.
const limit = Math.min(requested, client.maxObjectsInGet);
const calls: Array<[string, Record<string, unknown>, string]> = [
["Email/query", { accountId, filter: q.filter, sort: q.sort, collapseThreads: q.collapseThreads, position, limit, calculateTotal: true }, "q"],
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
];
if (q.collapseThreads) {
calls.push(["Thread/get", { accountId, "#ids": { resultOf: "e", name: "Email/get", path: "/list/*/threadId" } }, "t"]);
calls.push(["Email/get", { accountId, "#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" }, properties: LIST_PROPS }, "te"]);
}
const res = await client.chain(calls);
const query = res.get("q")?.[0] as unknown as QueryResponse;
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
const threadsRes = res.get("t")?.[0] as unknown as GetResponse<Thread> | undefined;
const threadEmails = res.get("te")?.[0] as unknown as GetResponse<Email> | undefined;
/*
* The other messages in each listed thread, for its count and unread state.
* These used to come back-referenced from Thread/get in the same request,
* with no bound: fifty long conversations could carry more ids than one
* Email/get may, and the server refused the whole page. Fetched separately
* instead, split to the limit, and only those not already held -- a cached
* one is kept current by Email/changes. When there is no state to follow
* changes from, every member is fetched, since nothing else will update it.
*/
const following = useMail.getState().emailState !== null;
useMail.setState((s) => {
const emails = { ...s.emails };
for (const e of emailsRes.list) emails[e.id] = { ...emails[e.id], ...e };
for (const e of threadEmails?.list ?? []) emails[e.id] = { ...emails[e.id], ...e };
for (const e of emailsRes.list) emails[e.id] = mergeEmail(emails[e.id], e);
const threads = { ...s.threads };
for (const t of threadsRes?.list ?? []) threads[t.id] = t;
return { emails, threads, emailState: s.emailState ?? emailsRes.state };
});
return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState };
const members = (threadsRes?.list ?? []).flatMap((t) => t.emailIds);
if (members.length) await refreshEmails(accountId, following ? members.filter((id) => !useMail.getState().emails[id]) : members);
return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState, limit };
}
/** Fetch list properties for `ids`, split to `maxObjectsInGet`, and merge them in. */
async function refreshEmails(accountId: Id, ids: Id[]): Promise<void> {
const unique = [...new Set(ids)];
if (!unique.length) return;
const results = await Promise.all(
chunk(unique, client.maxObjectsInGet).map((part) => client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: LIST_PROPS })),
);
useMail.setState((s) => {
const emails = { ...s.emails };
for (const r of results) for (const e of r.list) emails[e.id] = mergeEmail(emails[e.id], e);
return { emails };
});
}
/**
@@ -1148,14 +1262,16 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
const emails = await get().getEmails(created);
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return;
const { showNotification, playNewMailSound } = await import("@/lib/notify/notify");
if (s.notificationSound) playNewMailSound();
if (s.desktopNotifications) {
// Where background notifications are on in this browser, the service worker
// shows these already; showing them here too was the duplicate in #375.
if (s.desktopNotifications && !pushEnabledHere()) {
for (const e of fresh.slice(0, 3)) {
const from = e.from?.[0];
showNotification(from?.name || from?.email || "New message", {
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
tag: e.id,
tag: `ihasmail-${e.id}`,
data: { url: withBase(`/mail/${inbox}/${e.threadId}?m=${encodeURIComponent(e.id)}`) },
onClick: () => {
window.location.hash = "";
// The one navigation that does not go through wouter -- it is
+8
View File
@@ -78,6 +78,14 @@ export const useSession = create<SessionState>((set, get) => ({
/* never block signing out over this */
}
stopSettingsSync();
// A message still inside its undo window goes now, while there is a
// session to send it with; signing out is not an undo.
try {
const { useCompose } = await import("./compose");
await useCompose.getState().flushPendingSends();
} catch {
/* never block signing out over this */
}
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
+3 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
import { confirmDialog, Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { withoutBidiControls } from "@/lib/text/text";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
import { canShareFiles, shareFile } from "@/lib/share";
@@ -168,7 +169,7 @@ export function FilePreviewDialog({
const download = () => {
const l = document.createElement("a");
l.href = file.url;
l.download = file.name;
l.download = withoutBidiControls(file.name);
l.click();
};
try {
@@ -226,7 +227,7 @@ export function FilePreviewDialog({
<Dialog
open={Boolean(file)}
onClose={requestClose}
title={file?.name ?? t("Preview")}
title={file ? withoutBidiControls(file.name) : t("Preview")}
size="xl"
closeOnBackdrop={!editing}
footer={
+5 -2
View File
@@ -8,10 +8,13 @@ import { t } from "@/lib/i18n";
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
const email = typeof who === "string" ? who : (who?.email ?? "");
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
// Whatever cards are held count, the reader's own or a shared book's; the
// photo is fetched from the account the card belongs to.
const photo = useContacts((s) => {
if (!email || !s.loaded) return null;
if (!email) return null;
const c = s.lookupByEmail(email);
return c && s.accountId ? contactPhoto(c, s.accountId) : null;
const account = c ? (s.accountOfCard(c.id) ?? s.accountId) : null;
return c && account ? contactPhoto(c, account) : null;
});
return (
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
+11 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
@@ -13,9 +13,6 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter";
import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
import { ContactsSidebar } from "./contacts/ContactsSidebar";
import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format";
@@ -26,6 +23,11 @@ import { hasAdministration } from "@/lib/admin/adminAccess";
import { usePermissions } from "./admin/usePermissions";
import { AdminNav } from "./admin/AdminNav";
// The other sections' sidebars load with the section, as their views already do.
const FilesTree = lazy(() => import("./files/FilesTree").then((m) => ({ default: m.FilesTree })));
const ContactsSidebar = lazy(() => import("./contacts/ContactsSidebar").then((m) => ({ default: m.ContactsSidebar })));
const CalendarSidebar = lazy(() => import("./calendar/CalendarSidebar").then((m) => ({ default: m.CalendarSidebar })));
/*
* How far the sidebar edge can be dragged. Below about 228px the module bar
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
@@ -252,9 +254,11 @@ export function AppShell({ children }: { children: ReactNode }) {
</button>
<div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />}
{section === "calendar" && <CalendarSidebar />}
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
<Suspense fallback={null}>
{section === "calendar" && <CalendarSidebar />}
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
</Suspense>
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
{section === "admin" && <AdminNav />}
</div>
+5 -9
View File
@@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
import { plural, t } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
@@ -42,19 +43,14 @@ export function CalendarSidebar() {
const importInto = useRef<Id | null>(null);
/*
* Handing the file over, which the browser only does from a click. The
* revoke below is what keeps a calendar's worth of text from sitting in
* memory after the download has started.
* Handing the file over, which the browser only does from a click.
* `downloadFile` releases it once started, so a calendar's worth of text
* does not sit in memory afterwards.
*/
const exportFile = async (c: Calendar) => {
try {
const { text, count } = await cal.exportIcs(c.id);
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" }));
const a = document.createElement("a");
a.href = url;
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
a.click();
URL.revokeObjectURL(url);
downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`);
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
} catch (err) {
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
+19 -4
View File
@@ -1,7 +1,20 @@
import { lazy, Suspense } from "react";
import { useCompose } from "@/store/compose";
import { Composer } from "./Composer";
import { useIsMobile } from "@/ui/misc";
/*
* The composer -- the rich-text editor, the recipient and file pickers -- is
* loaded apart from the mail view, and fetched while the browser is idle
* after startup so the first Compose does not wait on the network.
*/
const loadComposer = () => import("./Composer");
const Composer = lazy(() => loadComposer().then((m) => ({ default: m.Composer })));
if (typeof window !== "undefined") {
const warm = () => void loadComposer().catch(() => {});
if ("requestIdleCallback" in window) window.requestIdleCallback(warm, { timeout: 5000 });
else setTimeout(warm, 2000);
}
export function ComposerDock() {
const drafts = useCompose((s) => s.drafts);
const activeKey = useCompose((s) => s.activeKey);
@@ -13,9 +26,11 @@ export function ComposerDock() {
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
return (
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
{visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))}
<Suspense fallback={null}>
{visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))}
</Suspense>
</div>
);
}
+5
View File
@@ -27,6 +27,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
const [picked, setPicked] = useState<Record<string, FileNode>>({});
const [returnTo] = useState(() => files.accountId);
// Shared accounts are not looked for at sign-in; the picker lists them, so it asks.
useEffect(() => {
void useFiles.getState().discoverShared();
}, []);
useEffect(() => {
void files.loadChildren(cur);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -42,35 +42,39 @@ describe("ComposerDock with a full-screen composer", () => {
useCompose.setState({ drafts: [], activeKey: null });
});
const render = (drafts: Draft[], activeKey: string) => {
// The composer is loaded on demand, so rendering waits for it to arrive.
const render = async (drafts: Draft[], activeKey: string) => {
useCompose.setState({ drafts, activeKey });
act(() => root.render(<ComposerDock />));
await act(async () => {
root.render(<ComposerDock />);
await import("../Composer");
});
};
const dock = () => host.querySelector(".composer-dock")!;
it("marks the dock so the other composers are hidden behind it", () => {
it("marks the dock so the other composers are hidden behind it", async () => {
setWidth(1300);
render([draft("a"), draft("b", { maximized: true }), draft("c")], "b");
await render([draft("a"), draft("b", { maximized: true }), draft("c")], "b");
expect(dock().classList.contains("has-maximized")).toBe(true);
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
expect(host.querySelectorAll(".composer").length).toBe(3);
});
it("leaves the dock alone while nobody is full screen", () => {
it("leaves the dock alone while nobody is full screen", async () => {
setWidth(1300);
render([draft("a"), draft("b")], "b");
await render([draft("a"), draft("b")], "b");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
it("does not count a full-screen composer that has since been minimized", () => {
it("does not count a full-screen composer that has since been minimized", async () => {
setWidth(1300);
render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
await render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
it("is not a phone concern: there the active composer is already the only one open", () => {
it("is not a phone concern: there the active composer is already the only one open", async () => {
setWidth(400);
render([draft("a"), draft("b", { maximized: true })], "b");
await render([draft("a"), draft("b", { maximized: true })], "b");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
});
+4 -11
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Plus, Trash2, Camera, X } from "lucide-react";
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
import { buildName, contactDisplayName, nameParts, newKey, withPhoto } from "@/lib/contacts";
import { Dialog } from "@/ui/dialog";
import { DateField } from "@/ui/datefield";
import { toast } from "@/ui/toast";
@@ -105,16 +105,9 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
obj.links = website ? { [newKey("l")]: { "@type": "Link", uri: /^https?:/i.test(website) ? website : `https://${website}` } } : null;
obj.notes = note.trim() ? { [newKey("x")]: { "@type": "Note", note: note.trim() } } : null;
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
if (photo) {
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
if (m) {
const bin = atob(m[2]!);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const up = await client.upload(contacts.accountId!, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
obj.media = { [newKey("p")]: { "@type": "Media", kind: "photo", blobId: up.blobId, mediaType: m[1]! } };
}
} else if (removePhoto) obj.media = null;
// Inline, not uploaded: see `withPhoto`. The card's other media stays.
if (photo) obj.media = withPhoto(card.media, photo);
else if (removePhoto) obj.media = withPhoto(card.media, null);
if (isNew) {
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
toast.success(t("Contact created"));
+7 -7
View File
@@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings";
import { ContactEditor } from "./ContactEditor";
import { avatarColor } from "@/lib/address";
import { plural, t as translate } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function ContactsView({ id }: { id?: string }) {
const [, navigate] = useLocation();
@@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) {
toast.error(translate("There is nothing in it to export"));
return;
}
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
a.download = "contacts.vcf";
a.click();
downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf");
};
const importFile = async (f: File, intoBookId?: string) => {
@@ -303,7 +301,8 @@ export function ContactsView({ id }: { id?: string }) {
<div className="contact-letter">{g.letter}</div>
{g.items.map((c) => {
const email = contactEmails(c)[0]?.email;
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
const photoAccount = contacts.accountOfCard(c.id) ?? contacts.accountId;
const photo = photoAccount ? contactPhoto(c, photoAccount) : null;
return (
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
{!readOnly && (
@@ -345,7 +344,8 @@ export function ContactsView({ id }: { id?: string }) {
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
const contacts = useContacts();
const [, navigate] = useLocation();
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
const photoAccount = contacts.accountOfCard(c.id) ?? contacts.accountId;
const photo = photoAccount ? contactPhoto(c, photoAccount) : null;
const name = contactDisplayName(c);
const org = Object.values(c.organizations ?? {})[0];
const title = Object.values(c.titles ?? {})[0];
@@ -359,7 +359,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm" onClick={() => downloadFile(toVCard(c), "text/vcard", `${name.replace(/[^\w.-]+/g, "_")}.vcf`)}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div>
<div className="contact-hero">
+1
View File
@@ -32,6 +32,7 @@ async function refreshShares(force = false): Promise<void> {
return;
}
await useFiles.getState().init();
await useFiles.getState().discoverShared();
}
+6 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, type MouseEvent, type ReactNode } from "react";
import { lazy, Suspense, useCallback, useState, type MouseEvent, type ReactNode } from "react";
import { Copy, Mail, Pencil, UserPlus } from "lucide-react";
import type { EmailAddress } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
@@ -7,9 +7,11 @@ import { contactFromAddress } from "@/lib/contacts";
import { formatAddress } from "@/lib/address";
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
import { toast } from "@/ui/toast";
import { ContactEditor } from "../contacts/ContactEditor";
import { t } from "@/lib/i18n";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const ContactEditor = lazy(() => import("../contacts/ContactEditor").then((m) => ({ default: m.ContactEditor })));
/**
* Right-click on anyone named in a message — sender, recipients, Reply-To — to
* add them to the address book. The contact editor opens prefilled rather than
@@ -65,12 +67,14 @@ export function useAddressMenu() {
</Popover>
)}
{editing && (
<Suspense fallback={null}>
<ContactEditor
card={editing}
defaultBookId={defaultBookId}
onClose={() => setEditing(null)}
onSaved={() => setEditing(null)}
/>
</Suspense>
)}
</>
);
+18 -1
View File
@@ -79,8 +79,25 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
[settings.listSortScope, settings.listSortPreset, settings.listSortLevels],
);
/*
* What the list query reads from the folder map: names, roles and places in
* the tree. `mailboxes` itself is replaced on every reload -- every push that
* touches mail reloads it for the counts -- and depending on it directly made
* each reload build a new query, which `query()` answered with a second full
* refresh of the list.
*/
const folderShape = useMemo(
() =>
Object.values(mailboxes)
.map((m) => `${m.id}\u0000${m.name}\u0000${m.role ?? ""}\u0000${m.parentId ?? ""}`)
.sort()
.join("\u0001"),
[mailboxes],
);
// Build & run the list query
const listQuery = useMemo<ListQuery | null>(() => {
const mailboxes = useMail.getState().mailboxes;
if (search) {
if (!q) return null;
const parsed = parseQuery(q);
@@ -94,7 +111,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
// are outgoing, and collapsing them into their threads hides them.
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
return { key: "", filter: { inMailbox: mailboxId }, sort: sortForFolder(mailboxId), collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
}, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]);
}, [search, q, mailboxId, folderShape, settings.conversationMode, scheduledId]);
useEffect(() => {
if (listQuery && mailboxesLoaded) void query(listQuery);
+5 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
import { useMail } from "@/store/mail";
@@ -11,7 +11,6 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { MailboxPicker } from "./MailboxPicker";
import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
@@ -19,6 +18,9 @@ import { haptic, useTouchRow } from "@/lib/input/touch";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const ShareDialog = lazy(() => import("../settings/ShareDialog").then((m) => ({ default: m.ShareDialog })));
const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />,
drafts: <File size={20} />,
@@ -273,7 +275,7 @@ export function MailboxTree() {
}}
/>
)}
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
{shareTarget && <Suspense fallback={null}><ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} /></Suspense>}
</>
);
}
+92 -42
View File
@@ -1,5 +1,6 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useShallow } from "zustand/react/shallow";
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail";
@@ -20,9 +21,11 @@ import { startAppointment } from "@/lib/calendar/appointment";
import { toast } from "@/ui/toast";
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import { plural, t } from "@/lib/i18n";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog })));
/**
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's
* icons: it is read at arm's length, in motion, out of the corner of an eye.
@@ -39,6 +42,26 @@ const SWIPE_ICON: Record<SwipeIcon, ReactNode> = {
move: <FolderInput size={22} />,
};
/**
* A callback whose identity never changes but which always runs the latest
* version of `fn`.
*
* The rows are memoized, and every handler they are given has to keep its
* identity for that to mean anything. The handlers here read the selection,
* the list and the menu, all of which change constantly -- so as ordinary
* `useCallback`s they were new on nearly every render, and every visible row
* rendered again with them.
*/
function useStableCallback<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
const ref = useRef(fn);
// Updated after render rather than during it, so a render React throws away
// never leaves its version behind. Handlers only run on events, which come later.
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args: A) => ref.current(...args), []);
}
export interface ListActions {
archive: (rows?: Id[]) => Promise<void>;
trash: (rows?: Id[]) => Promise<void>;
@@ -71,7 +94,6 @@ interface Props {
export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
const [, navigate] = useLocation();
const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
const selected = useMail((s) => s.selected);
const select = useMail((s) => s.select);
const selectAll = useMail((s) => s.selectAll);
@@ -161,7 +183,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []),
});
const onRowClick = useCallback(
const onRowClick = useStableCallback(
(e: MouseEvent, rowId: Id) => {
const action = rowClick({
rowId, ids, anchor: lastClick.current, selected,
@@ -179,18 +201,14 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
// leaves the rows looking smeared blue over the selection they meant.
else window.getSelection()?.removeAllRanges();
},
[ids, select, selected, isMobile, onOpen],
);
const onContext = useCallback(
(e: MouseEvent, rowId: Id) => {
e.preventDefault();
setCtxRow(rowId);
setFocusId(rowId);
ctxMenu.openAt(e.clientX, e.clientY);
},
[ctxMenu, setFocusId],
);
const onContext = useStableCallback((e: MouseEvent, rowId: Id) => {
e.preventDefault();
setCtxRow(rowId);
setFocusId(rowId);
ctxMenu.openAt(e.clientX, e.clientY);
});
/*
* Hold a row to select it, the way the mail app the phone came with does.
@@ -201,15 +219,12 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
* row *is* how you select it. Once one row is selected, plain taps toggle
* the rest (see `onRowClick`), so this only has to open the mode.
*/
const onLongPress = useCallback(
(rowId: Id) => {
haptic(15);
setFocusId(rowId);
lastClick.current = rowId;
select([rowId], !useMail.getState().selected[rowId]);
},
[select, setFocusId],
);
const onLongPress = useStableCallback((rowId: Id) => {
haptic(15);
setFocusId(rowId);
lastClick.current = rowId;
select([rowId], !useMail.getState().selected[rowId]);
});
const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => {
// A row clearing itself must not clear a gesture that has since moved on
@@ -217,7 +232,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur));
}, []);
const fireSwipe = useCallback(
const fireSwipe = useStableCallback(
async (rowId: Id, d: SwipeDescriptor) => {
switch (d.action) {
case "archive": await actions.archive([rowId]); break;
@@ -229,8 +244,15 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
case "move": actions.move([rowId]); break;
}
},
[actions],
);
const onRowSelect = useStableCallback((rowId: Id, on: boolean) => {
select([rowId], on);
lastClick.current = rowId;
});
const onRowStar = useStableCallback((rowId: Id, on: boolean) => void actions.star(on, [rowId]));
const onRowArchive = useStableCallback((rowId: Id) => void actions.archive([rowId]));
const onRowTrash = useStableCallback((rowId: Id) => void actions.trash([rowId]));
const onRowRead = useStableCallback((rowId: Id, read: boolean) => void actions.read(read, [rowId]));
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
@@ -463,7 +485,6 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
}
const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
const strip = swiping?.id === id ? swiping : null;
return (
<Fragment key={id}>
@@ -486,8 +507,8 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
</div>
)}
<Row
email={e}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
id={id}
threadId={list?.collapseThreads ? e.threadId : null}
top={vi.start}
height={vi.size}
selected={Boolean(selected[id])}
@@ -501,12 +522,11 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
isSent={mailbox?.role === "sent"}
onClick={onRowClick}
onContext={onContext}
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
onStar={(rowId, on) => void actions.star(on, [rowId])}
onArchive={(rowId) => void actions.archive([rowId])}
onTrash={(rowId) => void actions.trash([rowId])}
onRead={(rowId, read) => void actions.read(read, [rowId])}
selectedIds={selected}
onSelect={onRowSelect}
onStar={onRowStar}
onArchive={onRowArchive}
onTrash={onRowTrash}
onRead={onRowRead}
touch={isTouch}
role={mailbox?.role ?? null}
swipeLeft={settings.swipeLeft}
@@ -542,14 +562,15 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />}
</Popover>
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
{filterFrom && <Suspense fallback={null}><FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} /></Suspense>}
</div>
);
}
interface RowProps {
email: Email;
threadEmails?: Email[];
id: Id;
/** The conversation to summarize, in conversation view; null for a single message. */
threadId: Id | null;
top: number;
height: number;
selected: boolean;
@@ -561,7 +582,6 @@ interface RowProps {
isDrafts: boolean;
isSent: boolean;
mailboxId: Id | null;
selectedIds: Record<Id, true>;
onClick: (e: MouseEvent, id: Id) => void;
onContext: (e: MouseEvent, id: Id) => void;
onSelect: (id: Id, on: boolean) => void;
@@ -579,12 +599,40 @@ interface RowProps {
onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise<void>;
}
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowProps) {
const NO_EMAILS: Email[] = [];
/*
* A row reads its own message and conversation from the store, rather than
* being handed them. The list re-renders on every store write -- each star,
* each push, each thread loaded -- and objects built for a row in that render
* were new every time, so no row was ever skipped. Selected this way, a row
* renders when something it shows has changed, and not otherwise.
*/
const Row = memo(function Row(props: RowProps) {
const email = useMail((s) => s.emails[props.id]);
const threadEmails = useMail(
useShallow((s) => {
if (!props.threadId) return NO_EMAILS;
const ids = s.threads[props.threadId]?.emailIds;
if (!ids) return NO_EMAILS;
return ids.map((x) => s.emails[x]).filter((x): x is Email => Boolean(x));
}),
);
if (!email) return null;
const { id: _id, threadId, ...rest } = props;
return <RowView {...rest} email={email} threadEmails={threadId ? threadEmails : undefined} />;
});
type RowViewProps = Omit<RowProps, "id" | "threadId"> & { email: Email; threadEmails?: Email[] };
function RowView({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowViewProps) {
const labels = useSettings((s) => s.settings.labels);
// Subscribed purely so the row re-renders when the date format changes.
useSettings((s) => dateTimeKey(s.settings));
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
const scope = inScope.length ? inScope : [e];
const scope = useMemo(() => {
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
return inScope.length ? inScope : [e];
}, [threadEmails, mailboxId, e]);
const unread = scope.some((x) => !x.keywords.$seen);
const starred = scope.some((x) => x.keywords.$flagged);
const hasAtt = scope.some((x) => x.hasAttachment);
@@ -671,6 +719,8 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
});
const onDragStart = (ev: DragEvent) => {
// Read when the drag starts, so the row need not re-render on every change of selection.
const selectedIds = useMail.getState().selected;
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
// include thread emails in scope
const all = new Set<Id>();
@@ -762,4 +812,4 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
)}
</div>
);
});
}
+26 -13
View File
@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react";
import { useLocation } from "wouter";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
@@ -20,8 +19,11 @@ import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text/text";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog })));
// The preview carries a Markdown renderer, which is most of its weight.
const FilePreviewDialog = lazy(() => import("@/ui/filepreview").then((m) => ({ default: m.FilePreviewDialog })));
import { findQuoteStart, htmlToText, textToHtml, withoutBidiControls } from "@/lib/text/text";
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
@@ -456,7 +458,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</>
)}
{addrMenu.node}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
{filterOpen && <Suspense fallback={null}><FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} /></Suspense>}
<Dialog open={showSource} onClose={() => setShowSource(false)} title={translate("Original message")} size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
</Dialog>
@@ -796,7 +798,13 @@ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id
const blob = await client.fetchBlob(accountId, part.blobId, part.type);
const found = parseTnef(await blob.arrayBuffer());
setFiles(found);
setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: f.type }))));
/*
* The type inside a winmail.dat is whatever the sender wrote, and never
* passed the server's check on what may be shown inline. Opened from its
* blob: URL, text/html would render as a page on this origin -- so only
* the types the server itself would show are kept.
*/
setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: openableInTab(f.type) ? f.type : "application/octet-stream" }))));
setState("done");
} catch {
setState("error");
@@ -859,7 +867,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
*/
const shareAttachment = async (a: EmailBodyPart) => {
if (!a.blobId) return;
const name = a.name ?? "attachment";
const name = withoutBidiControls(a.name ?? "") || "attachment";
const download = () => {
const l = document.createElement("a");
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
@@ -885,16 +893,17 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
))}
<div className="attachments">
{attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
const name = a.name ? withoutBidiControls(a.name) : null;
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, name ?? "attachment", a.type) : "#";
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, name ?? "attachment", a.type, true) : "#";
return (
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? translate("Attachment")} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
<a key={a.blobId ?? i} className="attachment" href={url} download={name ?? undefined} title={`${name ?? translate("Attachment")} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
<span className="att-text">
<span className="att-name">{a.name ?? "(unnamed)"}</span>
<span className="att-name">{name ?? "(unnamed)"}</span>
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = name ?? ""; l.click(); }}><Download size={14} /></button>
{canShareFiles() && a.blobId && <button className="icon-btn xs" title={tc("share sheet", "Share")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); void shareAttachment(a); }}><Share2 size={14} /></button>}
{openableInTab(a.type) && a.blobId && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
@@ -908,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
</button>
)}
</div>
{preview && (
<Suspense fallback={null}>
<FilePreviewDialog
file={preview && preview.blobId ? {
file={preview.blobId ? {
name: preview.name ?? translate("file"),
type: preview.type,
size: preview.size,
@@ -919,6 +930,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
onClose={() => setPreview(null)}
caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>}
/>
</Suspense>
)}
</>
);
}
+2 -1
View File
@@ -25,6 +25,7 @@ import {
type DateFormat,
} from "@/lib/datetime";
import { isEnforced } from "@/lib/settingsPolicy";
import { downloadFile } from "@/lib/download";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23);
@@ -236,7 +237,7 @@ export function GeneralSettings() {
<h2>{t("Backup")}</h2>
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button>
<button className="btn" onClick={() => downloadFile(exportJson(), "application/json", "ihasmail-settings.json")}>{t("Export settings")}</button>
<label className="btn">
{t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />
+9 -2
View File
@@ -231,6 +231,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [name, setName] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
@@ -242,10 +243,11 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
try {
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
method: "POST",
body: JSON.stringify({ description: name }),
body: JSON.stringify({ description: name, current }),
});
setIssued({ description: name, secret: res.secret });
setName("");
setCurrent("");
await reload();
} catch (err) {
toast.error((err as Error).message);
@@ -296,7 +298,12 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
<label htmlFor="ap-name">{t("New app password for")}</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
{/* A credential that outlives this session: the server asks for the password first. */}
<div className="field" style={{ marginBottom: 0, minWidth: 200 }}>
<label htmlFor="ap-current">{t("Current password")}</label>
<input id="ap-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
<button className="btn" disabled={busy || !name.trim() || !current}>{busy ? "Creating…" : "Create"}</button>
</form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"