Compare commits

...
Author SHA1 Message Date
jcoffey 2740129c6a Keep only the app page as the app page (#396)
The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.

The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
2026-09-16 15:07:45 -07:00
jcoffey 82dc877fe1 Start at once on a device marked as your own (#395)
* Start at once on a device marked as your own

On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.

A trusted device now starts from what it kept:

- the service worker answers an app route from its kept page and fetches a
  fresh one behind it; the app checks the server's version at start, and a
  reload for a new build puts the new page in place first, so it is not
  answered with the old one. Assets of the page just replaced are kept one
  build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
  server has confirmed the session; the answer replaces it, and a session
  that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
  list properties only, so the folders and the inbox paint before any reply
  and the folder query does not wait on the folder list. The "folder no
  longer exists" check still waits for the server's list.

All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.

* Show nothing kept before the session is confirmed

Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.

The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
2026-09-16 13:47:59 -07:00
jcoffey 4c67460450 Fetch the rest of a new build in the background (#394)
The app page names only what it loads at start. The composer, settings,
viewers and the rest were fetched when first used, and after every deploy
that first use waited on the server -- and the worker's tidy-up dropped
them again at the next deploy anyway.

The build now writes the list of all its files into the page as an inert
JSON block. The service worker keeps everything listed and, once a page
names files it does not hold, fetches them three at a time; a load cut
short is resumed at the next navigation. Language catalogs are listed
apart and left to be cached when used, and nothing is fetched ahead when
the browser is set to save data.
2026-09-16 13:29:57 -07:00
jcoffey e158ebac5a Fold a push's follow-up requests together (#393)
A pushed mail change took three round trips: Email/changes beside a
Mailbox/get, then Email/get for what changed, then the list, the open
thread and a second Mailbox/get. Each page of changes now carries its own
Email/get calls by back-reference, and the one Mailbox/get goes out with
it, so a push settles in two. New mail fetched this way is not asked for
again by the notice.

A reply's sessionState that differs from the session is announced once
rather than on every reply, and session refreshes in flight are shared.
The mock's session state now matches the sessionState on its replies, as
Stalwart's does; tying it to the data counter made every reply trigger a
session refresh in development.
2026-09-16 13:22:26 -07:00
jcoffey 786976312f Open conversations in one request, and start them early (#392)
On a 250 ms link, opening a conversation took two round trips: Thread/get,
then the bodies. It now takes one. A known thread sends Thread/get and the
missing bodies in the same tick; an unknown one chains Email/get off
Thread/get with a back-reference, and falls back to fetching in parts when
the thread is longer than one Email/get may carry.

Conversations also start loading before the click: when the pointer rests
on a row, as soon as a press begins, and for the row below the open one.
The open waits for that load and does not repeat it.

Going back to one of the last twelve folders shows its previous list at
once, less messages that have left it, while the query runs.
2026-09-16 13:15:21 -07:00
jcoffey 5fe89d6e15 Merge pull request #391 from Coffey-Labs/feat/share-confirm
Ask before opening a shared item in a message
2026-09-16 12:27:33 -07:00
jcoffey-dev f79915aa89 Drop a wrong issue reference from a comment 2026-09-16 12:23:47 -07:00
jcoffey-dev 191c4e7e68 Ask before opening a shared item in a message
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
2026-09-16 12:23:27 -07:00
jcoffey 4c1ceca8e9 Merge pull request #390 from Coffey-Labs/fix/accept-ranges
Advertise byte ranges on downloads, and record the live checks
2026-09-16 12:13:20 -07:00
jcoffey-dev 8a08c3d6db Advertise byte ranges on downloads, and record the live checks
Stalwart honors a single byte range on its download endpoint but sends
no Accept-Ranges, and Chrome's PDF viewer only reads a file in pieces
when the first response says it can. The proxy now says so itself.

Checked live on 0.16.22: ContactCard/changes reports creates, updates
and destroys exactly, which the contacts store's sync relies on, and a
range the server cannot serve gets the whole file with 200, never 416.
The mock now answers ranges the same way and sends no Accept-Ranges.
2026-09-16 12:10:20 -07:00
jcoffey ebf678be73 Merge pull request #389 from Coffey-Labs/fix/push-subscriptions
Stop duplicate push notifications and piling up subscriptions
2026-09-16 11:41:37 -07:00
jcoffey-dev 37eb145652 Merge main into fix/push-subscriptions
# Conflicts:
#	KNOWN-ISSUES.md
2026-09-16 11:39:35 -07:00
jcoffey 3d7602ce74 Merge pull request #388 from Coffey-Labs/fix/contact-photos
Save contact photos inline, and load cards so avatars show
2026-09-16 11:38:22 -07:00
jcoffey-dev 4054f82c37 Stop duplicate push notifications and piling up subscriptions
Browsers subscribed to Email changes, so every read or move on any
client arrived as a push the worker could only show as "New mail". They
now subscribe to EmailDelivery, which changes only on delivery; Stalwart
sends a delivery to a subscription with an emailPush filter as an
EmailPush alone. The payload now names id and threadId, which Stalwart
sends only when asked, so notifications carry their actions and open the
message. The worker stays quiet while a focused window is open, and the
page leaves notifications to the worker where push is on.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bind the compose example to loopback, and run it read-only with no
capabilities and no-new-privileges. Keep .env.* out of git and the image
build context.
2026-09-16 07:54:20 -07:00
mbjboon-netizen 4cb1945eb5 Update nl.ts
Signed-off-by: mbjboon-netizen <[email protected]>
2026-09-16 04:32:50 +02:00
89 changed files with 3865 additions and 424 deletions
+3
View File
@@ -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
+3
View File
@@ -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/
+18 -5
View File
@@ -1414,8 +1414,13 @@ server settings is deliberately out of scope.
# Platform # Platform
- **Installable PWA** with a service worker: the app shell is cached for - **Installable PWA** with a service worker: the app shell is cached for
installability and fast loads, API requests never are, and navigations are installability and fast loads, API requests never are. An app route is
network-first with the shell as fallback. answered from the kept shell at once while a fresh copy is fetched behind it;
a shell a build behind is caught by the version check at start and reloaded.
After a new version is seen, the rest of its code (composer, settings,
viewers) is fetched in the background, so opening them later does not wait on
the server; language catalogs are cached when first used, and nothing is
fetched ahead when the browser is set to save data.
- **Manifest shortcuts** for Compose, Calendar and Contacts. - **Manifest shortcuts** for Compose, Calendar and Contacts.
- **One window, not one per launch.** A `mailto:` link, a shortcut or a - **One window, not one per launch.** A `mailto:` link, a shortcut or a
notification opened while ihasmail is already running arrives in the copy notification opened while ihasmail is already running arrives in the copy
@@ -1545,15 +1550,23 @@ costs something to get wrong is the one that assumes the machine is yours.
| --- | --- | --- | | --- | --- | --- |
| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) | | Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) |
| Idle sign-out | after 5 minutes | none | | Idle sign-out | after 5 minutes | none |
| Kept on the computer | nothing | settings cache, recent addresses, username | | Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) |
| Background notifications | refused | available | | Background notifications | refused | available |
| Administration | unavailable | available, if the role allows it | | Administration | unavailable | available, if the role allows it |
Local storage is gated on that answer for **reads** as well as writes — a Local storage is gated on that answer for **reads** as well as writes — a
machine trusted once still has residue, and honoring it would let a previous machine trusted once still has residue, and honoring it would let a previous
session's data surface in a later untrusted one. Signing out clears the settings session's data surface in a later untrusted one. Signing out clears the settings
cache and recent addresses and tears down the push subscription, whichever cache, recent addresses and kept folder list, and tears down the push
answer was given. subscription, whichever answer was given.
What a ticked device keeps is what makes it **start quickly on a distant
link**: once the server has confirmed the session, the folders and the inbox
paint from the kept copy straight away, and the request for the open folder
goes out without waiting on the folder list first. The server's answers
replace the copy a round trip later. **Nothing kept is shown before the session
is confirmed** — until then the app shows a spinner, so a session that has
ended goes from the spinner to the sign-in form and never past a mailbox.
The idle timer exists because the alternative does not work: `beforeunload` text The idle timer exists because the alternative does not work: `beforeunload` text
was removed from browsers years ago, and **no event fires at all** for walking was removed from browsers years ago, and **no event fires at all** for walking
+6
View File
@@ -48,6 +48,12 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged 0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support). [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards: - **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these. - **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
+14 -1
View File
@@ -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)}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/*
* Write a Brotli and a gzip copy beside every compressible file in a web build.
*
* The server used to gzip the bundle again on every request that asked for it,
* at a level chosen for speed. These are made once, at the level chosen for
* size, and `server/src/static.ts` hands one out when the browser accepts it.
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
* do per request, which is why it was never offered.
*
* node scripts/precompress.mjs web/dist
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, extname } from "node:path";
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
// Below this, the encoding costs more than it saves.
const MIN_BYTES = 1024;
function* files(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) yield* files(p);
else yield p;
}
}
const root = process.argv[2];
if (!root) {
console.error("usage: precompress.mjs <dir>");
process.exit(2);
}
let count = 0;
let before = 0;
let after = 0;
for (const p of files(root)) {
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
const data = readFileSync(p);
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
writeFileSync(`${p}.br`, br);
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
count++;
before += data.length;
after += br.length;
}
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
+80 -2
View File
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
}); });
test("app passwords are created, listed once with their secret, and revoked", async () => { test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" }); const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
assert.equal(created.status, 200); assert.equal(created.status, 200);
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned"); assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
assert.ok(created.body.id); assert.ok(created.body.id);
@@ -84,8 +84,86 @@ test("app passwords are created, listed once with their secret, and revoked", as
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
}); });
test("an app password needs the account password", async () => {
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
assert.equal(missing.status, 400);
assert.equal(missing.body.error, "missing_fields");
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
assert.equal(wrong.status, 403);
assert.equal(wrong.body.error, "invalid_credentials");
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
});
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
// Signed in without "my own device", so the proxy reads every request.
const res = await call("/api/jmap", {
method: "POST",
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
});
assert.equal(res.status, 403);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
assert.equal(up.status, 200);
const { blobId } = (await up.json()) as { blobId: string };
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("cache-control"), "no-store");
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
await res.arrayBuffer();
});
test("a download passes a byte range through, for viewers that read in pieces", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
const { blobId } = (await up.json()) as { blobId: string };
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
assert.equal(part.status, 206);
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
assert.equal(part.headers.get("accept-ranges"), "bytes");
assert.equal(await part.text(), "hello");
const whole = await app.request(url, { headers: { cookie } });
assert.equal(whole.status, 200);
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
assert.equal(await whole.text(), "hello world");
// Past the end, Stalwart sends the whole file rather than a 416.
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 200);
assert.equal(await beyond.text(), "hello world");
// Anything that is not a plain byte range is not passed on.
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
assert.equal(odd.status, 200);
await odd.arrayBuffer();
});
test("upstream caches let go of sessions that have aged out", async () => {
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
// Signed in above, so this session has an entry.
assert.ok(upstreamCacheSizes().sessions >= 1);
sweepUpstreamCaches(Date.now() + 60 * 60_000);
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
});
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
} }, "s"]]);
assert.equal(res.status, 200);
const set = res.body.methodResponses[0][1];
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
assert.ok(set.created.inline.id, "a data URI is accepted");
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
});
test("an app password needs a name", async () => { test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " " }); const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
assert.equal(res.status, 400); assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields"); assert.equal(res.body.error, "missing_fields");
}); });
+12 -2
View File
@@ -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
View File
@@ -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) };
} }
+177 -26
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie"; import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { bodyLimit } from "hono/body-limit";
import { compress } from "hono/compress"; import { compress } from "hono/compress";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https"; import { request as httpsRequest } from "node:https";
@@ -10,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js"; import { config } from "./config.js";
import { fetchPermissions } from "./permissionSchema.js"; import { fetchPermissions } from "./permissionSchema.js";
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js"; import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js"; import { RateLimiter } from "./ratelimit.js";
import { resolveClientIp } from "./clientip.js"; import { rateLimitKey, resolveClientIp } from "./clientip.js";
import { safeEqual } from "./crypto.js";
import { import {
type AccountInfo, type AccountInfo,
UpstreamError, UpstreamError,
@@ -209,6 +211,22 @@ const csrfGuard: MiddlewareHandler = async (c, next) => {
await next(); await next();
}; };
/**
* The largest body an API route that reads JSON will take.
*
* Hono reads a JSON body whole, and before this nothing bounded it: a few
* unauthenticated sign-in attempts carrying hundreds of megabytes each could
* run the process out of memory, and a restart signs everybody out. What
* these routes actually receive is a username and password, or a code.
*
* JMAP and uploads carry real payloads and bound themselves as they stream;
* the push callback has its own limit ahead of this one.
*/
const MAX_SMALL_BODY = 64 * 1024;
const LARGE_BODY_ROUTE = /\/api\/(jmap$|upload\/)/;
const limitSmallBody = bodyLimit({ maxSize: MAX_SMALL_BODY, onError: (c) => c.json({ error: "too_large" }, 413) });
const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req.path) ? next() : limitSmallBody(c, next));
const requireSession: MiddlewareHandler<Env> = async (c, next) => { const requireSession: MiddlewareHandler<Env> = async (c, next) => {
const cookie = getCookie(c, config.cookieName); const cookie = getCookie(c, config.cookieName);
const session = sessions.resolve(cookie); const session = sessions.resolve(cookie);
@@ -269,6 +287,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const api = new Hono<Env>(); const api = new Hono<Env>();
api.use("*", csrfGuard); api.use("*", csrfGuard);
api.use("*", smallBodies);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() })); api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
@@ -303,6 +322,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// ---------- Auth ---------- // ---------- Auth ----------
api.post("/auth/login", async (c) => { api.post("/auth/login", async (c) => {
const ip = clientIp(c); const ip = clientIp(c);
// What the limits count under: the address, or its /64 for IPv6.
const rateIp = rateLimitKey(ip);
// The flood ceiling needs nothing from the body, so it goes before reading one.
if (!loginFloodLimiter.check(rateIp)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
let body: { username?: string; password?: string; totp?: string; remember?: boolean }; let body: { username?: string; password?: string; totp?: string; remember?: boolean };
try { try {
body = await c.req.json(); body = await c.req.json();
@@ -318,7 +344,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
/* /*
* Three checks, answering different questions. * Three checks, answering different questions.
* *
* `limitKey` is this username from this address, and `ip` is any username * `limitKey` is this username from this address, and `rateIp` is any username
* from it -- both guard guessing, and both are given back when the upstream * from it -- both guard guessing, and both are given back when the upstream
* never got as far as judging the password. Refunding only the first would * never got as far as judging the password. Refunding only the first would
* not fix #239: ten retries through an outage would still spend the address * not fix #239: ten retries through an outage would still spend the address
@@ -328,12 +354,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
* The flood ceiling is the one that is never refunded, and it is the reason * The flood ceiling is the one that is never refunded, and it is the reason
* the other two safely can be. * the other two safely can be.
*/ */
const limitKey = `${ip}|${username.toLowerCase()}`; const limitKey = `${rateIp}|${username.toLowerCase()}`;
if (!loginFloodLimiter.check(ip)) { if (!loginLimiter.check(limitKey) || !loginLimiter.check(rateIp)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey))); c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429); return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
} }
@@ -351,7 +373,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// The credentials were accepted; only the server is too old. Not an // The credentials were accepted; only the server is too old. Not an
// attempt worth counting against them. // attempt worth counting against them.
loginLimiter.refund(limitKey); loginLimiter.refund(limitKey);
loginLimiter.refund(ip); loginLimiter.refund(rateIp);
return c.json( return c.json(
{ {
error: "unsupported_server", error: "unsupported_server",
@@ -364,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
loginLimiter.reset(limitKey); loginLimiter.reset(limitKey);
const { cookie, session } = sessions.create({ const { cookie, session } = sessions.create({
username, username,
account: accountKey(upstreamFor(username), upstream.username || username),
password: effectivePassword, password: effectivePassword,
remember: Boolean(body.remember), remember: Boolean(body.remember),
userAgent: c.req.header("user-agent") ?? "", userAgent: c.req.header("user-agent") ?? "",
@@ -411,7 +434,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
*/ */
if (!(err instanceof UpstreamError && err.status === 401)) { if (!(err instanceof UpstreamError && err.status === 401)) {
loginLimiter.refund(limitKey); loginLimiter.refund(limitKey);
loginLimiter.refund(ip); loginLimiter.refund(rateIp);
} }
return upstreamFailure(c, err); return upstreamFailure(c, err);
} }
@@ -445,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
api.get("/auth/sessions", requireSession, (c) => { api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) }); return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
}); });
api.post("/auth/sessions/revoke-others", requireSession, (c) => { api.post("/auth/sessions/revoke-others", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
const n = sessions.destroyAllForUser(session.username, session.id); const n = sessions.destroyAllForUser(session.account, session.id);
return c.json({ revoked: n }); return c.json({ revoked: n });
}); });
@@ -478,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}; };
/** Guard the endpoints that check a password against brute-forcing. */ /** Guard the endpoints that check a password against brute-forcing. */
const guarded = (c: Context<Env>): Response | null => { const guarded = (c: Context<Env>, scope = "account"): Response | null => {
const key = `account|${c.get("session").username.toLowerCase()}`; const key = `${scope}|${c.get("session").username.toLowerCase()}`;
if (accountLimiter.check(key)) return null; if (accountLimiter.check(key)) return null;
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key))); c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429); return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
@@ -517,7 +540,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const otpCode = body.otpCode?.trim(); const otpCode = body.otpCode?.trim();
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next); sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
forgetUpstreamSession(session.id); forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.username, session.id); const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, revokedSessions: revoked }); return c.json({ ok: true, revokedSessions: revoked });
}); });
@@ -531,12 +554,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} }
}); });
/*
* An app password is a credential that outlives this session, a password
* change and a sign-out -- so minting one asks for the account password, as
* changing the password does. Otherwise a session left open on somebody
* else's machine is enough to take a permanent key away from it.
*/
api.post("/account/app-passwords", requireSession, async (c) => { api.post("/account/app-passwords", requireSession, async (c) => {
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
const limited = guarded(c, "app-password");
if (limited) return limited;
const session = c.get("session"); const session = c.get("session");
const body = await readJson<{ description?: string }>(c); const body = await readJson<{ description?: string; current?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400); if (!body) return c.json({ error: "bad_request" }, 400);
const description = (body.description ?? "").trim().slice(0, 120); const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400); if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
const current = body.current ?? "";
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
if (!(await confirmsPassword(session, current))) {
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
}
try { try {
return c.json(await createAppPassword(await accountCtx(c), { description })); return c.json(await createAppPassword(await accountCtx(c), { description }));
} catch (err) { } catch (err) {
@@ -611,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
if (sessionKept) forgetUpstreamSession(session.id); if (sessionKept) forgetUpstreamSession(session.id);
} }
// Other sessions still hold the bare password and will be refused. // Other sessions still hold the bare password and will be refused.
const revoked = sessions.destroyAllForUser(session.username, session.id); const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, sessionKept, revokedSessions: revoked }); return c.json({ ok: true, sessionKept, revokedSessions: revoked });
}); });
@@ -648,12 +685,27 @@ export function createApp(basePath = config.basePath): Hono<Env> {
*/ */
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body; let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
if (!administrationAllowed(config.administration, session.remember)) { if (!administrationAllowed(config.administration, session.remember)) {
const held = gatedReads.get(session.id) ?? 0;
if (held >= MAX_GATED_PER_SESSION) {
c.header("Retry-After", "1");
return c.json({ error: "rate_limited" }, 429);
}
gatedReads.set(session.id, held + 1);
let raw: string; let raw: string;
try { try {
if (Number(c.req.header("content-length") ?? "0") > MAX_GATED_REQUEST) return c.json({ error: "too_large" }, 413);
// Counted as it arrives: a chunked body carries no length to refuse up front. // Counted as it arrives: a chunked body carries no length to refuse up front.
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : ""; raw = c.req.raw.body ? await readGated(c.req.raw.body) : "";
} catch { } catch (err) {
if (err instanceof GatedBudgetError) {
c.header("Retry-After", "1");
return c.json({ error: "busy" }, 503);
}
return c.json({ error: "too_large" }, 413); return c.json({ error: "too_large" }, 413);
} finally {
const left = (gatedReads.get(session.id) ?? 1) - 1;
if (left > 0) gatedReads.set(session.id, left);
else gatedReads.delete(session.id);
} }
const gate = gateAdministration(raw); const gate = gateAdministration(raw);
if (!gate.ok) { if (!gate.ok) {
@@ -751,23 +803,37 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try { try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl); const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
// A PDF viewer or a video element asks for pieces; pass that on. A server
// that ignores it answers with the whole file, as it did before.
const range = c.req.header("range");
const res = await fetch(url, { const res = await fetch(url, {
// Ask for the bytes as they are. undici would otherwise negotiate gzip // Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length // on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength. // header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" }, headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)), signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
}); });
if (res.status === 416) return c.body(null, 416);
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502); if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers(); const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept); const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type); headers.set("Content-Type", type);
const cl = forwardedContentLength(res.headers); const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl); if (cl) headers.set("Content-Length", cl);
const partial = res.status === 206 && res.headers.get("content-range");
if (partial) headers.set("Content-Range", partial);
/*
* Said here because Stalwart does not say it. It honors a single byte
* range but sends no `Accept-Ranges` (0.16.22, checked live on
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
* the first response advertises it. A server that ignores a range sends
* the whole file, which the browser takes just as well.
*/
headers.set("Accept-Ranges", "bytes");
const safeInline = inline && isInlineSafe(type); const safeInline = inline && isInlineSafe(type);
headers.set( headers.set(
"Content-Disposition", "Content-Disposition",
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`, `${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
); );
headers.set("X-Content-Type-Options", "nosniff"); headers.set("X-Content-Type-Options", "nosniff");
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render). // Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
@@ -786,8 +852,15 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} else { } else {
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"); headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
} }
headers.set("Cache-Control", "private, max-age=3600"); // Kept out of the browser's disk cache on a device that is not the
return new Response(res.body, { status: 200, headers }); // person's own: signing out wipes what the app stores, not that.
/*
* A blob id names its content -- the same id is the same bytes for good
* -- so on the reader's own device there is nothing to revalidate. On
* anyone else's, nothing is left in the disk cache at all.
*/
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
return new Response(res.body, { status: partial ? 206 : 200, headers });
} catch (err) { } catch (err) {
return upstreamFailure(c, err); return upstreamFailure(c, err);
} }
@@ -872,6 +945,43 @@ async function readJson<T>(c: Context): Promise<T | null> {
} }
} }
/**
* Is `candidate` the password of the account this session is signed in to?
*
* Compared with the credential the session holds first, which costs nothing
* and tells Stalwart nothing -- its auto-ban counts failures against the
* proxy's address, which every user shares. That credential is the password,
* with a TOTP code after a `$` when one was given at sign-in. A session that
* turning on 2FA moved onto an app password (Stalwart's secrets start
* `$app$`) holds something else, and only then is the candidate put to the
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
const held = decoded.slice(decoded.indexOf(":") + 1);
if (safeEqual(held, candidate)) return true;
const withoutCode = held.replace(/\$\d{6,8}$/, "");
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
// Holding the password, the comparison above is the answer, and a wrong
// guess never reaches the server's auto-ban.
if (!held.startsWith("$app$")) return false;
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch {
return false;
}
}
/**
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
* read as a PDF in the downloads list. A filename has no use for them.
*/
function withoutBidiControls(name: string): string {
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
/** Name the app password after the browser it will live in. */ /** Name the app password after the browser it will live in. */
function appPasswordName(c: Context): string { function appPasswordName(c: Context): string {
const ua = c.req.header("user-agent") ?? ""; const ua = c.req.header("user-agent") ?? "";
@@ -931,9 +1041,50 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
*/ */
/** /**
* The largest JMAP request read into memory for the administration check. * The largest JMAP request read into memory for the administration check.
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way. *
* Only sessions that may not administer come this way, and what the client
* sends is small: attachments and pasted images go through `/upload`, and the
* composer turns inline images into uploads before a draft is saved. Stalwart
* would take up to its `maxSizeRequest` (10 MB by default), but a request is
* held here as a string, parsed and serialized again, so each one costs
* several times its size; 4 MB is far past anything the client sends.
*/ */
const MAX_GATED_REQUEST = 16 * 1024 * 1024; const MAX_GATED_REQUEST = 4 * 1024 * 1024;
/**
* How many checked requests one session may have in flight at once. Matches
* the `maxConcurrentRequests` Stalwart advertises by default, which the client
* already stays within.
*/
const MAX_GATED_PER_SESSION = 4;
/**
* The bytes all checked requests together may hold at once. Counted as they
* arrive rather than reserved up front, so a slow body that has sent little
* holds little, and a burst of large ones is turned away with a 503 instead of
* taking the process down.
*/
const GATED_BUDGET = 32 * 1024 * 1024;
const gatedReads = new Map<string, number>();
let gatedBytes = 0;
class GatedBudgetError extends Error {}
async function readGated(stream: ReadableStream<Uint8Array>): Promise<string> {
let mine = 0;
const counted = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
mine += chunk.byteLength;
gatedBytes += chunk.byteLength;
if (mine > MAX_GATED_REQUEST) controller.error(new Error("request too large"));
else if (gatedBytes > GATED_BUDGET) controller.error(new GatedBudgetError("gated read budget spent"));
else controller.enqueue(chunk);
},
});
try {
return await new Response(stream.pipeThrough(counted)).text();
} finally {
gatedBytes -= mine;
}
}
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]); const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
+18
View File
@@ -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`;
}
+2 -1
View File
@@ -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",
+12
View File
@@ -18,6 +18,18 @@ export function recordEmailChange(change: { created?: string[]; updated?: string
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200); 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);
+65 -6
View File
@@ -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) ?? {};
+27 -3
View File
@@ -7,6 +7,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import { ACCOUNT, MAX_DELAYED_SEND, MOCK_EDITION, MOCK_LOCALE, NO_REGISTRY, Obj, PASS, PERMISSION_SNAPSHOT, PORT, SHARED_ACCOUNT, SHARED_CAPS, USER, account, nextState, state } from "./config.js"; import { ACCOUNT, MAX_DELAYED_SEND, MOCK_EDITION, MOCK_LOCALE, NO_REGISTRY, Obj, PASS, PERMISSION_SNAPSHOT, PORT, SHARED_ACCOUNT, SHARED_CAPS, USER, account, nextState, state } from "./config.js";
const SESSION_STATE = "1";
import { PING_FLOOR_SECONDS, addEmail, blobs, calendars, people, principals, putBlob, recount } from "./data.js"; import { PING_FLOOR_SECONDS, addEmail, blobs, calendars, people, principals, putBlob, recount } from "./data.js";
import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from "./engine.js"; import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from "./engine.js";
import { handlers } from "./handlers.js"; import { handlers } from "./handlers.js";
@@ -63,7 +65,12 @@ const session = () => ({
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`, uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`,
eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`, eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`,
state: String(state.n), /*
* The session's own state, which the account's data changes do not move.
* It matches the sessionState on every JMAP reply below, as Stalwart's does;
* tying it to the data counter made every reply look like a session change.
*/
state: SESSION_STATE,
}); });
@@ -125,7 +132,7 @@ export const server = createServer(async (req, res) => {
} }
if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); } if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); }
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" })); return res.end(JSON.stringify({ methodResponses: responses, sessionState: SESSION_STATE }));
} }
if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") { if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") {
const data = await readBody(req); const data = await readBody(req);
@@ -138,7 +145,24 @@ 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, answered as Stalwart answers it (0.16.22, checked live
* on 2026-09-16): a 206 for a single range it can serve, and the whole
* file with a 200 for anything else -- several ranges, or one past the
* end. It never sends Accept-Ranges.
*/
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(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}` });
return res.end(b.data.subarray(start, end + 1));
}
}
res.writeHead(200, { "content-type": type, "content-length": b.data.length });
return res.end(b.data); return res.end(b.data);
} }
/* /*
+52
View File
@@ -133,3 +133,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out"); 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;
}
});
+57 -4
View File
@@ -23,7 +23,7 @@
* transition loses no events, because a tab opened before verification keeps * transition loses no events, because a tab opened before verification keeps
* its own relay for its whole life. * its own relay for its whole life.
*/ */
import { randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import type { ServerResponse } from "node:http"; import type { ServerResponse } from "node:http";
import { config } from "./config.js"; import { config } from "./config.js";
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js"; import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
@@ -71,10 +71,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] }; return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
} }
/*
* Whose subscriptions are whose.
*
* Each process used to register a subscription per account and forget it when
* it stopped -- state here is in memory, and an immutable deployment restarts
* on every deploy -- so each restart left one more behind, receiving 404s until
* it expired. Stalwart keeps them all and allows fifteen per account (checked
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
* too (#375).
*
* So the device id names the installation -- a hash of the address Stalwart
* posts to, stable across restarts and different for another installation on
* the same server -- and a new subscription first removes the ones this
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
* the browsers' own, which the web client may clear to make room.
*/
function installationId(): string {
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
}
function deviceIdFor(entry: AccountPush): string {
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
}
async function removeLeftovers(entry: AccountPush) {
const mine = `ihasmail-proxy-${installationId()}-`;
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
}
/** Give the live subscription another week, rather than registering a second one. */
async function renew(entry: AccountPush) {
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
entry.expires = after ? Date.parse(after) : Date.parse(expires);
}
async function subscribe(entry: AccountPush) { async function subscribe(entry: AccountPush) {
try {
await removeLeftovers(entry);
} catch (err) {
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
}
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`; const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
const r = await jmap(entry, [["PushSubscription/set", { const r = await jmap(entry, [["PushSubscription/set", {
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url, create: { s: { deviceClientId: deviceIdFor(entry), url,
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } }, types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
}, "0"]]); }, "0"]]);
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s; const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
@@ -184,8 +232,13 @@ function startSweeper() {
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`); console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
} }
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) { if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
entry.state = "pending"; entry.since = now; // Extended in place, which keeps it verified. Only if the server will
subscribe(entry).catch(() => { entry.state = "failed"; }); // not is a new one registered, and that one has to verify again.
entry.expires = now + RENEW_BEFORE_MS;
renew(entry).catch(() => {
entry.state = "pending"; entry.since = Date.now();
subscribe(entry).catch(() => { entry.state = "failed"; });
});
} }
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) { if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
void unsubscribe(entry); void unsubscribe(entry);
+115
View File
@@ -0,0 +1,115 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* How much a request may make the proxy hold in memory.
*
* Routes that read JSON take a small body and no more, whether or not anyone
* is signed in. The JMAP route streams straight through for a session that may
* administer; for one that may not, it reads the body to check it, and that
* read is capped in size, in how many one session runs at once, and in bytes
* across everyone.
*/
const PORT = 18813;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-request-limits";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { rateLimitKey } = await import("./clientip.js");
const app = createApp();
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
let cookie = "";
/** A body that arrives in chunks with no content-length, as a chunked upload does. */
function chunked(size: number, chunk = 256 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream({
pull(controller) {
if (sent >= size) return controller.close();
const n = Math.min(chunk, size - sent);
controller.enqueue(new Uint8Array(n).fill(0x20));
sent += n;
},
});
}
const jmap = (body: BodyInit) =>
app.request("/api/jmap", { method: "POST", headers: { ...HEADERS, cookie }, body, duplex: "half" } as RequestInit);
before(async () => {
// Not remembered: a device that is not the person's own, so JMAP is checked.
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
assert.equal(res.status, 200, "login should succeed against the mock");
cookie = res.headers.get("set-cookie")!.split(";")[0]!;
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("sign-in refuses a large body by its length, before reading it", async () => {
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { ...HEADERS, "content-length": String(200 * 1024 * 1024) },
body: "{}",
});
assert.equal(res.status, 413);
});
test("sign-in refuses a large chunked body without holding all of it", async () => {
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: chunked(2 * 1024 * 1024), duplex: "half" } as RequestInit);
assert.equal(res.status, 413);
});
test("other JSON routes are limited too", async () => {
const res = await app.request("/api/account/password", { method: "POST", headers: { ...HEADERS, cookie }, body: chunked(1024 * 1024), duplex: "half" } as RequestInit);
assert.equal(res.status, 413);
});
test("an ordinary checked JMAP request still goes through", async () => {
const res = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: [["Mailbox/get", { accountId: "a1", ids: [] }, "0"]] }));
assert.equal(res.status, 200);
});
test("a JMAP request larger than the check allows is refused", async () => {
assert.equal((await jmap(chunked(5 * 1024 * 1024))).status, 413);
});
test("a JMAP request larger than a sign-in body is not caught by the small-body limit", async () => {
// 200 KB of whitespace around a real request: valid JSON, well past 64 KB.
const body = `${" ".repeat(200 * 1024)}{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Core/echo",{},"0"]]}`;
assert.equal((await jmap(body)).status, 200);
});
test("one session cannot hold more than a few checked reads at once", async () => {
// Bodies that never finish: each holds its slot until its stream fails.
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
const pending: Promise<Response>[] = [];
for (let i = 0; i < 4; i++) {
const s = new ReadableStream<Uint8Array>({ start(c) { controllers.push(c); c.enqueue(new TextEncoder().encode("{")); } });
pending.push(jmap(s));
}
await new Promise((r) => setTimeout(r, 50));
const fifth = await jmap("{}");
assert.equal(fifth.status, 429);
assert.ok(fifth.headers.get("retry-after"));
for (const c of controllers) c.error(new Error("client went away"));
await Promise.allSettled(pending);
// The slots are given back once those requests end.
const again = await jmap(JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]] }));
assert.equal(again.status, 200);
});
test("IPv6 addresses share a rate-limit key across their /64", () => {
assert.equal(rateLimitKey("2001:db8:1:2:aaaa::1"), rateLimitKey("2001:db8:1:2:ffff:ffff:ffff:ffff"));
assert.notEqual(rateLimitKey("2001:db8:1:2::1"), rateLimitKey("2001:db8:1:3::1"));
assert.equal(rateLimitKey("2001:db8:1:2::1"), "2001:db8:1:2::/64");
assert.equal(rateLimitKey("198.51.100.7"), "198.51.100.7");
assert.equal(rateLimitKey("unknown"), "unknown");
});
+15 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test"; import { 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
View File
@@ -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,
+79
View File
@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
/*
* The bundle goes out compressed once, at build time, and anything revalidated
* can be answered with a 304.
*
* Before this the server gzipped the bundle again for every request that asked,
* never offered Brotli, and sent no validator for the shell -- so each reload's
* revalidation of index.html and sw.js downloaded them in full.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
mkdirSync(join(root, "assets"));
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
// A copy left over from an older build of the same name must not be served.
writeFileSync(join(root, "assets", "stale-000000.js"), js);
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
const old = new Date(Date.now() - 60_000);
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const app = createApp();
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
test("Brotli is served where the browser takes it", async () => {
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "br");
assert.equal(res.headers.get("vary"), "Accept-Encoding");
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
assert.equal(gz.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
const plain = await get("/assets/app-a1b2c3.js");
assert.equal(plain.headers.get("content-encoding"), null);
assert.equal(await plain.text(), js);
});
test("a file without a copy is compressed as before", async () => {
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
assert.equal(res.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("a copy older than its file is ignored", async () => {
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
assert.notEqual(res.headers.get("content-encoding"), "br");
});
test("the shell and the worker answer a revalidation with 304", async () => {
for (const path of ["/", "/sw.js"]) {
const first = await get(path);
const etag = first.headers.get("etag");
assert.ok(etag, `${path} carries a validator`);
await first.arrayBuffer();
const again = await get(path, { "if-none-match": etag! });
assert.equal(again.status, 304, `${path} is not sent again`);
assert.equal(await again.text(), "");
const changed = await get(path, { "if-none-match": `"something-else"` });
assert.equal(changed.status, 200);
}
});
+76 -4
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs"; import { 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.
+17
View File
@@ -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
View File
@@ -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"
+205 -12
View File
@@ -18,18 +18,169 @@ const VERSION = "ihasmail-v2";
* eventually would. * eventually would.
*/ */
const BASE = new URL("./", self.location).pathname.replace(/\/$/, ""); const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`]; const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
/*
* Only the app page may be kept as the app page.
*
* The mount's root is not always the app: demo.ihasmail.com puts its landing
* page there, and a front door of any kind can. The worker used to cache
* whatever `/` returned at install and whatever HTML a navigation returned,
* and since app routes are answered from that copy first, a demo visitor who
* came back got the landing page on every route, for good. The app page is
* recognised by the asset list the build writes into it.
*/
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
/*
* The routes the app itself owns (App.tsx). Only these are answered from the
* kept page; anything else under the mount -- the root, a landing or farewell
* page in front of the app, a file -- goes to the network as it always did.
*/
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); event.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
.then(() => self.skipWaiting())
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim()) caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => dropForeignShell())
.then(() => tidy())
.catch(() => {})
.then(() => self.clients.claim())
); );
}); });
/*
* Keeping the cache to what the current build uses.
*
* Build assets are cached on first use and their names change with every
* build, and nothing used to take them out again: every deploy's chunks stayed
* in the browser for good. Worse, whatever the server answered was kept -- a
* 404 for a chunk asked for while a deploy was changing over became that
* chunk, from then on, in that browser.
*
* The rule now: only a successful response is cached, and whenever the app
* page changes, the assets it no longer names are dropped. A lazily loaded
* chunk the page does not name is dropped too, and fetched again the next time
* it is wanted -- a hash that did not change is still on the server.
*
* The cache name stays as it is. The same cache carries what the worker leaves
* for a tab to collect -- a push verification, a share, the facts it notifies
* from -- and a new name would throw those away along with the rubbish.
*/
const ASSETS = `${BASE}/assets/`;
const SHELL_KEY = `${BASE}/`;
function assetsNamedIn(html) {
const out = new Set();
for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
try {
out.add(new URL(m[1], self.location).pathname);
} catch {
/* not a URL */
}
}
return out;
}
/**
* Drop failed responses, and assets the cached app page does not name. `also`
* is a page whose assets are kept as well: the one just replaced, which a tab
* opened from the kept copy may still be running.
*/
async function tidy(also = "") {
const cache = await caches.open(VERSION);
const shell = await cache.match(SHELL_KEY);
// Without a page to go by, which assets are current is unknown; keep them.
const keep = shell ? assetsNamedIn(await shell.text()) : null;
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
for (const req of await cache.keys()) {
const path = new URL(req.url).pathname;
if (path.startsWith(ASSETS)) {
if (keep && !keep.has(path)) {
await cache.delete(req);
continue;
}
}
const res = await cache.match(req);
if (res && !res.ok) await cache.delete(req);
}
}
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
async function dropForeignShell() {
const cache = await caches.open(VERSION);
const kept = await cache.match(SHELL_KEY);
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
}
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) {
const html = await res.text();
if (!isAppPage(html)) return;
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
const prevHtml = prev ? await prev.text() : "";
if (prevHtml !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy(prevHtml);
}
await precache(html);
}
/*
* Fetching the rest of the build before it is asked for.
*
* The app page lists every file of its build (see the asset-list plugin in
* vite.config.ts). Without this, the first time after a deploy that a reader
* opened the composer, settings or a viewer, it waited on the server for the
* code -- on a distant link, a visible pause. Now those files are fetched
* quietly once a page names them, a few at a time, and only those not held
* already; a load cut short is carried on at the next navigation, which calls
* this again. Language catalogs are left to be cached when used, and nothing
* is fetched ahead when the reader has asked the browser to save data.
*/
const PRECACHE_PARALLEL = 3;
function precacheList(html) {
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
if (!m) return [];
try {
const list = JSON.parse(m[1]).precache;
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
} catch {
return [];
}
}
async function precache(html) {
if (self.navigator.connection && self.navigator.connection.saveData) return;
const cache = await caches.open(VERSION);
const wanted = [];
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
const next = async () => {
for (let path = wanted.shift(); path; path = wanted.shift()) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (res.ok) await cache.put(path, res);
} catch {
/* offline, or a deploy changing over; the next navigation tries again */
}
}
};
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
}
/* /*
* Where a share from the operating system is left for a tab to collect. * Where a share from the operating system is left for a tab to collect.
* *
@@ -103,21 +254,52 @@ self.addEventListener("fetch", (event) => {
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith(`${BASE}/api/`)) return; if (url.pathname.startsWith(`${BASE}/api/`)) return;
// Hashed build assets: cache-first. // Hashed build assets: cache-first, and only what actually arrived.
if (url.pathname.startsWith(`${BASE}/assets/`)) { if (url.pathname.startsWith(ASSETS)) {
event.respondWith( event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => { caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone(); if (res.ok && res.type === "basic") {
caches.open(VERSION).then((c) => c.put(req, copy)); const copy = res.clone();
event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {}));
}
return res; return res;
})) }))
); );
return; return;
} }
// Navigations & everything else: network-first, fall back to cached shell. /*
* Navigations: the kept app page at once, and the network's behind it.
*
* Every route in the app is the same page, and waiting on the server for it
* cost a full round trip before anything could start -- the longest single
* wait on a distant link. So a route is answered from the kept copy when
* there is one, and the fresh page is fetched alongside to replace it for
* next time. A page that is a build behind is caught the way it always was:
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then.
*
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
* app, and a file opened in a tab of its own go to the network as before. So
* does the first visit, which has no copy yet.
*/
if (req.mode === "navigate") { if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`))); const network = fetch(req).then((res) => {
// Every route is the same app page; a fresh one replaces the offline copy.
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
}
return res;
});
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) {
event.waitUntil(network.catch(() => {}));
return kept;
}
return network.catch(() => caches.match(SHELL_KEY));
})());
return; return;
} }
event.respondWith(fetch(req).catch(() => caches.match(req))); event.respondWith(fetch(req).catch(() => caches.match(req)));
@@ -272,6 +454,14 @@ self.addEventListener("push", (event) => {
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => { event.waitUntil((async () => {
/*
* Someone reading the app already knows. A focused, visible window of this
* app gets its new mail from its own event stream, so a notification on
* top of it is a second telling of the same thing (#375). Chrome does not
* require one while the site is in the foreground.
*/
const windows = await self.clients.matchAll({ type: "window" });
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
const facts = await readFacts(); const facts = await readFacts();
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" }; const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
/* /*
@@ -287,8 +477,10 @@ self.addEventListener("push", (event) => {
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {}); if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
if (!emails.length) { if (!emails.length) {
// A StateChange, or a payload too large to carry the message. Say // A delivery from a server that sends StateChange rather than EmailPush
// something true rather than inventing a sender. // -- the subscription asks for `EmailDelivery` only, so it is new mail --
// or a payload too large to carry the message. Say something true
// rather than inventing a sender.
await self.registration.showNotification(strings.newMail, { await self.registration.showNotification(strings.newMail, {
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` }, icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
}); });
@@ -308,7 +500,8 @@ self.addEventListener("push", (event) => {
// be drawn. // be drawn.
actions: email.id ? actionsFor(facts) : [], actions: email.id ? actionsFor(facts) : [],
data: { data: {
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`, // The route names a conversation, and `m` the message in it.
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
id: email.id || null, id: email.id || null,
title, title,
accountId: facts?.accountId ?? null, accountId: facts?.accountId ?? null,
+4 -6
View File
@@ -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
+4 -1
View File
@@ -104,6 +104,8 @@ export class JmapClient {
private callCounter = 0; private callCounter = 0;
private unauthHandlers = new Set<() => void>(); private unauthHandlers = new Set<() => void>();
private stateHandlers = new Set<(sessionState: string) => void>(); private stateHandlers = new Set<(sessionState: string) => void>();
/** The last session state announced, so a burst of replies announces it once. */
private announcedState: string | null = null;
get maxCallsInRequest(): number { get maxCallsInRequest(): number {
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined; const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
@@ -255,7 +257,8 @@ export class JmapClient {
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls }; const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds; if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) }); const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
if (res.sessionState && this.session && res.sessionState !== this.session.state) { if (res.sessionState && this.session && res.sessionState !== this.session.state && res.sessionState !== this.announcedState) {
this.announcedState = res.sessionState;
for (const fn of this.stateHandlers) fn(res.sessionState); for (const fn of this.stateHandlers) fn(res.sessionState);
} }
return res; return res;
+12 -1
View File
@@ -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]");
});
});
+33 -1
View File
@@ -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);
});
});
+17 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget"; import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/sw/swCache"; import { SW_CACHE_NAME } from "@/lib/sw/swCache";
/** /**
@@ -112,3 +112,19 @@ describe("the body a share turns into", () => {
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought"); expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
}); });
}); });
describe("shareSummary", () => {
const file = (name: string) => new File(["x"], name);
it("gives the title, the text and link together, and the file names", () => {
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
title: "Trip",
preview: "See this https://example.com",
files: ["a.jpg"],
});
});
it("shortens a long text rather than showing all of it", () => {
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
expect(preview.length).toBeLessThanOrEqual(160);
expect(preview.endsWith("…")).toBe(true);
});
});
+6
View File
@@ -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);
+7 -4
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types"; import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types";
import { withBase } from "@/lib/basePath"; 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;
+16
View File
@@ -0,0 +1,16 @@
/**
* Hand the browser a file the app made, to save.
*
* The object URL is released as soon as the download has been started: a
* click on the link starts it synchronously, and an unreleased URL keeps the
* whole file in memory for as long as the tab is open -- an address book's
* worth of vCards, per export.
*/
export function downloadFile(content: BlobPart, type: string, filename: string): void {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { setDeviceTrusted } from "@/lib/storage";
import { deviceClientId, isBrowserSubscription, rememberEndpoint, roomToMake, setPushEnabledHere, type JmapPushSubscription } from "@/lib/notify/webpush";
import { renewWebPush } from "@/lib/notify/webpushEnable";
/**
* #375: every renewal registered another subscription, on the belief that a
* repeated deviceClientId replaces the old one. Stalwart keeps both and allows
* fifteen per account, so accounts filled up with "too many subscriptions".
*
* The server below behaves as a live 0.16.22 was seen to: duplicates are kept,
* the sixteenth is refused with overQuota, and an expiry can be extended.
*/
const KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
const DAY = 24 * 60 * 60 * 1000;
const OTHER = (n: number) => `ihasmail-00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
let server: Array<JmapPushSubscription & { types?: string[] }>;
let writes: Array<[string, Record<string, unknown>]>;
let seq: number;
const fakeSub = (endpoint: string) => ({
endpoint,
toJSON: () => ({ endpoint, keys: { p256dh: "BPub", auth: "auth" } }),
getKey: () => null,
});
let browserSub: ReturnType<typeof fakeSub> | null;
function install() {
client.session = { capabilities: { "urn:ietf:params:jmap:core": { maxCallsInRequest: 16 }, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: KEY } }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
vi.stubGlobal("PushManager", function PushManager() {});
vi.stubGlobal("Notification", { permission: "granted" });
const reg = {
pushManager: {
getSubscription: async () => browserSub,
subscribe: async () => (browserSub = fakeSub("https://push.example/new-endpoint")),
},
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: { ready: Promise.resolve(reg), getRegistration: async () => reg, addEventListener: () => {} },
});
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, args, id]) => {
if (name === "PushSubscription/get") return [name, { list: server.map((s) => ({ ...s })), notFound: [] }, id];
writes.push([name, args]);
const created: Record<string, unknown> = {};
const notCreated: Record<string, unknown> = {};
const updated: Record<string, null> = {};
for (const [cid, body] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
if (server.length >= 15) { notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." }; continue; }
const sub = { id: `p${seq++}`, deviceClientId: String(body.deviceClientId), expires: new Date(Date.now() + 7 * DAY).toISOString(), verificationCode: null, types: body.types as string[] };
server.push(sub);
created[cid] = { id: sub.id, expires: sub.expires };
}
for (const [sid, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const s = server.find((x) => x.id === sid);
if (s && typeof patch.expires === "string") { s.expires = patch.expires; updated[sid] = null; }
}
const destroy = (args.destroy ?? []) as string[];
server = server.filter((s) => !destroy.includes(s.id));
return [name, { created, notCreated, updated, destroyed: destroy }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
}
beforeEach(() => {
localStorage.clear();
server = [];
writes = [];
seq = 1;
browserSub = fakeSub("https://push.example/endpoint-a");
setDeviceTrusted(true);
setPushEnabledHere(true);
install();
});
afterEach(() => {
client.session = null;
vi.unstubAllGlobals();
});
const mine = () => server.filter((s) => s.deviceClientId === deviceClientId());
const ours = (expiresIn: number, id = `m${seq++}`) => ({ id, deviceClientId: deviceClientId(), expires: new Date(Date.now() + expiresIn).toISOString(), verificationCode: "done" });
describe("keeping this browser registered", () => {
it("registers once, for new mail only, and remembers the endpoint", async () => {
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(mine()[0]!.types).toEqual(["EmailDelivery"]);
// Started again straight away: nothing more to do.
writes = [];
await renewWebPush();
expect(writes).toEqual([]);
expect(mine()).toHaveLength(1);
});
it("leaves a subscription with time on it alone", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(6 * DAY));
await renewWebPush();
expect(writes).toEqual([]);
});
it("extends one that is close to expiring instead of adding another", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(1 * DAY, "keep"));
await renewWebPush();
expect(writes.map(([n, a]) => `${n} ${Object.keys(a).join(",")}`)).toEqual(["PushSubscription/set update"]);
expect(mine()).toHaveLength(1);
expect(Date.parse(mine()[0]!.expires!) - Date.now()).toBeGreaterThan(6 * DAY);
});
it("clears the copies earlier versions left, keeping the newest", async () => {
rememberEndpoint(browserSub!.endpoint);
server.push(ours(1 * DAY), ours(3 * DAY), ours(6 * DAY, "newest"));
await renewWebPush();
expect(mine().map((s) => s.id)).toEqual(["newest"]);
});
it("replaces its registrations when the browser's endpoint has changed", async () => {
rememberEndpoint("https://push.example/an-old-endpoint");
server.push(ours(6 * DAY, "old1"), ours(6 * DAY, "old2"));
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(mine()[0]!.id).not.toMatch(/^old/);
expect(localStorage.getItem("ihasmail:pushEndpoint")).toBe(browserSub!.endpoint);
});
it("makes room when the account is full, taking another browser's never-verified one first", async () => {
for (let i = 0; i < 13; i++) server.push({ id: `o${i}`, deviceClientId: OTHER(i), expires: new Date(Date.now() + (i + 1) * DAY / 4).toISOString(), verificationCode: "done" });
server.push({ id: "unverified", deviceClientId: OTHER(99), expires: new Date(Date.now() + 6 * DAY).toISOString(), verificationCode: null });
server.push({ id: "proxy", deviceClientId: "ihasmail-proxy-abcdefghij-12345678", expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "done" });
await renewWebPush();
expect(mine()).toHaveLength(1);
expect(server.find((s) => s.id === "unverified")).toBeUndefined();
expect(server.find((s) => s.id === "proxy")).toBeDefined();
expect(server).toHaveLength(15);
});
});
describe("telling subscriptions apart", () => {
it("recognizes a browser's id, and not the server's or another client's", () => {
const sub = (deviceClientId: string) => ({ id: "x", deviceClientId, expires: null }) as JmapPushSubscription;
expect(isBrowserSubscription(sub(OTHER(1)))).toBe(true);
expect(isBrowserSubscription(sub("ihasmail-proxy-abcdefghij-12345678"))).toBe(false);
expect(isBrowserSubscription(sub("ihasmail-Ab3_x9Qz"))).toBe(false);
expect(isBrowserSubscription(sub("some-other-client"))).toBe(false);
});
it("chooses the soonest to expire when every candidate is verified", () => {
const subs = [
{ id: "later", deviceClientId: OTHER(1), expires: new Date(Date.now() + 5 * DAY).toISOString(), verificationCode: "v" },
{ id: "sooner", deviceClientId: OTHER(2), expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "v" },
{ id: "me", deviceClientId: OTHER(3), expires: new Date(Date.now()).toISOString(), verificationCode: "v" },
];
expect(roomToMake(subs, OTHER(3))).toEqual(["sooner"]);
});
});
+8 -2
View File
@@ -125,9 +125,15 @@ describe("what gets registered", () => {
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush"); 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"]));
}); });
}); });
+18 -2
View File
@@ -82,14 +82,30 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
} }
} }
/**
* Show a notification from the page, for a tab that is open but not in front.
*
* Through the service worker's registration where there is one: Android's
* Chrome refuses `new Notification()` outright, so notifications from an open
* tab never appeared there at all. The tag is the one the service worker uses
* for the same message (`ihasmail-<id>`), so if both ever show it, the second
* replaces the first instead of stacking beside it.
*/
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void { 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
View File
@@ -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);
} }
+56 -11
View File
@@ -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 {
+14
View File
@@ -117,3 +117,17 @@ export function shareBody(share: Pick<SharedContent, "text" | "url">): string {
if (!url || text.includes(url)) return text; if (!url || text.includes(url)) return text;
return text ? `${text}\n\n${url}` : url; return text ? `${text}\n\n${url}` : url;
} }
/**
* What a share holds, in the few words the confirmation shows.
*
* Only what the reader needs to recognize it as theirs: the title, the start
* of the text or link, and the names of the files. It is shown before any of
* it goes near a message, because the page cannot tell a share the reader
* made from one a website posted at the same address.
*/
export function shareSummary(share: SharedContent): { title: string; preview: string; files: string[] } {
const body = [share.text, share.url].map((s) => s.trim()).filter(Boolean).join(" ");
const preview = body.length > 160 ? `${body.slice(0, 157).trimEnd()}` : body;
return { title: share.title.trim(), preview, files: share.files.map((f) => f.name) };
}
+28
View File
@@ -1,5 +1,6 @@
import { APP_VERSION } from "../version"; import { APP_VERSION } from "../version";
import { withBase } from "../basePath"; import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "./swCache";
import { push, type PushState } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
/** /**
@@ -93,10 +94,33 @@ async function check(): Promise<boolean> {
// deploy -- this stops the two of them reloading each other in a loop. // deploy -- this stops the two of them reloading each other in a loop.
if (tried() === serverVersion) return false; if (tried() === serverVersion) return false;
remember(serverVersion); remember(serverVersion);
await primeShell();
window.location.reload(); window.location.reload();
return true; return true;
} }
/*
* The service worker answers a navigation from its kept copy of the app page
* and refreshes that copy behind it. A reload for a new build must not get the
* old copy back, so the new page is put in place first. Best effort: if this
* fails, the loop guard above still stops a second reload.
*/
async function primeShell(): Promise<void> {
if (!("caches" in window) || !navigator.serviceWorker?.controller) return;
try {
// An app route rather than the root: the root can be a page in front of
// the app (the demo's landing page is), and only the app page may be kept.
const res = await fetch(withBase("/mail"), { credentials: "same-origin", cache: "no-store" });
if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return;
const html = await res.text();
if (!html.includes('id="ihasmail-assets"')) return;
const cache = await caches.open(SW_CACHE_NAME);
await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
} catch {
/* reload anyway */
}
}
/** /**
* Watch for a deploy without waiting to be asked. * Watch for a deploy without waiting to be asked.
* *
@@ -139,6 +163,10 @@ export function makeConnectionWatcher(): (state: PushState) => void {
export function startBuildWatch(): void { export function startBuildWatch(): void {
push.onConnection(makeConnectionWatcher()); push.onConnection(makeConnectionWatcher());
// A page the service worker answered from its kept copy may be a build
// behind; ask now rather than a minute from now.
if (navigator.serviceWorker?.controller) void reloadIfServerRebuilt();
window.setInterval(() => { window.setInterval(() => {
// A hidden tab is not being read, and will be checked when it surfaces. // A hidden tab is not being read, and will be checked when it surfaces.
if (document.visibilityState === "visible") void reloadIfServerRebuilt(); if (document.visibilityState === "visible") void reloadIfServerRebuilt();
+9
View File
@@ -1,3 +1,12 @@
/**
* Remove the characters that reorder text around them: direction overrides,
* embeddings, isolates and marks. A sender-supplied name has no honest use for
* them, and `Invoice_\u202Efdp.exe` displays as "Invoice_exe.pdf".
*/
export function withoutBidiControls(s: string): string {
return s.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
export function escapeHtml(s: string): string { export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { withoutBidiControls } from "@/lib/text/text";
/** /**
* `winmail.dat`, opened. * `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),
+3
View File
@@ -1190,6 +1190,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Neue Nachricht", "New message": "Neue Nachricht",
"Start a new message with what was shared?": "Neue Nachricht mit dem geteilten Inhalt beginnen?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Es wurde etwas mit ihasmail geteilt. Gesendet wird erst, wenn Sie „Senden“ wählen. Wenn Sie dies nicht gerade selbst geteilt haben, verwerfen Sie es.",
"Start a message": "Nachricht beginnen",
"New mail": "Neue E-Mail", "New mail": "Neue E-Mail",
"Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut", "Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut",
"Sending…": "Wird gesendet…", "Sending…": "Wird gesendet…",
+3
View File
@@ -1163,6 +1163,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Mensaje nuevo", "New message": "Mensaje nuevo",
"Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Se ha compartido algo con ihasmail. No se envía nada hasta que elija Enviar. Si no acaba de compartirlo usted, descártelo.",
"Start a message": "Empezar mensaje",
"New mail": "Correo nuevo", "New mail": "Correo nuevo",
"Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo", "Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1168,6 +1168,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nouveau message", "New message": "Nouveau message",
"Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Un contenu a été partagé avec ihasmail. Rien n'est envoyé tant que vous n'avez pas choisi Envoyer. Si vous ne venez pas de le partager, abandonnez-le.",
"Start a message": "Commencer un message",
"New mail": "Nouveau courrier", "New mail": "Nouveau courrier",
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez", "Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
"Sending…": "Envoi…", "Sending…": "Envoi…",
+3
View File
@@ -1171,6 +1171,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新規メール", "New message": "新規メール",
"Start a new message with what was shared?": "共有された内容で新規メールを作成しますか?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "ihasmail に何かが共有されました。「送信」を選ぶまで何も送信されません。共有した覚えがない場合は破棄してください。",
"Start a message": "メールを作成",
"New mail": "新着メール", "New mail": "新着メール",
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください", "Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
"Sending…": "送信中…", "Sending…": "送信中…",
+68 -63
View File
@@ -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,13 +55,13 @@ export const catalog: Catalog = {
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.", "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.",
"Other names": "Andere namen", "Other names": "Andere namen",
"Delivery": "Bezorging", "Delivery": "Bezorging",
"Catch-all address": "Catch-alladres", "Catch-all address": "Catch-all adres",
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mail aan een adres dat niemand op dit domein heeft, wordt hier bezorgd. Laat leeg om die e-mail te weigeren.", "Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mail aan een adres dat niemand op dit domein heeft, wordt hier bezorgd. Laat leeg om die e-mail te weigeren.",
"Plus addressing": "Plus-adressering", "Plus addressing": "Plus-adressering",
"Set by a custom rule on the server.": "Ingesteld door een aangepaste regel op de server.", "Set by a custom rule on the server.": "Ingesteld door een aangepaste regel op de server.",
@@ -69,29 +69,29 @@ export const catalog: Catalog = {
"DNS records": "DNS-records", "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, agendas, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.", "This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mails, agendas, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
"Type {address} to confirm": "Typ {address} om te bevestigen", "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.",
@@ -563,28 +564,29 @@ export const catalog: Catalog = {
"Day": "Dag", "Day": "Dag",
"Week": "Week", "Week": "Week",
"Month": "Maand", "Month": "Maand",
"Agenda": "Agendaoverzicht", "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.",
@@ -1159,6 +1161,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht", "New message": "Nieuw bericht",
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.",
"Start a message": "Bericht beginnen",
"New mail": "Nieuwe e-mail", "New mail": "Nieuwe e-mail",
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw", "Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
"Sending…": "Bezig met verzenden…", "Sending…": "Bezig met verzenden…",
@@ -1503,7 +1508,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",
@@ -1545,7 +1550,7 @@ export const catalog: Catalog = {
"Unsaved changes": "Niet-opgeslagen wijzigingen", "Unsaved changes": "Niet-opgeslagen wijzigingen",
"View as": "Weergeven als", "View as": "Weergeven als",
"Warnings": "Waarschuwingen", "Warnings": "Waarschuwingen",
"Week view": "Weekweergave", "Week view": "Week weergave",
"What is it called?": "Hoe heet het?", "What is it called?": "Hoe heet het?",
"What reaches a sender, and what asks before it happens.": "Wat een afzender bereikt, en wat het vraagt voordat het gebeurt.", "What reaches a sender, and what asks before it happens.": "Wat een afzender bereikt, en wat het vraagt voordat het gebeurt.",
"What you changed here will be lost.": "Wat u hier hebt gewijzigd gaat verloren.", "What you changed here will be lost.": "Wat u hier hebt gewijzigd gaat verloren.",
@@ -1554,7 +1559,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",
+3
View File
@@ -1166,6 +1166,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nova mensagem", "New message": "Nova mensagem",
"Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Algo foi compartilhado com o ihasmail. Nada é enviado até você escolher Enviar. Se não foi você que acabou de compartilhar, descarte.",
"Start a message": "Iniciar mensagem",
"New mail": "Novo e-mail", "New mail": "Novo e-mail",
"Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente", "Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1165,6 +1165,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новое письмо", "New message": "Новое письмо",
"Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "В ihasmail что-то передали через «Поделиться». Ничего не отправится, пока вы не нажмёте «Отправить». Если вы только что ничего не передавали, нажмите «Не сохранять».",
"Start a message": "Начать письмо",
"New mail": "Новое письмо", "New mail": "Новое письмо",
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку", "Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
"Sending…": "Отправка…", "Sending…": "Отправка…",
+3
View File
@@ -1159,6 +1159,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новий лист", "New message": "Новий лист",
"Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "До ihasmail щось передали через «Поділитися». Нічого не буде надіслано, доки ви не натиснете «Надіслати». Якщо ви щойно нічого не передавали, натисніть «Не зберігати».",
"Start a message": "Почати лист",
"New mail": "Новий лист", "New mail": "Новий лист",
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу", "Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
"Sending…": "Надсилання…", "Sending…": "Надсилання…",
+3
View File
@@ -1170,6 +1170,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新邮件", "New message": "新邮件",
"Start a new message with what was shared?": "用共享的内容新建邮件吗?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "有内容被共享到 ihasmail。在您选择“发送”之前不会发送任何内容。如果不是您刚才共享的,请放弃。",
"Start a message": "新建邮件",
"New mail": "新邮件", "New mail": "新邮件",
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试", "Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
"Sending…": "正在发送…", "Sending…": "正在发送…",
@@ -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,301 @@
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, requests: () => fetchMock.mock.calls.length };
}
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.getState().setAccount("a1");
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", () => {
const known = (t: string, emailIds: string[]) => useMail.setState({ threads: { [t]: { id: t, emailIds } } });
it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3);
known("te0", ["e0", "e0m0", "e0m1"]);
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, in the same request as the thread", async () => {
const { calls, requests } = server(1, 3);
known("te0", ["e0", "e0m0", "e0m1"]);
useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true },
});
await useMail.getState().loadThread("te0");
expect(requests()).toBe(1);
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("opens a thread it has never seen in one request", async () => {
const { calls, requests } = server(1, 3);
const got = await useMail.getState().loadThread("te0");
expect(requests()).toBe(1);
expect(calls.map(([n]) => n)).toEqual(["Thread/get", "Email/get"]);
expect(calls[1]![1].fetchHTMLBodyValues).toBe(true);
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0", "e0m1"]);
expect(useMail.getState().fullIds).toEqual({ e0: true, e0m0: true, e0m1: true });
expect(useMail.getState().threads.te0?.emailIds).toEqual(["e0", "e0m0", "e0m1"]);
});
it("splits a thread longer than one Email/get may carry", async () => {
const { calls } = server(1, 1200);
known("te0", ["e0", ...Array.from({ length: 1199 }, (_, j) => `e0m${j}`)]);
const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
});
it("falls back to parts when a thread it has never seen is too long for one request", async () => {
const { calls } = server(1, 1200);
const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200);
// The refused call, then the members in parts the server takes.
expect(getSizes(calls)).toEqual([1200, 500, 500, 200]);
});
});
describe("prefetchThread", () => {
it("is the request a later open waits for", async () => {
const { requests } = server(1, 2);
useMail.getState().prefetchThread("te0");
const got = await useMail.getState().loadThread("te0");
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0"]);
// The open waits for the prefetch and asks for nothing more.
expect(requests()).toBe(1);
});
it("does nothing for a conversation already held in full", async () => {
const { requests } = server(1, 1);
useMail.setState({ threads: { te0: { id: "te0", emailIds: ["e0"] } }, emails: { e0: { id: "e0" } } as never, fullIds: { e0: true } });
useMail.getState().prefetchThread("te0");
await Promise.resolve();
expect(requests()).toBe(0);
});
it("asks once however often it is asked", async () => {
const { requests } = server(1, 1);
useMail.getState().prefetchThread("te0");
useMail.getState().prefetchThread("te0");
useMail.getState().prefetchThread("te0");
await vi.waitFor(() => expect(useMail.getState().fullIds.e0).toBe(true));
expect(requests()).toBe(1);
});
});
describe("going back to a folder", () => {
const folder = (mailboxId: string) => ({ key: "", filter: { inMailbox: mailboxId }, sort: [], collapseThreads: false, mailboxId });
it("shows its last list while the query is on its way, less what has left it", async () => {
server(3);
await useMail.getState().query(folder(INBOX));
expect(useMail.getState().list!.ids).toEqual(["e0", "e1", "e2"]);
await useMail.getState().query(folder("mbOther"));
// e1 is moved away meanwhile.
useMail.setState((s) => ({ emails: { ...s.emails, e1: { ...s.emails.e1!, mailboxIds: { mbOther: true } } } }));
const back = useMail.getState().query(folder(INBOX));
const shown = useMail.getState().list!;
expect(shown.loading).toBe(true);
expect(shown.ids).toEqual(["e0", "e2"]);
expect(shown.total).toBe(2);
await back;
expect(useMail.getState().list!.loading).toBe(false);
});
it("starts empty for a folder not read before, and for a search", async () => {
server(3);
await useMail.getState().query(folder(INBOX));
void useMail.getState().query(folder("mbNever"));
expect(useMail.getState().list!.ids).toEqual([]);
const search = { filter: { inMailbox: INBOX, text: "x" }, sort: [], collapseThreads: false, mailboxId: INBOX };
await useMail.getState().query(search as never);
await useMail.getState().query(folder(INBOX));
void useMail.getState().query(search as never);
expect(useMail.getState().list!.ids).toEqual([]);
});
});
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useMail } from "@/store/mail";
vi.mock("@/lib/notify/notify", () => ({ playNewMailSound: vi.fn(), showNotification: vi.fn() }));
/**
* What a push costs. On a slow link every round trip is felt, and a push
* arrives after almost everything the reader does -- marking one message read
* is echoed back as a change.
*/
const INBOX = "mbInbox";
type Call = [string, Record<string, unknown>, string];
const email = (id: string, keywords: Record<string, boolean> = {}) => ({ id, threadId: `t${id}`, mailboxIds: { [INBOX]: true }, keywords, receivedAt: "2026-09-16T00:00:00Z" });
function server(changes: { created?: string[]; updated?: string[]; destroyed?: string[] } | "cannotCalculateChanges") {
const requests: Call[][] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
requests.push(methodCalls);
const responses: Call[] = [];
for (const [name, args, id] of methodCalls) {
const ref = args["#ids"] as { resultOf: string; path: string } | undefined;
if (name === "Email/changes") {
if (changes === "cannotCalculateChanges") responses.push(["error", { type: "cannotCalculateChanges" }, id]);
else responses.push([name, { accountId: "a1", oldState: "s1", newState: "s2", hasMoreChanges: false, created: changes.created ?? [], updated: changes.updated ?? [], destroyed: changes.destroyed ?? [] }, id]);
} else if (name === "Email/get") {
const from = ref ? responses.find((r) => r[2] === ref.resultOf)?.[1] : undefined;
const ids = ref ? ((from?.[ref.path.slice(1)] as string[] | undefined) ?? []) : (args.ids as string[]);
responses.push([name, { accountId: "a1", state: "s2", list: ids.map((x) => email(x, { $seen: true })), notFound: [] }, id]);
} else if (name === "Mailbox/get") {
responses.push([name, { accountId: "a1", state: "m2", list: [{ id: INBOX, role: "inbox", name: "Inbox" }], notFound: [] }, id]);
} else {
responses.push([name, { accountId: "a1", state: "s2", list: [], ids: [], total: 0, notFound: [] }, id]);
}
}
return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "x" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { requests, names: () => requests.map((r) => r.map(([n]) => n)) };
}
beforeEach(() => {
client.session = { capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: {}, primaryAccounts: {}, state: "x" } as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
list: null,
emails: { e1: email("e1"), e2: email("e2") } as never,
fullIds: {},
threads: {},
emailState: "s1",
openThreadId: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("a pushed mail change", () => {
it("fetches the changes and what they name in one request, beside one Mailbox/get", async () => {
const { names } = server({ created: ["e9"], updated: ["e1", "e5"], destroyed: ["e2"] });
await useMail.getState().applyChanges(new Set(["Email", "Mailbox", "Thread"]));
await vi.waitFor(() => expect(useMail.getState().mailboxState).toBe("m2"));
const all = names();
expect(all.find((r) => r.includes("Email/changes"))).toEqual(["Email/changes", "Email/get", "Email/get"]);
expect(all.flat().filter((n) => n === "Mailbox/get")).toHaveLength(1);
// Nothing named by the changes was asked for again.
expect(all.flat().filter((n) => n === "Email/get")).toHaveLength(2);
const { emails, emailState } = useMail.getState();
expect(emailState).toBe("s2");
expect(emails.e1?.keywords).toEqual({ $seen: true });
expect(emails.e2).toBeUndefined();
expect(emails.e9).toBeDefined();
// An update to a message not held is not taken in.
expect(emails.e5).toBeUndefined();
});
it("forgets its state when the server cannot say what changed", async () => {
server("cannotCalculateChanges");
await useMail.getState().applyChanges(new Set(["Email"]));
expect(useMail.getState().emailState).toBeNull();
});
});
describe("a new session state", () => {
it("is announced once, however many replies carry it", async () => {
server({});
client.session = { ...client.session!, state: "old" };
const seen = vi.fn();
const off = client.onSessionState(seen);
await Promise.all([client.request([["Core/echo", {}, "a"]]), client.request([["Core/echo", {}, "b"]]), client.request([["Core/echo", {}, "c"]])]);
off();
expect(seen).toHaveBeenCalledTimes(1);
expect(seen).toHaveBeenCalledWith("x");
});
});
@@ -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);
});
});
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { buildSnapshot, useMail } from "@/store/mail";
import { useSession } from "@/store/session";
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
/**
* Starting from what a trusted device kept: the folder list and the first page
* of recent folders, applied once the server has confirmed the session. On a
* distant link each of those was a round trip before the inbox could show.
*/
const INBOX = "mbInbox";
const session = (remember: boolean) =>
({
capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: { a1: { name: "me", isPersonal: true, isReadOnly: false, accountCapabilities: { [CAP.mail]: {} } } },
primaryAccounts: { [CAP.mail]: "a1" },
state: "s",
username: "me",
apiUrl: "",
downloadUrl: "",
uploadUrl: "",
eventSourceUrl: "",
ihasmail: { remember, sessionId: "public-id" },
}) as unknown as JmapSession;
const email = (id: string) => ({
id,
threadId: `t${id}`,
mailboxIds: { [INBOX]: true },
keywords: {},
receivedAt: "2026-09-16T00:00:00Z",
subject: `Subject ${id}`,
preview: "p",
bodyValues: { "1": { value: "secret body" } },
});
const inboxQuery = { key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX };
/** The key the store gives the inbox list, learned by asking for it. */
function inboxKey(): string {
useMail.getState().setAccount("probe");
void useMail.getState().query(inboxQuery);
const key = useMail.getState().list!.key;
useMail.getState().setAccount(null);
return key;
}
function signedInWithList() {
const key = inboxKey();
useMail.getState().setAccount("a1");
useMail.setState({
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
mailboxesLoaded: true,
emails: { e1: email("e1"), e2: email("e2") } as never,
list: { key, filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX, ids: ["e1", "e2"], total: 7, queryState: "q", loading: false, loadingMore: false, error: null, exhausted: false },
});
}
let pending: ((v: Response) => void) | null;
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
pending = null;
// The session request stays unanswered unless a test answers it.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((resolve) => { pending = resolve; })));
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
setDeviceTrusted(false);
localStorage.clear();
});
describe("the kept mail snapshot", () => {
it("holds folders and list rows without bodies", () => {
signedInWithList();
const snap = buildSnapshot(useMail.getState())!;
expect(snap.mailboxes.map((m) => m.id)).toEqual([INBOX]);
expect(snap.lists).toEqual([{ key: inboxKey(), ids: ["e1", "e2"], total: 7 }]);
expect(snap.emails.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(JSON.stringify(snap)).not.toContain("secret body");
});
it("is not made before the server's folder list has arrived", () => {
signedInWithList();
useMail.setState({ mailboxesLoaded: false });
expect(buildSnapshot(useMail.getState())).toBeNull();
});
it("paints the folders and the inbox on the next start of a trusted device", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
// A change to the list schedules the save.
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toContain('"e1"');
// Next start.
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
const s = useMail.getState();
expect(s.mailboxesCached).toBe(true);
expect(s.mailboxesLoaded).toBe(false);
expect(s.mailboxes[INBOX]?.name).toBe("Inbox");
void s.query(inboxQuery);
expect(useMail.getState().list).toMatchObject({ ids: ["e1", "e2"], total: 8, loading: true });
});
it("is neither written nor read on a device not marked as the reader's own", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
setDeviceTrusted(false);
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().mailboxes).toEqual({});
localStorage.clear();
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("is gone after signing out, and a save scheduled before it does not bring it back", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
clearSignedInData();
useSession.setState({ status: "anonymous", accountId: null });
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("belongs to one account", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.getState().setAccount("a2");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().emails).toEqual({});
});
});
describe("before the server has confirmed the session", () => {
it("shows nothing kept: the spinner stays until the answer, and the kept folders arrive with it", async () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
// Next start.
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
const boot = useSession.getState().bootstrap();
expect(useSession.getState().status).toBe("loading");
expect(useMail.getState().accountId).toBeNull();
expect(useMail.getState().mailboxes).toEqual({});
expect(useMail.getState().emails).toEqual({});
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(useSession.getState().status).toBe("authenticated");
expect(useMail.getState().mailboxesCached).toBe(true);
expect(useMail.getState().mailboxes[INBOX]?.name).toBe("Inbox");
});
it("keeps no session of its own", async () => {
setDeviceTrusted(true);
const boot = useSession.getState().bootstrap();
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(localStorage.getItem("ihasmail:session")).toBeNull();
});
});
+59 -27
View File
@@ -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.
* *
+39 -2
View File
@@ -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
View File
@@ -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();
}, },
})); }));
+31 -11
View File
@@ -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 }); // Refused means nothing here is ours to see, which is the same answer.
} catch { () => null,
// Refused means nothing here is ours to see, which is the same answer. ),
continue; ),
} );
} 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) {
+402 -56
View File
@@ -19,6 +19,7 @@ import type {
VacationResponse, VacationResponse,
ChangesResponse, ChangesResponse,
Invocation, Invocation,
MethodError,
} from "@/jmap/types"; } from "@/jmap/types";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { settings, useSettings } from "../settings"; import { settings, useSettings } from "../settings";
@@ -26,8 +27,11 @@ import { useSession } from "../session";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage";
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
@@ -76,6 +80,7 @@ export const useMail = create<MailState>((set, get) => ({
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
mailboxesCached: false,
emails: {}, emails: {},
fullIds: {}, fullIds: {},
emailState: null, emailState: null,
@@ -98,11 +103,14 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) { setAccount(accountId) {
if (accountId === get().accountId) return; if (accountId === get().accountId) return;
resetBodyOrder();
snapshots.clear();
set({ set({
accountId, accountId,
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
mailboxesCached: false,
emails: {}, emails: {},
fullIds: {}, fullIds: {},
emailState: null, emailState: null,
@@ -116,6 +124,7 @@ export const useMail = create<MailState>((set, get) => ({
anchorId: null, anchorId: null,
lastSeenInboxEmailIds: null, lastSeenInboxEmailIds: null,
}); });
if (accountId) restoreSnapshot(accountId);
}, },
async loadMailboxes() { async loadMailboxes() {
@@ -124,7 +133,7 @@ export const useMail = create<MailState>((set, get) => ({
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS }); const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS });
const mailboxes: Record<Id, Mailbox> = {}; const mailboxes: Record<Id, Mailbox> = {};
for (const m of res.list) mailboxes[m.id] = m; for (const m of res.list) mailboxes[m.id] = m;
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true }); set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true, mailboxesCached: false });
// Label counts move for the same reasons folder counts do -- something was // Label counts move for the same reasons folder counts do -- something was
// read, moved or deleted -- so they are refreshed on the same beat rather // read, moved or deleted -- so they are refreshed on the same beat rather
// than on a timer of their own. Not awaited: the folder tree should not // than on a timer of their own. Not awaited: the folder tree should not
@@ -166,8 +175,10 @@ export const useMail = create<MailState>((set, get) => ({
void get().refreshList(); void get().refreshList();
return; return;
} }
if (cur && cur.key !== key) keepSnapshot(cur);
const shown = reuse ? { ids: cur.ids, total: cur.total } : snapshotFor(key, q.filter, get().emails);
set({ set({
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false }, list: { ...q, key, ids: shown.ids, total: shown.total, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
selected: {}, selected: {},
selectedAll: false, selectedAll: false,
anchorId: null, anchorId: null,
@@ -206,8 +217,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 +266,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 +286,29 @@ 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 thread = await fetchThread(accountId, threadId, get);
"e", if (!thread) {
], set((s) => {
]); const { [threadId]: _drop, ...rest } = s.loadingThreads;
const thread = (res.get("t")?.[0] as unknown as GetResponse<Thread>).list[0]; return { loadingThreads: rest };
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>; });
if (!thread) return []; 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 { threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
}); });
return get().threadEmails(threadId); return get().threadEmails(threadId);
} catch (err) { } catch (err) {
@@ -294,6 +320,21 @@ export const useMail = create<MailState>((set, get) => ({
} }
}, },
prefetchThread(threadId) {
const { accountId, threads, fullIds } = get();
if (!accountId || prefetched.has(threadId)) return;
const known = threads[threadId];
if (known && known.emailIds.every((id) => fullIds[id])) return;
const run = fetchThread(accountId, threadId, get)
.then((thread) => {
if (thread) set((s) => ({ threads: { ...s.threads, [threadId]: thread } }));
return thread;
})
.catch(() => null)
.finally(() => prefetched.delete(threadId));
prefetched.set(threadId, run);
},
threadEmails(threadId) { threadEmails(threadId) {
const { threads, emails } = get(); const { threads, emails } = get();
const t = threads[threadId]; const t = threads[threadId];
@@ -920,7 +961,11 @@ export const useMail = create<MailState>((set, get) => ({
async applyChanges(types) { async applyChanges(types) {
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return; if (!accountId) return;
if (types.has("Mailbox")) void get().loadMailboxes(); /*
* Folder counts move with mail, so one Mailbox/get serves both kinds of
* change. It goes out now, beside Email/changes, rather than again after.
*/
if (types.has("Mailbox") || types.has("Email")) void get().loadMailboxes();
if (types.has("Email")) { if (types.has("Email")) {
const state = get().emailState; const state = get().emailState;
if (state) { if (state) {
@@ -930,12 +975,25 @@ export const useMail = create<MailState>((set, get) => ({
const updated = new Set<Id>(); const updated = new Set<Id>();
const created = new Set<Id>(); const created = new Set<Id>();
const destroyed = new Set<Id>(); const destroyed = new Set<Id>();
// Page through Email/changes. const fetched: Email[] = [];
/*
* Page through Email/changes, each page in one request with the
* list-level properties of what it names. Asking for those after the
* ids came back cost a second round trip on every push.
*/
const maxChanges = Math.min(500, client.maxObjectsInGet);
while (guard++ < 10) { while (guard++ < 10) {
const ch = await client.call<ChangesResponse>("Email/changes", { accountId, sinceState: since, maxChanges: 500 }); const ref = (path: string) => ({ resultOf: "c", name: "Email/changes", path });
const res = await client.chain([
["Email/changes", { accountId, sinceState: since, maxChanges }, "c"],
["Email/get", { accountId, "#ids": ref("/updated"), properties: LIST_PROPS }, "u"],
["Email/get", { accountId, "#ids": ref("/created"), properties: LIST_PROPS }, "n"],
]);
const ch = res.get("c")![0] as unknown as ChangesResponse;
ch.created.forEach((id) => created.add(id)); ch.created.forEach((id) => created.add(id));
ch.updated.forEach((id) => updated.add(id)); ch.updated.forEach((id) => updated.add(id));
ch.destroyed.forEach((id) => destroyed.add(id)); ch.destroyed.forEach((id) => destroyed.add(id));
for (const key of ["u", "n"]) fetched.push(...((res.get(key)?.[0] as unknown as GetResponse<Email> | undefined)?.list ?? []));
since = ch.newState; since = ch.newState;
if (!ch.hasMoreChanges) break; if (!ch.hasMoreChanges) break;
} }
@@ -946,6 +1004,12 @@ export const useMail = create<MailState>((set, get) => ({
delete next[id]; delete next[id];
delete nextFull[id]; delete nextFull[id];
} }
// An update merges over what is held; a message not held stays out,
// except new mail, which the notice below and the list both want.
for (const e of fetched) {
if (destroyed.has(e.id)) continue;
if (next[e.id] || created.has(e.id)) next[e.id] = mergeEmail(next[e.id], e);
}
/* /*
* The full copy of an updated email is deliberately kept. * The full copy of an updated email is deliberately kept.
* *
@@ -969,18 +1033,6 @@ export const useMail = create<MailState>((set, get) => ({
*/ */
return { emails: next, fullIds: nextFull, emailState: since }; return { emails: next, fullIds: nextFull, emailState: since };
}); });
// Refresh the list-level props of updated/cached emails.
const cached = [...updated].filter((id) => get().emails[id]);
if (cached.length) {
const results = await Promise.all(
chunk(cached, client.maxObjectsInGet).map((part) => client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: LIST_PROPS })),
);
set((s) => {
const next = { ...s.emails };
for (const r of results) for (const e of r.list) next[e.id] = { ...next[e.id], ...e };
return { emails: next };
});
}
if (created.size) await notifyNewMail([...created], get); if (created.size) await notifyNewMail([...created], get);
} catch (err) { } catch (err) {
if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") { if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") {
@@ -989,7 +1041,6 @@ export const useMail = create<MailState>((set, get) => ({
} }
} }
void get().refreshList(); void get().refreshList();
void get().loadMailboxes();
} }
if (types.has("Thread") || types.has("Email")) { if (types.has("Thread") || types.has("Email")) {
const open = get().openThreadId; const open = get().openThreadId;
@@ -1025,6 +1076,258 @@ 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;
}
/*
* Opening a conversation in one round trip.
*
* It used to take two: Thread/get, then the bodies once the ids came back --
* half a second on a 250 ms link before anything showed. When the list has
* already fetched the thread (it has, in conversation view), the missing
* bodies are asked for in the same tick as the Thread/get, and the client
* sends both in one request. When it has not, the two are chained with a
* back-reference, which is also one request. Either way a member the list did
* not know about is fetched afterwards, which is rare.
*
* Loads of the same thread share one request: a conversation fetched ahead of
* the click (`prefetchThread`) is the one the click then waits for, and what
* it brought back is not asked for again.
*/
const prefetched = new Map<Id, Promise<Thread | null>>();
async function fetchThread(accountId: Id, threadId: Id, get: () => MailState): Promise<Thread | null> {
const ahead = prefetched.get(threadId);
if (ahead) {
const thread = await ahead;
if (thread && thread.emailIds.every((id) => get().fullIds[id])) return thread;
}
const { threads, fullIds } = get();
const known = threads[threadId];
let thread: Thread | undefined;
if (known) {
const missing = known.emailIds.filter((id) => !fullIds[id]);
const [res] = await Promise.all([
client.call<GetResponse<Thread>>("Thread/get", { accountId, ids: [threadId] }),
missing.length ? get().getEmails(missing, true) : Promise.resolve([]),
]);
thread = res.list[0];
} else {
const res = await client.chain([
["Thread/get", { accountId, ids: [threadId] }, "t"],
[
"Email/get",
{
accountId,
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
properties: FULL_PROPS,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: 2 * 1024 * 1024,
bodyProperties: BODY_PROPS,
},
"e",
],
], { allowErrors: true });
const threadRes = res.get("t")?.[0];
if (threadRes && "__error" in threadRes) throw new JmapMethodError("Thread/get", threadRes.__error as MethodError);
thread = (threadRes as unknown as GetResponse<Thread> | undefined)?.list[0];
// A thread longer than one Email/get may carry is refused whole; the members are fetched in parts below.
const got = (res.get("e")?.[0] as unknown as Partial<GetResponse<Email>> | undefined)?.list ?? [];
if (got.length) {
useMail.setState((s) => {
const emails = { ...s.emails };
const full = { ...s.fullIds };
for (const e of got) {
emails[e.id] = mergeEmail(emails[e.id], e);
full[e.id] = true;
}
return { emails, fullIds: full };
});
touchBodies(got.map((e) => e.id));
useMail.setState((s) => releaseBodies(s));
}
}
if (!thread) return null;
const late = thread.emailIds.filter((id) => !get().fullIds[id]);
if (late.length) await get().getEmails(late, true);
return thread;
}
/*
* The last few folders' lists, shown again while their query is on its way.
* Going back to a folder read a moment ago otherwise blanks the list for a
* round trip. Only plain folder views are kept: a message that has since left
* the folder is dropped here, and anything else that changed is corrected by
* the query a round trip later.
*/
const SNAPSHOTS_KEPT = 12;
const SNAPSHOT_IDS = 200;
const snapshots = new Map<string, { ids: Id[]; total: number }>();
function folderOf(filter: EmailFilter): Id | null {
const keys = Object.keys(filter);
return keys.length === 1 && "inMailbox" in filter && typeof filter.inMailbox === "string" ? filter.inMailbox : null;
}
function keepSnapshot(list: NonNullable<MailState["list"]>): void {
if (list.loading || list.error || !folderOf(list.filter)) return;
snapshots.delete(list.key);
snapshots.set(list.key, { ids: list.ids.slice(0, SNAPSHOT_IDS), total: list.total });
while (snapshots.size > SNAPSHOTS_KEPT) snapshots.delete(snapshots.keys().next().value!);
}
function snapshotFor(key: string, filter: EmailFilter, emails: Record<Id, Email>): { ids: Id[]; total: number } {
const snap = snapshots.get(key);
const folder = folderOf(filter);
if (!snap || !folder) return { ids: [], total: 0 };
const ids = snap.ids.filter((id) => emails[id]?.mailboxIds[folder]);
return { ids, total: snap.total - (snap.ids.length - ids.length) };
}
/*
* What a device marked as the reader's own keeps between visits.
*
* Opening the app used to wait on the folder list before it could ask for a
* folder, and on that before anything showed: on a distant link, a second or
* so of skeleton on every start. A trusted device now keeps the folder list
* and the first page of the last few folders, list properties only -- no
* bodies -- and starts from them: the folders and the inbox paint as soon as
* the server has confirmed the session, and the query for the open folder goes
* out then without waiting for the folder list, which corrects both a round
* trip later.
*
* Never sooner. This is applied from `setAccount`, which runs only once the
* session is confirmed, so a session that has ended shows the spinner and then
* the sign-in form, and none of this in between.
*
* It is written through the same gated storage as the settings cache: nothing
* is kept on a device not marked as the reader's own, nothing is read there
* either, and signing out clears it with everything else.
*/
const SNAPSHOT_KEY = "mail-snapshot";
const SNAPSHOT_LISTS = 4;
const SNAPSHOT_ROWS = 50;
const SNAPSHOT_MAX_CHARS = 400_000;
export interface MailSnapshot {
v: 1;
accountId: Id;
mailboxes: Mailbox[];
lists: { key: string; ids: Id[]; total: number }[];
emails: Email[];
}
export function buildSnapshot(s: MailState): MailSnapshot | null {
if (!s.accountId || !s.mailboxesLoaded) return null;
const lists: MailSnapshot["lists"] = [];
const cur = s.list;
if (cur && !cur.loading && !cur.error && folderOf(cur.filter)) lists.push({ key: cur.key, ids: cur.ids, total: cur.total });
for (const [key, snap] of [...snapshots].reverse()) {
if (lists.length >= SNAPSHOT_LISTS) break;
if (!lists.some((l) => l.key === key)) lists.push({ key, ...snap });
}
const ids = new Set<Id>();
const kept = lists.map((l) => {
const rows = l.ids.filter((id) => s.emails[id]).slice(0, SNAPSHOT_ROWS);
rows.forEach((id) => ids.add(id));
return { key: l.key, ids: rows, total: l.total };
});
const emails = [...ids].map((id) => Object.fromEntries(Object.entries(s.emails[id]!).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email);
return { v: 1, accountId: s.accountId, mailboxes: Object.values(s.mailboxes), lists: kept, emails };
}
let snapshotTimer: ReturnType<typeof setTimeout> | null = null;
function saveSnapshot(): void {
if (snapshotTimer) clearTimeout(snapshotTimer);
snapshotTimer = null;
if (!isDeviceTrusted() || useSession.getState().status !== "authenticated") return;
const s = useMail.getState();
if (s.accountId !== useSession.getState().accountId) return;
const snap = buildSnapshot(s);
if (!snap) return;
if (JSON.stringify(snap).length > SNAPSHOT_MAX_CHARS) return;
saveJson(SNAPSHOT_KEY, snap);
}
function restoreSnapshot(accountId: Id): void {
const snap = loadRaw<MailSnapshot | null>(SNAPSHOT_KEY, null);
if (!snap || snap.v !== 1 || snap.accountId !== accountId || !Array.isArray(snap.mailboxes) || !snap.mailboxes.length) return;
const mailboxes: Record<Id, Mailbox> = {};
for (const m of snap.mailboxes) mailboxes[m.id] = m;
const emails: Record<Id, Email> = {};
for (const e of snap.emails ?? []) emails[e.id] = e;
for (const l of snap.lists ?? []) snapshots.set(l.key, { ids: l.ids, total: l.total });
useMail.setState({ mailboxes, mailboxesCached: true, emails });
}
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 +1360,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 +1476,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
@@ -1264,3 +1594,19 @@ async function followFolders(before: FolderRef[]): Promise<void> {
toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message })); toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message }));
} }
} }
/*
* Kept a few seconds after the folders or the list last changed, and when the
* page is being put away, which is the last chance a closing tab gets.
*/
useMail.subscribe((s, prev) => {
if (s.mailboxes === prev.mailboxes && s.list === prev.list) return;
if (!isDeviceTrusted()) return;
if (snapshotTimer) clearTimeout(snapshotTimer);
snapshotTimer = setTimeout(saveSnapshot, 3000);
});
if (typeof window !== "undefined") {
window.addEventListener("pagehide", () => {
if (snapshotTimer) saveSnapshot();
});
}
+4
View File
@@ -36,6 +36,8 @@ export interface MailState {
mailboxes: Record<Id, Mailbox>; mailboxes: Record<Id, Mailbox>;
mailboxState: string | null; mailboxState: string | null;
mailboxesLoaded: boolean; mailboxesLoaded: boolean;
/** The folder list shown is the copy this device kept, not yet confirmed by the server. */
mailboxesCached: boolean;
emails: Record<Id, Email>; emails: Record<Id, Email>;
fullIds: Record<Id, true>; fullIds: Record<Id, true>;
emailState: string | null; emailState: string | null;
@@ -71,6 +73,8 @@ export interface MailState {
getEmails(ids: Id[], full?: boolean): Promise<Email[]>; getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
loadThread(threadId: Id): Promise<Email[]>; loadThread(threadId: Id): Promise<Email[]>;
/** Start loading a conversation that is likely to be opened next; quiet, and shared with a later loadThread. */
prefetchThread(threadId: Id): void;
threadEmails(threadId: Id): Email[]; threadEmails(threadId: Id): Email[];
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[]; threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
+25 -9
View File
@@ -32,6 +32,8 @@ interface SessionState {
ownAccountFor(cap: string): Id | null; ownAccountFor(cap: string): Id | null;
} }
let refreshing: Promise<void> | null = null;
export const useSession = create<SessionState>((set, get) => ({ export const useSession = create<SessionState>((set, get) => ({
status: "loading", status: "loading",
session: null, session: null,
@@ -78,6 +80,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 {
@@ -92,15 +102,21 @@ export const useSession = create<SessionState>((set, get) => ({
set({ status: "anonymous", session: null, accountId: null }); set({ status: "anonymous", session: null, accountId: null });
}, },
async refresh() { refresh() {
try { // Callers arriving while a refresh is on its way share it.
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1"); refreshing ??= (async () => {
client.session = s; try {
setServerLocale(s.ihasmail?.userLocale); const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
set({ session: s }); client.session = s;
} catch { setServerLocale(s.ihasmail?.userLocale);
/* ignore */ set({ session: s });
} } catch {
/* ignore */
} finally {
refreshing = null;
}
})();
return refreshing;
}, },
setAccount(id) { setAccount(id) {
+4
View File
@@ -2418,3 +2418,7 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; } .ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; }
.ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; } .ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; }
.ev-block:hover .ev-resize::after { opacity: .5; } .ev-block:hover .ev-resize::after { opacity: .5; }
/* What arrived from the operating system's share sheet, shown before it is opened. */
.share-summary { margin: 0 0 8px; padding-left: 1ex; border-left: 2px solid var(--border-strong); color: var(--fg-muted); overflow-wrap: anywhere; }
.share-summary-files { margin: 0 0 8px; padding-left: 1.2em; color: var(--fg-muted); overflow-wrap: anywhere; }
+2 -1
View File
@@ -185,7 +185,8 @@ export function ConfirmHost() {
) )
} }
> >
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>} {/* A paragraph for text; a block for anything with blocks of its own in it. */}
{req.message && (typeof req.message === "string" ? <p style={{ marginTop: 0 }}>{req.message}</p> : <div style={{ marginTop: 0 }}>{req.message}</div>)}
{req.kind === "choice" && ( {req.kind === "choice" && (
<div className="dialog-choices"> <div className="dialog-choices">
{req.choices?.map((c) => ( {req.choices?.map((c) => (
+3 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react"; import { 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
View File
@@ -8,10 +8,13 @@ import { t } from "@/lib/i18n";
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) { 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">
+15 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { 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,19 +13,22 @@ 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";
import { collectShare } from "@/lib/shareTarget"; import { collectShare } from "@/lib/shareTarget";
import { offerShare } from "./ShareOffer";
import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/admin/adminAccess"; 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
@@ -117,11 +120,12 @@ export function AppShell({ children }: { children: ReactNode }) {
* `addFiles` uploads as it goes, and there is nothing to upload to until the * `addFiles` uploads as it goes, and there is nothing to upload to until the
* session is in place. AppShell only exists once there is one. * session is in place. AppShell only exists once there is one.
*/ */
// Asked about first, not opened straight away: see `offerShare`.
useEffect(() => { useEffect(() => {
void collectShare().then((share) => { void collectShare().then(async (share) => {
if (!share) return; if (!share) return;
openShare(share);
if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true });
await offerShare(share, openShare);
}); });
}, [openShare, navigate]); }, [openShare, navigate]);
@@ -252,9 +256,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 />}
{section === "calendar" && <CalendarSidebar />} <Suspense fallback={null}>
{section === "contacts" && <ContactsSidebar />} {section === "calendar" && <CalendarSidebar />}
{section === "files" && <FilesTree />} {section === "contacts" && <ContactsSidebar />}
{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>
+45
View File
@@ -0,0 +1,45 @@
import { shareSummary, type SharedContent } from "@/lib/shareTarget";
import { confirmDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
/**
* Ask before a share becomes a message.
*
* The share address takes a plain form POST, so any website can send one, and
* the page cannot tell that from a share the reader made. Nothing would be
* sent without them pressing Send, but a composer that appears full of
* somebody else's text and files is still something to be asked about first.
*/
export async function offerShare(share: SharedContent, open: (share: SharedContent) => unknown): Promise<boolean> {
const yes = await confirmDialog({
title: t("Start a new message with what was shared?"),
message: <ShareSummary share={share} />,
confirmLabel: t("Start a message"),
cancelLabel: t("Discard"),
});
if (yes) open(share);
return yes;
}
/** What arrived, so the reader can tell whether it is theirs. */
function ShareSummary({ share }: { share: SharedContent }) {
const { title, preview, files } = shareSummary(share);
return (
<div>
{(title || preview) && (
<blockquote className="share-summary notranslate" translate="no">
{title && <strong>{title}</strong>}
{title && preview && <br />}
{preview}
</blockquote>
)}
{files.length > 0 && (
<ul className="share-summary-files notranslate" translate="no">
{files.slice(0, 5).map((name, i) => <li key={`${name}-${i}`}>{name}</li>)}
{files.length > 5 && <li></li>}
</ul>
)}
<p>{t("Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.")}</p>
</div>
);
}
@@ -0,0 +1,69 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmHost } from "@/ui/dialog";
import { offerShare } from "../ShareOffer";
import type { SharedContent } from "@/lib/shareTarget";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* A share becomes a message only when the reader says so. The share address
* takes a plain form POST, which any website can make.
*/
const share: SharedContent = {
title: "Quarterly figures",
text: "Have a look at these before Friday",
url: "https://example.com/q3",
files: [new File(["x"], "q3.xlsx"), new File(["y"], "notes.txt")],
};
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<ConfirmHost />));
});
afterEach(() => {
act(() => root.unmount());
host.remove();
document.body.innerHTML = "";
});
const button = (label: string) => [...document.querySelectorAll("button")].find((b) => b.textContent?.trim() === label);
describe("offering a share", () => {
it("shows what arrived before anything is opened", async () => {
const open = vi.fn();
let pending!: Promise<boolean>;
await act(async () => {
pending = offerShare(share, open);
});
const text = document.body.textContent ?? "";
expect(text).toContain("Start a new message with what was shared?");
expect(text).toContain("Quarterly figures");
expect(text).toContain("Have a look at these before Friday https://example.com/q3");
expect(text).toContain("q3.xlsx");
expect(text).toContain("notes.txt");
expect(open).not.toHaveBeenCalled();
await act(async () => button("Start a message")!.click());
expect(await pending).toBe(true);
expect(open).toHaveBeenCalledWith(share);
});
it("opens nothing when discarded", async () => {
const open = vi.fn();
let pending!: Promise<boolean>;
await act(async () => {
pending = offerShare(share, open);
});
await act(async () => button("Discard")!.click());
expect(await pending).toBe(false);
expect(open).not.toHaveBeenCalled();
});
});
+5 -9
View File
@@ -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 }));
+19 -4
View File
@@ -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" : ""}`}>
{visible.map((d) => ( <Suspense fallback={null}>
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} /> {visible.map((d) => (
))} <Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))}
</Suspense>
</div> </div>
); );
} }
+5
View File
@@ -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);
}); });
}); });
+4 -11
View File
@@ -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"));
+7 -7
View File
@@ -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">
+1
View File
@@ -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();
} }
+6 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, type MouseEvent, type ReactNode } from "react"; import { lazy, Suspense, useCallback, useState, type MouseEvent, type ReactNode } from "react";
import { Copy, Mail, Pencil, UserPlus } from "lucide-react"; import { 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>
)} )}
</> </>
); );
+41 -3
View File
@@ -27,6 +27,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const searchStr = useSearch(); const searchStr = useSearch();
const mailboxes = useMail((s) => s.mailboxes); const mailboxes = useMail((s) => s.mailboxes);
const mailboxesLoaded = useMail((s) => s.mailboxesLoaded); const mailboxesLoaded = useMail((s) => s.mailboxesLoaded);
// Enough to ask for a folder: the kept copy of the folder list will do, and
// staying true as the server's copy replaces it keeps the query from repeating.
const mailboxesKnown = useMail((s) => s.mailboxesLoaded || s.mailboxesCached);
const inboxId = useMail((s) => s.roleId("inbox")); const inboxId = useMail((s) => s.roleId("inbox"));
const query = useMail((s) => s.query); const query = useMail((s) => s.query);
const list = useMail((s) => s.list); const list = useMail((s) => s.list);
@@ -79,8 +82,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,11 +114,11 @@ 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 && mailboxesKnown) void query(listQuery);
}, [listQuery, query, mailboxesLoaded]); }, [listQuery, query, mailboxesKnown]);
// Nothing moves a message out of Scheduled when its hold expires, so settle // Nothing moves a message out of Scheduled when its hold expires, so settle
// the folder up on the way in: sent messages to Sent, canceled ones back to // the folder up on the way in: sent messages to Sent, canceled ones back to
@@ -166,6 +186,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
return -1; return -1;
}, [ids, focusId, openMessageId, threadId, rowThreadId]); }, [ids, focusId, openMessageId, threadId, rowThreadId]);
/*
* Reading down a folder usually means the next row is next. Once the open
* conversation has had its turn, the one below starts loading while the
* reader reads, so moving on waits on nothing.
*/
const nextRowId = threadId && currentRowIndex >= 0 ? ids[currentRowIndex + 1] : undefined;
const nextThreadId = nextRowId ? rowThreadId(nextRowId) : undefined;
useEffect(() => {
if (!nextThreadId || nextThreadId === threadId) return;
const start = () => useMail.getState().prefetchThread(nextThreadId);
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(start, { timeout: 2000 });
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(start, 500);
return () => window.clearTimeout(handle);
}, [nextThreadId, threadId]);
/** Email ids affected by an action on rows (selection or focused/open row). */ /** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback( const targetIds = useCallback(
async (rowIds?: Id[]): Promise<Id[]> => { async (rowIds?: Id[]): Promise<Id[]> => {
+6 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { 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} />,
@@ -36,7 +38,7 @@ const FOLDER_MIME = "application/x-ihasmail-folder";
export function MailboxTree() { export function MailboxTree() {
const mailboxes = useMail((s) => s.mailboxes); const mailboxes = useMail((s) => s.mailboxes);
const loaded = useMail((s) => s.mailboxesLoaded); const loaded = useMail((s) => s.mailboxesLoaded || s.mailboxesCached);
const [location] = useLocation(); const [location] = useLocation();
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined; const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
const showHidden = useSettings((s) => s.settings.showHiddenFolders); const showHidden = useSettings((s) => s.settings.showHiddenFolders);
@@ -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>}
</> </>
); );
} }
+116 -42
View File
@@ -1,5 +1,6 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type PointerEvent, 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 inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e]; const scope = useMemo(() => {
const scope = inScope.length ? inScope : [e]; const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
return inScope.length ? inScope : [e];
}, [threadEmails, mailboxId, e]);
const unread = scope.some((x) => !x.keywords.$seen); const 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);
@@ -670,7 +718,30 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
}, },
}); });
/*
* A conversation starts loading when the pointer settles on its row, or the
* moment a finger or button goes down, so that on a slow link the click
* finds it on its way. Drafts open in the composer instead.
*/
const hover = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(hover.current), []);
const prefetch = () => {
if (!isDrafts) useMail.getState().prefetchThread(e.threadId);
};
const onPointerEnter = (ev: PointerEvent) => {
if (ev.pointerType !== "mouse") return;
window.clearTimeout(hover.current);
hover.current = window.setTimeout(prefetch, 80);
};
const onPointerLeave = () => window.clearTimeout(hover.current);
const onPointerDown = (ev: PointerEvent<HTMLDivElement>) => {
prefetch();
gesture.onPointerDown?.(ev);
};
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>();
@@ -704,6 +775,9 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
draggable={!touch} draggable={!touch}
onDragStart={onDragStart} onDragStart={onDragStart}
{...gesture} {...gesture}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
role="row" role="row"
aria-selected={selected} aria-selected={selected}
> >
@@ -762,4 +836,4 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
)} )}
</div> </div>
); );
}); }
+26 -13
View File
@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react"; import { 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>
)}
</> </>
); );
} }
+2 -1
View File
@@ -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 = ""; }} />
+9 -2
View File
@@ -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"
+37 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vitest/config"; import { defineConfig, type Plugin } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs"; import { resolveVersion } from "../scripts/version.mjs";
@@ -22,9 +22,44 @@ const version = resolveVersion();
*/ */
const base = baseUrlOf(process.env.BASE_PATH); const base = baseUrlOf(process.env.BASE_PATH);
/*
* Every file the build made, written into the app page for the service worker.
*
* The page names only what it loads at start; the composer, settings, viewers
* and the rest arrive when first used, and after each deploy that first use
* went back to the server. With the whole list in the page, the worker can
* fetch them in the background once a new version is seen, and knows to keep
* them. Language catalogs are listed apart: a reader wants one of them, which
* is cached when it is first loaded.
*
* An inert JSON block rather than prefetch links, which the browser would
* fetch on every load.
*/
function assetList(): Plugin {
return {
name: "ihasmail-asset-list",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const precache: string[] = [];
const onDemand: string[] = [];
for (const file of Object.values(ctx.bundle)) {
if (!file.fileName.startsWith("assets/") || file.fileName.endsWith(".map")) continue;
const catalog = file.type === "chunk" && file.moduleIds.length > 0 && file.moduleIds.every((id) => /[\\/]src[\\/]locales[\\/][^\\/]+\.ts$/.test(id));
(catalog ? onDemand : precache).push(`${base}${file.fileName}`);
}
const json = JSON.stringify({ precache: precache.sort(), onDemand: onDemand.sort() });
return html.replace("</body>", ` <script type="application/json" id="ihasmail-assets">${json}</script>\n </body>`);
},
},
};
}
export default defineConfig({ export default defineConfig({
base, base,
plugins: [react()], plugins: [react(), assetList()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: { resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },