Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf678be73 | ||
|
|
37eb145652 | ||
|
|
3d7602ce74 | ||
|
|
4054f82c37 | ||
|
|
d38dee7eb9 | ||
|
|
aa9bf1b9b2 | ||
|
|
c63fd0dfe0 | ||
|
|
f123467897 | ||
|
|
71d211a13f | ||
|
|
56bd48e891 | ||
|
|
da87925b9c | ||
|
|
360420402d | ||
|
|
8bd7904a21 | ||
|
|
6090442058 | ||
|
|
4c7b2ec370 | ||
|
|
9560ad06f4 | ||
|
|
6139031689 | ||
|
|
e3cd56314b | ||
|
|
c6dbcaef63 | ||
|
|
460760ba12 | ||
|
|
a607450aaa | ||
|
|
a9302075e7 | ||
|
|
dfe885a921 | ||
|
|
9691a7bbf5 | ||
|
|
e2b4cc18db | ||
|
|
47a2477d9f | ||
|
|
98e105efd6 | ||
|
|
4cb1945eb5 |
@@ -3,4 +3,7 @@ node_modules
|
|||||||
**/dist
|
**/dist
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
|
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
server/data
|
server/data
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
# deploy.example.sh keeps its settings in .env.production; any .env.* holds APP_SECRET.
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/data/
|
server/data/
|
||||||
|
|||||||
@@ -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
|
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
|
||||||
|
|
||||||
|
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
||||||
|
|
||||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||||
|
|
||||||
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
||||||
|
|||||||
+14
-1
@@ -9,8 +9,21 @@ services:
|
|||||||
BASE_PATH: ${BASE_PATH:-}
|
BASE_PATH: ${BASE_PATH:-}
|
||||||
image: ihasmail:2
|
image: ihasmail:2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Loopback only: ihasmail expects a TLS reverse proxy in front of it. On
|
||||||
|
# every interface the app is reachable over plain HTTP, passwords and all,
|
||||||
|
# and with TRUST_PROXY any machine on a private network can set its own
|
||||||
|
# X-Forwarded-For. A proxy running in Docker can reach the service by name
|
||||||
|
# on the compose network and needs no published port at all.
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
|
# The app needs no privileges and writes only to /data and /tmp.
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
environment:
|
environment:
|
||||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
|
||||||
assert.equal(created.status, 200);
|
assert.equal(created.status, 200);
|
||||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||||
assert.ok(created.body.id);
|
assert.ok(created.body.id);
|
||||||
@@ -84,8 +84,83 @@ test("app passwords are created, listed once with their secret, and revoked", as
|
|||||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an app password needs the account password", async () => {
|
||||||
|
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
|
||||||
|
assert.equal(missing.status, 400);
|
||||||
|
assert.equal(missing.body.error, "missing_fields");
|
||||||
|
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
|
||||||
|
assert.equal(wrong.status, 403);
|
||||||
|
assert.equal(wrong.body.error, "invalid_credentials");
|
||||||
|
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
|
||||||
|
// Signed in without "my own device", so the proxy reads every request.
|
||||||
|
const res = await call("/api/jmap", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
|
||||||
|
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
|
||||||
|
assert.equal(up.status, 200);
|
||||||
|
const { blobId } = (await up.json()) as { blobId: string };
|
||||||
|
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
|
||||||
|
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("cache-control"), "no-store");
|
||||||
|
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
|
||||||
|
await res.arrayBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a download passes a byte range through, for viewers that read in pieces", async () => {
|
||||||
|
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
|
||||||
|
const { blobId } = (await up.json()) as { blobId: string };
|
||||||
|
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
|
||||||
|
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
|
||||||
|
assert.equal(part.status, 206);
|
||||||
|
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
|
||||||
|
assert.equal(part.headers.get("accept-ranges"), "bytes");
|
||||||
|
assert.equal(await part.text(), "hello");
|
||||||
|
const whole = await app.request(url, { headers: { cookie } });
|
||||||
|
assert.equal(whole.status, 200);
|
||||||
|
assert.equal(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 () => {
|
test("an app password needs a name", async () => {
|
||||||
const res = await post("/api/account/app-passwords", { description: " " });
|
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
assert.equal(res.body.error, "missing_fields");
|
assert.equal(res.body.error, "missing_fields");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
|
|||||||
assert.equal(r.ok, true);
|
assert.equal(r.ok, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the account's own registry objects pass", () => {
|
test("the account's own registry objects can be read", () => {
|
||||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("but not written: a credential minted here would outlive a borrowed session", () => {
|
||||||
|
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
|
||||||
|
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("API keys are not the account's to reach from here at all", () => {
|
||||||
|
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("directory and server objects are refused, and named", () => {
|
test("directory and server objects are refused, and named", () => {
|
||||||
|
|||||||
+10
-3
@@ -18,7 +18,7 @@
|
|||||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||||
* touched: they act on what the account can already reach.
|
* touched: they act on what the account can already reach.
|
||||||
*/
|
*/
|
||||||
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
|
||||||
|
|
||||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||||
|
|
||||||
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
|
|||||||
const name = Array.isArray(call) ? call[0] : undefined;
|
const name = Array.isArray(call) ? call[0] : undefined;
|
||||||
if (typeof name !== "string") return { ok: false, method: null };
|
if (typeof name !== "string") return { ok: false, method: null };
|
||||||
if (!name.startsWith("x:")) continue;
|
if (!name.startsWith("x:")) continue;
|
||||||
const object = name.slice(2).split("/")[0] ?? "";
|
const [object = "", op = ""] = name.slice(2).split("/");
|
||||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
/*
|
||||||
|
* Read, never write. The browser sends none of these itself -- password,
|
||||||
|
* app-password and 2FA changes go through /api/account, which checks the
|
||||||
|
* account password first -- so a write here could only come from
|
||||||
|
* somebody working the console of a session on a borrowed machine, and
|
||||||
|
* `x:AppPassword/set` would hand them a credential that outlives it.
|
||||||
|
*/
|
||||||
|
if (!SELF_SERVICE.has(object) || op === "set") return { ok: false, method: name };
|
||||||
}
|
}
|
||||||
return { ok: true, body: JSON.stringify(parsed) };
|
return { ok: true, body: JSON.stringify(parsed) };
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-26
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { compress } from "hono/compress";
|
import { compress } from "hono/compress";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import { request as httpsRequest } from "node:https";
|
import { request as httpsRequest } from "node:https";
|
||||||
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { fetchPermissions } from "./permissionSchema.js";
|
import { fetchPermissions } from "./permissionSchema.js";
|
||||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||||
import { RateLimiter } from "./ratelimit.js";
|
import { RateLimiter } from "./ratelimit.js";
|
||||||
import { resolveClientIp } from "./clientip.js";
|
import { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||||
|
import { safeEqual } from "./crypto.js";
|
||||||
import {
|
import {
|
||||||
type AccountInfo,
|
type AccountInfo,
|
||||||
UpstreamError,
|
UpstreamError,
|
||||||
@@ -209,6 +211,22 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest body an API route that reads JSON will take.
|
||||||
|
*
|
||||||
|
* Hono reads a JSON body whole, and before this nothing bounded it: a few
|
||||||
|
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
|
||||||
|
* run the process out of memory, and a restart signs everybody out. What
|
||||||
|
* these routes actually receive is a username and password, or a code.
|
||||||
|
*
|
||||||
|
* JMAP and uploads carry real payloads and bound themselves as they stream;
|
||||||
|
* the push callback has its own limit ahead of this one.
|
||||||
|
*/
|
||||||
|
const MAX_SMALL_BODY = 64 * 1024;
|
||||||
|
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
|
||||||
|
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
|
||||||
|
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
|
||||||
|
|
||||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
const cookie = getCookie(c, config.cookieName);
|
const cookie = getCookie(c, config.cookieName);
|
||||||
const session = sessions.resolve(cookie);
|
const session = sessions.resolve(cookie);
|
||||||
@@ -269,6 +287,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
|
|
||||||
const api = new Hono<Env>();
|
const api = new Hono<Env>();
|
||||||
api.use("*", csrfGuard);
|
api.use("*", csrfGuard);
|
||||||
|
api.use("*", smallBodies);
|
||||||
|
|
||||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
||||||
|
|
||||||
@@ -303,6 +322,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
// ---------- Auth ----------
|
// ---------- Auth ----------
|
||||||
api.post("/auth/login", async (c) => {
|
api.post("/auth/login", async (c) => {
|
||||||
const ip = clientIp(c);
|
const ip = clientIp(c);
|
||||||
|
// What the limits count under: the address, or its /64 for IPv6.
|
||||||
|
const rateIp = rateLimitKey(ip);
|
||||||
|
// The flood ceiling needs nothing from the body, so it goes before reading one.
|
||||||
|
if (!loginFloodLimiter.check(rateIp)) {
|
||||||
|
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||||
|
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||||
|
}
|
||||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||||
try {
|
try {
|
||||||
body = await c.req.json();
|
body = await c.req.json();
|
||||||
@@ -318,7 +344,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
/*
|
/*
|
||||||
* Three checks, answering different questions.
|
* Three checks, answering different questions.
|
||||||
*
|
*
|
||||||
* `limitKey` is this username from this address, and `ip` is any username
|
* `limitKey` is this username from this address, and `rateIp` is any username
|
||||||
* from it -- both guard guessing, and both are given back when the upstream
|
* from it -- both guard guessing, and both are given back when the upstream
|
||||||
* never got as far as judging the password. Refunding only the first would
|
* never got as far as judging the password. Refunding only the first would
|
||||||
* not fix #239: ten retries through an outage would still spend the address
|
* not fix #239: ten retries through an outage would still spend the address
|
||||||
@@ -328,12 +354,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
* The flood ceiling is the one that is never refunded, and it is the reason
|
* The flood ceiling is the one that is never refunded, and it is the reason
|
||||||
* the other two safely can be.
|
* the other two safely can be.
|
||||||
*/
|
*/
|
||||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
const limitKey = `${rateIp}|${username.toLowerCase()}`;
|
||||||
if (!loginFloodLimiter.check(ip)) {
|
if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
|
||||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
|
|
||||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
|
||||||
}
|
|
||||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
|
||||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
||||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||||
}
|
}
|
||||||
@@ -351,7 +373,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
// The credentials were accepted; only the server is too old. Not an
|
// The credentials were accepted; only the server is too old. Not an
|
||||||
// attempt worth counting against them.
|
// attempt worth counting against them.
|
||||||
loginLimiter.refund(limitKey);
|
loginLimiter.refund(limitKey);
|
||||||
loginLimiter.refund(ip);
|
loginLimiter.refund(rateIp);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: "unsupported_server",
|
error: "unsupported_server",
|
||||||
@@ -364,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
loginLimiter.reset(limitKey);
|
loginLimiter.reset(limitKey);
|
||||||
const { cookie, session } = sessions.create({
|
const { cookie, session } = sessions.create({
|
||||||
username,
|
username,
|
||||||
|
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||||
password: effectivePassword,
|
password: effectivePassword,
|
||||||
remember: Boolean(body.remember),
|
remember: Boolean(body.remember),
|
||||||
userAgent: c.req.header("user-agent") ?? "",
|
userAgent: c.req.header("user-agent") ?? "",
|
||||||
@@ -411,7 +434,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
*/
|
*/
|
||||||
if (!(err instanceof UpstreamError && err.status === 401)) {
|
if (!(err instanceof UpstreamError && err.status === 401)) {
|
||||||
loginLimiter.refund(limitKey);
|
loginLimiter.refund(limitKey);
|
||||||
loginLimiter.refund(ip);
|
loginLimiter.refund(rateIp);
|
||||||
}
|
}
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -445,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
|
|
||||||
api.get("/auth/sessions", requireSession, (c) => {
|
api.get("/auth/sessions", requireSession, (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
const n = sessions.destroyAllForUser(session.username, session.id);
|
const n = sessions.destroyAllForUser(session.account, session.id);
|
||||||
return c.json({ revoked: n });
|
return c.json({ revoked: n });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Guard the endpoints that check a password against brute-forcing. */
|
/** Guard the endpoints that check a password against brute-forcing. */
|
||||||
const guarded = (c: Context<Env>): Response | null => {
|
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||||
if (accountLimiter.check(key)) return null;
|
if (accountLimiter.check(key)) return null;
|
||||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||||
@@ -517,7 +540,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
const otpCode = body.otpCode?.trim();
|
const otpCode = body.otpCode?.trim();
|
||||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||||
forgetUpstreamSession(session.id);
|
forgetUpstreamSession(session.id);
|
||||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||||
return c.json({ ok: true, revokedSessions: revoked });
|
return c.json({ ok: true, revokedSessions: revoked });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -531,12 +554,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An app password is a credential that outlives this session, a password
|
||||||
|
* change and a sign-out -- so minting one asks for the account password, as
|
||||||
|
* changing the password does. Otherwise a session left open on somebody
|
||||||
|
* else's machine is enough to take a permanent key away from it.
|
||||||
|
*/
|
||||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||||
|
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
|
||||||
|
const limited = guarded(c, "app-password");
|
||||||
|
if (limited) return limited;
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
const body = await readJson<{ description?: string }>(c);
|
const body = await readJson<{ description?: string; current?: string }>(c);
|
||||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||||
const description = (body.description ?? "").trim().slice(0, 120);
|
const description = (body.description ?? "").trim().slice(0, 120);
|
||||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||||
|
const current = body.current ?? "";
|
||||||
|
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
|
||||||
|
if (!(await confirmsPassword(session, current))) {
|
||||||
|
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -611,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
if (sessionKept) forgetUpstreamSession(session.id);
|
if (sessionKept) forgetUpstreamSession(session.id);
|
||||||
}
|
}
|
||||||
// Other sessions still hold the bare password and will be refused.
|
// Other sessions still hold the bare password and will be refused.
|
||||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
const revoked = sessions.destroyAllForUser(session.account, session.id);
|
||||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -648,12 +685,27 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
*/
|
*/
|
||||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||||
if (!administrationAllowed(config.administration, session.remember)) {
|
if (!administrationAllowed(config.administration, session.remember)) {
|
||||||
|
const held = gatedReads.get(session.id) ?? 0;
|
||||||
|
if (held >= MAX_GATED_PER_SESSION) {
|
||||||
|
c.header("Retry-After", "1");
|
||||||
|
return c.json({ error: "rate_limited" }, 429);
|
||||||
|
}
|
||||||
|
gatedReads.set(session.id, held + 1);
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
|
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
|
||||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||||
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof GatedBudgetError) {
|
||||||
|
c.header("Retry-After", "1");
|
||||||
|
return c.json({ error: "busy" }, 503);
|
||||||
|
}
|
||||||
return c.json({ error: "too_large" }, 413);
|
return c.json({ error: "too_large" }, 413);
|
||||||
|
} finally {
|
||||||
|
const left = (gatedReads.get(session.id) ?? 1) - 1;
|
||||||
|
if (left > 0) gatedReads.set(session.id, left);
|
||||||
|
else gatedReads.delete(session.id);
|
||||||
}
|
}
|
||||||
const gate = gateAdministration(raw);
|
const gate = gateAdministration(raw);
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
@@ -751,23 +803,30 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||||
|
// A PDF viewer or a video element asks for pieces; pass that on. A server
|
||||||
|
// that ignores it answers with the whole file, as it did before.
|
||||||
|
const range = c.req.header("range");
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||||
// on our behalf and hand back a decompressed body whose content-length
|
// on our behalf and hand back a decompressed body whose content-length
|
||||||
// header still describes the compressed one -- see forwardedContentLength.
|
// header still describes the compressed one -- see forwardedContentLength.
|
||||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
|
||||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||||
});
|
});
|
||||||
|
if (res.status === 416) return c.body(null, 416);
|
||||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||||
headers.set("Content-Type", type);
|
headers.set("Content-Type", type);
|
||||||
const cl = forwardedContentLength(res.headers);
|
const cl = forwardedContentLength(res.headers);
|
||||||
if (cl) headers.set("Content-Length", cl);
|
if (cl) headers.set("Content-Length", cl);
|
||||||
|
const partial = res.status === 206 && res.headers.get("content-range");
|
||||||
|
if (partial) headers.set("Content-Range", partial);
|
||||||
|
if (res.headers.get("accept-ranges") === "bytes") headers.set("Accept-Ranges", "bytes");
|
||||||
const safeInline = inline && isInlineSafe(type);
|
const safeInline = inline && isInlineSafe(type);
|
||||||
headers.set(
|
headers.set(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
|
||||||
);
|
);
|
||||||
headers.set("X-Content-Type-Options", "nosniff");
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||||
@@ -786,8 +845,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
} else {
|
} else {
|
||||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||||
}
|
}
|
||||||
headers.set("Cache-Control", "private, max-age=3600");
|
// Kept out of the browser's disk cache on a device that is not the
|
||||||
return new Response(res.body, { status: 200, headers });
|
// person's own: signing out wipes what the app stores, not that.
|
||||||
|
/*
|
||||||
|
* A blob id names its content -- the same id is the same bytes for good
|
||||||
|
* -- so on the reader's own device there is nothing to revalidate. On
|
||||||
|
* anyone else's, nothing is left in the disk cache at all.
|
||||||
|
*/
|
||||||
|
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
|
||||||
|
return new Response(res.body, { status: partial ? 206 : 200, headers });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -872,6 +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. */
|
/** Name the app password after the browser it will live in. */
|
||||||
function appPasswordName(c: Context): string {
|
function appPasswordName(c: Context): string {
|
||||||
const ua = c.req.header("user-agent") ?? "";
|
const ua = c.req.header("user-agent") ?? "";
|
||||||
@@ -931,9 +1034,50 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The largest JMAP request read into memory for the administration check.
|
* The largest JMAP request read into memory for the administration check.
|
||||||
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
*
|
||||||
|
* Only sessions that may not administer come this way, and what the client
|
||||||
|
* sends is small: attachments and pasted images go through `/upload`, and the
|
||||||
|
* composer turns inline images into uploads before a draft is saved. Stalwart
|
||||||
|
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
|
||||||
|
* held here as a string, parsed and serialized again, so each one costs
|
||||||
|
* several times its size; 4 MB is far past anything the client sends.
|
||||||
*/
|
*/
|
||||||
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
const MAX_GATED_REQUEST = 4 * 1024 * 1024;
|
||||||
|
/**
|
||||||
|
* How many checked requests one session may have in flight at once. Matches
|
||||||
|
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
|
||||||
|
* already stays within.
|
||||||
|
*/
|
||||||
|
const MAX_GATED_PER_SESSION = 4;
|
||||||
|
/**
|
||||||
|
* The bytes all checked requests together may hold at once. Counted as they
|
||||||
|
* arrive rather than reserved up front, so a slow body that has sent little
|
||||||
|
* holds little, and a burst of large ones is turned away with a 503 instead of
|
||||||
|
* taking the process down.
|
||||||
|
*/
|
||||||
|
const GATED_BUDGET = 32 * 1024 * 1024;
|
||||||
|
const gatedReads = new Map<string, number>();
|
||||||
|
let gatedBytes = 0;
|
||||||
|
|
||||||
|
class GatedBudgetError extends Error {}
|
||||||
|
|
||||||
|
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||||
|
let mine = 0;
|
||||||
|
const counted = new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
mine += chunk.byteLength;
|
||||||
|
gatedBytes += chunk.byteLength;
|
||||||
|
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
|
||||||
|
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
|
||||||
|
else controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await new Response(stream.pipeThrough(counted)).text();
|
||||||
|
} finally {
|
||||||
|
gatedBytes -= mine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||||
|
|
||||||
|
|||||||
@@ -97,3 +97,21 @@ export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: Trus
|
|||||||
const real = headers.realIp?.trim();
|
const real = headers.realIp?.trim();
|
||||||
return real && isIP(real) !== 0 ? real : peer;
|
return real && isIP(real) !== 0 ? real : peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key a rate limit counts an address under.
|
||||||
|
*
|
||||||
|
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
|
||||||
|
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
|
||||||
|
* address holds 2^64 of them, and a limit keyed on the full address is no
|
||||||
|
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
|
||||||
|
* IPv4 NAT already makes.
|
||||||
|
*/
|
||||||
|
export function rateLimitKey(ip: string): string {
|
||||||
|
if (isIP(ip) !== 6) return ip;
|
||||||
|
const bits = toBits(ip);
|
||||||
|
if (!bits) return ip;
|
||||||
|
const prefix = bits.value >> 64n;
|
||||||
|
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
|
||||||
|
return `${groups.join(":")}::/64`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
|||||||
res.on("close", done);
|
res.on("close", done);
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
"Content-Type": type,
|
"Content-Type": type,
|
||||||
"Cache-Control": "private, max-age=86400",
|
// As for attachments: nothing left in the disk cache of a device that is not the person's own.
|
||||||
|
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||||
"Cross-Origin-Resource-Policy": "same-origin",
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export function recordEmailChange(change: { created?: string[]; updated?: string
|
|||||||
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
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[]) {
|
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`;
|
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);
|
for (const c of sseClients) c.write(payload);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { checkOtp } from "./auth.js";
|
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 { randomUUID } from "node:crypto";
|
||||||
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.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 { 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";
|
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> = {
|
export const handlers: Record<string, Handler> = {
|
||||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
// 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." };
|
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||||
continue;
|
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 deviceId = String(o.deviceClientId ?? "");
|
||||||
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
|
||||||
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
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)}`;
|
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||||
/*
|
/*
|
||||||
* A subscription expires, and this used to hand back `expires: null`.
|
* 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.
|
* so "does this client renew?" is a question the mock can answer.
|
||||||
*/
|
*/
|
||||||
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
|
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 };
|
created[cid] = { id, expires };
|
||||||
state.n++;
|
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; }
|
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||||
s.verified = true;
|
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;
|
updated[id] = null;
|
||||||
state.n++;
|
state.n++;
|
||||||
}
|
}
|
||||||
@@ -428,7 +449,45 @@ export const handlers: Record<string, Handler> = {
|
|||||||
// An empty `properties` list returns `id` alone, which `pick` already does.
|
// 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.
|
// 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/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: [] }; },
|
"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) => {
|
"FileNode/query": (a) => {
|
||||||
const f = (a.filter as Obj) ?? {};
|
const f = (a.filter as Obj) ?? {};
|
||||||
|
|||||||
@@ -138,7 +138,21 @@ export const server = createServer(async (req, res) => {
|
|||||||
const [, , , , blobId] = url.pathname.split("/");
|
const [, , , , blobId] = url.pathname.split("/");
|
||||||
const b = blobs.get(blobId ?? "");
|
const b = blobs.get(blobId ?? "");
|
||||||
if (!b) { res.writeHead(404); return res.end(); }
|
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);
|
return res.end(b.data);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -133,3 +133,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
|
|||||||
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||||
} finally { restore(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a new subscription clears what this installation left behind, and only that", async () => {
|
||||||
|
// What a restart finds: its own subscription from the last process, another
|
||||||
|
// installation's on the same server, a browser's, and the old id format.
|
||||||
|
const calls: Array<[string, Record<string, unknown>]> = [];
|
||||||
|
let ownPrefix = "";
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||||
|
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||||
|
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const [name, args, id] = methodCalls[0]!;
|
||||||
|
calls.push([name, args]);
|
||||||
|
let result: Record<string, unknown> = {};
|
||||||
|
if (name === "PushSubscription/get") {
|
||||||
|
result = { list: [
|
||||||
|
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
|
||||||
|
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
|
||||||
|
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
|
||||||
|
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
|
||||||
|
] };
|
||||||
|
} else if (name === "PushSubscription/set" && args.create) {
|
||||||
|
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
|
||||||
|
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
|
||||||
|
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
|
||||||
|
} else {
|
||||||
|
result = { destroyed: args.destroy };
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||||
|
push.prepare("[email protected]", "a", "Basic p");
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||||
|
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||||
|
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
|
||||||
|
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
push.prepare("[email protected]", "a", "Basic r");
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||||
|
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||||
|
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = real;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+56
-3
@@ -23,7 +23,7 @@
|
|||||||
* transition loses no events, because a tab opened before verification keeps
|
* transition loses no events, because a tab opened before verification keeps
|
||||||
* its own relay for its whole life.
|
* its own relay for its whole life.
|
||||||
*/
|
*/
|
||||||
import { randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import type { ServerResponse } from "node:http";
|
import type { ServerResponse } from "node:http";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
||||||
@@ -71,10 +71,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
|
|||||||
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Whose subscriptions are whose.
|
||||||
|
*
|
||||||
|
* Each process used to register a subscription per account and forget it when
|
||||||
|
* it stopped -- state here is in memory, and an immutable deployment restarts
|
||||||
|
* on every deploy -- so each restart left one more behind, receiving 404s until
|
||||||
|
* it expired. Stalwart keeps them all and allows fifteen per account (checked
|
||||||
|
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
|
||||||
|
* too (#375).
|
||||||
|
*
|
||||||
|
* So the device id names the installation -- a hash of the address Stalwart
|
||||||
|
* posts to, stable across restarts and different for another installation on
|
||||||
|
* the same server -- and a new subscription first removes the ones this
|
||||||
|
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
|
||||||
|
* the browsers' own, which the web client may clear to make room.
|
||||||
|
*/
|
||||||
|
function installationId(): string {
|
||||||
|
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceIdFor(entry: AccountPush): string {
|
||||||
|
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLeftovers(entry: AccountPush) {
|
||||||
|
const mine = `ihasmail-proxy-${installationId()}-`;
|
||||||
|
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
|
||||||
|
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
|
||||||
|
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
|
||||||
|
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give the live subscription another week, rather than registering a second one. */
|
||||||
|
async function renew(entry: AccountPush) {
|
||||||
|
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||||
|
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
|
||||||
|
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
|
||||||
|
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
|
||||||
|
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
|
||||||
|
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
|
||||||
|
entry.expires = after ? Date.parse(after) : Date.parse(expires);
|
||||||
|
}
|
||||||
|
|
||||||
async function subscribe(entry: AccountPush) {
|
async function subscribe(entry: AccountPush) {
|
||||||
|
try {
|
||||||
|
await removeLeftovers(entry);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||||
const r = await jmap(entry, [["PushSubscription/set", {
|
const r = await jmap(entry, [["PushSubscription/set", {
|
||||||
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
|
create: { s: { deviceClientId: deviceIdFor(entry), url,
|
||||||
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||||
}, "0"]]);
|
}, "0"]]);
|
||||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
||||||
@@ -184,8 +232,13 @@ function startSweeper() {
|
|||||||
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
||||||
}
|
}
|
||||||
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||||
entry.state = "pending"; entry.since = now;
|
// Extended in place, which keeps it verified. Only if the server will
|
||||||
|
// 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"; });
|
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||||
void unsubscribe(entry);
|
void unsubscribe(entry);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much a request may make the proxy hold in memory.
|
||||||
|
*
|
||||||
|
* Routes that read JSON take a small body and no more, whether or not anyone
|
||||||
|
* is signed in. The JMAP route streams straight through for a session that may
|
||||||
|
* administer; for one that may not, it reads the body to check it, and that
|
||||||
|
* read is capped in size, in how many one session runs at once, and in bytes
|
||||||
|
* across everyone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = 18813;
|
||||||
|
process.env.MOCK_PORT = String(PORT);
|
||||||
|
process.env.MOCK_USER = "[email protected]";
|
||||||
|
process.env.MOCK_PASS = "demo-password";
|
||||||
|
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
process.env.APP_SECRET = "test-secret-for-request-limits";
|
||||||
|
|
||||||
|
const mock = await import("./mock/index.js");
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
const { rateLimitKey } = await import("./clientip.js");
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||||
|
let cookie = "";
|
||||||
|
|
||||||
|
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
|
||||||
|
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
|
||||||
|
let sent = 0;
|
||||||
|
return new ReadableStream({
|
||||||
|
pull(controller) {
|
||||||
|
if (sent >= size) return controller.close();
|
||||||
|
const n = Math.min(chunk, size - sent);
|
||||||
|
controller.enqueue(new Uint8Array(n).fill(0x20));
|
||||||
|
sent += n;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmap = (body: BodyInit) =>
|
||||||
|
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Not remembered: a device that is not the person's own, so JMAP is checked.
|
||||||
|
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||||
|
assert.equal(res.status, 200, "login should succeed against the mock");
|
||||||
|
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
(mock as { server?: { close(): void } }).server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sign-in refuses a large body by its length, before reading it", async () => {
|
||||||
|
const res = await app.request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sign-in refuses a large chunked body without holding all of it", async () => {
|
||||||
|
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
|
||||||
|
assert.equal(res.status, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other JSON routes are limited too", async () => {
|
||||||
|
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
|
||||||
|
assert.equal(res.status, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ordinary checked JMAP request still goes through", async () => {
|
||||||
|
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a JMAP request larger than the check allows is refused", async () => {
|
||||||
|
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
|
||||||
|
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
|
||||||
|
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
|
||||||
|
assert.equal((await jmap(body)).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one session cannot hold more than a few checked reads at once", async () => {
|
||||||
|
// Bodies that never finish: each holds its slot until its stream fails.
|
||||||
|
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
||||||
|
const pending: Promise<Response>[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
|
||||||
|
pending.push(jmap(s));
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const fifth = await jmap("{}");
|
||||||
|
assert.equal(fifth.status, 429);
|
||||||
|
assert.ok(fifth.headers.get("retry-after"));
|
||||||
|
for (const c of controllers) c.error(new Error("client went away"));
|
||||||
|
await Promise.allSettled(pending);
|
||||||
|
// The slots are given back once those requests end.
|
||||||
|
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
|
||||||
|
assert.equal(again.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IPv6 addresses share a rate-limit key across their /64", () => {
|
||||||
|
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
|
||||||
|
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
|
||||||
|
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
|
||||||
|
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
|
||||||
|
assert.equal(rateLimitKey("unknown"), "unknown");
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { SessionStore } from "./sessions.js";
|
import { SessionStore, accountKey } from "./sessions.js";
|
||||||
import { normalizeLocale } from "./upstream.js";
|
import { normalizeLocale } from "./upstream.js";
|
||||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||||
import { RateLimiter } from "./ratelimit.js";
|
import { RateLimiter } from "./ratelimit.js";
|
||||||
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
|
|||||||
assert.equal(store.resolve(cookie), null);
|
assert.equal(store.resolve(cookie), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sessions group by the account, however its name was typed", () => {
|
||||||
|
const store = new SessionStore("");
|
||||||
|
const key = accountKey("https://mail.example.com", "[email protected]");
|
||||||
|
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
|
||||||
|
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||||
|
// The same name on another configured server is another account.
|
||||||
|
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
|
||||||
|
assert.equal(a.session.account, b.session.account);
|
||||||
|
assert.equal(store.listForUser(a.session.account).length, 2);
|
||||||
|
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
|
||||||
|
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
|
||||||
|
assert.ok(store.resolve(a.cookie), "this session was kept");
|
||||||
|
});
|
||||||
|
|
||||||
test("persisted session data does not contain the password", () => {
|
test("persisted session data does not contain the password", () => {
|
||||||
const store = new SessionStore("");
|
const store = new SessionStore("");
|
||||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||||
|
|||||||
+33
-7
@@ -13,6 +13,8 @@ export interface StoredSession {
|
|||||||
/** sealed JSON {username, password} */
|
/** sealed JSON {username, password} */
|
||||||
sealedCredentials: string;
|
sealedCredentials: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||||
|
account?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeenAt: number;
|
lastSeenAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -24,6 +26,8 @@ export interface StoredSession {
|
|||||||
export interface LiveSession {
|
export interface LiveSession {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** See `accountKey`. */
|
||||||
|
account: string;
|
||||||
/** Basic Authorization header value for upstream calls. */
|
/** Basic Authorization header value for upstream calls. */
|
||||||
authorization: string;
|
authorization: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
@@ -46,8 +50,27 @@ export interface SessionSummary {
|
|||||||
ip: string;
|
ip: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key sessions are grouped by for "sign out everywhere else".
|
||||||
|
*
|
||||||
|
* Not the username as typed: Stalwart takes `[email protected]` and a bare
|
||||||
|
* `alice` as the same account, and a session opened either way was missing
|
||||||
|
* from the list and survived the sign-out. The server's own name for the
|
||||||
|
* account, lower-cased, and the server it lives on -- the same name on two
|
||||||
|
* configured servers is two accounts.
|
||||||
|
*/
|
||||||
|
export function accountKey(upstream: string, canonicalUsername: string): string {
|
||||||
|
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountOf(s: StoredSession): string {
|
||||||
|
return s.account ?? s.username.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSessionParams {
|
export interface CreateSessionParams {
|
||||||
username: string;
|
username: string;
|
||||||
|
/** From `accountKey`; defaults to the lower-cased username. */
|
||||||
|
account?: string;
|
||||||
password: string;
|
password: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
@@ -85,8 +108,9 @@ export interface SessionBackend {
|
|||||||
resolve(cookie: string | undefined): LiveSession | null;
|
resolve(cookie: string | undefined): LiveSession | null;
|
||||||
reseal(cookie: string | undefined, password: string): boolean;
|
reseal(cookie: string | undefined, password: string): boolean;
|
||||||
destroy(id: string): void;
|
destroy(id: string): void;
|
||||||
destroyAllForUser(username: string, exceptId?: string): number;
|
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||||
listForUser(username: string): SessionSummary[];
|
destroyAllForUser(account: string, exceptId?: string): number;
|
||||||
|
listForUser(account: string): SessionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COOKIE_SEP = ".";
|
const COOKIE_SEP = ".";
|
||||||
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
salt: salt.toString("base64"),
|
salt: salt.toString("base64"),
|
||||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||||
username: params.username,
|
username: params.username,
|
||||||
|
account: params.account ?? params.username.trim().toLowerCase(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
expiresAt: now + ttl,
|
expiresAt: now + ttl,
|
||||||
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
|
|||||||
if (this.sessions.delete(id)) this.scheduleSave();
|
if (this.sessions.delete(id)) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyAllForUser(username: string, exceptId?: string): number {
|
destroyAllForUser(account: string, exceptId?: string): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const [id, s] of this.sessions) {
|
for (const [id, s] of this.sessions) {
|
||||||
if (s.username === username && id !== exceptId) {
|
if (accountOf(s) === account && id !== exceptId) {
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
listForUser(username: string): SessionSummary[] {
|
listForUser(account: string): SessionSummary[] {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const s of this.sessions.values()) {
|
for (const s of this.sessions.values()) {
|
||||||
if (s.username !== username) continue;
|
if (accountOf(s) !== account) continue;
|
||||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||||
out.push(rest);
|
out.push(rest);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
username,
|
username,
|
||||||
|
account: accountOf(s),
|
||||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||||
remember: s.remember,
|
remember: s.remember,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The bundle goes out compressed once, at build time, and anything revalidated
|
||||||
|
* can be answered with a 304.
|
||||||
|
*
|
||||||
|
* Before this the server gzipped the bundle again for every request that asked,
|
||||||
|
* never offered Brotli, and sent no validator for the shell -- so each reload's
|
||||||
|
* revalidation of index.html and sw.js downloaded them in full.
|
||||||
|
*/
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
|
||||||
|
mkdirSync(join(root, "assets"));
|
||||||
|
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
|
||||||
|
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
|
||||||
|
// A copy left over from an older build of the same name must not be served.
|
||||||
|
writeFileSync(join(root, "assets", "stale-000000.js"), js);
|
||||||
|
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
|
||||||
|
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||||
|
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||||
|
|
||||||
|
process.env.STATIC_DIR = root;
|
||||||
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
|
||||||
|
|
||||||
|
test("Brotli is served where the browser takes it", async () => {
|
||||||
|
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "br");
|
||||||
|
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
||||||
|
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||||
|
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
|
||||||
|
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
|
||||||
|
assert.equal(gz.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
|
||||||
|
const plain = await get("/assets/app-a1b2c3.js");
|
||||||
|
assert.equal(plain.headers.get("content-encoding"), null);
|
||||||
|
assert.equal(await plain.text(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file without a copy is compressed as before", async () => {
|
||||||
|
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a copy older than its file is ignored", async () => {
|
||||||
|
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
|
||||||
|
assert.notEqual(res.headers.get("content-encoding"), "br");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the shell and the worker answer a revalidation with 304", async () => {
|
||||||
|
for (const path of ["/", "/sw.js"]) {
|
||||||
|
const first = await get(path);
|
||||||
|
const etag = first.headers.get("etag");
|
||||||
|
assert.ok(etag, `${path} carries a validator`);
|
||||||
|
await first.arrayBuffer();
|
||||||
|
const again = await get(path, { "if-none-match": etag! });
|
||||||
|
assert.equal(again.status, 304, `${path} is not sent again`);
|
||||||
|
assert.equal(await again.text(), "");
|
||||||
|
const changed = await get(path, { "if-none-match": `"something-else"` });
|
||||||
|
assert.equal(changed.status, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
+76
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { stat, readFile } from "node:fs/promises";
|
import { stat, readFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
|||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What a file is, for the purpose of "has it changed". The shell and the
|
||||||
|
* never-stale files are revalidated on every load; with no validator to send
|
||||||
|
* back, every revalidation downloaded the whole file again.
|
||||||
|
*/
|
||||||
|
function etagOf(size: number, mtimeMs: number): string {
|
||||||
|
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notModified(c: Context, etag: string): boolean {
|
||||||
|
const sent = c.req.header("if-none-match");
|
||||||
|
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The encodings a build can carry beside a file, best first. See
|
||||||
|
* scripts/precompress.mjs, which writes them.
|
||||||
|
*/
|
||||||
|
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
|
||||||
|
{ token: "br", suffix: ".br", encoding: "br" },
|
||||||
|
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function accepts(c: Context, token: string): boolean {
|
||||||
|
const header = c.req.header("accept-encoding") ?? "";
|
||||||
|
return header.split(",").some((part) => {
|
||||||
|
const [name, ...params] = part.trim().split(";");
|
||||||
|
if (name?.trim().toLowerCase() !== token) return false;
|
||||||
|
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
||||||
|
return !q || Number(q.slice(2)) > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function staticHandler(root: string, basePath = ""): Handler {
|
export function staticHandler(root: string, basePath = ""): Handler {
|
||||||
const absRoot = resolve(root);
|
const absRoot = resolve(root);
|
||||||
let indexCache: { body: string; mtime: number } | null = null;
|
let indexCache: { body: string; mtime: number; etag: string } | null = null;
|
||||||
|
/** Which precompressed copies exist, per file and modification time. */
|
||||||
|
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
|
||||||
|
|
||||||
|
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
|
||||||
|
const known = variants.get(filePath);
|
||||||
|
if (known && known.mtime === mtime) return known.found;
|
||||||
|
const found = new Map<string, number>();
|
||||||
|
for (const v of PRECOMPRESSED) {
|
||||||
|
try {
|
||||||
|
const st = await stat(filePath + v.suffix);
|
||||||
|
// A copy older than the file it came from describes something else.
|
||||||
|
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
|
||||||
|
} catch {
|
||||||
|
/* none */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.set(filePath, { mtime, found });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
let mismatchWarned = false;
|
let mismatchWarned = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
const p = join(absRoot, "index.html");
|
const p = join(absRoot, "index.html");
|
||||||
const st = await stat(p);
|
const st = await stat(p);
|
||||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
const body = await readFile(p, "utf8");
|
||||||
|
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
|
||||||
mismatchWarned = false;
|
mismatchWarned = false;
|
||||||
}
|
}
|
||||||
warnOnBaseMismatch(indexCache.body);
|
warnOnBaseMismatch(indexCache.body);
|
||||||
c.header("Content-Type", "text/html; charset=utf-8");
|
c.header("Content-Type", "text/html; charset=utf-8");
|
||||||
c.header("Cache-Control", "no-cache");
|
c.header("Cache-Control", "no-cache");
|
||||||
c.header("Content-Security-Policy", APP_CSP);
|
c.header("Content-Security-Policy", APP_CSP);
|
||||||
|
c.header("ETag", indexCache.etag);
|
||||||
|
if (notModified(c, indexCache.etag)) return c.body(null, 304);
|
||||||
return c.body(indexCache.body);
|
return c.body(indexCache.body);
|
||||||
} catch {
|
} catch {
|
||||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||||
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
if (!st.isFile()) return serveIndex(c);
|
if (!st.isFile()) return serveIndex(c);
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||||
c.header("Content-Length", String(st.size));
|
const etag = etagOf(st.size, st.mtimeMs);
|
||||||
|
c.header("ETag", etag);
|
||||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
} else {
|
} else {
|
||||||
c.header("Cache-Control", "public, max-age=3600");
|
c.header("Cache-Control", "public, max-age=3600");
|
||||||
}
|
}
|
||||||
|
if (notModified(c, etag)) return c.body(null, 304);
|
||||||
|
// Serve a copy made at build time where the browser takes one.
|
||||||
|
let servePath = filePath;
|
||||||
|
let size = st.size;
|
||||||
|
const found = await variantsOf(filePath, st.mtimeMs);
|
||||||
|
if (found.size) {
|
||||||
|
c.header("Vary", "Accept-Encoding");
|
||||||
|
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
|
||||||
|
if (pick) {
|
||||||
|
servePath = filePath + pick.suffix;
|
||||||
|
size = found.get(pick.suffix)!;
|
||||||
|
c.header("Content-Encoding", pick.encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.header("Content-Length", String(size));
|
||||||
if (c.req.method === "HEAD") return c.body(null);
|
if (c.req.method === "HEAD") return c.body(null);
|
||||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||||
return c.body(stream);
|
return c.body(stream);
|
||||||
} catch {
|
} catch {
|
||||||
// SPA fallback for client-side routes (no file extension) only.
|
// SPA fallback for client-side routes (no file extension) only.
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ export interface AccountInfo {
|
|||||||
|
|
||||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
const INFO_CACHE_MS = 30 * 60_000;
|
const INFO_CACHE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both caches are keyed by session, and used to lose an entry only when that
|
||||||
|
* session signed out or was refused -- not when it simply expired, which is how
|
||||||
|
* most sessions end. An entry past its age is never used again, so dropping
|
||||||
|
* those on a timer is all it takes to stop them accumulating.
|
||||||
|
*/
|
||||||
|
export function sweepUpstreamCaches(now = Date.now()): void {
|
||||||
|
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
|
||||||
|
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
|
||||||
|
}
|
||||||
|
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
|
||||||
|
|
||||||
|
/** How many sessions the caches hold; for tests. */
|
||||||
|
export function upstreamCacheSizes(): { sessions: number; info: number } {
|
||||||
|
return { sessions: sessionCache.size, info: infoCache.size };
|
||||||
|
}
|
||||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
+93
-8
@@ -26,10 +26,76 @@ self.addEventListener("install", (event) => {
|
|||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
||||||
|
.then(() => 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.
|
* 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.origin !== self.location.origin) return;
|
||||||
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
if (url.pathname.startsWith(`${BASE}/api/`)) return;
|
||||||
|
|
||||||
// Hashed build assets: cache-first.
|
// Hashed build assets: cache-first, and only what actually arrived.
|
||||||
if (url.pathname.startsWith(`${BASE}/assets/`)) {
|
if (url.pathname.startsWith(ASSETS)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||||
|
if (res.ok && res.type === "basic") {
|
||||||
const copy = res.clone();
|
const copy = res.clone();
|
||||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -117,7 +185,13 @@ self.addEventListener("fetch", (event) => {
|
|||||||
|
|
||||||
// Navigations & everything else: network-first, fall back to cached shell.
|
// Navigations & everything else: network-first, fall back to cached shell.
|
||||||
if (req.mode === "navigate") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
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 : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
|
/*
|
||||||
|
* Someone reading the app already knows. A focused, visible window of this
|
||||||
|
* app gets its new mail from its own event stream, so a notification on
|
||||||
|
* top of it is a second telling of the same thing (#375). Chrome does not
|
||||||
|
* require one while the site is in the foreground.
|
||||||
|
*/
|
||||||
|
const windows = await self.clients.matchAll({ type: "window" });
|
||||||
|
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||||
const facts = await readFacts();
|
const facts = await readFacts();
|
||||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||||
/*
|
/*
|
||||||
@@ -287,8 +369,10 @@ self.addEventListener("push", (event) => {
|
|||||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||||
|
|
||||||
if (!emails.length) {
|
if (!emails.length) {
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
// A delivery from a server that sends StateChange rather than EmailPush
|
||||||
// something true rather than inventing a sender.
|
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
|
||||||
|
// or a payload too large to carry the message. Say something true
|
||||||
|
// rather than inventing a sender.
|
||||||
await self.registration.showNotification(strings.newMail, {
|
await self.registration.showNotification(strings.newMail, {
|
||||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||||
});
|
});
|
||||||
@@ -308,7 +392,8 @@ self.addEventListener("push", (event) => {
|
|||||||
// be drawn.
|
// be drawn.
|
||||||
actions: email.id ? actionsFor(facts) : [],
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
data: {
|
data: {
|
||||||
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
// The route names a conversation, and `m` the message in it.
|
||||||
|
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||||
id: email.id || null,
|
id: email.id || null,
|
||||||
title,
|
title,
|
||||||
accountId: facts?.accountId ?? null,
|
accountId: facts?.accountId ?? null,
|
||||||
|
|||||||
+3
-5
@@ -16,7 +16,7 @@ import { LoginPage } from "@/views/Login";
|
|||||||
import { AppShell } from "@/views/AppShell";
|
import { AppShell } from "@/views/AppShell";
|
||||||
import { MailView } from "@/views/mail/MailView";
|
import { MailView } from "@/views/mail/MailView";
|
||||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||||
import { setUnreadBadge } from "@/lib/notify/notify";
|
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
@@ -260,10 +260,8 @@ function AuthedApp() {
|
|||||||
});
|
});
|
||||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("@/lib/notify/notify").then((m) => {
|
setBaseTitle(appName);
|
||||||
m.setBaseTitle(appName);
|
|
||||||
setUnreadBadge(inboxUnread);
|
setUnreadBadge(inboxUnread);
|
||||||
});
|
|
||||||
}, [inboxUnread, appName]);
|
}, [inboxUnread, appName]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -284,7 +282,7 @@ function AuthedApp() {
|
|||||||
// Request notification permission lazily when enabled
|
// Request notification permission lazily when enabled
|
||||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notif) void import("@/lib/notify/notify").then((m) => m.requestNotificationPermission());
|
if (notif) void requestNotificationPermission();
|
||||||
}, [notif]);
|
}, [notif]);
|
||||||
|
|
||||||
// Nothing worth painting until the account's settings are in force; see the
|
// Nothing worth painting until the account's settings are in force; see the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
import { displayName, formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||||
|
|
||||||
describe("address parsing", () => {
|
describe("address parsing", () => {
|
||||||
it("parses mixed lists", () => {
|
it("parses mixed lists", () => {
|
||||||
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
|
|||||||
expect(m.to).toHaveLength(1);
|
expect(m.to).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("names that reorder themselves", () => {
|
||||||
|
const spoof = { name: "[email protected]\u202E", email: "[email protected]" };
|
||||||
|
it("lose their direction controls when displayed", () => {
|
||||||
|
expect(displayName({ name: "\u202Egnp.exe\u202C Ann", email: "[email protected]" })).toBe("gnp.exe Ann");
|
||||||
|
expect(formatAddress(spoof)).toBe("[email protected] <[email protected]>");
|
||||||
|
});
|
||||||
|
it("fall back to the address when nothing else is left", () => {
|
||||||
|
expect(displayName({ name: "\u200F\u202E", email: "[email protected]" })).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { contactFromAddress, nameParts } from "../contacts";
|
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
|
||||||
import type { ContactCard } from "@/jmap/types";
|
import type { ContactCard } from "@/jmap/types";
|
||||||
|
|
||||||
const parts = (name: string | null, email = "[email protected]") =>
|
const parts = (name: string | null, email = "[email protected]") =>
|
||||||
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
|
|||||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #376: a photo saved as a `blobId` was refused by Stalwart, which only takes
|
||||||
|
* the `uri` form. Saving one must also leave a card's other media alone.
|
||||||
|
*/
|
||||||
|
describe("withPhoto", () => {
|
||||||
|
const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" };
|
||||||
|
|
||||||
|
it("puts the photo in as a data URI, never a blob id", () => {
|
||||||
|
const media = withPhoto(undefined, photo)!;
|
||||||
|
const [m] = Object.values(media);
|
||||||
|
expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" });
|
||||||
|
expect(m).not.toHaveProperty("blobId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an existing photo and keeps a logo", () => {
|
||||||
|
const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!;
|
||||||
|
expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1);
|
||||||
|
expect(media.old).toBeUndefined();
|
||||||
|
expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes only the photo, and clears media when nothing is left", () => {
|
||||||
|
expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } });
|
||||||
|
expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is read back by contactPhoto", () => {
|
||||||
|
const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard;
|
||||||
|
expect(contactPhoto(card, "a1")).toBe(photo.dataUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ describe("isTnef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("parseTnef", () => {
|
describe("parseTnef", () => {
|
||||||
|
it("takes the direction overrides out of a name", () => {
|
||||||
|
const out = parseTnef(tnef(file("x.bin", "MZ", [
|
||||||
|
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "Invoice_\u202Efdp.exe" }]) },
|
||||||
|
])));
|
||||||
|
expect(out[0]!.name).toBe("Invoice_fdp.exe");
|
||||||
|
});
|
||||||
it("pulls one attachment out, with its name and bytes", () => {
|
it("pulls one attachment out, with its name and bytes", () => {
|
||||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||||
expect(out).toHaveLength(1);
|
expect(out).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EmailAddress } from "@/jmap/types";
|
import type { EmailAddress } from "@/jmap/types";
|
||||||
|
import { withoutBidiControls } from "@/lib/text/text";
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
||||||
|
|
||||||
@@ -48,9 +49,10 @@ export function parseOne(raw: string): EmailAddress | null {
|
|||||||
|
|
||||||
export function formatAddress(a: EmailAddress | null | undefined): string {
|
export function formatAddress(a: EmailAddress | null | undefined): string {
|
||||||
if (!a) return "";
|
if (!a) return "";
|
||||||
if (!a.name) return a.email;
|
const clean = a.name ? withoutBidiControls(a.name) : "";
|
||||||
const needsQuote = /[,;<>"()\\]/.test(a.name);
|
if (!clean) return a.email;
|
||||||
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
|
const needsQuote = /[,;<>"()\\]/.test(clean);
|
||||||
|
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
|
||||||
return `${name} <${a.email}>`;
|
return `${name} <${a.email}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +62,8 @@ export function formatAddressList(list: EmailAddress[] | null | undefined): stri
|
|||||||
|
|
||||||
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
||||||
if (!a) return fallback;
|
if (!a) return fallback;
|
||||||
if (a.name?.trim()) return a.name.trim();
|
const name = a.name ? withoutBidiControls(a.name).trim() : "";
|
||||||
|
if (name) return name;
|
||||||
return a.email || fallback;
|
return a.email || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
|
|
||||||
/** Best display name for a card. */
|
/** Best display name for a card. */
|
||||||
@@ -57,6 +57,22 @@ export function contactEmails(c: ContactCard): EmailAddress[] {
|
|||||||
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
|
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card's `media` with its photo replaced by `photo`, or removed when that is
|
||||||
|
* null, and everything else in it -- a logo, a sound -- left as it was.
|
||||||
|
*
|
||||||
|
* The photo goes in as a `data:` URI. Stalwart (0.16.22, checked live on
|
||||||
|
* 2026-09-16) refuses a `blobId` in `media` outright -- "blobIds in media is
|
||||||
|
* not supported" -- which is RFC 9610's JMAP extension to JSContact, and
|
||||||
|
* accepts the plain RFC 9553 `uri` form, returning it unchanged (#376). The
|
||||||
|
* editor's photo is a 256px JPEG, tens of kilobytes; 134 KB was accepted.
|
||||||
|
*/
|
||||||
|
export function withPhoto(media: Record<string, JSContactMedia> | undefined | null, photo: { dataUrl: string; type: string } | null): Record<string, JSContactMedia> | null {
|
||||||
|
const rest: Record<string, JSContactMedia> = Object.fromEntries(Object.entries(media ?? {}).filter(([, m]) => m.kind !== "photo"));
|
||||||
|
if (photo) rest[newKey("p")] = { "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: photo.type };
|
||||||
|
return Object.keys(rest).length ? rest : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Hand the browser a file the app made, to save.
|
||||||
|
*
|
||||||
|
* The object URL is released as soon as the download has been started: a
|
||||||
|
* click on the link starts it synchronously, and an unreleased URL keeps the
|
||||||
|
* whole file in memory for as long as the tab is open -- an address book's
|
||||||
|
* worth of vCards, per export.
|
||||||
|
*/
|
||||||
|
export function downloadFile(content: BlobPart, type: string, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,9 +125,15 @@ describe("what gets registered", () => {
|
|||||||
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
|
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 } });
|
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"]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
|
||||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||||
if (document.visibilityState === "visible" && document.hasFocus()) 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 {
|
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 = () => {
|
n.onclick = () => {
|
||||||
window.focus();
|
window.focus();
|
||||||
opts.onClick?.();
|
onClick?.();
|
||||||
n.close();
|
n.close();
|
||||||
};
|
};
|
||||||
setTimeout(() => n.close(), 8000);
|
setTimeout(() => n.close(), 8000);
|
||||||
|
|||||||
+126
-10
@@ -24,17 +24,36 @@ import { isDeviceTrusted } from "@/lib/storage";
|
|||||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
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 {
|
export interface JmapPushSubscription {
|
||||||
id: Id;
|
id: Id;
|
||||||
deviceClientId: string;
|
deviceClientId: string;
|
||||||
url: string;
|
/** Write-only: Stalwart never returns it, so a subscription cannot be matched by endpoint. */
|
||||||
|
url?: string;
|
||||||
expires: string | null;
|
expires: string | null;
|
||||||
verificationCode?: 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. */
|
/** The VAPID key this server signs with, or null if it does not do Web Push. */
|
||||||
export function applicationServerKey(): string | null {
|
export function applicationServerKey(): string | null {
|
||||||
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
|
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(),
|
deviceClientId: deviceClientId(),
|
||||||
url: sub.endpoint,
|
url: sub.endpoint,
|
||||||
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
|
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.
|
* New mail, and nothing else.
|
||||||
types: ["Email"],
|
*
|
||||||
|
* `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()) {
|
if (accountId && supportsEmailPush()) {
|
||||||
body.emailPush = {
|
body.emailPush = {
|
||||||
@@ -185,9 +214,51 @@ export function setPushEnabledHere(on: boolean): void {
|
|||||||
*/
|
*/
|
||||||
export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000;
|
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. */
|
/** This browser's registered subscription, out of everything the account has. */
|
||||||
export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null {
|
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 } },
|
{ create: { s: body } },
|
||||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
[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;
|
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.
|
* 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 */
|
/* the browser end is gone or was never there; still clear the server end */
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const subs = await listSubscriptions();
|
await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id));
|
||||||
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* signing out must not fail over this */
|
/* signing out must not fail over this */
|
||||||
}
|
}
|
||||||
|
rememberEndpoint(null);
|
||||||
setPushEnabledHere(false);
|
setPushEnabledHere(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ import {
|
|||||||
applicationServerKey,
|
applicationServerKey,
|
||||||
createSubscription,
|
createSubscription,
|
||||||
decodeApplicationServerKey,
|
decodeApplicationServerKey,
|
||||||
|
destroySubscriptions,
|
||||||
deviceClientId,
|
deviceClientId,
|
||||||
|
extendSubscription,
|
||||||
findSubscription,
|
findSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
needsRenewal,
|
mySubscriptions,
|
||||||
|
PushSetError,
|
||||||
pushEnabledHere,
|
pushEnabledHere,
|
||||||
|
registeredEndpoint,
|
||||||
|
rememberEndpoint,
|
||||||
|
RENEW_WITHIN_MS,
|
||||||
|
roomToMake,
|
||||||
setPushEnabledHere,
|
setPushEnabledHere,
|
||||||
subscriptionPayload,
|
subscriptionPayload,
|
||||||
unsubscribeThisDevice,
|
unsubscribeThisDevice,
|
||||||
@@ -64,8 +71,7 @@ async function collectStoredVerification(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe this browser. Safe to call again — the deviceClientId makes a
|
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||||
* repeat replace rather than accumulate.
|
|
||||||
*
|
*
|
||||||
* Returns why it could not, rather than throwing, because every reason is
|
* 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
|
* 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
|
* Shared by turning push on and by renewing it. It used to create a new
|
||||||
* call: `deviceClientId` makes a repeat registration replace rather than
|
* subscription every time, on the belief that a repeated `deviceClientId`
|
||||||
* accumulate, so there is no separate "update" path to get wrong.
|
* 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.
|
* 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`
|
* 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,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: decodeApplicationServerKey(key),
|
applicationServerKey: decodeApplicationServerKey(key),
|
||||||
}));
|
}));
|
||||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
const deviceId = deviceClientId();
|
||||||
const inboxId = useMail.getState().roleId("inbox");
|
const subs = await listSubscriptions();
|
||||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
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();
|
const key = applicationServerKey();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
try {
|
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);
|
await registerThisBrowser(key);
|
||||||
listenForVerification();
|
listenForVerification();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 {
|
export function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { withoutBidiControls } from "@/lib/text/text";
|
||||||
/**
|
/**
|
||||||
* `winmail.dat`, opened.
|
* `winmail.dat`, opened.
|
||||||
*
|
*
|
||||||
@@ -210,7 +211,7 @@ export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] {
|
|||||||
current = null;
|
current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = (current.mapiName || current.title || "attachment").trim() || "attachment";
|
const name = withoutBidiControls(current.mapiName || current.title || "attachment").trim() || "attachment";
|
||||||
out.push({
|
out.push({
|
||||||
name,
|
name,
|
||||||
type: current.mapiType || guessType(name),
|
type: current.mapiType || guessType(name),
|
||||||
|
|||||||
+62
-60
@@ -10,7 +10,7 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
* deleting a bad entry is a valid fix.
|
* deleting a bad entry is a valid fix.
|
||||||
*
|
*
|
||||||
* ── Decisions this file is consistent about ──────────────────────────────
|
* ── 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
|
* 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
|
* — 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
|
* 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.
|
* which is ordinary good Dutch UI and sidesteps it entirely.
|
||||||
*
|
*
|
||||||
* Terminology, fixed once so it cannot drift:
|
* Terminology, fixed once so it cannot drift:
|
||||||
*
|
* --- Used capitals on all words for unity ---
|
||||||
* Inbox Postvak IN Archive (verb) archiveren
|
* Inbox Postvak IN Archive (verb) Archiveren
|
||||||
* Drafts Concepten Delete verwijderen
|
* Drafts Concepten Delete Verwijderen
|
||||||
* Sent Verzonden Move to verplaatsen naar
|
* Sent Verzonden Move to Verplaatsen naar
|
||||||
* Deleted Items Prullenbak Reply beantwoorden
|
* Deleted Items Prullenbak Reply Beantwoorden
|
||||||
* Junk / Spam Spam Reply all allen beantwoorden
|
* Junk / Spam Spam Reply all Allen beantwoorden
|
||||||
* Folder Map Forward doorsturen
|
* Folder Map Forward Doorsturen
|
||||||
* Label Label Star ster
|
* Label Label Star Ster
|
||||||
* Conversation Gesprek Read / unread gelezen / ongelezen
|
* Conversation Gesprek Read / unread Gelezen / ongelezen
|
||||||
* Message Bericht Settings Instellingen
|
* Message Bericht Settings Instellingen
|
||||||
* Attachment Bijlage Signature Handtekening
|
* Attachment Bijlage Signature Handtekening
|
||||||
* Contact Contact Identity Identiteit
|
* Contact Contact Identity Identiteit
|
||||||
@@ -46,8 +46,8 @@ export const catalog: Catalog = {
|
|||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"Domains": "Domeinen",
|
"Domains": "Domeinen",
|
||||||
"By hand": "Handmatig",
|
"By hand": "Handmatig",
|
||||||
"Signing": "Ondertekent",
|
"Signing": "Ondertekenen",
|
||||||
"Published, not signing yet": "Gepubliceerd, ondertekent nog niet",
|
"Published, not signing yet": "Gepubliceerd, nog niet ondertekend",
|
||||||
"Retiring": "Wordt uitgefaseerd",
|
"Retiring": "Wordt uitgefaseerd",
|
||||||
"Retired": "Uitgefaseerd",
|
"Retired": "Uitgefaseerd",
|
||||||
"This domain no longer exists. Someone may have removed it.": "Dit domein bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
"This domain no longer exists. Someone may have removed it.": "Dit domein bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
||||||
@@ -55,7 +55,7 @@ export const catalog: Catalog = {
|
|||||||
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.",
|
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.",
|
||||||
"Saved {name}": "{name} opgeslagen",
|
"Saved {name}": "{name} opgeslagen",
|
||||||
"Add domain": "Domein toevoegen",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
||||||
@@ -69,29 +69,29 @@ export const catalog: Catalog = {
|
|||||||
"DNS records": "DNS-records",
|
"DNS records": "DNS-records",
|
||||||
"Published automatically through {provider}.": "Automatisch gepubliceerd via {provider}.",
|
"Published automatically through {provider}.": "Automatisch gepubliceerd via {provider}.",
|
||||||
"Published automatically by the server.": "Automatisch gepubliceerd door de server.",
|
"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 {type} record for {name}": "{type}-record voor {name} kopiëren",
|
||||||
"Copy value": "Waarde kopiëren",
|
"Copy value": "Waarde kopiëren",
|
||||||
"Copied the zone file": "Zonebestand gekopieerd",
|
"Copied the zone file": "Zonebestand gekopieerd",
|
||||||
"Copy all as a zone file": "Alles als zonebestand kopiëren",
|
"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",
|
"DKIM keys": "DKIM-sleutels",
|
||||||
"The server creates and rotates these keys itself.": "De server maakt en vervangt deze sleutels zelf.",
|
"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.",
|
"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",
|
"Managed by the server": "Beheerd door de server",
|
||||||
"Certificate": "Certificaat",
|
"Certificate": "Certificaat",
|
||||||
"Another name for this domain": "Andere naam voor dit domein",
|
"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.",
|
"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 domain…": "Domein verwijderen…",
|
||||||
"Remove {name}?": "{name} verwijderen?",
|
"Remove {name}?": "{name} verwijderen?",
|
||||||
"Removed {name}": "{name} verwijderd",
|
"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}.",
|
"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",
|
"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.",
|
"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 uw adressen wonen, en de DNS-records waardoor e-mail aankomt en wordt vertrouwd.",
|
"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",
|
"Search domains": "Domeinen zoeken",
|
||||||
"No domains match": "Geen domeinen gevonden",
|
"No domains match": "Geen domeinen gevonden",
|
||||||
"No domains yet": "Nog geen domeinen",
|
"No domains yet": "Nog geen domeinen",
|
||||||
@@ -101,17 +101,17 @@ export const catalog: Catalog = {
|
|||||||
"also {names}": "ook {names}",
|
"also {names}": "ook {names}",
|
||||||
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
|
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
|
||||||
// ── Administration: refusals ───────────────────────────────────
|
// ── 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 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 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 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.",
|
"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.",
|
"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}).",
|
"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.",
|
"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.",
|
"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.",
|
"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 andere naam van een ander domein.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"Nothing to show": "Niets om te tonen",
|
||||||
"Could not be loaded": "Kon niet worden geladen",
|
"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",
|
"Open Stalwart admin": "Stalwart-beheer openen",
|
||||||
"Default group role": "Standaardrol voor groepen",
|
"Default group role": "Standaardrol voor groepen",
|
||||||
"A group needs an address.": "Een groep heeft een adres nodig.",
|
"A group needs an address.": "Een groep heeft een adres nodig.",
|
||||||
"New group": "Nieuwe groep",
|
"New group": "Nieuwe groep",
|
||||||
"Your role lets you view groups but not change them.": "Met uw rol kunt u groepen bekijken, maar niet wijzigen.",
|
"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.",
|
"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.",
|
"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…",
|
"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.",
|
"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",
|
"Remove from group": "Uit de groep halen",
|
||||||
"No members yet": "Nog geen leden",
|
"No members yet": "Nog geen leden",
|
||||||
"Showing {shown} of {total} members.": "{shown} van {total} leden getoond.",
|
"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 by name or address": "Lid toevoegen op naam of adres",
|
||||||
"Add a member": "Lid toevoegen",
|
"Add a member": "Lid toevoegen",
|
||||||
"No one else matches": "Verder komt niemand overeen",
|
"No one else matches": "Verder komt niemand overeen",
|
||||||
@@ -167,7 +167,7 @@ export const catalog: Catalog = {
|
|||||||
"Search groups": "Groepen zoeken",
|
"Search groups": "Groepen zoeken",
|
||||||
"No groups match": "Geen groepen gevonden",
|
"No groups match": "Geen groepen gevonden",
|
||||||
"No groups yet": "Nog geen groepen",
|
"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.",
|
"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.",
|
"The server did not say whether the group was created.": "De server heeft niet gemeld of de groep is aangemaakt.",
|
||||||
"Mailing lists": "Mailinglijsten",
|
"Mailing lists": "Mailinglijsten",
|
||||||
@@ -176,19 +176,19 @@ export const catalog: Catalog = {
|
|||||||
"New mailing list": "Nieuwe mailinglijst",
|
"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.",
|
"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.",
|
"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",
|
"Create list": "Lijst aanmaken",
|
||||||
"Filter recipients": "Ontvangers filteren",
|
"Filter recipients": "Ontvangers filteren",
|
||||||
"No recipients match": "Geen ontvangers gevonden",
|
"No recipients match": "Geen ontvangers gevonden",
|
||||||
"Add recipients": "Ontvangers toevoegen",
|
"Add recipients": "Ontvangers toevoegen",
|
||||||
"Addresses, separated by commas": "Adressen, gescheiden door komma's",
|
"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}",
|
"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 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-mail aan dit adres wordt niet meer doorgestuurd. De eigen e-mail van de ontvangers blijft ongemoeid.",
|
"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…",
|
||||||
"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.",
|
"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-mail doorsturen naar iedereen die erop staat.",
|
"Addresses that pass mail on to everyone on them.": "Adressen die e-mails doorsturen naar iedereen die erop staat.",
|
||||||
"Search mailing lists": "Mailinglijsten zoeken",
|
"Search mailing lists": "Mailinglijsten zoeken",
|
||||||
"No mailing lists match": "Geen mailinglijsten gevonden",
|
"No mailing lists match": "Geen mailinglijsten gevonden",
|
||||||
"No mailing lists yet": "Nog geen mailinglijsten",
|
"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.",
|
"A role needs a name.": "Een rol heeft een naam nodig.",
|
||||||
"Created {name}": "{name} aangemaakt",
|
"Created {name}": "{name} aangemaakt",
|
||||||
"New role": "Nieuwe rol",
|
"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.",
|
"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.",
|
"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",
|
"Builds on": "Bouwt voort op",
|
||||||
"Permissions": "Rechten",
|
"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.",
|
"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 de uwe niet heeft.",
|
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
|
||||||
"Create role": "Rol aanmaken",
|
"Create role": "Rol aanmaken",
|
||||||
"builds on this one": "bouwt voort op deze",
|
"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",
|
"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.",
|
"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",
|
"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.",
|
"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…",
|
"Delete role…": "Rol verwijderen…",
|
||||||
"Deleted {name}": "{name} verwijderd",
|
"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.",
|
"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",
|
"Delete role": "Rol verwijderen",
|
||||||
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
|
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
|
||||||
@@ -295,7 +296,7 @@ export const catalog: Catalog = {
|
|||||||
"Search accounts": "Accounts zoeken",
|
"Search accounts": "Accounts zoeken",
|
||||||
"No accounts match": "Geen accounts gevonden",
|
"No accounts match": "Geen accounts gevonden",
|
||||||
"No accounts yet": "Nog geen accounts",
|
"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",
|
"Open {address}": "{address} openen",
|
||||||
"{from}–{to} of {total}": "{from}–{to} van {total}",
|
"{from}–{to} of {total}": "{from}–{to} van {total}",
|
||||||
"Previous page": "Vorige pagina",
|
"Previous page": "Vorige pagina",
|
||||||
@@ -305,7 +306,7 @@ export const catalog: Catalog = {
|
|||||||
"{used} · no limit": "{used} · geen limiet",
|
"{used} · no limit": "{used} · geen limiet",
|
||||||
"Profile": "Profiel",
|
"Profile": "Profiel",
|
||||||
"Domain": "Domein",
|
"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",
|
"Sign-in": "Inloggen",
|
||||||
"Other addresses": "Andere adressen",
|
"Other addresses": "Andere adressen",
|
||||||
"Not in any group": "Geen lid van een groep",
|
"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.",
|
"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",
|
"Limit in GB": "Limiet in GB",
|
||||||
"No limit": "Geen limiet",
|
"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.",
|
"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.",
|
"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",
|
"Create account": "Account aanmaken",
|
||||||
"An account needs an address.": "Een account heeft een adres nodig.",
|
"An account needs an address.": "Een account heeft een adres nodig.",
|
||||||
@@ -331,11 +332,11 @@ export const catalog: Catalog = {
|
|||||||
"Remove {address}": "{address} verwijderen",
|
"Remove {address}": "{address} verwijderen",
|
||||||
"New address": "Nieuw adres",
|
"New address": "Nieuw adres",
|
||||||
"another name": "andere naam",
|
"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.",
|
"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 erin.",
|
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles wat erin zit.",
|
||||||
"Delete account…": "Account verwijderen…",
|
"Delete account…": "Account verwijderen…",
|
||||||
"Delete {address}?": "{address} 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, agenda’s, 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, agenda’s, 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",
|
"Type {address} to confirm": "Typ {address} om te bevestigen",
|
||||||
"Delete account": "Account verwijderen",
|
"Delete account": "Account verwijderen",
|
||||||
"Deleted {address}": "{address} verwijderd",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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: {reason}": "Het wachtwoord is niet geaccepteerd: {reason}",
|
||||||
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
|
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
|
||||||
"Go to folder…": "Ga naar map…",
|
"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",
|
"Export iCAL file": "iCAL-bestand exporteren",
|
||||||
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
|
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
|
||||||
// ── Actions ────────────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────────────
|
||||||
@@ -418,7 +419,7 @@ export const catalog: Catalog = {
|
|||||||
"Validate": "Controleren",
|
"Validate": "Controleren",
|
||||||
"Revoke": "Intrekken",
|
"Revoke": "Intrekken",
|
||||||
"Turn off": "Uitschakelen",
|
"Turn off": "Uitschakelen",
|
||||||
"Clear": "Legen",
|
"Clear": "Wissen",
|
||||||
"Clear selection": "Selectie opheffen",
|
"Clear selection": "Selectie opheffen",
|
||||||
"Clear custom color": "Eigen kleur wissen",
|
"Clear custom color": "Eigen kleur wissen",
|
||||||
"Select": "Selecteren",
|
"Select": "Selecteren",
|
||||||
@@ -472,7 +473,7 @@ export const catalog: Catalog = {
|
|||||||
"Body": "Tekst",
|
"Body": "Tekst",
|
||||||
"Body text": "Bodytekst",
|
"Body text": "Bodytekst",
|
||||||
"Attach files": "Bestanden bijvoegen",
|
"Attach files": "Bestanden bijvoegen",
|
||||||
"Attach from Files": "Bijvoegen uit Bestanden",
|
"Attach from Files": "Bijvoegen uit bestanden",
|
||||||
"Remove attachment": "Bijlage verwijderen",
|
"Remove attachment": "Bijlage verwijderen",
|
||||||
"Has attachment": "Heeft bijlage",
|
"Has attachment": "Heeft bijlage",
|
||||||
"Has the words": "Bevat de woorden",
|
"Has the words": "Bevat de woorden",
|
||||||
@@ -503,7 +504,7 @@ export const catalog: Catalog = {
|
|||||||
"Low": "Laag",
|
"Low": "Laag",
|
||||||
"to {recipients}": "aan {recipients}",
|
"to {recipients}": "aan {recipients}",
|
||||||
"From: {sender}": "Van: {sender}",
|
"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",
|
"Scheduled — click to clear the schedule": "Gepland — klik om de planning te wissen",
|
||||||
"Nothing scheduled": "Niets gepland",
|
"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.",
|
"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.",
|
||||||
@@ -565,26 +566,27 @@ export const catalog: Catalog = {
|
|||||||
"Month": "Maand",
|
"Month": "Maand",
|
||||||
"Agenda": "Agenda overzicht",
|
"Agenda": "Agenda overzicht",
|
||||||
"Today": "Vandaag",
|
"Today": "Vandaag",
|
||||||
"Go to day": "Naar dag",
|
"Go to day": "Ga naar dag",
|
||||||
"Go to week": "Naar week",
|
"Go to week": "Ga naar week",
|
||||||
"Previous month": "Vorige maand",
|
"Previous month": "Vorige maand",
|
||||||
"Next month": "Volgende maand",
|
"Next month": "Volgende maand",
|
||||||
"Does not repeat": "Herhaalt niet",
|
"Does not repeat": "Herhaalt niet",
|
||||||
"Daily": "Dagelijks",
|
"Daily": "Dagelijks",
|
||||||
"Every weekday": "Elke werkdag",
|
"Every weekday": "Elke doordeweekse dag",
|
||||||
"Yearly": "Jaarlijks",
|
"Yearly": "Jaarlijks",
|
||||||
"Custom…": "Aangepast…",
|
"Custom…": "Aangepast…",
|
||||||
"Weekly on {weekday}": "Wekelijks op {weekday}",
|
"Weekly on {weekday}": "Wekelijks op {weekday}",
|
||||||
"Monthly on day {day}": "Maandelijks op dag {day}",
|
"Monthly on day {day}": "Maandelijks op dag {day}",
|
||||||
"Repeat every": "Herhalen elke",
|
"Repeat every": "Herhaal elke",
|
||||||
"Repeat until": "Herhalen tot",
|
"Repeat until": "Herhalen tot",
|
||||||
"after N times": "na N keer",
|
"after N times": "na N keer",
|
||||||
"on date": "op datum",
|
"on date": "op datum",
|
||||||
"never": "nooit",
|
"never": "nooit",
|
||||||
|
// consistency, but i would suggest "dag/dagen", "week/weken", "maand/maanden", "jaar/jaren"
|
||||||
"day(s)": "dag(en)",
|
"day(s)": "dag(en)",
|
||||||
"week(s)": "we(e)k(en)",
|
"week(s)": "we(e)k(en)",
|
||||||
"month(s)": "maand(en)",
|
"month(s)": "maand(en)",
|
||||||
"year(s)": "jaar/jaren",
|
"year(s)": "ja(a)r(en)",
|
||||||
"Reminders": "Herinneringen",
|
"Reminders": "Herinneringen",
|
||||||
"Add reminder": "Herinnering toevoegen",
|
"Add reminder": "Herinnering toevoegen",
|
||||||
"Remove reminder": "Herinnering verwijderen",
|
"Remove reminder": "Herinnering verwijderen",
|
||||||
@@ -606,7 +608,7 @@ export const catalog: Catalog = {
|
|||||||
"Default view": "Standaardweergave",
|
"Default view": "Standaardweergave",
|
||||||
"Guests": "Genodigden",
|
"Guests": "Genodigden",
|
||||||
"Add guests by name or email": "Genodigden toevoegen op naam of e-mail",
|
"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?",
|
"Going?": "Bent u erbij?",
|
||||||
"Yes": "Ja",
|
"Yes": "Ja",
|
||||||
"No": "Nee",
|
"No": "Nee",
|
||||||
@@ -1021,7 +1023,7 @@ export const catalog: Catalog = {
|
|||||||
"New label": "Nieuw label",
|
"New label": "Nieuw label",
|
||||||
"Delete label": "Label verwijderen",
|
"Delete label": "Label verwijderen",
|
||||||
"Large attachments may be rejected by some servers": "Grote bijlagen worden door sommige servers geweigerd",
|
"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.",
|
"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.",
|
"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.",
|
"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 birthdays from your contacts": "Verjaardagen van uw contacten tonen",
|
||||||
"Show in the sidebar": "In de zijbalk tonen",
|
"Show in the sidebar": "In de zijbalk tonen",
|
||||||
"Show keyboard shortcuts": "Sneltoetsen 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",
|
"Sort by, in order": "Sorteren op, in deze volgorde",
|
||||||
"Source": "Bron",
|
"Source": "Bron",
|
||||||
"Spam filter": "Spamfilter",
|
"Spam filter": "Spamfilter",
|
||||||
@@ -1554,7 +1556,7 @@ export const catalog: Catalog = {
|
|||||||
"You": "U",
|
"You": "U",
|
||||||
"Your Sieve script has changes that have not been saved.": "Uw Sieve-script bevat wijzigingen die niet zijn opgeslagen.",
|
"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 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:",
|
"Your own:": "Uw eigen:",
|
||||||
"dark mode": "de donkere modus",
|
"dark mode": "de donkere modus",
|
||||||
"file": "bestand",
|
"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
@@ -409,14 +409,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||||
set({ available });
|
set({ available });
|
||||||
if (!available) return;
|
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();
|
void get().loadSharedCalendars();
|
||||||
try {
|
await Promise.all([get().loadCalendars(), identities]);
|
||||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
|
||||||
set({ identities: res.list });
|
|
||||||
} catch {
|
|
||||||
set({ identities: [] });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -434,15 +433,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const session = useSession.getState();
|
const session = useSession.getState();
|
||||||
const own = session.ownAccountFor(CAP.calendars);
|
const own = session.ownAccountFor(CAP.calendars);
|
||||||
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||||
const found: SharedCalendar[] = [];
|
// Every account at once, in one request, and listed in the session's order.
|
||||||
for (const [accountId, account] of accounts) {
|
const answers = await Promise.all(
|
||||||
try {
|
accounts.map(([accountId, account]) =>
|
||||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }).then(
|
||||||
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
(res) => res.list.map((calendar): SharedCalendar => ({ accountId, accountName: account.name, calendar })),
|
||||||
} catch {
|
(): SharedCalendar[] => [],
|
||||||
continue;
|
),
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
const found = answers.flat();
|
||||||
set({ sharedCalendars: found });
|
set({ sharedCalendars: found });
|
||||||
// Fill in whatever windows are already on screen.
|
// Fill in whatever windows are already on screen.
|
||||||
for (const key of Object.keys(get().ranges)) {
|
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 accounts = [...new Set(shared.map((c) => c.accountId))];
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
const events: Record<string, CalendarEvent> = {};
|
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 {
|
try {
|
||||||
const res = await client.chain([
|
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"],
|
["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 {
|
} catch {
|
||||||
// One account refusing must not empty the calendar of the others.
|
// 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() {
|
async loadCalendars() {
|
||||||
@@ -535,7 +535,12 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
const key = `${start.getTime()}|${end.getTime()}`;
|
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 });
|
set({ loading: true });
|
||||||
const tz = settings().timeZone ?? browserTimeZone;
|
const tz = settings().timeZone ?? browserTimeZone;
|
||||||
try {
|
try {
|
||||||
@@ -560,7 +565,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
const events = { ...s.events };
|
const events = { ...s.events };
|
||||||
for (const e of g.list) events[e.id] = e;
|
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);
|
void get().loadSharedRange(start, end);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -665,6 +671,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
hiding one is remembered under the same account-qualified key. */
|
hiding one is remembered under the same account-qualified key. */
|
||||||
const sharedKeys = new Set<string>();
|
const sharedKeys = new Set<string>();
|
||||||
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
|
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) {
|
for (const k of sharedKeys) {
|
||||||
const e = sharedEvents[k];
|
const e = sharedEvents[k];
|
||||||
if (!e) continue;
|
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`
|
an account linked for its files offered its calendar too. `isSubscribed`
|
||||||
is the only thing separating "shared with me" from "reachable", so
|
is the only thing separating "shared with me" from "reachable", so
|
||||||
nothing unsubscribed is drawn. */
|
nothing unsubscribed is drawn. */
|
||||||
const added = new Set(settings().addedShares);
|
|
||||||
const theirs: Record<Id, Calendar> = {};
|
const theirs: Record<Id, Calendar> = {};
|
||||||
for (const c of sharedCalendars) {
|
for (const c of sharedCalendars) {
|
||||||
if (c.accountId !== accountId) continue;
|
if (c.accountId !== accountId) continue;
|
||||||
@@ -1000,11 +1006,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
if (types.has("CalendarEvent")) get().invalidate();
|
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() {
|
invalidate() {
|
||||||
// Force reload of all ranges currently cached.
|
for (const k of Object.keys(get().ranges)) {
|
||||||
const keys = Object.keys(get().ranges);
|
|
||||||
set({ ranges: {} });
|
|
||||||
for (const k of keys) {
|
|
||||||
const [s, e] = k.split("|").map(Number) as [number, number];
|
const [s, e] = k.split("|").map(Number) as [number, number];
|
||||||
void get().loadRange(new Date(s), new Date(e), true);
|
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.
|
* Whether an event is part of a series.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/l
|
|||||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||||
|
import { useSession } from "./session";
|
||||||
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
@@ -82,7 +83,9 @@ export interface Draft {
|
|||||||
interface ComposeState {
|
interface ComposeState {
|
||||||
drafts: Draft[];
|
drafts: Draft[];
|
||||||
activeKey: string | null;
|
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(init?: Partial<Draft>): string;
|
||||||
/** Open a draft holding what the operating system's share sheet sent us. */
|
/** Open a draft holding what the operating system's share sheet sent us. */
|
||||||
openFromShare(share: SharedContent): string;
|
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 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);
|
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) {
|
undoSend(key) {
|
||||||
@@ -999,3 +1011,28 @@ export function draftFromMailto(url: string): Partial<Draft> {
|
|||||||
...(body ? { html: body, text: m.body } : {}),
|
...(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
@@ -1,7 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client";
|
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||||
@@ -173,6 +173,8 @@ interface ContactsState {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
books: Record<Id, AddressBook>;
|
books: Record<Id, AddressBook>;
|
||||||
cards: Record<Id, ContactCard>;
|
cards: Record<Id, ContactCard>;
|
||||||
|
/** The server's ContactCard state `cards` was read at, for asking what changed since. */
|
||||||
|
cardState: string | null;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -189,6 +191,11 @@ interface ContactsState {
|
|||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
loadBooks(): Promise<void>;
|
loadBooks(): Promise<void>;
|
||||||
loadAll(): 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. */
|
/** Books and cards from accounts that shared with the reader. */
|
||||||
loadShared(): Promise<void>;
|
loadShared(): Promise<void>;
|
||||||
select(selection: BookSelection): void;
|
select(selection: BookSelection): void;
|
||||||
@@ -247,6 +254,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
available: false,
|
available: false,
|
||||||
books: {},
|
books: {},
|
||||||
cards: {},
|
cards: {},
|
||||||
|
cardState: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -264,11 +272,18 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
// should move when the switcher does.
|
// should move when the switcher does.
|
||||||
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
|
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
|
||||||
const available = Boolean(accountId && client.hasCapability(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 });
|
set({ available });
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
await get().loadBooks();
|
await get().loadBooks();
|
||||||
void get().loadShared();
|
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 books: SharedBook[] = [];
|
||||||
const cards: Record<string, ContactCard> = {};
|
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 {
|
try {
|
||||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
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 });
|
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 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));
|
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,
|
// 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.
|
// and the alternative is holding the reader's own list hostage to it.
|
||||||
const cardsRes = await client.chain([
|
const cardsRes = await client.chain([
|
||||||
@@ -325,9 +342,11 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
// An account that refuses is one that shared nothing here. Not an
|
// 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.
|
// 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 });
|
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -404,6 +423,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
const cards: Record<Id, ContactCard> = {};
|
const cards: Record<Id, ContactCard> = {};
|
||||||
let position = 0;
|
let position = 0;
|
||||||
const limit = 500;
|
const limit = 500;
|
||||||
|
let cardState: string | null = null;
|
||||||
for (let guard = 0; guard < 50; guard++) {
|
for (let guard = 0; guard < 50; guard++) {
|
||||||
const res = await client.chain([
|
const res = await client.chain([
|
||||||
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
|
["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 q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||||
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
||||||
for (const c of g.list) cards[c.id] = c;
|
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;
|
position += q.ids.length;
|
||||||
if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
|
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) {
|
} catch (err) {
|
||||||
set({ loading: false, error: (err as Error).message });
|
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) {
|
async getCard(id) {
|
||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return null;
|
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];
|
refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await get().loadAll();
|
await get().syncCards();
|
||||||
}
|
}
|
||||||
return { destroyed: gone.length, unfiled, refused };
|
return { destroyed: gone.length, unfiled, refused };
|
||||||
},
|
},
|
||||||
@@ -570,7 +638,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
const err = res.notDestroyed?.[id];
|
const err = res.notDestroyed?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
await get().loadBooks();
|
await get().loadBooks();
|
||||||
await get().loadAll();
|
await get().syncCards();
|
||||||
},
|
},
|
||||||
|
|
||||||
async importVCard(text, addressBookId) {
|
async importVCard(text, addressBookId) {
|
||||||
@@ -619,7 +687,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
matched on it rather than guessed at. */
|
matched on it rather than guessed at. */
|
||||||
return { created, updated, alike: 0 };
|
return { created, updated, alike: 0 };
|
||||||
} finally {
|
} 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");
|
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
|
||||||
return { created, updated, alike };
|
return { created, updated, alike };
|
||||||
} finally {
|
} finally {
|
||||||
await get().loadAll();
|
await get().syncCards();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -782,7 +850,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
|
|
||||||
applyChanges(types) {
|
applyChanges(types) {
|
||||||
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
|
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();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -50,7 +50,10 @@ interface FilesState {
|
|||||||
*/
|
*/
|
||||||
draggingIds: Id[];
|
draggingIds: Id[];
|
||||||
|
|
||||||
|
/** Whether Files is available and which account is the reader's. No round trip. */
|
||||||
init(): Promise<void>;
|
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. */
|
/** Browse an account: the reader's own, or one shared with them. */
|
||||||
openAccount(accountId: Id | null): void;
|
openAccount(accountId: Id | null): void;
|
||||||
loadChildren(parentId: Id | null): Promise<void>;
|
loadChildren(parentId: Id | null): Promise<void>;
|
||||||
@@ -138,6 +141,17 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
const session = useSession.getState();
|
const session = useSession.getState();
|
||||||
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
||||||
const available = Boolean(ownAccountId && client.hasCapability(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:
|
* Which accounts hold shared files cannot be worked out from capabilities:
|
||||||
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
* 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 whose calendar or contacts were the thing actually shared. An
|
||||||
* account that shares no files does not belong in a list of shared files.
|
* 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 s = session.session;
|
||||||
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
||||||
const sharedAccounts: SharedAccount[] = [];
|
const answers = await Promise.all(
|
||||||
for (const [id, a] of candidates) {
|
candidates.map(([id, a]) =>
|
||||||
try {
|
client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }).then(
|
||||||
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
|
(res): SharedAccount | null => (res.ids.length ? { id, name: a.name } : null),
|
||||||
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
|
|
||||||
} catch {
|
|
||||||
// Refused means nothing here is ours to see, which is the same answer.
|
// Refused means nothing here is ours to see, which is the same answer.
|
||||||
continue;
|
() => 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.
|
// Stay where the reader is if they are reading a share that still exists.
|
||||||
const browsing = get().accountId;
|
const browsing = get().accountId;
|
||||||
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
||||||
if (!keep) set(emptyForAccount(ownAccountId));
|
if (!keep) set(emptyForAccount(ownAccountId));
|
||||||
set({ available, ownAccountId, sharedAccounts });
|
set({ sharedAccounts });
|
||||||
},
|
},
|
||||||
|
|
||||||
openAccount(accountId) {
|
openAccount(accountId) {
|
||||||
|
|||||||
+155
-39
@@ -28,6 +28,8 @@ import { plural, t } from "@/lib/i18n";
|
|||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
||||||
import { type ListQuery, type MailState } from "./types";
|
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
|
* `@/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) {
|
setAccount(accountId) {
|
||||||
if (accountId === get().accountId) return;
|
if (accountId === get().accountId) return;
|
||||||
|
resetBodyOrder();
|
||||||
set({
|
set({
|
||||||
accountId,
|
accountId,
|
||||||
mailboxes: {},
|
mailboxes: {},
|
||||||
@@ -206,8 +209,24 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
const l = get().list;
|
const l = get().list;
|
||||||
if (!accountId || !l) return;
|
if (!accountId || !l) return;
|
||||||
try {
|
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;
|
const cur = get().list;
|
||||||
if (!cur || cur.key !== l.key) return;
|
if (!cur || cur.key !== l.key) return;
|
||||||
set({ list: { ...cur, ids, total, queryState, loading: false, error: null, exhausted: ids.length >= total } });
|
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) {
|
for (const r of results) {
|
||||||
state = r.state;
|
state = r.state;
|
||||||
for (const e of r.list) {
|
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;
|
if (full) nextFull[e.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
|
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (full) {
|
||||||
|
touchBodies(ids);
|
||||||
|
set((s) => releaseBodies(s));
|
||||||
|
}
|
||||||
const now = get().emails;
|
const now = get().emails;
|
||||||
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e));
|
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 [];
|
if (!accountId) return [];
|
||||||
set((s) => ({ loadingThreads: { ...s.loadingThreads, [threadId]: true } }));
|
set((s) => ({ loadingThreads: { ...s.loadingThreads, [threadId]: true } }));
|
||||||
try {
|
try {
|
||||||
const res = await client.chain([
|
/*
|
||||||
["Thread/get", { accountId, ids: [threadId] }, "t"],
|
* Bodies are fetched only for messages not already held in full.
|
||||||
[
|
*
|
||||||
"Email/get",
|
* 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
|
||||||
accountId,
|
* thread in full each time -- up to 2 MB of body apiece, and new
|
||||||
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
|
* attachment objects that made the reading pane rebuild what it had
|
||||||
properties: FULL_PROPS,
|
* already rendered. A body cannot change under an id (RFC 8621), and
|
||||||
fetchHTMLBodyValues: true,
|
* keywords and mailboxes come in with the list refresh, so a message
|
||||||
fetchTextBodyValues: true,
|
* held in full needs nothing more. getEmails also splits the fetch to
|
||||||
maxBodyValueBytes: 2 * 1024 * 1024,
|
* `maxObjectsInGet`, which a long thread could exceed.
|
||||||
bodyProperties: BODY_PROPS,
|
*/
|
||||||
},
|
const res = await client.call<GetResponse<Thread>>("Thread/get", { accountId, ids: [threadId] });
|
||||||
"e",
|
const thread = res.list[0];
|
||||||
],
|
if (!thread) {
|
||||||
]);
|
|
||||||
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 [];
|
|
||||||
set((s) => {
|
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;
|
const { [threadId]: _drop, ...rest } = s.loadingThreads;
|
||||||
return { emails: next, fullIds: nextFull, threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
|
return { loadingThreads: rest };
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
await get().getEmails(thread.emailIds, true);
|
||||||
|
set((s) => {
|
||||||
|
const { [threadId]: _drop, ...rest } = s.loadingThreads;
|
||||||
|
return { threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
|
||||||
});
|
});
|
||||||
return get().threadEmails(threadId);
|
return get().threadEmails(threadId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -977,7 +997,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = { ...s.emails };
|
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 };
|
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.
|
* 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;
|
let sortRefused = false;
|
||||||
|
|
||||||
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
|
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);
|
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]> = [
|
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/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"],
|
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
|
||||||
];
|
];
|
||||||
if (q.collapseThreads) {
|
if (q.collapseThreads) {
|
||||||
calls.push(["Thread/get", { accountId, "#ids": { resultOf: "e", name: "Email/get", path: "/list/*/threadId" } }, "t"]);
|
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 res = await client.chain(calls);
|
||||||
const query = res.get("q")?.[0] as unknown as QueryResponse;
|
const query = res.get("q")?.[0] as unknown as QueryResponse;
|
||||||
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
|
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
|
||||||
const threadsRes = res.get("t")?.[0] as unknown as GetResponse<Thread> | undefined;
|
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) => {
|
useMail.setState((s) => {
|
||||||
const emails = { ...s.emails };
|
const emails = { ...s.emails };
|
||||||
for (const e of emailsRes.list) emails[e.id] = { ...emails[e.id], ...e };
|
for (const e of emailsRes.list) emails[e.id] = mergeEmail(emails[e.id], e);
|
||||||
for (const e of threadEmails?.list ?? []) emails[e.id] = { ...emails[e.id], ...e };
|
|
||||||
const threads = { ...s.threads };
|
const threads = { ...s.threads };
|
||||||
for (const t of threadsRes?.list ?? []) threads[t.id] = t;
|
for (const t of threadsRes?.list ?? []) threads[t.id] = t;
|
||||||
return { emails, threads, emailState: s.emailState ?? emailsRes.state };
|
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 emails = await get().getEmails(created);
|
||||||
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||||
if (!fresh.length) return;
|
if (!fresh.length) return;
|
||||||
const { showNotification, playNewMailSound } = await import("@/lib/notify/notify");
|
|
||||||
if (s.notificationSound) playNewMailSound();
|
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)) {
|
for (const e of fresh.slice(0, 3)) {
|
||||||
const from = e.from?.[0];
|
const from = e.from?.[0];
|
||||||
showNotification(from?.name || from?.email || "New message", {
|
showNotification(from?.name || from?.email || "New message", {
|
||||||
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
|
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: () => {
|
onClick: () => {
|
||||||
window.location.hash = "";
|
window.location.hash = "";
|
||||||
// The one navigation that does not go through wouter -- it is
|
// The one navigation that does not go through wouter -- it is
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
/* never block signing out over this */
|
/* never block signing out over this */
|
||||||
}
|
}
|
||||||
stopSettingsSync();
|
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 {
|
try {
|
||||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
|
||||||
import { confirmDialog, Dialog } from "./dialog";
|
import { confirmDialog, Dialog } from "./dialog";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
|
import { withoutBidiControls } from "@/lib/text/text";
|
||||||
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
||||||
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
|
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
|
||||||
import { canShareFiles, shareFile } from "@/lib/share";
|
import { canShareFiles, shareFile } from "@/lib/share";
|
||||||
@@ -168,7 +169,7 @@ export function FilePreviewDialog({
|
|||||||
const download = () => {
|
const download = () => {
|
||||||
const l = document.createElement("a");
|
const l = document.createElement("a");
|
||||||
l.href = file.url;
|
l.href = file.url;
|
||||||
l.download = file.name;
|
l.download = withoutBidiControls(file.name);
|
||||||
l.click();
|
l.click();
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -226,7 +227,7 @@ export function FilePreviewDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(file)}
|
open={Boolean(file)}
|
||||||
onClose={requestClose}
|
onClose={requestClose}
|
||||||
title={file?.name ?? t("Preview")}
|
title={file ? withoutBidiControls(file.name) : t("Preview")}
|
||||||
size="xl"
|
size="xl"
|
||||||
closeOnBackdrop={!editing}
|
closeOnBackdrop={!editing}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
+5
-2
@@ -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 }) {
|
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 email = typeof who === "string" ? who : (who?.email ?? "");
|
||||||
const name = typeof who === "string" ? who : (who?.name ?? 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) => {
|
const photo = useContacts((s) => {
|
||||||
if (!email || !s.loaded) return null;
|
if (!email) return null;
|
||||||
const c = s.lookupByEmail(email);
|
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 (
|
return (
|
||||||
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
||||||
|
|||||||
@@ -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 { 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 { 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";
|
import { useSession } from "@/store/session";
|
||||||
@@ -13,9 +13,6 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
|||||||
import { Splitter } from "@/ui/Splitter";
|
import { Splitter } from "@/ui/Splitter";
|
||||||
import { SearchBar } from "./SearchBar";
|
import { SearchBar } from "./SearchBar";
|
||||||
import { MailboxTree } from "./mail/MailboxTree";
|
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 { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||||
import { MailboxPicker } from "./mail/MailboxPicker";
|
import { MailboxPicker } from "./mail/MailboxPicker";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
@@ -26,6 +23,11 @@ import { hasAdministration } from "@/lib/admin/adminAccess";
|
|||||||
import { usePermissions } from "./admin/usePermissions";
|
import { usePermissions } from "./admin/usePermissions";
|
||||||
import { AdminNav } from "./admin/AdminNav";
|
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
|
* 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
|
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
|
||||||
@@ -252,9 +254,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||||
|
<Suspense fallback={null}>
|
||||||
{section === "calendar" && <CalendarSidebar />}
|
{section === "calendar" && <CalendarSidebar />}
|
||||||
{section === "contacts" && <ContactsSidebar />}
|
{section === "contacts" && <ContactsSidebar />}
|
||||||
{section === "files" && <FilesTree />}
|
{section === "files" && <FilesTree />}
|
||||||
|
</Suspense>
|
||||||
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
|
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
|
||||||
{section === "admin" && <AdminNav />}
|
{section === "admin" && <AdminNav />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types";
|
|||||||
import { CalendarDialog } from "./CalendarDialog";
|
import { CalendarDialog } from "./CalendarDialog";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
import { plural, t } from "@/lib/i18n";
|
import { plural, t } from "@/lib/i18n";
|
||||||
|
import { downloadFile } from "@/lib/download";
|
||||||
|
|
||||||
export function CalendarSidebar() {
|
export function CalendarSidebar() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -42,19 +43,14 @@ export function CalendarSidebar() {
|
|||||||
const importInto = useRef<Id | null>(null);
|
const importInto = useRef<Id | null>(null);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Handing the file over, which the browser only does from a click. The
|
* Handing the file over, which the browser only does from a click.
|
||||||
* revoke below is what keeps a calendar's worth of text from sitting in
|
* `downloadFile` releases it once started, so a calendar's worth of text
|
||||||
* memory after the download has started.
|
* does not sit in memory afterwards.
|
||||||
*/
|
*/
|
||||||
const exportFile = async (c: Calendar) => {
|
const exportFile = async (c: Calendar) => {
|
||||||
try {
|
try {
|
||||||
const { text, count } = await cal.exportIcs(c.id);
|
const { text, count } = await cal.exportIcs(c.id);
|
||||||
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" }));
|
downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`);
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
|
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
|
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { Composer } from "./Composer";
|
|
||||||
import { useIsMobile } from "@/ui/misc";
|
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() {
|
export function ComposerDock() {
|
||||||
const drafts = useCompose((s) => s.drafts);
|
const drafts = useCompose((s) => s.drafts);
|
||||||
const activeKey = useCompose((s) => s.activeKey);
|
const activeKey = useCompose((s) => s.activeKey);
|
||||||
@@ -13,9 +26,11 @@ export function ComposerDock() {
|
|||||||
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
|
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
|
||||||
return (
|
return (
|
||||||
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
|
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
{visible.map((d) => (
|
{visible.map((d) => (
|
||||||
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
||||||
))}
|
))}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
|||||||
const [picked, setPicked] = useState<Record<string, FileNode>>({});
|
const [picked, setPicked] = useState<Record<string, FileNode>>({});
|
||||||
const [returnTo] = useState(() => files.accountId);
|
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(() => {
|
useEffect(() => {
|
||||||
void files.loadChildren(cur);
|
void files.loadChildren(cur);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -42,35 +42,39 @@ describe("ComposerDock with a full-screen composer", () => {
|
|||||||
useCompose.setState({ drafts: [], activeKey: null });
|
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 });
|
useCompose.setState({ drafts, activeKey });
|
||||||
act(() => root.render(<ComposerDock />));
|
await act(async () => {
|
||||||
|
root.render(<ComposerDock />);
|
||||||
|
await import("../Composer");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const dock = () => host.querySelector(".composer-dock")!;
|
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);
|
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);
|
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.
|
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
|
||||||
expect(host.querySelectorAll(".composer").length).toBe(3);
|
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);
|
setWidth(1300);
|
||||||
render([draft("a"), draft("b")], "b");
|
await render([draft("a"), draft("b")], "b");
|
||||||
expect(dock().classList.contains("has-maximized")).toBe(false);
|
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);
|
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);
|
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);
|
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);
|
expect(dock().classList.contains("has-maximized")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Plus, Trash2, Camera, X } from "lucide-react";
|
import { Plus, Trash2, Camera, X } from "lucide-react";
|
||||||
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
||||||
import { useContacts } from "@/store/contacts";
|
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 { Dialog } from "@/ui/dialog";
|
||||||
import { DateField } from "@/ui/datefield";
|
import { DateField } from "@/ui/datefield";
|
||||||
import { toast } from "@/ui/toast";
|
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.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.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;
|
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
|
||||||
if (photo) {
|
// Inline, not uploaded: see `withPhoto`. The card's other media stays.
|
||||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
|
if (photo) obj.media = withPhoto(card.media, photo);
|
||||||
if (m) {
|
else if (removePhoto) obj.media = withPhoto(card.media, null);
|
||||||
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;
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
||||||
toast.success(t("Contact created"));
|
toast.success(t("Contact created"));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { ContactEditor } from "./ContactEditor";
|
import { ContactEditor } from "./ContactEditor";
|
||||||
import { avatarColor } from "@/lib/address";
|
import { avatarColor } from "@/lib/address";
|
||||||
import { plural, t as translate } from "@/lib/i18n";
|
import { plural, t as translate } from "@/lib/i18n";
|
||||||
|
import { downloadFile } from "@/lib/download";
|
||||||
|
|
||||||
export function ContactsView({ id }: { id?: string }) {
|
export function ContactsView({ id }: { id?: string }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
toast.error(translate("There is nothing in it to export"));
|
toast.error(translate("There is nothing in it to export"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const a = document.createElement("a");
|
downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf");
|
||||||
a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
|
|
||||||
a.download = "contacts.vcf";
|
|
||||||
a.click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFile = async (f: File, intoBookId?: string) => {
|
const importFile = async (f: File, intoBookId?: string) => {
|
||||||
@@ -303,7 +301,8 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
<div className="contact-letter">{g.letter}</div>
|
<div className="contact-letter">{g.letter}</div>
|
||||||
{g.items.map((c) => {
|
{g.items.map((c) => {
|
||||||
const email = contactEmails(c)[0]?.email;
|
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 (
|
return (
|
||||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||||
{!readOnly && (
|
{!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 }) {
|
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
|
||||||
const contacts = useContacts();
|
const contacts = useContacts();
|
||||||
const [, navigate] = useLocation();
|
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 name = contactDisplayName(c);
|
||||||
const org = Object.values(c.organizations ?? {})[0];
|
const org = Object.values(c.organizations ?? {})[0];
|
||||||
const title = Object.values(c.titles ?? {})[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>}
|
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
|
<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>
|
<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>
|
||||||
<div className="contact-hero">
|
<div className="contact-hero">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function refreshShares(force = false): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await useFiles.getState().init();
|
await useFiles.getState().init();
|
||||||
|
await useFiles.getState().discoverShared();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Copy, Mail, Pencil, UserPlus } from "lucide-react";
|
||||||
import type { EmailAddress } from "@/jmap/types";
|
import type { EmailAddress } from "@/jmap/types";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
@@ -7,9 +7,11 @@ import { contactFromAddress } from "@/lib/contacts";
|
|||||||
import { formatAddress } from "@/lib/address";
|
import { formatAddress } from "@/lib/address";
|
||||||
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ContactEditor } from "../contacts/ContactEditor";
|
|
||||||
import { t } from "@/lib/i18n";
|
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
|
* 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
|
* add them to the address book. The contact editor opens prefilled rather than
|
||||||
@@ -65,12 +67,14 @@ export function useAddressMenu() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<ContactEditor
|
<ContactEditor
|
||||||
card={editing}
|
card={editing}
|
||||||
defaultBookId={defaultBookId}
|
defaultBookId={defaultBookId}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
onSaved={() => setEditing(null)}
|
onSaved={() => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,8 +79,25 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
[settings.listSortScope, settings.listSortPreset, settings.listSortLevels],
|
[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
|
// Build & run the list query
|
||||||
const listQuery = useMemo<ListQuery | null>(() => {
|
const listQuery = useMemo<ListQuery | null>(() => {
|
||||||
|
const mailboxes = useMail.getState().mailboxes;
|
||||||
if (search) {
|
if (search) {
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
const parsed = parseQuery(q);
|
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.
|
// are outgoing, and collapsing them into their threads hides them.
|
||||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
|
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
|
||||||
return { key: "", filter: { inMailbox: mailboxId }, sort: sortForFolder(mailboxId), collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
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(() => {
|
useEffect(() => {
|
||||||
if (listQuery && mailboxesLoaded) void query(listQuery);
|
if (listQuery && mailboxesLoaded) void query(listQuery);
|
||||||
|
|||||||
@@ -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 { 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 { 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";
|
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 { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
|
||||||
import { MailboxPicker } from "./MailboxPicker";
|
import { MailboxPicker } from "./MailboxPicker";
|
||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
import { loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
|
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 { plural, t } from "@/lib/i18n";
|
||||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
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> = {
|
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||||
inbox: <Inbox size={20} />,
|
inbox: <Inbox size={20} />,
|
||||||
drafts: <File 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>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { 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 { useLocation } from "wouter";
|
||||||
import { useMail, type ListState } from "@/store/mail";
|
import { useMail, type ListState } from "@/store/mail";
|
||||||
@@ -20,9 +21,11 @@ import { startAppointment } from "@/lib/calendar/appointment";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
|
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
|
||||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
|
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
|
||||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
|
||||||
import { plural, t } from "@/lib/i18n";
|
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
|
* 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.
|
* 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} />,
|
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 {
|
export interface ListActions {
|
||||||
archive: (rows?: Id[]) => Promise<void>;
|
archive: (rows?: Id[]) => Promise<void>;
|
||||||
trash: (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) {
|
export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const emails = useMail((s) => s.emails);
|
const emails = useMail((s) => s.emails);
|
||||||
const threads = useMail((s) => s.threads);
|
|
||||||
const selected = useMail((s) => s.selected);
|
const selected = useMail((s) => s.selected);
|
||||||
const select = useMail((s) => s.select);
|
const select = useMail((s) => s.select);
|
||||||
const selectAll = useMail((s) => s.selectAll);
|
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 }), []),
|
onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onRowClick = useCallback(
|
const onRowClick = useStableCallback(
|
||||||
(e: MouseEvent, rowId: Id) => {
|
(e: MouseEvent, rowId: Id) => {
|
||||||
const action = rowClick({
|
const action = rowClick({
|
||||||
rowId, ids, anchor: lastClick.current, selected,
|
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.
|
// leaves the rows looking smeared blue over the selection they meant.
|
||||||
else window.getSelection()?.removeAllRanges();
|
else window.getSelection()?.removeAllRanges();
|
||||||
},
|
},
|
||||||
[ids, select, selected, isMobile, onOpen],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onContext = useCallback(
|
const onContext = useStableCallback((e: MouseEvent, rowId: Id) => {
|
||||||
(e: MouseEvent, rowId: Id) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCtxRow(rowId);
|
setCtxRow(rowId);
|
||||||
setFocusId(rowId);
|
setFocusId(rowId);
|
||||||
ctxMenu.openAt(e.clientX, e.clientY);
|
ctxMenu.openAt(e.clientX, e.clientY);
|
||||||
},
|
});
|
||||||
[ctxMenu, setFocusId],
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Hold a row to select it, the way the mail app the phone came with does.
|
* 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
|
* 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.
|
* the rest (see `onRowClick`), so this only has to open the mode.
|
||||||
*/
|
*/
|
||||||
const onLongPress = useCallback(
|
const onLongPress = useStableCallback((rowId: Id) => {
|
||||||
(rowId: Id) => {
|
|
||||||
haptic(15);
|
haptic(15);
|
||||||
setFocusId(rowId);
|
setFocusId(rowId);
|
||||||
lastClick.current = rowId;
|
lastClick.current = rowId;
|
||||||
select([rowId], !useMail.getState().selected[rowId]);
|
select([rowId], !useMail.getState().selected[rowId]);
|
||||||
},
|
});
|
||||||
[select, setFocusId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => {
|
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
|
// 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));
|
setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fireSwipe = useCallback(
|
const fireSwipe = useStableCallback(
|
||||||
async (rowId: Id, d: SwipeDescriptor) => {
|
async (rowId: Id, d: SwipeDescriptor) => {
|
||||||
switch (d.action) {
|
switch (d.action) {
|
||||||
case "archive": await actions.archive([rowId]); break;
|
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;
|
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]);
|
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];
|
const e = emails[id];
|
||||||
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
|
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;
|
const strip = swiping?.id === id ? swiping : null;
|
||||||
return (
|
return (
|
||||||
<Fragment key={id}>
|
<Fragment key={id}>
|
||||||
@@ -486,8 +507,8 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Row
|
<Row
|
||||||
email={e}
|
id={id}
|
||||||
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
|
threadId={list?.collapseThreads ? e.threadId : null}
|
||||||
top={vi.start}
|
top={vi.start}
|
||||||
height={vi.size}
|
height={vi.size}
|
||||||
selected={Boolean(selected[id])}
|
selected={Boolean(selected[id])}
|
||||||
@@ -501,12 +522,11 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
|
|||||||
isSent={mailbox?.role === "sent"}
|
isSent={mailbox?.role === "sent"}
|
||||||
onClick={onRowClick}
|
onClick={onRowClick}
|
||||||
onContext={onContext}
|
onContext={onContext}
|
||||||
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
|
onSelect={onRowSelect}
|
||||||
onStar={(rowId, on) => void actions.star(on, [rowId])}
|
onStar={onRowStar}
|
||||||
onArchive={(rowId) => void actions.archive([rowId])}
|
onArchive={onRowArchive}
|
||||||
onTrash={(rowId) => void actions.trash([rowId])}
|
onTrash={onRowTrash}
|
||||||
onRead={(rowId, read) => void actions.read(read, [rowId])}
|
onRead={onRowRead}
|
||||||
selectedIds={selected}
|
|
||||||
touch={isTouch}
|
touch={isTouch}
|
||||||
role={mailbox?.role ?? null}
|
role={mailbox?.role ?? null}
|
||||||
swipeLeft={settings.swipeLeft}
|
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); }} />
|
<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)); }} />}
|
{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>
|
</Popover>
|
||||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
{filterFrom && <Suspense fallback={null}><FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} /></Suspense>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RowProps {
|
interface RowProps {
|
||||||
email: Email;
|
id: Id;
|
||||||
threadEmails?: Email[];
|
/** The conversation to summarize, in conversation view; null for a single message. */
|
||||||
|
threadId: Id | null;
|
||||||
top: number;
|
top: number;
|
||||||
height: number;
|
height: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -561,7 +582,6 @@ interface RowProps {
|
|||||||
isDrafts: boolean;
|
isDrafts: boolean;
|
||||||
isSent: boolean;
|
isSent: boolean;
|
||||||
mailboxId: Id | null;
|
mailboxId: Id | null;
|
||||||
selectedIds: Record<Id, true>;
|
|
||||||
onClick: (e: MouseEvent, id: Id) => void;
|
onClick: (e: MouseEvent, id: Id) => void;
|
||||||
onContext: (e: MouseEvent, id: Id) => void;
|
onContext: (e: MouseEvent, id: Id) => void;
|
||||||
onSelect: (id: Id, on: boolean) => void;
|
onSelect: (id: Id, on: boolean) => void;
|
||||||
@@ -579,12 +599,40 @@ interface RowProps {
|
|||||||
onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise<void>;
|
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);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
// Subscribed purely so the row re-renders when the date format changes.
|
// Subscribed purely so the row re-renders when the date format changes.
|
||||||
useSettings((s) => dateTimeKey(s.settings));
|
useSettings((s) => dateTimeKey(s.settings));
|
||||||
|
const scope = useMemo(() => {
|
||||||
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
||||||
const scope = inScope.length ? inScope : [e];
|
return inScope.length ? inScope : [e];
|
||||||
|
}, [threadEmails, mailboxId, e]);
|
||||||
const unread = scope.some((x) => !x.keywords.$seen);
|
const unread = scope.some((x) => !x.keywords.$seen);
|
||||||
const starred = scope.some((x) => x.keywords.$flagged);
|
const starred = scope.some((x) => x.keywords.$flagged);
|
||||||
const hasAtt = scope.some((x) => x.hasAttachment);
|
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) => {
|
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];
|
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
|
||||||
// include thread emails in scope
|
// include thread emails in scope
|
||||||
const all = new Set<Id>();
|
const all = new Set<Id>();
|
||||||
@@ -762,4 +812,4 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -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 { 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 { useLocation } from "wouter";
|
||||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
|
||||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
@@ -20,8 +19,11 @@ import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
|||||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
||||||
import { openableInTab, previewKind } from "@/lib/preview";
|
import { openableInTab, previewKind } from "@/lib/preview";
|
||||||
import { FilePreviewDialog } from "@/ui/filepreview";
|
// Loaded when first opened: it is not needed to show mail, and it is not small.
|
||||||
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text/text";
|
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 { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
|
||||||
import { Avatar } from "@/ui/misc";
|
import { Avatar } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
@@ -456,7 +458,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{addrMenu.node}
|
{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">
|
<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>}
|
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -796,7 +798,13 @@ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id
|
|||||||
const blob = await client.fetchBlob(accountId, part.blobId, part.type);
|
const blob = await client.fetchBlob(accountId, part.blobId, part.type);
|
||||||
const found = parseTnef(await blob.arrayBuffer());
|
const found = parseTnef(await blob.arrayBuffer());
|
||||||
setFiles(found);
|
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");
|
setState("done");
|
||||||
} catch {
|
} catch {
|
||||||
setState("error");
|
setState("error");
|
||||||
@@ -859,7 +867,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
*/
|
*/
|
||||||
const shareAttachment = async (a: EmailBodyPart) => {
|
const shareAttachment = async (a: EmailBodyPart) => {
|
||||||
if (!a.blobId) return;
|
if (!a.blobId) return;
|
||||||
const name = a.name ?? "attachment";
|
const name = withoutBidiControls(a.name ?? "") || "attachment";
|
||||||
const download = () => {
|
const download = () => {
|
||||||
const l = document.createElement("a");
|
const l = document.createElement("a");
|
||||||
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
|
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
|
||||||
@@ -885,16 +893,17 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
))}
|
))}
|
||||||
<div className="attachments">
|
<div className="attachments">
|
||||||
{attachments.map((a, i) => {
|
{attachments.map((a, i) => {
|
||||||
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
const name = a.name ? withoutBidiControls(a.name) : null;
|
||||||
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
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 (
|
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-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-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-size">{formatSize(a.size)}</span>
|
||||||
<span className="att-actions">
|
<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>}
|
{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>}
|
{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>
|
</span>
|
||||||
@@ -908,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{preview && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<FilePreviewDialog
|
<FilePreviewDialog
|
||||||
file={preview && preview.blobId ? {
|
file={preview.blobId ? {
|
||||||
name: preview.name ?? translate("file"),
|
name: preview.name ?? translate("file"),
|
||||||
type: preview.type,
|
type: preview.type,
|
||||||
size: preview.size,
|
size: preview.size,
|
||||||
@@ -919,6 +930,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
onClose={() => setPreview(null)}
|
onClose={() => setPreview(null)}
|
||||||
caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>}
|
caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type DateFormat,
|
type DateFormat,
|
||||||
} from "@/lib/datetime";
|
} from "@/lib/datetime";
|
||||||
import { isEnforced } from "@/lib/settingsPolicy";
|
import { isEnforced } from "@/lib/settingsPolicy";
|
||||||
|
import { downloadFile } from "@/lib/download";
|
||||||
|
|
||||||
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
|
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
|
||||||
const SAMPLE = new Date(2025, 10, 22, 18, 23);
|
const SAMPLE = new Date(2025, 10, 22, 18, 23);
|
||||||
@@ -236,7 +237,7 @@ export function GeneralSettings() {
|
|||||||
|
|
||||||
<h2>{t("Backup")}</h2>
|
<h2>{t("Backup")}</h2>
|
||||||
<div className="row wrap">
|
<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">
|
<label className="btn">
|
||||||
{t("Import settings")}
|
{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 = ""; }} />
|
<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 = ""; }} />
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
|||||||
|
|
||||||
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
|
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
|
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
|
||||||
|
|
||||||
@@ -242,10 +243,11 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
|
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ description: name }),
|
body: JSON.stringify({ description: name, current }),
|
||||||
});
|
});
|
||||||
setIssued({ description: name, secret: res.secret });
|
setIssued({ description: name, secret: res.secret });
|
||||||
setName("");
|
setName("");
|
||||||
|
setCurrent("");
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
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>
|
<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 />
|
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"
|
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user