Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
806e63d071 | ||
|
|
06943fd473 | ||
|
|
362bd8b282 | ||
|
|
91481965bc | ||
|
|
5ced44ec13 | ||
|
|
dd8998f178 | ||
|
|
6ec2304fc2 | ||
|
|
d6f9ef1243 | ||
|
|
b0cb73a924 | ||
|
|
373822c2ae | ||
|
|
12157f47bf | ||
|
|
e41742a26c | ||
|
|
1e01b34384 | ||
|
|
95f640b24c | ||
|
|
826a13ab39 | ||
|
|
2f55b1e3e1 | ||
|
|
d75e9bc769 | ||
|
|
08fd08e6fe | ||
|
|
ecc030aa3f | ||
|
|
8844fc9836 | ||
|
|
ef7823d6de | ||
|
|
8d475e2b07 | ||
|
|
0277b5b6a8 | ||
|
|
22c8eb4af6 | ||
|
|
bd3c4bf964 | ||
|
|
7eca17665a | ||
|
|
b6327ffb98 | ||
|
|
d8fc47d765 | ||
|
|
ddd1bbf9b3 | ||
|
|
0b01956535 | ||
|
|
045dda109b | ||
|
|
1c678fabed | ||
|
|
6bbe2448c4 | ||
|
|
490b15e8c6 | ||
|
|
f2e0cb6326 | ||
|
|
8f9d253939 | ||
|
|
fedd6ed161 | ||
|
|
a3dc7e017c | ||
|
|
e327df818a | ||
|
|
d6aa4d543a | ||
|
|
4cd7b895e9 | ||
|
|
37bf96409d | ||
|
|
f72c67864e | ||
|
|
312a833d78 | ||
|
|
0fe75b280b | ||
|
|
05be820be4 | ||
|
|
5a7cc5cc5a | ||
|
|
b4082d5bb2 | ||
|
|
6efac64b37 | ||
|
|
8cc12b8f56 | ||
|
|
a2337f6ad8 | ||
|
|
e4b6413f46 | ||
|
|
3c417f070c | ||
|
|
e3de0bd500 | ||
|
|
06b89111df | ||
|
|
4c4821b5db | ||
|
|
e14fc36785 | ||
|
|
506865ca67 | ||
|
|
f83157464c | ||
|
|
9544fa5f12 | ||
|
|
298264aeb8 | ||
|
|
3a2f60189f | ||
|
|
31239ed9be | ||
|
|
6a98dd22fd | ||
|
|
25b51069a9 | ||
|
|
cd402a6ce4 | ||
|
|
453a62115b | ||
|
|
c0fc0083ff | ||
|
|
8a3e0b9954 | ||
|
|
5e5bec31b7 | ||
|
|
04ec57058a | ||
|
|
3416a41de9 | ||
|
|
d9995cd0b4 | ||
|
|
5f32d3d82c | ||
|
|
0215255280 | ||
|
|
8d55652587 | ||
|
|
270fb3d32c | ||
|
|
25fd6404f2 | ||
|
|
350f4f4197 | ||
|
|
006190d523 | ||
|
|
1e2db95577 | ||
|
|
88f9474b24 | ||
|
|
52299ce8ef | ||
|
|
ad94efb65b | ||
|
|
9f4c0c3351 | ||
|
|
e014521fb6 | ||
|
|
cf9474ce35 | ||
|
|
2360e40733 | ||
|
|
c9531c577c | ||
|
|
fb789bc36f | ||
|
|
f70eb184c2 | ||
|
|
6566f4c2d3 | ||
|
|
24ee502532 | ||
|
|
650ba0020b | ||
|
|
32227722a7 | ||
|
|
d64249b46d |
@@ -28,8 +28,20 @@ SESSION_TTL=43200
|
||||
SESSION_REMEMBER_TTL=2592000
|
||||
|
||||
# Where to persist sessions so restarts don't log everyone out (optional).
|
||||
# Leave it empty to hold sessions in memory only, which is what an immutable
|
||||
# instance does -- see IMMUTABLE below.
|
||||
SESSION_FILE=./data/sessions.json
|
||||
|
||||
# Assert that this instance is running as an immutable container: read-only
|
||||
# root filesystem, no durable state of its own. It is checked rather than
|
||||
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
|
||||
# the filesystem it is installed on turns out to be writable. Off by default.
|
||||
# Running one looks like:
|
||||
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
# The cost today is that a restart signs everyone out, since there is nowhere
|
||||
# left to keep the sessions. Removing that cost is what the OAuth work is for.
|
||||
# IMMUTABLE=1
|
||||
|
||||
# Upstream timeouts / limits
|
||||
UPSTREAM_TIMEOUT=30000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
@@ -44,4 +56,4 @@ APP_NAME=ihasmail
|
||||
# asks whoever runs a modified version to offer *that* version's source -- so if
|
||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||
# and in Settings > About.
|
||||
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
|
||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
**johnellisATlinuxDOTcom**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
|
||||
@@ -37,7 +37,15 @@ COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
RUN mkdir -p /data && chown -R node:node /data /app
|
||||
USER node
|
||||
VOLUME ["/data"]
|
||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||
# anonymous volume mounted there anyway, and that mount stays writable even
|
||||
# under `--read-only`. So the directive quietly put a writable hole in a
|
||||
# container meant to be immutable, and left an orphaned volume behind every
|
||||
# time one was replaced -- while never persisting anything across a redeploy,
|
||||
# since each new container got a fresh empty volume of its own. Deployments
|
||||
# that want the sessions to survive say so themselves: docker-compose.yml and
|
||||
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
|
||||
@@ -4,11 +4,17 @@ What was checked, against which server, and when. For a failure you are hitting
|
||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
|
||||
left pending**: every entry below has been exercised against it. What remains
|
||||
here is not a list of unknowns but of things worth knowing — where Stalwart
|
||||
departs from a spec, where a setting has to be turned on for a feature to work,
|
||||
and what ihasmail deliberately does not do.
|
||||
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
|
||||
eight seconds of downtime, and as of **2026-08-26 there is nothing left
|
||||
pending**. Every entry below was exercised against 0.16.19 on the date it
|
||||
names, and the dates still say so: the upgrade was read against the
|
||||
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
|
||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
||||
The calendar entries below carrying a 2026-08-31 date are the exception: those
|
||||
were exercised against the live 0.16.20 directly.
|
||||
What remains here is not a list of unknowns but of things worth knowing — where
|
||||
Stalwart departs from a spec, where a setting has to be turned on for a feature
|
||||
to work, and what ihasmail deliberately does not do.
|
||||
|
||||
Entries keep saying what was checked and when, because this section has been
|
||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
||||
@@ -19,17 +25,29 @@ upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
||||
works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
|
||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
|
||||
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
|
||||
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
|
||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
||||
|
||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
||||
|
||||
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
|
||||
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
|
||||
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
|
||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
|
||||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art) — a container
|
||||
with nothing to persist, and a Gmail-class client on top of it.**
|
||||
|
||||
Mail, calendars, contacts, files and filters in a responsive single-page app
|
||||
that works equally well on a desktop monitor and a phone. It talks only JMAP
|
||||
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
|
||||
database.
|
||||
database, and with `IMMUTABLE=1` no writable filesystem either. Everything
|
||||
durable belongs to Stalwart; the container is disposable.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
@@ -47,6 +49,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
||||
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
@@ -61,8 +64,8 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
||||
reached production. With one supported generation a wrong guess is a loud error
|
||||
on the first call.
|
||||
|
||||
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
- Still on 0.15? 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).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
@@ -83,6 +86,29 @@ Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### Running immutably
|
||||
|
||||
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
|
||||
and there is nothing left to write, so the container can run with no writable
|
||||
filesystem at all:
|
||||
|
||||
```bash
|
||||
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||
or if the filesystem it is installed on turns out to be writable after all.
|
||||
Without it the same misconfiguration is silent — sessions are held in memory
|
||||
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||
the first sign-in and nothing else until the instance is replaced and everyone
|
||||
is signed out.
|
||||
|
||||
That sign-out is the standing cost of this mode today, since sessions have
|
||||
nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -135,21 +161,39 @@ the sign-in refusal can be tested.
|
||||
|
||||
### Version numbers
|
||||
|
||||
`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
||||
this build targets, `84` the pull request the commit came from. The first two
|
||||
live in the root `package.json`; the third comes from git at build time, since
|
||||
it does not exist until the PR has merged. A commit that did not arrive through
|
||||
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
||||
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
|
||||
the pull request that commit arrived through. A commit that did not arrive
|
||||
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
|
||||
from git at build time; nothing writes a version into the tree, and
|
||||
`package.json` sits at `0.0.0` because it is no longer the source of anything.
|
||||
|
||||
The date is the commit's own rather than today's, so rebuilding an old commit
|
||||
gives the version it had the first time.
|
||||
|
||||
```bash
|
||||
node scripts/version.mjs # the version for the current checkout
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
|
||||
```
|
||||
|
||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||
out for itself — pass it in. Left out, the build falls back to the base version
|
||||
from `package.json`, so a version with no PR number means whoever built the
|
||||
image did not pass one.
|
||||
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
|
||||
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
|
||||
not pass one.
|
||||
|
||||
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
|
||||
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
|
||||
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
|
||||
image and About screen would read as a downgrade. Which Stalwart a build needs is
|
||||
stated where it can be precise, in the badge at the top of this file and in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
|
||||
|
||||
The pull request lives after the `+`, as build metadata, because it is
|
||||
provenance rather than a rank: at the rate they merge here it climbs without
|
||||
bound and says nothing about how new a build is. Everything after the `+` is
|
||||
ignored when versions are compared, which is the right reading — two builds from
|
||||
the same day differ in where they came from, not in age. Nothing here depends on
|
||||
that comparison: images are pruned oldest-first by creation time, and a rollback
|
||||
names a git ref.
|
||||
|
||||
### Deploying
|
||||
|
||||
@@ -162,7 +206,7 @@ container, waits for healthy, then prunes all but the newest
|
||||
```bash
|
||||
./deploy.sh # origin/main, asks before shipping new commits
|
||||
./deploy.sh --dry-run # run the guards and stop
|
||||
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
```
|
||||
|
||||
`--yes` does not override a hold; clearing one means deleting its line.
|
||||
@@ -174,7 +218,7 @@ container, waits for healthy, then prunes all but the newest
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
|
||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
|
||||
[LICENSE](LICENSE).
|
||||
|
||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Roadmap / not yet
|
||||
|
||||
Things ihasmail does not do, and why. Anything with an issue number is tracked
|
||||
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
|
||||
rest is here because the answer is "no", not "not yet".
|
||||
Things ihasmail does not do, and why. An issue number here says where the entry
|
||||
came from, not that it is tracked elsewhere — a report can be closed because the
|
||||
bug in it was fixed while the larger thing it asked for stays on this page. What
|
||||
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
|
||||
the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
||||
- Translations (strings are English-only for now)
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
|
||||
@@ -20,8 +20,12 @@
|
||||
# whatever main happens to have picked up since the last
|
||||
# release.
|
||||
#
|
||||
# --dry-run runs both guards, says what it would deploy, and stops before
|
||||
# building or touching the container.
|
||||
# --dry-run checks the hold list, says what it would deploy, and stops
|
||||
# before building or touching the container. It does not ask
|
||||
# for confirmation: there is nothing to agree to when nothing
|
||||
# changes, and needing a terminal would make it useless over
|
||||
# SSH -- which is where wanting to look before leaping is most
|
||||
# likely.
|
||||
#
|
||||
# The container is replaced rather than restarted, because the image is rebuilt
|
||||
# from the new checkout. Data lives in a named volume and survives that; the
|
||||
@@ -41,8 +45,23 @@ HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
|
||||
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
|
||||
NAME="${IHASMAIL_NAME:-ihasmail}"
|
||||
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
|
||||
# Named volume for /data (sessions).
|
||||
# Named volume for /data (sessions). Unused when running immutably.
|
||||
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
||||
# Run the container immutably: read-only root filesystem, no volume, sessions
|
||||
# held in memory only. See "Running immutably" in the README. The server is told
|
||||
# the same thing through IMMUTABLE=1 and checks it, so a half-applied switch --
|
||||
# the flag without the read-only filesystem, or a SESSION_FILE still pointing
|
||||
# somewhere -- refuses to start here instead of looking fine until the next
|
||||
# redeploy signs everyone out.
|
||||
#
|
||||
# The standing cost is that sessions do not outlive a deploy, because there is
|
||||
# nowhere left to keep them. Going back is this variable and nothing else:
|
||||
#
|
||||
# IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes
|
||||
#
|
||||
# The named volume is never touched either way, so whatever was in it when the
|
||||
# switch was thrown is still there to come back to.
|
||||
IMMUTABLE="${IHASMAIL_IMMUTABLE:-0}"
|
||||
# Image repository. Each build is tagged with its version as well, so an
|
||||
# earlier one can be run again without rebuilding it.
|
||||
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
|
||||
@@ -82,7 +101,7 @@ for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
-n|--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
|
||||
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*)
|
||||
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
|
||||
@@ -130,7 +149,22 @@ if [ -n "$NEW" ]; then
|
||||
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
|
||||
echo "==> introduces:"
|
||||
printf '%s\n' "$NEW" | sed 's/^/ /'
|
||||
if [ "$ASSUME_YES" -ne 1 ]; then
|
||||
else
|
||||
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||
fi
|
||||
|
||||
# A dry run has now said everything it has to say, so it stops here -- before
|
||||
# the confirmation rather than after it. Asking whether to go ahead with
|
||||
# something that is not going to happen is noise at a terminal; over SSH it was
|
||||
# worse, because the refusal came out *instead of* the report above and a dry
|
||||
# run could not be used from another machine at all. Which is the machine you
|
||||
# are most likely to be on when you want one.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
read -r -p "deploy these to production? [y/N] " reply
|
||||
case "$reply" in
|
||||
@@ -142,14 +176,6 @@ if [ -n "$NEW" ]; then
|
||||
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git reset --hard --quiet "$TARGET"
|
||||
@@ -182,10 +208,11 @@ prune_old_images() {
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain "+", which a version for a commit that did not
|
||||
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
|
||||
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
|
||||
# so About and /api/health still report it correctly.
|
||||
# A Docker tag may not contain "+", and every version has one now:
|
||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||
# *told* it is keeps the real form, so About and /api/health still report it
|
||||
# correctly.
|
||||
TAG="${VERSION//+/-}"
|
||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
||||
docker build \
|
||||
@@ -194,10 +221,19 @@ docker build \
|
||||
-t "$IMAGE_REPO:current" \
|
||||
.
|
||||
|
||||
echo "==> restarting container"
|
||||
RUN_ARGS=(-d --name "$NAME" --restart unless-stopped -p "$BIND:8080" --env-file "$ENVF")
|
||||
if [ "$IMMUTABLE" = "1" ]; then
|
||||
# -e wins over --env-file, so this clears a SESSION_FILE set there or baked
|
||||
# into the image, rather than needing the environment file edited to match.
|
||||
RUN_ARGS+=(--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=)
|
||||
echo "==> restarting container -- immutable: read-only, no volume, sessions in memory"
|
||||
echo " (everyone signed in is signed out; IHASMAIL_IMMUTABLE=0 puts it back)"
|
||||
else
|
||||
RUN_ARGS+=(-v "$VOLUME:/data")
|
||||
echo "==> restarting container"
|
||||
fi
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$NAME" --restart unless-stopped \
|
||||
-p "$BIND:8080" --env-file "$ENVF" -v "$VOLUME:/data" "$IMAGE_REPO:$TAG" >/dev/null
|
||||
docker run "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
|
||||
|
||||
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
|
||||
if health=$(curl -sf "http://$BIND/api/health"); then
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -13,7 +13,7 @@ const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--window-size=1420,790", "--force-device-scale-factor=1",
|
||||
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
|
||||
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
*
|
||||
* The files shot was taken by hand until 2026-08-27, and had gone stale twice
|
||||
* over by the time anyone noticed. Anything the docs show should be generated
|
||||
* from the mock, or it describes whatever the app looked like on the day
|
||||
* somebody had a screenshot tool open.
|
||||
*
|
||||
* Two shots are deliberately not taken here:
|
||||
*
|
||||
* - **mobile**, because at the tail of this sequence the app would not render
|
||||
@@ -43,7 +48,7 @@ const PORT = 9333;
|
||||
const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
|
||||
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
|
||||
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (path) => {
|
||||
@@ -198,6 +203,26 @@ try {
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("compose.jpg");
|
||||
|
||||
// The recipient picker, taken here because the composer is already open. The
|
||||
// site claims you can pick recipients by reading the address books rather
|
||||
// than remembering a name, and this is that claim photographed. Doing it from
|
||||
// a later step meant navigating back to the mail list, which turned out not
|
||||
// to be reliable once the run had been through Files.
|
||||
await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(x => x.getAttribute('aria-label') === 'Choose from address books');
|
||||
if (b) b.click();
|
||||
})()`);
|
||||
await waitFor("/Choose recipients/.test(document.body.innerText)", "the recipient picker");
|
||||
await evaluate(`(() => {
|
||||
// Two ticked, so the shot shows a selection rather than an empty list.
|
||||
for (const b of [...document.querySelectorAll('.menu-item input[type=checkbox]')].slice(0, 2)) b.click();
|
||||
})()`);
|
||||
await sleep(1500);
|
||||
await shot("recipients.jpg");
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); if (c) c.click(); })()`);
|
||||
await sleep(600);
|
||||
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
@@ -227,6 +252,23 @@ try {
|
||||
await sleep(1800);
|
||||
await shot("contacts.jpg");
|
||||
|
||||
// --- files ---
|
||||
// Was the one shot taken by hand, which is why it outlived two rewrites of
|
||||
// the view it was meant to show. The tree makes it worth automating: opening
|
||||
// a folder is now the difference between a screenshot of a file manager and a
|
||||
// screenshot of a list.
|
||||
await go("http://localhost:5173/files");
|
||||
await waitFor("document.querySelector('.files-table, .files-layout')", "the files view");
|
||||
await evaluate(`(() => {
|
||||
// Expand the tree and open a folder, so the shot shows the pane doing its job.
|
||||
const twisty = document.querySelector('.sidebar .nav-twisty');
|
||||
if (twisty) twisty.click();
|
||||
const folder = [...document.querySelectorAll('.sidebar .nav-item')].find(e => /Documents/.test(e.textContent || ""));
|
||||
if (folder) folder.click();
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("files.jpg");
|
||||
|
||||
// --- filters, with rules that actually say something ---
|
||||
await go("http://localhost:5173/settings/filters");
|
||||
await evaluate(HELPERS);
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "2.16.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
||||
export function baseVersion(): string;
|
||||
export const UNVERSIONED: string;
|
||||
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
|
||||
export function versionFromGit(): string | null;
|
||||
export function resolveVersion(): string;
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
/**
|
||||
* Work out this build's version: `2.16.57`.
|
||||
* Work out this build's version: `2026.8.30+pr129`.
|
||||
*
|
||||
* 2 ihasmail's own major
|
||||
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
|
||||
* 57 the pull request the checked-out commit came from
|
||||
* 2026.8.30 the date of the commit this was built from
|
||||
* +pr129 the pull request it arrived through
|
||||
*
|
||||
* The first two are the `version` in the root package.json, so there is one
|
||||
* place to bump them; the third is read from git, because it does not exist
|
||||
* until the pull request has actually merged. Nothing writes a version back
|
||||
* into the tree: a committed one would always be describing a merge that had
|
||||
* not happened yet, and every branch would collide on the same line.
|
||||
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
|
||||
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
|
||||
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
|
||||
* every image and About screen would read as a downgrade. Tying our
|
||||
* numbering to somebody else's was the mistake; which Stalwart a build needs is
|
||||
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
|
||||
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
|
||||
*
|
||||
* A commit that did not arrive through a pull request has no number of its
|
||||
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
|
||||
* — which is honest about being past that PR rather than silently claiming to
|
||||
* be it.
|
||||
* The pull request moved into build metadata, after the `+`, because it is
|
||||
* provenance rather than a position in a sequence: at a hundred merges a week
|
||||
* it climbs without bound and says nothing about how new a build is. SemVer
|
||||
* ignores everything after the `+` when comparing versions, which is the right
|
||||
* reading -- two builds from the same day differ in where they came from, not
|
||||
* in rank. Nothing here relies on that comparison anyway: images are pruned
|
||||
* oldest-first by creation time and rollbacks name a git ref.
|
||||
*
|
||||
* A commit that did not arrive through a pull request carries its short SHA
|
||||
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
|
||||
* that day rather than claiming a pull request it was only built after.
|
||||
*
|
||||
* The date is the commit's own, not today's, so rebuilding an old commit gives
|
||||
* the same answer it gave the first time. It comes from the commit object,
|
||||
* timezone included, so two machines agree.
|
||||
*
|
||||
* Nothing writes a version back into the tree: a committed one would always be
|
||||
* describing a merge that had not happened yet, and every branch would collide
|
||||
* on the same line. `package.json` no longer carries it either -- npm wants the
|
||||
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
|
||||
* is meant to look wrong.
|
||||
*
|
||||
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
|
||||
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
|
||||
* whoever builds is responsible for computing it — see ihasmail-deploy.sh.
|
||||
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
|
||||
export function baseVersion() {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const [major, minor] = String(pkg.version).split(".");
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
/** What a build with nothing to go on reports, and it should look wrong. */
|
||||
export const UNVERSIONED = "0.0.0";
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
@@ -40,41 +53,53 @@ function git(...args) {
|
||||
|
||||
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
|
||||
|
||||
/**
|
||||
* The version for a commit, from the three things about it that decide one.
|
||||
* Pure, so the rules can be exercised without a repository staged to produce
|
||||
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
|
||||
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
|
||||
*
|
||||
* Leading zeros are stripped because a version field may not carry them, so
|
||||
* September is `9` rather than `09`.
|
||||
*/
|
||||
export function formatVersion({ date, subject = "", sha }) {
|
||||
const [y, m, d] = date.split("-");
|
||||
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version for the commit checked out here, or null when there is no git to
|
||||
* ask — an unpacked tarball, or the Docker build context.
|
||||
* ask -- an unpacked tarball, or the Docker build context.
|
||||
*/
|
||||
export function versionFromGit() {
|
||||
let head;
|
||||
let date;
|
||||
try {
|
||||
head = git("rev-parse", "--short", "HEAD");
|
||||
// %cs is the committer date in the commit's own timezone, which is stored
|
||||
// in the commit -- so this does not depend on the clock or zone of whoever
|
||||
// is building.
|
||||
date = git("show", "-s", "--format=%cs", "HEAD");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const base = baseVersion();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
let subject = "";
|
||||
try {
|
||||
// Walk back over first parents: a merge commit's subject names its PR, and
|
||||
// anything after the newest one is work that has not been through one.
|
||||
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
|
||||
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
|
||||
for (const [sha, subject = ""] of commits) {
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
if (!pr) continue;
|
||||
// The PR's own merge commit is the version; anything above it is past it.
|
||||
const exact = sha.startsWith(git("rev-parse", "HEAD"));
|
||||
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
|
||||
}
|
||||
subject = git("show", "-s", "--format=%s", "HEAD");
|
||||
} catch {
|
||||
/* a shallow clone, or no history to read */
|
||||
/* no subject to read; fall through to the SHA */
|
||||
}
|
||||
return `${base}.0+g${head}`;
|
||||
return formatVersion({ date, subject, sha: head });
|
||||
}
|
||||
|
||||
/** Whatever the environment was told, else git, else just the base. */
|
||||
/** Whatever the environment was told, else git, else an answer that looks wrong. */
|
||||
export function resolveVersion() {
|
||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return versionFromGit() ?? `${baseVersion()}.0`;
|
||||
return versionFromGit() ?? UNVERSIONED;
|
||||
}
|
||||
|
||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
||||
|
||||
@@ -35,3 +35,56 @@ test("image proxy refuses private targets", async () => {
|
||||
const res = await app.request("/api/image?url=http://127.0.0.1/x");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
// gzip: the body we forward has already been decompressed, so the length on
|
||||
// the wire describes different bytes and must not be copied (issue #76).
|
||||
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
|
||||
assert.equal(forwardedContentLength(gz), null);
|
||||
// identity, spelled out or absent: the length describes the body we send.
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
|
||||
// Nothing to forward is not an error.
|
||||
assert.equal(forwardedContentLength(new Headers()), null);
|
||||
});
|
||||
|
||||
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
|
||||
const http = await import("node:http");
|
||||
const zlib = await import("node:zlib");
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
|
||||
const script =
|
||||
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
|
||||
["a", "b", "c"]
|
||||
.map(
|
||||
(k) =>
|
||||
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
|
||||
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
|
||||
)
|
||||
.join("");
|
||||
const gz = zlib.gzipSync(Buffer.from(script));
|
||||
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
|
||||
|
||||
// A hop that compresses regardless of what we asked for.
|
||||
const origin = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
|
||||
res.end(gz);
|
||||
});
|
||||
await new Promise<void>((r) => origin.listen(0, () => r()));
|
||||
const port = (origin.address() as { port: number }).port;
|
||||
|
||||
try {
|
||||
const up = await fetch(`http://127.0.0.1:${port}/`);
|
||||
// What the blob route forwards.
|
||||
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
|
||||
const cl = forwardedContentLength(up.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
|
||||
assert.equal(out.headers.get("content-length"), null);
|
||||
assert.equal(await out.text(), script);
|
||||
} finally {
|
||||
origin.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { SessionStore, type LiveSession } from "./sessions.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions = new SessionStore(config.sessionFile);
|
||||
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
|
||||
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
||||
/**
|
||||
* Credential changes verify the current password upstream, and Stalwart's
|
||||
@@ -519,14 +519,17 @@ export function createApp(): Hono<Env> {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: session.authorization },
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = res.headers.get("content-length");
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
@@ -651,6 +654,32 @@ function passthrough(res: Response): Response {
|
||||
return new Response(res.body, { status: res.status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream content-length, but only when it describes the bytes we are
|
||||
* about to forward.
|
||||
*
|
||||
* A compressed response is decompressed for us before we ever see the body --
|
||||
* undici does it transparently -- while the content-length header is left
|
||||
* describing the *compressed* length. Copying it onto the longer body we then
|
||||
* send makes the browser stop reading exactly that many bytes in and call the
|
||||
* download complete, so the file arrives silently truncated.
|
||||
*
|
||||
* That is the second half of issue #76. A hop in front of Stalwart compressed
|
||||
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
|
||||
* pushed it past the threshold and it came back cut off mid-rule. Nothing
|
||||
* reported an error: the script parsed, just with rules missing, and saving
|
||||
* wrote that shortened version back over the real one.
|
||||
*
|
||||
* We ask for `identity` above so the usual case still carries a length the
|
||||
* browser can show progress against; this is the guard for a hop that
|
||||
* compresses anyway.
|
||||
*/
|
||||
export function forwardedContentLength(headers: Headers): string | null {
|
||||
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
|
||||
if (encoding && encoding !== "identity") return null;
|
||||
return headers.get("content-length");
|
||||
}
|
||||
|
||||
function sanitizeContentType(ct: string): string {
|
||||
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
||||
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { assertImmutable } from "./config.js";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
|
||||
}
|
||||
|
||||
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("", root), /is writable/);
|
||||
assert.equal(existsSync(join(root, ".immutable-probe")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE accepts a root it cannot write to", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
chmodSync(root, 0o555);
|
||||
assert.doesNotThrow(() => assertImmutable("", root));
|
||||
} finally {
|
||||
chmodSync(root, 0o755);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveVersion } from "../../scripts/version.mjs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
|
||||
@@ -58,6 +58,58 @@ if (!appSecret || appSecret === "change-me") {
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Declares that this instance is running as an immutable container: read-only
|
||||
* root filesystem, nothing durable of its own, replaceable by its image.
|
||||
*
|
||||
* It is a claim the process checks rather than one it takes on trust, because
|
||||
* the failure it guards against is silent. Left to itself the server survives
|
||||
* a read-only filesystem perfectly well -- sessions are held in memory and the
|
||||
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
|
||||
* is one warning at the first login, long after anyone was watching. The
|
||||
* instance looks healthy right up until it is replaced and everyone is signed
|
||||
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
|
||||
*/
|
||||
const immutable = bool("IMMUTABLE", false);
|
||||
const sessionFile = process.env.SESSION_FILE ?? "";
|
||||
|
||||
/**
|
||||
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
|
||||
* keep. Exported so it can be tested without a read-only filesystem to hand.
|
||||
*/
|
||||
export function assertImmutable(sessionFile: string, root: string): void {
|
||||
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
|
||||
// refusal rather than a formality: running immutably means clearing it. It
|
||||
// is not quietly ignored, because a configured path that silently persists
|
||||
// nothing is exactly the failure this flag exists to surface.
|
||||
if (sessionFile) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
|
||||
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
|
||||
);
|
||||
}
|
||||
// And check the property itself, not just the intention to have it. Setting
|
||||
// the variable while forgetting `--read-only` is the easy mistake, and it
|
||||
// leaves an instance claiming a guarantee it does not have.
|
||||
const probe = resolve(root, ".immutable-probe");
|
||||
let writable = false;
|
||||
try {
|
||||
writeFileSync(probe, "");
|
||||
writable = true;
|
||||
unlinkSync(probe);
|
||||
} catch {
|
||||
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
|
||||
}
|
||||
if (writable) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
@@ -75,7 +127,7 @@ export const config = {
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
stalwartUrl,
|
||||
@@ -92,7 +144,9 @@ export const config = {
|
||||
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
|
||||
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
|
||||
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
|
||||
sessionFile: process.env.SESSION_FILE ?? "",
|
||||
sessionFile,
|
||||
/** True when this instance has asserted, and verified, that it is immutable. */
|
||||
immutable,
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
|
||||
@@ -26,6 +27,13 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||
const MAX_DELAYED_SEND = 86400 * 30;
|
||||
const ACCOUNT = "a1";
|
||||
/** An account somebody has shared with the demo user. See the session below. */
|
||||
const SHARED_ACCOUNT = "a2";
|
||||
const SHARED_CAPS: Obj = {
|
||||
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||
};
|
||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
@@ -137,6 +145,25 @@ addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: id
|
||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||
// A thread whose unread message is not the last one: someone's server queued
|
||||
// their reply for hours, so it landed after messages that answer it and sits in
|
||||
// the middle of the conversation. Opening this thread at the newest message
|
||||
// left that reply above the fold until the mark-read timer swept it (#87).
|
||||
{
|
||||
const subj = "Compiler timings for the release";
|
||||
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
|
||||
const tid = t.threadId as string;
|
||||
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
|
||||
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
|
||||
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
|
||||
// Long enough after the unread one that the thread scrolls: opening at the
|
||||
// bottom put four messages between the reader and the mail they had not read.
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
|
||||
}
|
||||
// Invitation email
|
||||
{
|
||||
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
@@ -153,6 +180,12 @@ const identities: Obj[] = [
|
||||
];
|
||||
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
|
||||
const sieveScripts: Obj[] = [];
|
||||
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||
const sharedEvents: Obj[] = [];
|
||||
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||
const events: Obj[] = [];
|
||||
@@ -165,19 +198,43 @@ const events: Obj[] = [];
|
||||
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
|
||||
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
|
||||
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
|
||||
// Two in the shared account, so a colleague's calendar has something in it.
|
||||
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||
}
|
||||
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }];
|
||||
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
|
||||
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||
what a share usually is. */
|
||||
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||
const sharedCards: Obj[] = [
|
||||
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
];
|
||||
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
|
||||
const [given, surname] = p[0]!.split(" ");
|
||||
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined };
|
||||
});
|
||||
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
|
||||
const fileNodes: Obj[] = [
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
|
||||
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
|
||||
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
|
||||
/* What the shared account holds. Its own nodes, so opening the share in Files
|
||||
shows something different from the reader's own folders rather than the same
|
||||
list under another name. */
|
||||
const sharedFileNodes: Obj[] = [
|
||||
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||
];
|
||||
/** The node list an account owns. */
|
||||
const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
|
||||
|
||||
function fr() {
|
||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
}
|
||||
@@ -323,6 +380,19 @@ function enforceLimits(name: string, args: Obj): void {
|
||||
|
||||
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||
|
||||
/*
|
||||
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
||||
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
||||
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
||||
* shared. The mock handing it over unasked meant a client that never asked
|
||||
* still saw every share, and the one place that did not -- the real server --
|
||||
* showed nothing shared at all.
|
||||
*/
|
||||
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||
if (a.properties) return res;
|
||||
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||
}
|
||||
|
||||
function genericGet(list: Obj[]) {
|
||||
return (a: Obj) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
@@ -330,6 +400,24 @@ function genericGet(list: Obj[]) {
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An id, as either a stored event or one occurrence of one.
|
||||
*
|
||||
* A synthetic id whose base is gone, or whose index falls outside the series
|
||||
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
||||
* server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||
const direct = list.find((x) => x.id === id);
|
||||
if (direct) return { base: direct };
|
||||
const parsed = parseSyntheticId(id);
|
||||
if (!parsed) return null;
|
||||
const base = list.find((x) => x.id === parsed.baseId);
|
||||
if (!base) return null;
|
||||
const occ = occurrenceAt(base, parsed.slot);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
@@ -367,6 +455,125 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- calendar events ---------- */
|
||||
|
||||
/**
|
||||
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||
*
|
||||
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||
*
|
||||
* The refusals are the point of reproducing this at all:
|
||||
*
|
||||
* - a base event and one of its instances in the same request is refused, both
|
||||
* ids at once, because the server cannot apply them in a defined order;
|
||||
* - the same id twice is "Duplicate event id.";
|
||||
* - the ten event-level properties are refused with `invalidProperties`;
|
||||
* - and the twelve inherited ones are dropped in silence, with the response
|
||||
* still saying the update succeeded. A mock that applied them would let a
|
||||
* client that sends them look correct everywhere except a real server.
|
||||
*/
|
||||
function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is
|
||||
// how #26 and #30 reached a live server unnoticed — so it does both.
|
||||
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
o.uid = o.uid ?? randomUUID();
|
||||
events.push(o);
|
||||
created[cid] = { id: o.id };
|
||||
}
|
||||
|
||||
const updates = Object.entries((a.update as Obj) ?? {});
|
||||
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||
const seen = new Set<string>();
|
||||
|
||||
/* A base and one of its instances cannot be settled in the same request. */
|
||||
const baseOf = (id: string): string | null => {
|
||||
const r = resolveEvent(events, id);
|
||||
return r ? (r.base.id as string) : null;
|
||||
};
|
||||
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||
const b = baseOf(id);
|
||||
if (!b) continue;
|
||||
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||
touched.set(b, entry);
|
||||
}
|
||||
const conflicted = new Set<string>();
|
||||
for (const [, e] of touched) {
|
||||
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||
}
|
||||
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||
|
||||
for (const [id, patch] of updates) {
|
||||
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||
seen.add(id);
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||
writeOverride(resolved.base, resolved.occ, applied);
|
||||
updated[id] = null;
|
||||
}
|
||||
|
||||
for (const id of destroys) {
|
||||
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||
if (resolved.occ) {
|
||||
// One date off a series, which is an override rather than a deletion.
|
||||
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||
destroyed.push(id);
|
||||
continue;
|
||||
}
|
||||
const i = events.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into the override for one date.
|
||||
*
|
||||
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||
* an override always carries its own timing; the mock does the same, or a
|
||||
* client could depend on inheriting them and be right only here.
|
||||
*/
|
||||
function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||
const next: Obj = { ...existing };
|
||||
if (!replace) {
|
||||
if (!("start" in next)) next.start = occ.start;
|
||||
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||
}
|
||||
applyPatch(next, patch);
|
||||
overrides[occ.recurrenceId] = next;
|
||||
base.recurrenceOverrides = overrides;
|
||||
}
|
||||
|
||||
/* ---------- submissions ---------- */
|
||||
/**
|
||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||
@@ -401,7 +608,7 @@ const handlers: Record<string, Handler> = {
|
||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||
},
|
||||
"Mailbox/get": genericGet(mailboxes),
|
||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
"Email/query": (a) => {
|
||||
@@ -416,7 +623,22 @@ const handlers: Record<string, Handler> = {
|
||||
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
|
||||
},
|
||||
"Email/get": (a) => genericGet(emails)(a),
|
||||
"Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
/*
|
||||
* Real changes, not an empty answer.
|
||||
*
|
||||
* This used to return three empty arrays whatever had happened, so the
|
||||
* client's whole reconciliation path -- `Email/changes`, then deciding what
|
||||
* to do with what came back -- never ran against the mock. A bug living in
|
||||
* that path could not be reproduced here at all, which is how one reached
|
||||
* production and survived being "fixed" once (#100). The log below is what
|
||||
* the real server can answer from.
|
||||
*/
|
||||
"Email/changes": (a) => {
|
||||
const since = Number(a.sinceState ?? 0);
|
||||
const relevant = emailChanges.filter((c) => c.state > since);
|
||||
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||
},
|
||||
"Email/set": (a) => {
|
||||
const r = genericSet(emails, "e", (o) => {
|
||||
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
|
||||
@@ -437,6 +659,19 @@ const handlers: Record<string, Handler> = {
|
||||
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
|
||||
})(a);
|
||||
recount();
|
||||
nextState();
|
||||
recordEmailChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||
updated: Object.keys((a.update as Obj) ?? {}),
|
||||
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||
});
|
||||
/* A real server pushes a state change after a set, and the client acts on
|
||||
it -- `Email/changes` runs and the store reconciles what came back. The
|
||||
mock stayed silent, so that whole path never ran here and a bug living
|
||||
in it could not be reproduced: marking a message read went round the
|
||||
server and back on the live instance, and did nothing at all on the mock
|
||||
(#100). Announced now, the way Stalwart does. */
|
||||
broadcast(["Email", "Mailbox", "Thread"]);
|
||||
return r;
|
||||
},
|
||||
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||
@@ -675,40 +910,87 @@ const handlers: Record<string, Handler> = {
|
||||
"SieveScript/get": genericGet(sieveScripts),
|
||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||
"Calendar/get": genericGet(calendars),
|
||||
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
|
||||
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
|
||||
"CalendarEvent/get": genericGet(events),
|
||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||
/*
|
||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||
* included, which is what a live 0.16.19 does and what makes `baseEventId`
|
||||
* useless as a test for a series. Without it (the `findByUid` path) the
|
||||
* stored ids come back untouched, because callers hand those straight to a
|
||||
* destroy and mean the whole event.
|
||||
*/
|
||||
"CalendarEvent/query": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const filter = (a.filter as Obj) ?? {};
|
||||
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||
if (!a.expandRecurrences) {
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||
}
|
||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||
const ids: string[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||
},
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
if (!ids) return genericGet(list)(a);
|
||||
const found: Obj[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const resolved = resolveEvent(list, id);
|
||||
if (!resolved) { notFound.push(id); continue; }
|
||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||
}
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
||||
},
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
"CalendarEvent/set": genericSet(events, "ev", (o) => {
|
||||
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
return Object.assign(o, { uid: o.uid ?? randomUUID() });
|
||||
}),
|
||||
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||
"Principal/get": genericGet(principals),
|
||||
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
|
||||
"AddressBook/get": genericGet(addressBooks),
|
||||
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
|
||||
"ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }),
|
||||
"ContactCard/get": genericGet(cards),
|
||||
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||
"AddressBook/set": (a) => {
|
||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||
included -- "You are not allowed to modify this address book", confirmed
|
||||
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
|
||||
that accepted it would have agreed that subscribing works, which is
|
||||
exactly the belief that shipped. Calendars accept the same write; the
|
||||
difference is the server's, not ours. */
|
||||
if (a.accountId === SHARED_ACCOUNT && a.update) {
|
||||
const notUpdated: Obj = {};
|
||||
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
|
||||
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
|
||||
}
|
||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||
},
|
||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||
"ContactCard/set": genericSet(cards, "cc"),
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
const fileNodes = nodesFor(a.accountId);
|
||||
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||
// directories and was handed files, which it then drew as folders.
|
||||
const list = fileNodes.filter((n) => {
|
||||
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||
return true;
|
||||
});
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": genericGet(fileNodes),
|
||||
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
|
||||
"FileNode/set": (a) => {
|
||||
return genericSet(fileNodes, "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
return genericSet(nodesFor(a.accountId), "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
// file properties. Keep it internally so query and get stay consistent.
|
||||
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
|
||||
@@ -752,7 +1034,18 @@ const session = () => ({
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
|
||||
"urn:ietf:params:jmap:emailpush": {},
|
||||
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
/*
|
||||
* Two accounts: the demo user's own, and one somebody has shared.
|
||||
*
|
||||
* The shared one carries the *same* capability list, because that is what
|
||||
* Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account
|
||||
* advertised mail, calendars, contacts and the rest, identical to a personal
|
||||
* one, whatever had actually been shared. Giving the mock a truthful shared
|
||||
* account is the only way to exercise the Files "Shared with me" list, and
|
||||
* the only way this stays honest about what can be inferred from a
|
||||
* capability, which is nothing.
|
||||
*/
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
@@ -763,6 +1056,14 @@ const session = () => ({
|
||||
});
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
/** What changed and when, so `Email/changes` can answer honestly. */
|
||||
const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||
function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||
// A window is plenty; the client refetches from scratch if it falls behind.
|
||||
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
||||
}
|
||||
|
||||
function broadcast(types: string[]) {
|
||||
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
|
||||
for (const c of sseClients) c.write(payload);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
* against something. What it has to get right is not the expansion — that is
|
||||
* the easy half — but the three things a live server does that a client will
|
||||
* otherwise be written against wrongly:
|
||||
*
|
||||
* - every expanded id is synthetic, one-offs included;
|
||||
* - an occurrence carries a `recurrenceId` and no rule;
|
||||
* - a per-occurrence patch loses some properties in silence.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
||||
|
||||
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
||||
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
||||
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
||||
|
||||
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||
|
||||
describe("expandOccurrences", () => {
|
||||
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(series(), a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
||||
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a one-off exactly one occurrence, at index 0", () => {
|
||||
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
||||
const out = expandOccurrences(oneOff(), a, b);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0]!.index, 0);
|
||||
});
|
||||
|
||||
it("honours count", () => {
|
||||
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||
});
|
||||
|
||||
it("drops an excluded date from the expansion, keeping the series positions", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
// The position within the series is unchanged — Wednesday is still the
|
||||
// third date the rule produces, whatever happened to Tuesday. It is the
|
||||
// *id* built on top of that which moves, and only after a write.
|
||||
assert.equal(out[1]!.index, 2);
|
||||
});
|
||||
|
||||
it("carries an override onto the occurrence it keys", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrenceView", () => {
|
||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||
const base = series();
|
||||
const occ = occurrenceAt(base, 1)!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.id, syntheticId("ev1", 1));
|
||||
assert.equal(view.baseEventId, "ev1");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
assert.equal(view.recurrenceRule, undefined);
|
||||
assert.equal(view.recurrenceOverrides, undefined);
|
||||
});
|
||||
|
||||
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||
const base = oneOff();
|
||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
||||
assert.equal(view.id, "ev2-o0");
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
assert.notEqual(view.id, view.baseEventId);
|
||||
assert.equal(view.recurrenceId, undefined);
|
||||
});
|
||||
|
||||
it("lets an override win over the series", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
||||
// for the id this occurrence had *before* the write is the bug below.
|
||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||
assert.equal(view.title, "Moved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
||||
});
|
||||
it("does not claim a stored id", () => {
|
||||
assert.equal(parseSyntheticId("ev1"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOccurrencePatch", () => {
|
||||
it("applies what an occurrence takes", () => {
|
||||
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
||||
});
|
||||
|
||||
it("refuses an event-level property by name", () => {
|
||||
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
||||
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
||||
});
|
||||
|
||||
it("drops an inherited property in silence, which is the dangerous half", () => {
|
||||
// No `rejected`, nothing applied, and a real server would still answer
|
||||
// "updated". Anything that trusts the response believes this landed.
|
||||
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, {});
|
||||
});
|
||||
|
||||
it("judges a pointer patch on its first token", () => {
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
||||
{ "participants/me/participationStatus": "accepted" });
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("synthetic ids are only true until the next write", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
||||
* entry renumbered a five-week series so that the *same* ids addressed
|
||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
||||
* that rather than the exact permutation, because the property that bites is
|
||||
* not which date an id moves to but that it moves at all, silently.
|
||||
*/
|
||||
it("makes a cached id address a different date after an override is written", () => {
|
||||
const before = series();
|
||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
assert.notEqual(dateAfter, dateBefore);
|
||||
// And crucially it still resolves — a stale id is wrong, not invalid, so a
|
||||
// client that trusts it gets a confident answer about the wrong day.
|
||||
assert.ok(dateAfter);
|
||||
});
|
||||
|
||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
||||
const before = series();
|
||||
const occ = occurrenceAt(before, 3)!;
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
||||
assert.equal(same!.start, occ.start);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("an override that moves an occurrence", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
|
||||
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
|
||||
* still at 09:00 — the slot the rule made, which the move does not touch.
|
||||
*
|
||||
* The mock used to clobber the override's `start` with the slot time, so a
|
||||
* moved occurrence did not move. That made per-occurrence *time* editing —
|
||||
* one of the main things the feature is for — look broken against the mock
|
||||
* and fine against the server.
|
||||
*/
|
||||
const moved = () => ({
|
||||
...series(),
|
||||
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
|
||||
});
|
||||
|
||||
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
assert.equal(occ.start, "2026-09-08T14:00:00");
|
||||
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("shows the moved time on the occurrence a get returns", () => {
|
||||
const base = moved();
|
||||
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
|
||||
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.start, "2026-09-08T14:00:00");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("keeps the occurrence findable by recurrenceId after the move", () => {
|
||||
// This is the property the store depends on: `recurrenceId` survives both
|
||||
// a renumbering and a move, so it is the handle a mutation resolves from.
|
||||
const base = moved();
|
||||
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
|
||||
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||
*
|
||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||
* things that only a live server showed were therefore impossible to develop
|
||||
* against, and all three had already cost a debugging session:
|
||||
*
|
||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
||||
* `recurrenceOverrides` entry rather than touching the series.
|
||||
*
|
||||
* A mock that agrees with the client rather than with the server is how #26 and
|
||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||
* what Stalwart rejects is rejected here, and what it drops in silence is
|
||||
* dropped here, in silence, on purpose.
|
||||
*/
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
/** How far the expander will walk before giving up on a rule. */
|
||||
const MAX_ITERATIONS = 750;
|
||||
|
||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||
|
||||
/**
|
||||
* The id an occurrence is addressed by, which is only true until the next write.
|
||||
*
|
||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||
* them, and nothing in ihasmail may read either.
|
||||
*
|
||||
* They are also deliberately **unstable**, because the real ones are.
|
||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
||||
* component that renumbers it. A five-week series held `e i m q u` over
|
||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
||||
* different dates.
|
||||
*
|
||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
||||
* would hand back a loud `notFound` and let a client that caches ids look
|
||||
* careful. So the numbering is shifted by the number of overrides — an
|
||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
||||
* matters: hold an id across a write and it silently addresses another date.
|
||||
*/
|
||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
||||
|
||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
||||
}
|
||||
|
||||
/** How far the id numbering has been rotated away from the series order. */
|
||||
function rotation(base: Obj): number {
|
||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
||||
}
|
||||
|
||||
/** The id slot this occurrence currently answers to. */
|
||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
||||
return occ.index + rotation(base);
|
||||
}
|
||||
|
||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||
export function localDateTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const parseLocal = (s: string): Date => new Date(s);
|
||||
|
||||
export interface Occurrence {
|
||||
index: number;
|
||||
/** The slot in the series this instance fills, which keys any override. */
|
||||
recurrenceId: string;
|
||||
start: string;
|
||||
/** Set when a `recurrenceOverrides` entry applies to this date. */
|
||||
override?: Obj;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
frequency?: string;
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: string;
|
||||
byDay?: { day: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of `base` between `from` and `to`, in series order.
|
||||
*
|
||||
* An event with no rule has exactly one, at index 0 — which is what gives a
|
||||
* one-off the synthetic id a real server would give it.
|
||||
*/
|
||||
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const startStr = base.start as string;
|
||||
if (!startStr) return [];
|
||||
const first = parseLocal(startStr);
|
||||
const rule = base.recurrenceRule as Rule | undefined;
|
||||
|
||||
const out: Occurrence[] = [];
|
||||
const emit = (index: number, at: Date): boolean => {
|
||||
const recurrenceId = localDateTime(at);
|
||||
const override = overrides[recurrenceId];
|
||||
// An excluded date is simply gone from the expansion. Its slot is not
|
||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
||||
if (override?.excluded === true) return true;
|
||||
/*
|
||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||
* are two different times: the slot it fills stays where the rule put it,
|
||||
* and only the clock time moves. **Confirmed live on 0.16.20
|
||||
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
|
||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||
* `2027-06-14T09:00:00`.
|
||||
*
|
||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
||||
* the one name for this instance that neither a renumbering nor a move
|
||||
* changes.
|
||||
*/
|
||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||
const shown = parseLocal(start);
|
||||
if (shown >= from && shown < to) {
|
||||
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
|
||||
}
|
||||
return at < to;
|
||||
};
|
||||
|
||||
if (!rule?.frequency) {
|
||||
emit(0, first);
|
||||
return out;
|
||||
}
|
||||
|
||||
const interval = Math.max(1, rule.interval ?? 1);
|
||||
const until = rule.until ? parseLocal(rule.until) : null;
|
||||
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
|
||||
|
||||
let index = 0;
|
||||
let emitted = 0;
|
||||
const cursor = new Date(first);
|
||||
|
||||
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||
if (until && cursor > until) break;
|
||||
if (rule.count != null && emitted >= rule.count) break;
|
||||
|
||||
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
|
||||
if (matches) {
|
||||
emitted++;
|
||||
const keepGoing = emit(index, new Date(cursor));
|
||||
index++;
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
|
||||
// A rule with byDay walks day by day and keeps the days it names; without
|
||||
// one it steps by its own frequency.
|
||||
if (byDay) cursor.setDate(cursor.getDate() + 1);
|
||||
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
|
||||
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
|
||||
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fields that describe the series and never travel down to one instance. */
|
||||
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
|
||||
|
||||
/**
|
||||
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||
*
|
||||
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
||||
* nothing else, which is the shape `isRecurring` was written against.
|
||||
*/
|
||||
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
const view: Obj = { ...base };
|
||||
for (const k of SERIES_ONLY) delete view[k];
|
||||
Object.assign(view, occ.override ?? {});
|
||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
||||
view.baseEventId = base.id;
|
||||
view.start = occ.start;
|
||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||
// its single occurrence does not, or every one-off would look recurring.
|
||||
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
|
||||
delete view.excluded;
|
||||
return view;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
export const OCCURRENCE_REJECTED = new Set([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dropped from the patch, with the response still reporting success.
|
||||
*
|
||||
* This is the half that has to be reproduced most carefully. A mock that
|
||||
* *applied* these would agree with a client that sends them, and the belief
|
||||
* would ship — which is exactly the road #26 took to a live server.
|
||||
*/
|
||||
export const OCCURRENCE_INHERITED = new Set([
|
||||
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Split a per-occurrence patch the way the server's validator does.
|
||||
*
|
||||
* `rejected` is the first property that would be refused, if any; `applied` is
|
||||
* what actually lands on the override. Everything else vanishes without a word.
|
||||
*/
|
||||
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
|
||||
const applied: Obj = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const [head, , third] = key.split("/");
|
||||
const root = head ?? key;
|
||||
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
|
||||
if (OCCURRENCE_INHERITED.has(root)) continue;
|
||||
if (root === "participants" && third === "calendarAddress") continue;
|
||||
if (root === "id") continue;
|
||||
applied[key] = value;
|
||||
}
|
||||
return { applied };
|
||||
}
|
||||
|
||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
||||
const index = slot - rotation(base);
|
||||
if (index < 0) return null;
|
||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||
return all.find((o) => o.index === index) ?? null;
|
||||
}
|
||||
@@ -34,9 +34,64 @@ export interface LiveSession {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** What `/api/auth/sessions` reports about a session, with nothing secret in it. */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the rest of the server asks of a session store.
|
||||
*
|
||||
* There is one implementation today -- `SessionStore` below, which keeps the
|
||||
* records in memory and optionally mirrors them to `SESSION_FILE`. The reason
|
||||
* it is named as an interface anyway is that a second one is planned: a
|
||||
* stateless backend that carries the whole record in the cookie, so that a
|
||||
* replica can serve a session it never issued and `/data` can go away. Callers
|
||||
* written against the concrete class would all have to be revisited then.
|
||||
*
|
||||
* Five of these are already stateless in shape -- `create`, `resolve`,
|
||||
* `reseal` and `destroy` each touch exactly one session, and the sealing key is
|
||||
* derived from the cookie secret (see `crypto.ts`), so the record can move into
|
||||
* the cookie without the server keeping a map.
|
||||
*
|
||||
* The other two cannot be. `listForUser` and `destroyAllForUser` have to reach
|
||||
* sessions other than the one presenting itself, which means something has to
|
||||
* be enumerable somewhere. `destroyAllForUser` is not only the "sign out my
|
||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||
* password changes, so it carries the guarantee that changing a credential
|
||||
* invalidates the sessions still holding the old one. A stateless backend
|
||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
||||
* Stalwart's own token registry, which can already answer both questions.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
init(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
export class SessionStore {
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
@@ -104,13 +159,7 @@ export class SessionStore {
|
||||
}
|
||||
|
||||
/** Create a session; returns the cookie value to hand to the client. */
|
||||
create(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}): { cookie: string; session: LiveSession } {
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession } {
|
||||
const id = randomToken(18);
|
||||
const secret = randomToken(32);
|
||||
const salt = randomBytes(16);
|
||||
@@ -211,7 +260,7 @@ export class SessionStore {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
|
||||
|
||||
/**
|
||||
* The version is this build's public identity: it names the image, and it is
|
||||
* what About and /api/health report. It had no tests while it was
|
||||
* `2.16.<pr>`; it has them now that the rules moved.
|
||||
*/
|
||||
|
||||
test("a pull request merge is named by its number", () => {
|
||||
assert.equal(
|
||||
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
|
||||
"2026.8.30+pr129",
|
||||
);
|
||||
});
|
||||
|
||||
test("a commit that did not come through a pull request carries its SHA", () => {
|
||||
// Claiming the last PR would say it *is* that PR rather than something after it.
|
||||
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
|
||||
});
|
||||
|
||||
test("leading zeros are stripped, since a version field may not carry them", () => {
|
||||
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
|
||||
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
|
||||
});
|
||||
|
||||
test("it sorts forward from the versions it replaces", () => {
|
||||
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
|
||||
// whole reason the Stalwart generation left the version.
|
||||
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
|
||||
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
|
||||
});
|
||||
|
||||
test("two builds from the same day differ, even though they rank the same", () => {
|
||||
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
|
||||
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a.split("+")[0], b.split("+")[0]);
|
||||
});
|
||||
|
||||
test("the same commit always resolves to the same version", () => {
|
||||
// Built from the commit's own date, not today's, so an old commit rebuilt
|
||||
// now reports what it reported then.
|
||||
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
|
||||
assert.equal(formatVersion(commit), formatVersion(commit));
|
||||
});
|
||||
|
||||
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
|
||||
const before = process.env.IHASMAIL_VERSION;
|
||||
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
|
||||
try {
|
||||
assert.equal(resolveVersion(), "2026.8.30+pr129");
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.IHASMAIL_VERSION;
|
||||
else process.env.IHASMAIL_VERSION = before;
|
||||
}
|
||||
});
|
||||
|
||||
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
|
||||
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
|
||||
assert.equal(UNVERSIONED, "0.0.0");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ihasmail/web",
|
||||
"version": "2.16.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { accountForCapability, ownAccountForCapability, type SessionLike } from "@/lib/accountRouting";
|
||||
|
||||
/**
|
||||
* Found by sharing a folder between two real accounts.
|
||||
*
|
||||
* Switching to the account somebody shared pointed everything at it, because
|
||||
* the rule was "use the selected account if it can do this" and a shared file
|
||||
* account can, by definition, do files. ihasmail keeps its own settings in the
|
||||
* account's Files, so changing any setting while looking at somebody's shared
|
||||
* folder wrote `settings.json` into *their* storage, creating the `ihasmail`
|
||||
* folder there to do it. Reading someone else's data by mistake is bad; writing
|
||||
* yours into it is worse, and it was the same one-line rule doing both.
|
||||
*/
|
||||
|
||||
const CAL = "urn:ietf:params:jmap:calendars";
|
||||
const FILES = "urn:ietf:params:jmap:filenode";
|
||||
const MAIL = "urn:ietf:params:jmap:mail";
|
||||
|
||||
/** Mine does everything; theirs is a shared account with only files on it. */
|
||||
const shared = (): SessionLike => ({
|
||||
accounts: {
|
||||
mine: { isPersonal: true, accountCapabilities: { [MAIL]: {}, [FILES]: {}, [CAL]: {} } },
|
||||
theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } },
|
||||
},
|
||||
primaryAccounts: { [MAIL]: "mine", [FILES]: "mine", [CAL]: "mine" },
|
||||
});
|
||||
|
||||
describe("what the reader is looking at", () => {
|
||||
it("follows the switch into a shared account for what was shared", () => {
|
||||
expect(accountForCapability(shared(), "theirs", FILES)).toBe("theirs");
|
||||
});
|
||||
|
||||
it("leaves everything else on the reader's own account", () => {
|
||||
expect(accountForCapability(shared(), "theirs", MAIL)).toBe("mine");
|
||||
expect(accountForCapability(shared(), "theirs", CAL)).toBe("mine");
|
||||
});
|
||||
|
||||
it("still follows a switch between the reader's own accounts", () => {
|
||||
const s = shared();
|
||||
s.accounts.second = { isPersonal: true, accountCapabilities: { [MAIL]: {} } };
|
||||
expect(accountForCapability(s, "second", MAIL)).toBe("second");
|
||||
});
|
||||
|
||||
it("gives up rather than aim at a shared account for something unshared", () => {
|
||||
// No primary for calendars, and theirs does not offer them. The old rule
|
||||
// fell back to the selection, which is somebody else's account.
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[CAL];
|
||||
expect(accountForCapability(s, "theirs", CAL)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets one of the reader's own accounts stand in when there is no primary", () => {
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[CAL];
|
||||
expect(accountForCapability(s, "mine", CAL)).toBe("mine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what belongs to the reader", () => {
|
||||
it("stays on their own account while they look at a shared one", () => {
|
||||
// The one that matters: settings are written through this.
|
||||
expect(ownAccountForCapability(shared(), FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("ignores a primary account the server says is not the reader's", () => {
|
||||
const s = shared();
|
||||
s.primaryAccounts[FILES] = "theirs";
|
||||
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("finds a personal account when no primary is named", () => {
|
||||
const s = shared();
|
||||
delete s.primaryAccounts[FILES];
|
||||
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||
});
|
||||
|
||||
it("answers nothing rather than a shared account", () => {
|
||||
const s: SessionLike = {
|
||||
accounts: { theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } } },
|
||||
primaryAccounts: {},
|
||||
};
|
||||
expect(ownAccountForCapability(s, FILES)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Whether a shared collection counts as added.
|
||||
*
|
||||
* JMAP keeps this on the collection, in `isSubscribed`, and that is the better
|
||||
* place: a preference the server holds is one every client sees. But
|
||||
* subscribing writes to the *owner's* account, and Stalwart 0.16.19 refuses
|
||||
* that for an address book shared read-only — "You are not allowed to modify
|
||||
* this address book" — while accepting the identical write on a shared
|
||||
* calendar. Confirmed against the live server on 2026-08-27, from a second
|
||||
* account holding the share.
|
||||
*
|
||||
* So there are two records and either counts. The rule is the whole of the
|
||||
* fix, which is why it is worth pinning down here rather than leaving it
|
||||
* spelled out in three components that could drift apart.
|
||||
*/
|
||||
|
||||
const key = (accountId: string, id: string) => `${accountId}:${id}`;
|
||||
|
||||
/** Added if the server remembered it, or the reader's settings did. */
|
||||
function isAdded(collection: { accountId: string; id: string; isSubscribed?: boolean }, addedShares: string[]): boolean {
|
||||
return Boolean(collection.isSubscribed) || new Set(addedShares).has(key(collection.accountId, collection.id));
|
||||
}
|
||||
|
||||
const book = (over: Partial<{ accountId: string; id: string; isSubscribed: boolean }> = {}) =>
|
||||
({ accountId: "acct", id: "ab1", ...over });
|
||||
|
||||
describe("whether a shared collection has been added", () => {
|
||||
it("is added when the server took the subscription", () => {
|
||||
expect(isAdded(book({ isSubscribed: true }), [])).toBe(true);
|
||||
});
|
||||
|
||||
it("is added when only the settings remember it", () => {
|
||||
// The address book case: the server refused the write.
|
||||
expect(isAdded(book(), ["acct:ab1"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is not added when neither says so", () => {
|
||||
expect(isAdded(book(), [])).toBe(false);
|
||||
expect(isAdded(book(), ["other:ab1", "acct:ab2"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys are account-qualified", () => {
|
||||
it("does not confuse the same id in another account", () => {
|
||||
// Two accounts each having a book "ab1" is ordinary, not unlucky.
|
||||
expect(isAdded(book({ accountId: "theirs" }), ["mine:ab1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes two collections in one account", () => {
|
||||
expect(isAdded(book({ id: "ab2" }), ["acct:ab1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
* gets wrong if you take it at face value.
|
||||
*
|
||||
* `readEntries` answers with *up to* some number of entries and signals the end
|
||||
* of a directory with an empty array, so a single call quietly loses everything
|
||||
* past the first batch — a real folder of a few hundred files would upload the
|
||||
* first hundred and look like it had finished. And a directory tree that cycles
|
||||
* has to stop somewhere the tab is still alive.
|
||||
*/
|
||||
|
||||
const file = (name: string) => new File([name], name);
|
||||
|
||||
/** A directory whose contents arrive a batch at a time, as a real one does. */
|
||||
const dir = (name: string, children: unknown[], batch = 2) => {
|
||||
let at = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb: (e: never[]) => void) => {
|
||||
const slice = children.slice(at, at + batch);
|
||||
at += slice.length;
|
||||
cb(slice as never[]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const leaf = (name: string) => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (cb: (f: File) => void) => cb(file(name)),
|
||||
});
|
||||
|
||||
describe("walking a dropped folder", () => {
|
||||
it("reads a directory across as many batches as it takes", async () => {
|
||||
// Five children, two per readEntries call: a single read would find two.
|
||||
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
|
||||
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the folder each file came from", async () => {
|
||||
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
|
||||
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
|
||||
["outer", "top"],
|
||||
["outer/inner", "deep"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a loose file at the drop itself", async () => {
|
||||
const plan = await planUpload([leaf("loose")] as never[]);
|
||||
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
|
||||
});
|
||||
|
||||
it("stops rather than following a cycle for ever", async () => {
|
||||
const loop: Record<string, unknown> = {};
|
||||
Object.assign(loop, dir("loop", []));
|
||||
(loop as { createReader: () => unknown }).createReader = () => ({
|
||||
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
|
||||
});
|
||||
// Terminating at all is the assertion; the caps decide where. Both are set
|
||||
// low so the test does not have to read twenty thousand phantom entries.
|
||||
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
|
||||
expect(plan).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a plan needs", () => {
|
||||
it("lists parents before their children", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a", "b", "c"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("names each folder once, however many files are in it", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("asks for nothing when everything lands at the drop", () => {
|
||||
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotting a folder in the drop", () => {
|
||||
it("is true when any entry is a directory", () => {
|
||||
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
|
||||
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFileNode } from "@/lib/filenode";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Dragging a folder into its own subtree is the move that has to be refused
|
||||
* rather than reported: the server would orphan the branch, and the folder the
|
||||
* reader was dragging would leave the tree with everything under it.
|
||||
*/
|
||||
|
||||
const rights = (over: Partial<FileNode["myRights"]> = {}) => ({
|
||||
mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true, ...over,
|
||||
});
|
||||
|
||||
/** a > b > c, plus a file in a and a second top-level folder. */
|
||||
const tree = (): Record<Id, FileNode> => {
|
||||
const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial<FileNode> = {}) =>
|
||||
({ id, parentId, nodeType, name: id, myRights: rights(), ...over }) as FileNode;
|
||||
return {
|
||||
a: mk("a", null, "directory"),
|
||||
b: mk("b", "a", "directory"),
|
||||
c: mk("c", "b", "directory"),
|
||||
other: mk("other", null, "directory"),
|
||||
doc: mk("doc", "a", "file"),
|
||||
};
|
||||
};
|
||||
|
||||
describe("what a folder may be dropped on", () => {
|
||||
it("allows a move to an unrelated folder", () => {
|
||||
expect(canDropFileNode(tree(), "a", "other")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a drop on itself", () => {
|
||||
expect(canDropFileNode(tree(), "a", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a drop into its own subtree, however deep", () => {
|
||||
expect(canDropFileNode(tree(), "a", "b")).toBe(false);
|
||||
expect(canDropFileNode(tree(), "a", "c")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the parent it already has, which is a no-op dressed as a move", () => {
|
||||
expect(canDropFileNode(tree(), "b", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a child up to the top level, but not one already there", () => {
|
||||
expect(canDropFileNode(tree(), "b", null)).toBe(true);
|
||||
expect(canDropFileNode(tree(), "a", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("targets that cannot take it", () => {
|
||||
it("refuses a file as a target", () => {
|
||||
expect(canDropFileNode(tree(), "b", "doc")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder that will not take children", () => {
|
||||
const t = tree();
|
||||
t.other = { ...t.other!, myRights: rights({ mayAddChildren: false }) };
|
||||
expect(canDropFileNode(t, "a", "other")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a target that is not there at all", () => {
|
||||
expect(canDropFileNode(tree(), "a", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a file to be moved like anything else", () => {
|
||||
expect(canDropFileNode(tree(), "doc", "other")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isShared } from "@/lib/filenode";
|
||||
|
||||
/**
|
||||
* The one thing about file sharing that a mock would never have told us.
|
||||
*
|
||||
* Stalwart 0.16.19 answers `shareWith` as `{}` for a node shared with nobody,
|
||||
* not `null` — every unshared node in a live account came back that way on
|
||||
* 2026-08-27. A truthiness test on the property is therefore true for every
|
||||
* node the server has ever returned, and a badge driven by one would report
|
||||
* the entire account as shared while being, technically, about the right
|
||||
* property.
|
||||
*/
|
||||
|
||||
describe("whether a node is shared", () => {
|
||||
it("treats the empty object Stalwart sends as not shared", () => {
|
||||
expect(isShared({ shareWith: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing or null shareWith as not shared", () => {
|
||||
expect(isShared({ shareWith: null })).toBe(false);
|
||||
expect(isShared({})).toBe(false);
|
||||
});
|
||||
|
||||
it("is shared once a principal is on it", () => {
|
||||
expect(isShared({ shareWith: { p1: { mayRead: true } } as never })).toBe(true);
|
||||
});
|
||||
|
||||
it("stays shared when the rights granted are all false", () => {
|
||||
// An entry with nothing enabled is still an entry: the principal is on the
|
||||
// list, and the owner should see that rather than an empty-looking folder.
|
||||
expect(isShared({ shareWith: { p1: { mayRead: false } } as never })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
|
||||
|
||||
describe("idle sign-out on an untrusted device", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
stopIdleLogout();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("signs out after five minutes of nothing happening", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the clock again on any sign of a person", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires once, not repeatedly, and stops listening afterwards", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A late event must not resurrect a timer for a session that has ended.
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops cleanly, so a trusted sign-in is never signed out", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
stopIdleLogout();
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Issue #111: a folder id the account does not have rendered the ordinary
|
||||
* empty state — "Nothing here. This folder is empty" — which is a claim about
|
||||
* a folder that is not there. A stale link read as a folder that had emptied
|
||||
* itself rather than one that was gone.
|
||||
*
|
||||
* The interesting case is not the unknown id. It is `loaded`: the folder list
|
||||
* arrives after the first paint, so for a moment *every* id is unknown,
|
||||
* including the right one. A version without that gate sends the reader to
|
||||
* their inbox from the folder they asked for, on every cold load, and looks
|
||||
* exactly like a flaky link.
|
||||
*/
|
||||
|
||||
const boxes = (...ids: string[]): Record<string, Mailbox> =>
|
||||
Object.fromEntries(ids.map((id) => [id, { id, name: id } as Mailbox]));
|
||||
|
||||
describe("spotting a folder the account does not have", () => {
|
||||
it("is unknown when the list is loaded and does not contain it", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a", "b"), loaded: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is not unknown when the list contains it", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: boxes("a", "b"), loaded: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to call unknown", () => {
|
||||
it("says nothing before the folder list has arrived", () => {
|
||||
// The whole point. Every id is unknown at this moment, the real one too.
|
||||
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: {}, loaded: false })).toBe(false);
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: {}, loaded: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("says nothing when there is no folder in the address", () => {
|
||||
// /mail has its own redirect to the inbox; this must not race it.
|
||||
expect(isUnknownMailbox({ mailboxId: undefined, mailboxes: boxes("a"), loaded: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("says nothing on a search, which has no folder to be wrong about", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a"), loaded: true, search: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe("what it refuses to acknowledge", () => {
|
||||
});
|
||||
|
||||
const OPTS = {
|
||||
from: { name: "John Ellis", email: "[email protected]" } as EmailAddress,
|
||||
from: { name: "John Coffey", email: "[email protected]" } as EmailAddress,
|
||||
to: { name: null, email: "[email protected]" } as EmailAddress,
|
||||
finalRecipient: "[email protected]",
|
||||
reportingUa: "mail.example.org; ihasmail 2.0",
|
||||
|
||||
@@ -3,14 +3,14 @@ import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLo
|
||||
|
||||
describe("signature compaction", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Ellis</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const out = compactHtml(src);
|
||||
expect(out).not.toContain("mso-");
|
||||
expect(out).not.toContain("class=");
|
||||
expect(out).not.toContain("<xml");
|
||||
expect(out).not.toContain("o:p");
|
||||
expect(out).toContain("color:#1F4E79");
|
||||
expect(out).toContain("<b>John Ellis</b>");
|
||||
expect(out).toContain("<b>John Coffey</b>");
|
||||
expect(out).toContain('href="https://linuxexpert.org"');
|
||||
expect(out).toContain('width="100"');
|
||||
expect(out.length).toBeLessThan(src.length / 2);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/staleBuild";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function healthReplies(body: unknown, ok = true) {
|
||||
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("reloadIfServerRebuilt", () => {
|
||||
it("reloads when the server reports a different build", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("leaves the page alone when the versions match", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once per version, not once per 401", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears the guard once the versions agree again", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reload when the server cannot be reached", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a bad response or a missing version", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("noticing without being asked", () => {
|
||||
it("checks when the push stream drops, but not before it has connected", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onState = makeConnectionWatcher();
|
||||
|
||||
// never connected: a disconnect is not news
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
onState("connected");
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the server once when several things notice at the same moment", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the poll is what the guarantee rests on", () => {
|
||||
it("checks on its own while the tab is visible, with nobody touching it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
||||
|
||||
startBuildWatch();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves a hidden tab alone until it is looked at", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let visibility = "hidden";
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
|
||||
|
||||
startBuildWatch();
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
accountKey,
|
||||
clearAllData,
|
||||
clearSignedInData,
|
||||
isDeviceTrusted,
|
||||
loadJson,
|
||||
loadRaw,
|
||||
saveJson,
|
||||
setDeviceTrusted,
|
||||
} from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* The gate is a privacy boundary rather than a convenience, so it is tested
|
||||
* from both sides: that a trusted device still works exactly as it did, and
|
||||
* that an untrusted one leaves nothing to find.
|
||||
*/
|
||||
describe("device-trusted storage", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
|
||||
it("writes nothing at all when the device is not trusted", () => {
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not read residue left by an earlier trusted session", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
setDeviceTrusted(false);
|
||||
// The bytes are still on disk until a purge; the gate must not serve them.
|
||||
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips normally on a trusted device", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
|
||||
expect(isDeviceTrusted()).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
|
||||
setDeviceTrusted(true);
|
||||
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
|
||||
setDeviceTrusted(false);
|
||||
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson("mbx-expanded", { a: true });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
|
||||
|
||||
clearSignedInData();
|
||||
|
||||
expect(store.has("ihasmail:settings")).toBe(false);
|
||||
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
|
||||
expect(store.has("ihasmail:acct1:recent")).toBe(false);
|
||||
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
|
||||
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
|
||||
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
|
||||
});
|
||||
|
||||
it("clears everything, lastUser included, for an untrusted sign-in", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
|
||||
clearAllData();
|
||||
|
||||
expect([...store.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves keys belonging to anything else alone", () => {
|
||||
setDeviceTrusted(true);
|
||||
store.set("someone-elses-key", "keep me");
|
||||
clearAllData();
|
||||
expect(store.get("someone-elses-key")).toBe("keep me");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
||||
@@ -60,9 +60,14 @@ describe("the default theme", () => {
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
// Reads and writes are gated on device trust now, and the gate defaults to
|
||||
// closed. These tests are about `loadJson`'s merge, so open it and put it
|
||||
// back -- an untrusted device is covered by storage.test.ts instead.
|
||||
setDeviceTrusted(true);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { threadScrollTarget } from "@/lib/threadScroll";
|
||||
|
||||
/**
|
||||
* Issue #87: a conversation opened on its newest message, so unread mail sat
|
||||
* above the fold with nothing to announce it but a marker you had to scroll up
|
||||
* to see — and the auto-mark-read timer marked it read while you were still
|
||||
* looking at the bottom of the thread.
|
||||
*
|
||||
* The case that makes "second to last" the wrong answer is out-of-order
|
||||
* delivery: a message sent hours ago but queued on the sender's server arrives
|
||||
* last and sorts early. Messages here are in the order the pane renders them,
|
||||
* oldest first, which is receivedAt order.
|
||||
*/
|
||||
|
||||
const thread = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `m${i + 1}` }));
|
||||
const unread = (...ids: string[]) => new Set(ids);
|
||||
|
||||
describe("where a conversation opens", () => {
|
||||
it("opens on the oldest unread message", () => {
|
||||
expect(threadScrollTarget(thread(5), unread("m3", "m4"))).toBe("m3");
|
||||
});
|
||||
|
||||
it("opens on an unread message that arrived late and sorted early", () => {
|
||||
// The one the issue is about: m2 was delivered after m5, so opening at the
|
||||
// bottom hides it three messages up.
|
||||
expect(threadScrollTarget(thread(5), unread("m2"))).toBe("m2");
|
||||
});
|
||||
|
||||
it("opens on the newest message when the thread is all read", () => {
|
||||
expect(threadScrollTarget(thread(5), unread())).toBe("m5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it leaves the pane where it is", () => {
|
||||
it("stays at the top when the first message is the unread one", () => {
|
||||
// Scrolling to it would push the subject off the top for nothing.
|
||||
expect(threadScrollTarget(thread(4), unread("m1", "m3"))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not scroll a single message", () => {
|
||||
expect(threadScrollTarget(thread(1), unread("m1"))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not scroll an empty thread", () => {
|
||||
expect(threadScrollTarget([], unread())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Which account a request goes to.
|
||||
*
|
||||
* A JMAP session lists more than one account whenever anything is shared with
|
||||
* you: the sharer's account appears alongside your own, carrying whichever
|
||||
* capabilities they shared. Switching to one is how you read their files, so
|
||||
* some requests have to follow that selection.
|
||||
*
|
||||
* Others must never follow it, and telling the two apart is the whole point of
|
||||
* this file. ihasmail keeps its own settings in the account's Files — that is
|
||||
* what makes them travel between devices — and a shared file account advertises
|
||||
* the file capability by definition. So the obvious rule, "use whichever
|
||||
* account is selected if it can do this", writes your settings into the other
|
||||
* person's storage the moment you change one while looking at their folder. It
|
||||
* would create the `ihasmail` folder there to do it.
|
||||
*
|
||||
* Two questions, then, and they have different answers:
|
||||
*
|
||||
* - what am I *looking at* -> `accountForCapability`, follows the selection
|
||||
* - what is *mine* -> `ownAccountForCapability`, never does
|
||||
*
|
||||
* There is a third rule hiding in the first. A capability the selected account
|
||||
* does not advertise used to fall back to that account anyway, so a session
|
||||
* with no primary account for something would aim it at whoever was selected —
|
||||
* someone else. Falling back to nothing is the honest answer: the feature is
|
||||
* unavailable, which is true, rather than pointed at a stranger's data.
|
||||
*/
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
export interface AccountLike {
|
||||
/** JMAP: true when the account belongs to the authenticated user. */
|
||||
isPersonal: boolean;
|
||||
accountCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionLike {
|
||||
accounts: Record<Id, AccountLike>;
|
||||
primaryAccounts: Record<string, Id>;
|
||||
}
|
||||
|
||||
const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
||||
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
||||
|
||||
/**
|
||||
* The account to read and write for this capability, honouring the switcher.
|
||||
*
|
||||
* Use for anything the reader is looking at: their mail, a shared calendar,
|
||||
* somebody's files. Not for anything of the reader's own — see below.
|
||||
*/
|
||||
export function accountForCapability(session: SessionLike | null, selectedId: Id | null, cap: string): Id | null {
|
||||
if (!session) return null;
|
||||
const selected = selectedId ? session.accounts[selectedId] : undefined;
|
||||
if (selected && advertises(selected, cap)) return selectedId;
|
||||
const primary = session.primaryAccounts[cap];
|
||||
if (primary) return primary;
|
||||
// No primary, and the selection cannot serve this. Falling back to the
|
||||
// selection would aim the request at a shared account for something nobody
|
||||
// shared; only one of the reader's own accounts may stand in.
|
||||
if (selected && selected.isPersonal) return selectedId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's own account for this capability, whatever they are looking at.
|
||||
*
|
||||
* Use for the reader's own state -- synced settings, signature images, push
|
||||
* registration. These belong to them and follow them, and must not land in an
|
||||
* account somebody shared just because it happens to be on screen.
|
||||
*/
|
||||
export function ownAccountForCapability(session: SessionLike | null, cap: string): Id | null {
|
||||
if (!session) return null;
|
||||
const primary = session.primaryAccounts[cap];
|
||||
// A primary account is the reader's own by definition, but check rather than
|
||||
// assume: a server that named a shared one here would otherwise be trusted.
|
||||
if (primary && session.accounts[primary]?.isPersonal !== false) return primary;
|
||||
const own = Object.entries(session.accounts).find(([, a]) => a.isPersonal && advertises(a, cap));
|
||||
return own?.[0] ?? null;
|
||||
}
|
||||
@@ -8,11 +8,12 @@
|
||||
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import type { Id } from "@/jmap/types";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { descendantIds } from "./folderMove";
|
||||
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"];
|
||||
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
|
||||
}
|
||||
|
||||
/** Create-arguments for a directory. */
|
||||
@@ -24,3 +25,43 @@ export function directoryCreate(parentId: Id | null, name: string): Record<strin
|
||||
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
||||
return { parentId, name, blobId, type, nodeType: "file" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a node is shared with anyone.
|
||||
*
|
||||
* Stalwart answers `shareWith` as `{}` for "nobody", not `null` — confirmed
|
||||
* against 0.16.19 on 2026-08-27, where every unshared node in the account came
|
||||
* back that way. So a truthiness test passes for every node ever returned, and
|
||||
* a badge driven by one would say the whole account is shared. Count the keys.
|
||||
*/
|
||||
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
||||
return Object.keys(node.shareWith ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the node being dragged may be dropped on `targetId`, null being the
|
||||
* top level.
|
||||
*
|
||||
* The same four refusals as folders: onto itself, into its own subtree, onto
|
||||
* the parent it already has, or -- for the top level -- when it is already
|
||||
* there. `descendantIds` is shared with the mailbox tree, since both are the
|
||||
* same shape of tree asking the same question.
|
||||
*
|
||||
* Rights are deliberately only half-checked. A target that will not take
|
||||
* children is refused here, because that is unambiguous. Whether the node may
|
||||
* leave the parent it is in is not: JMAP models a move as an update of
|
||||
* `parentId` and does not say which right covers it, and guessing would hide
|
||||
* legal moves behind a disabled drop. The server refuses those with a message
|
||||
* of its own, which is a better answer than a silent one.
|
||||
*/
|
||||
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
||||
const dragged = nodes[draggedId];
|
||||
if (!dragged) return false;
|
||||
if (targetId === null) return dragged.parentId != null;
|
||||
if (targetId === draggedId) return false;
|
||||
if (dragged.parentId === targetId) return false;
|
||||
const target = nodes[targetId];
|
||||
if (!target || target.nodeType !== "directory") return false;
|
||||
if (target.myRights && !target.myRights.mayAddChildren) return false;
|
||||
return !descendantIds(nodes, draggedId).has(targetId);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ export function movable(m: Mailbox): boolean {
|
||||
return !m.role || m.role === "subscribed";
|
||||
}
|
||||
|
||||
/** Every folder beneath this one, so a folder cannot be dropped inside itself. */
|
||||
export function descendantIds(mailboxes: Record<Id, Mailbox>, id: Id): Set<Id> {
|
||||
/**
|
||||
* Every node beneath this one, so a node cannot be dropped inside itself.
|
||||
*
|
||||
* Written against `{ id, parentId }` rather than `Mailbox` because file nodes
|
||||
* form the same shape of tree and need the same answer -- see `canDropFileNode`.
|
||||
*/
|
||||
export function descendantIds<T extends { id: Id; parentId: Id | null }>(tree: Record<Id, T>, id: Id): Set<Id> {
|
||||
const out = new Set<Id>();
|
||||
const all = Object.values(mailboxes);
|
||||
const all = Object.values(tree);
|
||||
let frontier = new Set<Id>([id]);
|
||||
// Depth is bounded by the server's own mailbox depth limit; the guard is only
|
||||
// here so a cycle in the data cannot spin forever.
|
||||
// Depth is bounded by the server's own depth limit; the guard is only here so
|
||||
// a cycle in the data cannot spin forever.
|
||||
for (let depth = 0; depth < 20 && frontier.size; depth++) {
|
||||
const next = new Set<Id>();
|
||||
for (const m of all) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Sign out an untrusted device after a few minutes of inactivity.
|
||||
*
|
||||
* This exists because the alternative does not work. Asking someone to
|
||||
* remember to sign out relies on the person, which is the part you cannot rely
|
||||
* on when the machine is not theirs — and a browser cannot help: custom
|
||||
* `beforeunload` text was removed years ago, and no event fires at all for the
|
||||
* case that actually matters, which is walking away from a signed-in screen.
|
||||
*
|
||||
* A timer needs nobody's cooperation, so that is what this is.
|
||||
*
|
||||
* Trusted devices are left alone entirely: the whole point of saying a machine
|
||||
* is yours is not being signed out of it.
|
||||
*/
|
||||
const IDLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Coarse enough not to fire constantly, broad enough to catch a person reading. */
|
||||
const ACTIVITY = ["mousedown", "keydown", "touchstart", "scroll", "focus"] as const;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let onExpire: (() => void) | null = null;
|
||||
|
||||
function arm(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const fn = onExpire;
|
||||
stopIdleLogout();
|
||||
fn?.();
|
||||
}, IDLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading a long message is not idleness, but it produces no events either.
|
||||
* Visibility is the honest signal available: a hidden tab is one nobody is
|
||||
* looking at, so the clock keeps running; showing it again is activity.
|
||||
*/
|
||||
function onVisibility(): void {
|
||||
if (document.visibilityState === "visible") arm();
|
||||
}
|
||||
|
||||
export function startIdleLogout(expire: () => void): void {
|
||||
stopIdleLogout();
|
||||
onExpire = expire;
|
||||
for (const ev of ACTIVITY) window.addEventListener(ev, arm, { passive: true, capture: true });
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
arm();
|
||||
}
|
||||
|
||||
export function stopIdleLogout(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
onExpire = null;
|
||||
for (const ev of ACTIVITY) window.removeEventListener(ev, arm, { capture: true });
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
/** Exported for tests, which should not wait five real minutes. */
|
||||
export const IDLE_TIMEOUT_MS = IDLE_MS;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Whether the folder in the address is one this account does not have.
|
||||
*
|
||||
* Rendering it as an empty folder was the bug (#111): "Nothing here. This
|
||||
* folder is empty" is a claim about a folder that is not there, so a stale link
|
||||
* read as a folder that had emptied itself rather than one that was gone.
|
||||
*
|
||||
* The condition that matters is `loaded`. The folder list arrives after the
|
||||
* first paint, so for a moment every id is unknown -- including the right one.
|
||||
* Without that gate this answers true on every cold load and sends the reader
|
||||
* to their inbox from the folder they asked for, which is a worse bug than the
|
||||
* one it fixes and would look exactly like a flaky link.
|
||||
*/
|
||||
export function isUnknownMailbox(args: {
|
||||
mailboxId: Id | undefined;
|
||||
mailboxes: Record<Id, Mailbox>;
|
||||
loaded: boolean;
|
||||
search?: boolean;
|
||||
}): boolean {
|
||||
const { mailboxId, mailboxes, loaded, search } = args;
|
||||
if (search) return false;
|
||||
if (!mailboxId) return false;
|
||||
if (!loaded) return false;
|
||||
return !mailboxes[mailboxId];
|
||||
}
|
||||
@@ -36,7 +36,7 @@ let armed = false;
|
||||
let listenersBound = false;
|
||||
|
||||
export function settingsSyncAvailable(): boolean {
|
||||
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
||||
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().ownAccountFor(CAP.filenode));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,7 @@ export function settingsSyncAvailable(): boolean {
|
||||
*/
|
||||
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
|
||||
if (!settingsSyncAvailable()) return null;
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode)!;
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
|
||||
try {
|
||||
const folderId = await ensureFolder(accountId);
|
||||
const node = await findInFolder(accountId, folderId, FILE);
|
||||
@@ -109,7 +109,7 @@ export async function flushSettingsPush(): Promise<void> {
|
||||
|
||||
async function writeSettings(body: Record<string, unknown>): Promise<void> {
|
||||
if (!settingsSyncAvailable()) return;
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode)!;
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
|
||||
const json = JSON.stringify(body, null, 2);
|
||||
// Byte length, not character count: a template or a signature with any
|
||||
// non-ASCII in it would otherwise be reported shorter than it is.
|
||||
|
||||
@@ -174,6 +174,100 @@ export function rulesToSieve(rules: SieveRule[]): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this script must not be rewritten from the rules parsed out of it, or
|
||||
* null when rewriting it is safe.
|
||||
*
|
||||
* Saving replaces the whole script with a fresh serialization of the rules read
|
||||
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
||||
* the alarm by itself: it skips what it does not recognise, so a script cut off
|
||||
* partway through parses cleanly into a shorter list and looks exactly like one
|
||||
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
||||
* truncated download, a plausible parse, and a save that wrote the short
|
||||
* version back over the real one. The transport fault behind it is fixed in the
|
||||
* blob proxy; this is the check that makes the save path refuse regardless of
|
||||
* how the content came to be short.
|
||||
*
|
||||
* The tests are structural rather than an equality check against
|
||||
* `rulesToSieve(sieveToRules(content))`. A script written by an older version
|
||||
* whose serializer differed in some detail is intact, and refusing to let
|
||||
* anyone edit their rules over a changed byte would be the worse bug.
|
||||
*/
|
||||
export function scriptDamage(content: string): string | null {
|
||||
// Not one of ours: genuinely empty, or hand-written. Both mean something
|
||||
// else and are answered elsewhere.
|
||||
if (!content.includes("# rule:") && !content.includes(SCRIPT_HEADER)) {
|
||||
// Unless it is one of ours cut off inside its own first line, which reads
|
||||
// as a very short hand-written script -- and that reading is the one that
|
||||
// offers to replace it.
|
||||
const head = content.replace(/\n+$/, "");
|
||||
if (head !== "" && SCRIPT_HEADER.startsWith(head)) return "breaks off inside its first line";
|
||||
return null;
|
||||
}
|
||||
// Every generated script ends with a newline, so a body that stops mid-line
|
||||
// stopped early. The rest of the walk covers the cuts that land on one.
|
||||
if (!content.endsWith("\n")) return "stops in the middle of a line";
|
||||
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
let i = 0;
|
||||
let hadRequire = false;
|
||||
if (lines[0] === SCRIPT_HEADER) {
|
||||
i = 1;
|
||||
hadRequire = lines[i]?.startsWith("require ") ?? false;
|
||||
if (hadRequire) i++;
|
||||
if (lines[i] !== "") return "breaks off in its opening lines";
|
||||
i++;
|
||||
} else {
|
||||
// Header edited away but the rule comments kept. Still ours to walk.
|
||||
i = lines.findIndex((l) => l.startsWith("# rule:"));
|
||||
}
|
||||
|
||||
// Walk the shape rulesToSieve emits, one rule block at a time. Deliberately
|
||||
// structural: the condition and action lines are read only for their
|
||||
// presence, so a serializer that words them differently is still intact.
|
||||
let seen = 0;
|
||||
const cut = (r: SieveRule, what: string) => `has a rule in it (“${r.name}”) ${what}`;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (!line.startsWith("# rule:")) return "has a stray line where a rule should start";
|
||||
let rule: SieveRule | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(7)) as SieveRule;
|
||||
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tests) && Array.isArray(parsed.actions)) rule = parsed;
|
||||
} catch {
|
||||
/* reported below */
|
||||
}
|
||||
if (!rule) return "has a rule in it that breaks off unfinished";
|
||||
seen++;
|
||||
i++;
|
||||
// `!r.enabled` is how rulesToSieve chooses the branch, so match it exactly
|
||||
// rather than testing for `=== false`.
|
||||
if (rule.enabled) {
|
||||
if (!lines[i]?.startsWith("if ")) return cut(rule, "with nothing below it");
|
||||
i++;
|
||||
if (lines[i] !== "{") return cut(rule, "whose body never opens");
|
||||
i++;
|
||||
while (i < lines.length && lines[i] !== "}") {
|
||||
if (lines[i] === "") return cut(rule, "whose body breaks off");
|
||||
i++;
|
||||
}
|
||||
if (i >= lines.length) return cut(rule, "whose body never closes");
|
||||
i++;
|
||||
} else {
|
||||
if (!lines[i]?.startsWith("# (disabled) ")) return cut(rule, "with nothing below it");
|
||||
i++;
|
||||
}
|
||||
// Each block is followed by a blank line, the last one included: it is the
|
||||
// empty final element left by the trailing newline.
|
||||
if (lines[i] !== "") return cut(rule, "that runs into what follows it");
|
||||
i++;
|
||||
}
|
||||
// A require line is written only for rules that need it, so one standing over
|
||||
// no rules at all means the rules it was written for are gone.
|
||||
if (hadRequire && seen === 0) return "breaks off before the first rule";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
|
||||
export function sieveToRules(script: string): SieveRule[] | null {
|
||||
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { toast } from "@/ui/toast";
|
||||
|
||||
/** Upload an image for use in a signature; returns a same-origin blob URL. */
|
||||
export async function uploadSignatureImage(file: File): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) {
|
||||
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
|
||||
throw new Error("filenode unavailable");
|
||||
@@ -42,7 +42,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
|
||||
|
||||
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
|
||||
export async function storeSignatureHtml(html: string): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
|
||||
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
||||
const folderId = await ensureFolder(accountId);
|
||||
@@ -77,7 +77,9 @@ export async function externalizeDataImages(html: string): Promise<string> {
|
||||
|
||||
/** Load the full HTML of a marker signature. */
|
||||
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
|
||||
// No `?? accountId` fallback: a signature is the reader's own, and the
|
||||
// selected account may be somebody else's shared one.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||
if (!accountId) throw new Error("no account");
|
||||
return client.fetchBlobText(accountId, blobId, type);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
* source. The server says where its own lives, via SOURCE_URL; this is only the
|
||||
* fallback for when it has not been asked yet, or has nothing to say.
|
||||
*/
|
||||
export const DEFAULT_SOURCE_URL = "https://github.com/LINUXexpert-org/ihasmail";
|
||||
export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail";
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { APP_VERSION } from "./version";
|
||||
import { push, type PushState } from "@/jmap/push";
|
||||
|
||||
/**
|
||||
* Reload the page when the server is serving a build this one did not come
|
||||
* from.
|
||||
*
|
||||
* Signing out and picking up a new version are separate things, and only the
|
||||
* first happens on its own. An immutable instance holds sessions in memory, so
|
||||
* a deploy signs everyone out -- but the tab that was open still has the old
|
||||
* bundle in it, and a 401 only swaps the view to the sign-in form. The old
|
||||
* JavaScript would go on talking to the new server until someone happened to
|
||||
* reload by hand.
|
||||
*
|
||||
* `index.html` is served `no-cache` and the assets under it are content-hashed
|
||||
* and immutable, so a reload is all it takes; the only missing part was
|
||||
* something to ask for one. Comparing versions rather than reloading on every
|
||||
* 401 means an ordinary session expiry still lands on the sign-in form with the
|
||||
* page intact -- only a build that actually moved costs the page.
|
||||
*
|
||||
* The reload is unconditional once the versions differ. A compose window can
|
||||
* be holding text that never reached the server, and after a deploy it cannot
|
||||
* be saved either, since the session went with the container -- so this will
|
||||
* sometimes take an unsent draft with it. That is a deliberate trade: a tab
|
||||
* running code the server no longer speaks is the worse failure, and one that
|
||||
* stays behind because someone left a draft open is not automatic at all.
|
||||
*/
|
||||
const TRIED_KEY = "ihasmail:reloaded-for";
|
||||
|
||||
/** sessionStorage throws outright in some privacy modes; treat that as absent. */
|
||||
function tried(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(TRIED_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(version: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(TRIED_KEY, version);
|
||||
} catch {
|
||||
/* nothing to do: the guard below is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function forget(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(TRIED_KEY);
|
||||
} catch {
|
||||
/* as above */
|
||||
}
|
||||
}
|
||||
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* True when a reload has been asked for and the caller should leave the page
|
||||
* alone. False for every other outcome, including not being able to tell --
|
||||
* failing to reach the server is not a reason to throw away what is on screen.
|
||||
*/
|
||||
export function reloadIfServerRebuilt(): Promise<boolean> {
|
||||
// Several things can notice a deploy at once -- the stream dropping and the
|
||||
// request that follows it -- and they should not each ask the server.
|
||||
inFlight ??= check().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
let serverVersion: string;
|
||||
try {
|
||||
const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" });
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { version?: unknown };
|
||||
if (typeof body.version !== "string" || !body.version) return false;
|
||||
serverVersion = body.version;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serverVersion === APP_VERSION) {
|
||||
// Back in step, either because nothing changed or because an earlier
|
||||
// reload worked. Clear the guard so the next deploy is not mistaken for
|
||||
// one already attempted.
|
||||
forget();
|
||||
return false;
|
||||
}
|
||||
// Reloading once per version, not once per 401: if the new bundle somehow
|
||||
// still reports the old version -- a stale proxy cache, a half-finished
|
||||
// deploy -- this stops the two of them reloading each other in a loop.
|
||||
if (tried() === serverVersion) return false;
|
||||
remember(serverVersion);
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for a deploy without waiting to be asked.
|
||||
*
|
||||
* Checking on a 401 alone was not automatic, only deferred: it needs the tab to
|
||||
* make a request, so one sitting idle keeps running the old build until someone
|
||||
* touches it.
|
||||
*
|
||||
* The obvious signal turned out to be the wrong one. A deploy kills the
|
||||
* EventSource behind `/api/events`, which looks like the perfect cue -- except
|
||||
* it arrives while the container is still being replaced, so the check that
|
||||
* follows cannot reach the server. Waiting for the stream to come back instead
|
||||
* does not work either: the session died with the old container, so the
|
||||
* reconnect is answered with a 401 and never reaches "connected" at all. The
|
||||
* drop is kept below because it is free and sometimes lands early enough to be
|
||||
* useful, but nothing depends on it.
|
||||
*
|
||||
* What the guarantee rests on is a slow poll while the tab is visible, plus a
|
||||
* check when it becomes visible again. Neither cares what the stream is doing
|
||||
* or whether anyone is at the keyboard: a tab left open through a deploy
|
||||
* notices within a minute, and a backgrounded one notices the moment it is
|
||||
* looked at. `/api/health` touches nothing upstream, so the cost is one small
|
||||
* request a minute per open tab.
|
||||
*/
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
export function makeConnectionWatcher(): (state: PushState) => void {
|
||||
let wasConnected = false;
|
||||
return (state) => {
|
||||
if (state === "connected") {
|
||||
wasConnected = true;
|
||||
return;
|
||||
}
|
||||
// Only a drop is news. Never having connected is not evidence of anything.
|
||||
if (!wasConnected) return;
|
||||
wasConnected = false;
|
||||
void reloadIfServerRebuilt();
|
||||
};
|
||||
}
|
||||
|
||||
export function startBuildWatch(): void {
|
||||
push.onConnection(makeConnectionWatcher());
|
||||
|
||||
window.setInterval(() => {
|
||||
// A hidden tab is not being read, and will be checked when it surfaces.
|
||||
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
||||
}, POLL_MS);
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,93 @@
|
||||
/**
|
||||
* Local storage, gated on whether this device is trusted.
|
||||
*
|
||||
* Everything here is a *cache* or a screen preference — the real copy lives in
|
||||
* the account's JMAP Files (see `settingsSync`). That makes it safe to write
|
||||
* nothing at all, which is what an untrusted device does: on a shared or public
|
||||
* machine the cost of a stale first frame is nothing beside leaving someone's
|
||||
* address book on it.
|
||||
*
|
||||
* Reads are gated as well as writes. A machine that was trusted once still has
|
||||
* the residue, and honouring it would let a previous session's data surface in
|
||||
* a later untrusted one.
|
||||
*/
|
||||
const PREFIX = "ihasmail:";
|
||||
|
||||
/**
|
||||
* Kept when a session ends. Everything else is cleared, so a key added later
|
||||
* is forgotten by default rather than by nobody having thought about it.
|
||||
*
|
||||
* - `lastUser` is a deliberate convenience: it prefills the sign-in field, and
|
||||
* it is only ever written by a trusted device in the first place.
|
||||
* - `deviceTrusted` is how the next boot knows to read at all.
|
||||
* - `pushDeviceId` is a random id for this browser, so re-subscribing replaces
|
||||
* rather than accumulates. The subscription itself is removed on sign-out.
|
||||
*/
|
||||
const KEEP_ON_SIGN_OUT = ["lastUser", "deviceTrusted", "pushDeviceId"];
|
||||
|
||||
const TRUST_KEY = `${PREFIX}deviceTrusted`;
|
||||
|
||||
/**
|
||||
* Read at module load rather than waiting for the session, so a trusted device
|
||||
* still paints its first frame from cache. An untrusted one has nothing to
|
||||
* read, so there is nothing to wait for.
|
||||
*/
|
||||
let trusted = (() => {
|
||||
try {
|
||||
return localStorage.getItem(TRUST_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
export function isDeviceTrusted(): boolean {
|
||||
return trusted;
|
||||
}
|
||||
|
||||
/** Set from the session's `remember` flag, which is the answer given at sign-in. */
|
||||
export function setDeviceTrusted(value: boolean): void {
|
||||
trusted = value;
|
||||
try {
|
||||
if (value) localStorage.setItem(TRUST_KEY, "1");
|
||||
else localStorage.removeItem(TRUST_KEY);
|
||||
} catch {
|
||||
/* private mode: the in-memory flag still holds for this tab */
|
||||
}
|
||||
}
|
||||
|
||||
/** Every `ihasmail:` key currently present, without the prefix. */
|
||||
function ownKeys(): string[] {
|
||||
const out: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(PREFIX)) out.push(k.slice(PREFIX.length));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop what this browser was holding for a signed-in account. Called on every
|
||||
* sign-out, trusted or not: handing a laptop to someone else is the same
|
||||
* exposure as a public machine, only quieter.
|
||||
*/
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
removeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything, `lastUser` included — for signing in to a device we do not trust. */
|
||||
export function clearAllData(): void {
|
||||
for (const key of ownKeys()) removeKey(key);
|
||||
}
|
||||
|
||||
export function loadJson<T>(key: string, fallback: T): T {
|
||||
if (!trusted) return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
@@ -11,6 +98,7 @@ export function loadJson<T>(key: string, fallback: T): T {
|
||||
}
|
||||
|
||||
export function loadRaw<T>(key: string, fallback: T): T {
|
||||
if (!trusted) return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
@@ -21,6 +109,7 @@ export function loadRaw<T>(key: string, fallback: T): T {
|
||||
}
|
||||
|
||||
export function saveJson(key: string, value: unknown): void {
|
||||
if (!trusted) return;
|
||||
try {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Where a conversation opens.
|
||||
*
|
||||
* It used to open on the newest message, which is wrong whenever anything in
|
||||
* the thread is unread: the unread mail sits above the fold, and the only clue
|
||||
* it exists is the marker on a message you have to scroll up to find. The
|
||||
* auto-mark-read timer then sweeps the whole thread, so scrolling up late is
|
||||
* scrolling up to mail that is already marked read (#87).
|
||||
*
|
||||
* Order is receivedAt, not arrival, so the first unread is not the second-to-
|
||||
* last message or any other position you can guess at. A thread where one
|
||||
* participant's server queued a message for hours delivers it late and sorts it
|
||||
* early -- exactly the case where opening at the bottom hides the most.
|
||||
*
|
||||
* Two answers are "don't move":
|
||||
*
|
||||
* - a single message, which is already the whole pane
|
||||
* - the first unread being the first message, where the top of the pane
|
||||
* shows it anyway, together with the subject
|
||||
*
|
||||
* `unread` is the set captured when the thread was opened rather than live
|
||||
* `$seen` state, for the same reason expansion uses it: the mark-read timer
|
||||
* must not change the shape of what you are looking at (#69).
|
||||
*/
|
||||
export function threadScrollTarget<T extends { id: string }>(
|
||||
messages: readonly T[],
|
||||
unread: ReadonlySet<string>,
|
||||
): string | null {
|
||||
if (messages.length < 2) return null;
|
||||
const firstUnread = messages.findIndex((m) => unread.has(m.id));
|
||||
if (firstUnread === 0) return null;
|
||||
if (firstUnread > 0) return messages[firstUnread]!.id;
|
||||
// Nothing unread: the newest message, which is what you came for.
|
||||
return messages[messages.length - 1]!.id;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`, or `2.16.57+g1fa6578` for a commit
|
||||
* that did not come through a pull request. Baked in by Vite; see
|
||||
* `scripts/version.mjs` for where the parts come from.
|
||||
* What this build calls itself: `2026.8.30+pr129` -- the date of the commit it
|
||||
* was built from, and the pull request that commit arrived through. A commit
|
||||
* that did not come through one carries its short SHA instead,
|
||||
* `2026.8.30+g1fa6578`. Baked in by Vite; see `scripts/version.mjs` for why the
|
||||
* parts are what they are.
|
||||
*/
|
||||
export const APP_VERSION = __IHASMAIL_VERSION__;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
||||
@@ -93,6 +94,10 @@ export function encodeKey(buffer: ArrayBuffer | null): string {
|
||||
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
|
||||
export function deviceClientId(): string {
|
||||
const KEY = "ihasmail:pushDeviceId";
|
||||
// An untrusted device gets a per-session id instead of a stored one. It is
|
||||
// the same trade private mode already makes below: re-subscribing will not
|
||||
// reuse it, which costs nothing when push is refused there anyway.
|
||||
if (!isDeviceTrusted()) return `ihasmail-${crypto.randomUUID()}`;
|
||||
try {
|
||||
const existing = localStorage.getItem(KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import {
|
||||
@@ -67,6 +68,12 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
if (Notification.permission === "denied") {
|
||||
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
|
||||
}
|
||||
// A subscription outlives the tab and belongs to the account, not the
|
||||
// session -- so on a machine the user has told us is not theirs, it would go
|
||||
// on delivering their mail to it long after they had gone.
|
||||
if (!isDeviceTrusted()) {
|
||||
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
|
||||
}
|
||||
const key = applicationServerKey();
|
||||
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
|
||||
|
||||
@@ -78,7 +85,7 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeApplicationServerKey(key),
|
||||
}));
|
||||
const accountId = useSession.getState().accountFor(CAP.mail);
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||
const inboxId = useMail.getState().roleId("inbox");
|
||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
||||
listenForVerification();
|
||||
|
||||
@@ -2,6 +2,9 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/app.css";
|
||||
import { App } from "./App";
|
||||
import { startBuildWatch } from "@/lib/staleBuild";
|
||||
|
||||
startBuildWatch();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { CalendarSetError, eventIdForScope, isOccurrence, isThisAndFutureRefusal, occurrencePatch, OccurrenceScopeError, useCalendar } from "@/store/calendar";
|
||||
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
|
||||
* refused one outright, so a mutation aimed at the wrong id of an expanded
|
||||
* occurrence arrived as a toast rather than as data loss.
|
||||
*
|
||||
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
|
||||
* destroy that meant the series removes one date and reports success, under a
|
||||
* dialog that said "Delete all occurrences?". The resolution therefore lives in
|
||||
* the store behind a required `scope`, and these tests are what stops it
|
||||
* drifting back out to the callers.
|
||||
*/
|
||||
|
||||
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
|
||||
const OCCURRENCE: CalendarEvent = {
|
||||
id: "iaaaaas",
|
||||
baseEventId: "i",
|
||||
"@type": "Event",
|
||||
uid: "u1",
|
||||
calendarIds: { c1: true },
|
||||
start: "2026-09-02T09:00:00",
|
||||
duration: "PT30M",
|
||||
recurrenceId: "2026-09-02T09:00:00",
|
||||
participants: {
|
||||
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
/** A one-off, which an expanded query still hands back with a base of its own. */
|
||||
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
|
||||
|
||||
/** A master, fetched by id rather than expanded. */
|
||||
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
|
||||
|
||||
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
|
||||
|
||||
/**
|
||||
* A server that renumbers, the way 0.16.20 does.
|
||||
*
|
||||
* `resolvesTo` is the id the occurrence answers to *now* — deliberately not the
|
||||
* id the cached object carries, because that is exactly the situation a write
|
||||
* to the series leaves behind. A store that sends the id it was handed rather
|
||||
* than the one it looked up will send `iaaaaas` and these tests will say so.
|
||||
*/
|
||||
function server(opts: { resolvesTo?: string | null } = {}) {
|
||||
const calls: SetCall[] = [];
|
||||
const resolved = opts.resolvesTo === undefined ? OCCURRENCE.id : opts.resolvesTo;
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "CalendarEvent/get" && id === "g") {
|
||||
// The re-resolution lookup: same recurrenceId, whatever id it wears now.
|
||||
const list = resolved ? [{ ...OCCURRENCE, id: resolved }] : [];
|
||||
return [name, { accountId: "a1", state: "1", list, notFound: [] }, id];
|
||||
}
|
||||
if (name === "CalendarEvent/set") {
|
||||
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
|
||||
return [name, {
|
||||
accountId: "a1", oldState: "1", newState: "2",
|
||||
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
|
||||
destroyed: (args.destroy ?? []) as string[],
|
||||
notUpdated: {}, notDestroyed: {},
|
||||
}, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
||||
accounts: {},
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useCalendar.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
calendars: {},
|
||||
events: { [OCCURRENCE.id]: OCCURRENCE },
|
||||
ranges: {},
|
||||
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("eventIdForScope", () => {
|
||||
it("walks an occurrence up to its master for the series", () => {
|
||||
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
|
||||
});
|
||||
it("sends the instance as it came for a single occurrence", () => {
|
||||
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
|
||||
});
|
||||
it("resolves a master to itself under either scope", () => {
|
||||
expect(eventIdForScope(MASTER, "series")).toBe("i");
|
||||
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
|
||||
});
|
||||
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
|
||||
// An expanded query gives a one-off an instance id over a different base.
|
||||
// Stalwart resolves a synthetic id on a component that is neither recurrent
|
||||
// nor an override back to the base event, so both scopes are safe here —
|
||||
// but only `series` sends the id that is unambiguously the event.
|
||||
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
|
||||
expect(isOccurrence(ONE_OFF)).toBe(true);
|
||||
});
|
||||
it("does not call a master an occurrence", () => {
|
||||
expect(isOccurrence(MASTER)).toBe(false);
|
||||
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyEvent", () => {
|
||||
it("sends the master id for a series, never the synthetic one", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
|
||||
expect(calls[0]!.destroy).toEqual(["i"]);
|
||||
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
||||
});
|
||||
|
||||
it("sends the id the occurrence answers to now, not the one it was handed", async () => {
|
||||
// The live finding: writing one override renumbers the series, so an id
|
||||
// cached a moment ago addresses a different date. `recurrenceId` is the
|
||||
// stable handle, so the store looks the current id up by it.
|
||||
const calls = server({ resolvesTo: "renumbered7" });
|
||||
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||
expect(calls[0]!.destroy).toEqual(["renumbered7"]);
|
||||
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
||||
});
|
||||
|
||||
it("refuses rather than guessing when the date is no longer in the series", async () => {
|
||||
const calls = server({ resolvesTo: null });
|
||||
await expect(useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence"))
|
||||
.rejects.toThrow(/no longer part of this series/i);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops the occurrence from the cache without evicting the master", async () => {
|
||||
server();
|
||||
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
|
||||
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
|
||||
expect(useCalendar.getState().events.i).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateEvent", () => {
|
||||
it("patches the master for a series", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
|
||||
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||
});
|
||||
|
||||
it("patches the id the occurrence answers to now", async () => {
|
||||
const calls = server({ resolvesTo: "renumbered7" });
|
||||
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
|
||||
expect(Object.keys(calls[0]!.update!)).toEqual(["renumbered7"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsvp", () => {
|
||||
it("answers for the series even when handed an occurrence", async () => {
|
||||
// The patch itself survives either scope: `participationStatus` is one of
|
||||
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
|
||||
// instance would quietly mean "only that day" and nothing would say so.
|
||||
const calls = server();
|
||||
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
|
||||
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
|
||||
});
|
||||
|
||||
it("refuses when the signed-in identity is not a participant", async () => {
|
||||
server();
|
||||
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
|
||||
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("occurrencePatch", () => {
|
||||
it("lets through what one date will actually take", () => {
|
||||
const { patch, dropped } = occurrencePatch({ title: "Just today", color: "#f00" });
|
||||
expect(patch).toEqual({ title: "Just today", color: "#f00" });
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on a property the server refuses outright", () => {
|
||||
// Loud is correct here: moving one occurrence to another calendar is not
|
||||
// something the user can be quietly given a different answer to.
|
||||
expect(() => occurrencePatch({ calendarIds: { c2: true } })).toThrow(OccurrenceScopeError);
|
||||
expect(() => occurrencePatch({ useDefaultAlerts: false })).toThrow(/whole series/i);
|
||||
});
|
||||
|
||||
it("removes an inherited property and reports it, rather than letting it vanish", () => {
|
||||
// The server would take this patch, drop `privacy`, and answer "updated".
|
||||
// Anything that believes the response believes the change landed.
|
||||
const { patch, dropped } = occurrencePatch({ title: "x", privacy: "private", recurrenceRule: null });
|
||||
expect(patch).toEqual({ title: "x" });
|
||||
expect(dropped).toEqual(["privacy", "recurrenceRule"]);
|
||||
});
|
||||
|
||||
it("judges a pointer patch on its first token, as the server does", () => {
|
||||
expect(occurrencePatch({ "participants/me/participationStatus": "accepted" }).patch)
|
||||
.toEqual({ "participants/me/participationStatus": "accepted" });
|
||||
expect(occurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).dropped)
|
||||
.toEqual(["participants/me/calendarAddress"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateEvent, per occurrence", () => {
|
||||
it("narrows the patch before sending it and reports what it kept back", async () => {
|
||||
const calls = server();
|
||||
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { title: "Just today", privacy: "private" }, false, "occurrence");
|
||||
expect(calls[0]!.update).toEqual({ iaaaaas: { title: "Just today" } });
|
||||
expect(dropped).toEqual(["privacy"]);
|
||||
});
|
||||
|
||||
it("sends nothing at all when a patch is entirely inherited", async () => {
|
||||
// A request that could only be a no-op is worse than no request: the
|
||||
// response would say "updated" and mean nothing by it.
|
||||
const calls = server();
|
||||
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private" }, false, "occurrence");
|
||||
expect(calls).toEqual([]);
|
||||
expect(dropped).toEqual(["privacy"]);
|
||||
});
|
||||
|
||||
it("leaves a series patch exactly as the caller wrote it", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private", useDefaultAlerts: false }, false, "series");
|
||||
expect(calls[0]!.update!.i).toEqual({ privacy: "private", useDefaultAlerts: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isThisAndFutureRefusal", () => {
|
||||
it("recognises the refusal worth offering the series for", () => {
|
||||
expect(isThisAndFutureRefusal(new CalendarSetError({
|
||||
type: "invalidProperties",
|
||||
description: "Occurrences of a this-and-future change cannot be modified individually.",
|
||||
}))).toBe(true);
|
||||
});
|
||||
it("does not claim an unrelated refusal", () => {
|
||||
expect(isThisAndFutureRefusal(new CalendarSetError({ type: "forbidden", description: "Nope." }))).toBe(false);
|
||||
expect(isThisAndFutureRefusal(new Error("Occurrences of a this-and-future change"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emptyForAccount } from "../files";
|
||||
|
||||
/**
|
||||
* Switching to an account somebody shared with you showed an empty folder tree.
|
||||
*
|
||||
* The switch cleared `nodes` and `children` and stopped there, so `treeLoaded`
|
||||
* stayed true from the previous account — the sidebar never asked the new one
|
||||
* for its folders — while `dirIds` still named the old account's folders, which
|
||||
* no longer resolved against the cleared `nodes`. The result was a tree with
|
||||
* nothing in it and no error to explain it, in the one place a tree matters
|
||||
* most: someone else's files, where you have no idea what the shape should be.
|
||||
*
|
||||
* The test that matters is the last one. The bug was not bad logic, it was a
|
||||
* field nobody remembered, and the only durable guard is asserting the whole
|
||||
* set rather than the fields we happen to think of today.
|
||||
*/
|
||||
|
||||
describe("what a switch to another account keeps", () => {
|
||||
it("keeps nothing but the new account's own id", () => {
|
||||
expect(emptyForAccount("b")).toEqual({
|
||||
accountId: "b",
|
||||
nodes: {},
|
||||
children: {},
|
||||
dirIds: [],
|
||||
treeLoaded: false,
|
||||
draggingId: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("asks the new account for its tree", () => {
|
||||
// The sidebar loads when `treeLoaded` is false. True here means an empty
|
||||
// tree for as long as the account stays selected.
|
||||
expect(emptyForAccount("b").treeLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("carries no folder ids over from the account before it", () => {
|
||||
expect(emptyForAccount("b").dirIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a drag that was in flight", () => {
|
||||
// Its id belongs to the other account and would name a different node here.
|
||||
expect(emptyForAccount("b").draggingId).toBeNull();
|
||||
});
|
||||
|
||||
it("names every piece of per-account state", () => {
|
||||
// Add a per-account field to the store and forget it here, and this fails
|
||||
// rather than the field quietly following someone into another account.
|
||||
expect(Object.keys(emptyForAccount(null)).sort()).toEqual(
|
||||
["accountId", "children", "dirIds", "draggingId", "error", "nodes", "treeLoaded"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("makeParticipant", () => {
|
||||
expect(guest.expectReply).toBe(true);
|
||||
});
|
||||
it("marks the organizer as owner and keeps a status already given", () => {
|
||||
const me = makeParticipant("john@linuxexperts.net", "John Coffey", "owner");
|
||||
const me = makeParticipant("john@example.org", "John Coffey", "owner");
|
||||
expect(me.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(me.participationStatus).toBe("accepted");
|
||||
expect(me.expectReply).toBe(false);
|
||||
|
||||
@@ -83,3 +83,91 @@ describe("reloading", () => {
|
||||
expect(useSieve.getState().rules().rules).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #76, second round. The transport fault is fixed in the blob proxy, but
|
||||
* the save path had no answer for a script that arrives *partly* read: it is
|
||||
* neither unknown nor empty, so the guards above all pass it through. It parses
|
||||
* into a shorter rule list that looks exactly like a script with fewer rules,
|
||||
* and saving writes that shorter version back over the real one.
|
||||
*
|
||||
* These pin the third state: read, but not all of it.
|
||||
*/
|
||||
describe("a script that was only partly read", () => {
|
||||
/** Cut at 384 bytes, the way a compressing hop cut the reporter's script. */
|
||||
const truncate = (content: string, at: number) => content.slice(0, at);
|
||||
const full = rulesToSieve(threeRules);
|
||||
|
||||
it("reports its rules as unknown rather than handing back the ones that parsed", () => {
|
||||
useSieve.setState({ contents: { s1: truncate(full, 384) } });
|
||||
const { rules, loaded, damage } = useSieve.getState().rules();
|
||||
expect(rules).toBeNull();
|
||||
expect(loaded).toBe(true);
|
||||
expect(damage).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses to save over the part it never saw", async () => {
|
||||
useSieve.setState({ contents: { s1: truncate(full, 384) } });
|
||||
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/overwrite the rest of it/i);
|
||||
});
|
||||
|
||||
it("catches a cut at every offset through the script, not just a lucky one", () => {
|
||||
// The offsets that cannot be caught are the ends of complete rule blocks:
|
||||
// each is a valid shorter script and nothing in the bytes says otherwise.
|
||||
// That is the residual the proxy fix covers and this check cannot.
|
||||
const safe = new Set<number>();
|
||||
for (let n = 0; n <= threeRules.length; n++) safe.add(rulesToSieve(threeRules.slice(0, n)).length);
|
||||
let missed = 0;
|
||||
for (let at = 1; at < full.length; at++) {
|
||||
useSieve.setState({ contents: { s1: truncate(full, at) } });
|
||||
const { damage } = useSieve.getState().rules();
|
||||
if (!damage && !safe.has(at)) missed++;
|
||||
}
|
||||
expect(missed).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves an intact script alone at every length it can legitimately have", () => {
|
||||
for (let n = 0; n <= threeRules.length; n++) {
|
||||
useSieve.setState({ contents: { s1: rulesToSieve(threeRules.slice(0, n)) } });
|
||||
const { rules, damage } = useSieve.getState().rules();
|
||||
expect(damage).toBeNull();
|
||||
expect(rules).toHaveLength(n);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the shapes a rule can take alone — disabled, many actions, extensions", () => {
|
||||
// A false positive here costs someone the use of the rules editor, so the
|
||||
// walk has to pass everything rulesToSieve can legitimately produce.
|
||||
const varied = [
|
||||
newRule({ name: "Disabled", enabled: false }),
|
||||
newRule({ name: "Many actions", actions: [{ type: "fileinto", mailbox: "A" }, { type: "markread" }, { type: "flag" }, { type: "stop" }] }),
|
||||
newRule({ name: "Two tests", join: "anyof", tests: [{ type: "body", op: "contains", value: "x" }, { type: "size", op: "over", value: 1024 }] }),
|
||||
newRule({ name: "No actions at all", actions: [] }),
|
||||
newRule({ name: "Quotes \" and \\ backslash" }),
|
||||
];
|
||||
useSieve.setState({ contents: { s1: rulesToSieve(varied) } });
|
||||
const { rules, damage } = useSieve.getState().rules();
|
||||
expect(damage).toBeNull();
|
||||
expect(rules).toHaveLength(varied.length);
|
||||
});
|
||||
|
||||
it("catches a cut at every offset through that script too", () => {
|
||||
const varied = [newRule({ name: "Disabled", enabled: false }), newRule({ name: "Live" }), newRule({ name: "Also off", enabled: false })];
|
||||
const full = rulesToSieve(varied);
|
||||
const safe = new Set<number>();
|
||||
for (let n = 0; n <= varied.length; n++) safe.add(rulesToSieve(varied.slice(0, n)).length);
|
||||
let missed = 0;
|
||||
for (let at = 1; at < full.length; at++) {
|
||||
useSieve.setState({ contents: { s1: full.slice(0, at) } });
|
||||
if (!useSieve.getState().rules().damage && !safe.has(at)) missed++;
|
||||
}
|
||||
expect(missed).toBe(0);
|
||||
});
|
||||
|
||||
it("does not call a hand-written script damaged", () => {
|
||||
useSieve.setState({ contents: { s1: 'require ["fileinto"];\nif header :contains "from" "x" { fileinto "X"; }' } });
|
||||
const { rules, damage } = useSieve.getState().rules();
|
||||
expect(damage).toBeNull();
|
||||
expect(rules).toBeNull(); // hand-written, which is a different refusal
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
|
||||
import { settings } from "./settings";
|
||||
import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export interface EventInstance {
|
||||
@@ -15,10 +15,225 @@ export interface EventInstance {
|
||||
calendar: Calendar | undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
* Asked for by name, because `shareWith` is not among the properties Stalwart
|
||||
* returns by default.
|
||||
*
|
||||
* A `Calendar/get` with no `properties` comes back without it -- not null, not
|
||||
* empty, absent -- confirmed against 0.16.19 on 2026-08-27 with a calendar that
|
||||
* was genuinely shared: omit the list and there is no `shareWith`; name it and
|
||||
* the sharee is right there. So the client believed nothing was ever shared.
|
||||
* The badge never appeared, "Stop sharing" never appeared, and the share dialog
|
||||
* opened on "not shared with anyone yet" over a live share.
|
||||
*
|
||||
* Files had this right already, for the same reason and after the same
|
||||
* surprise; calendars and address books did not.
|
||||
*/
|
||||
export const CALENDAR_PROPS = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"color",
|
||||
"sortOrder",
|
||||
"isSubscribed",
|
||||
"isVisible",
|
||||
"isDefault",
|
||||
"includeInAvailability",
|
||||
"defaultAlertsWithTime",
|
||||
"defaultAlertsWithoutTime",
|
||||
"timeZone",
|
||||
"shareWith",
|
||||
"myRights",
|
||||
];
|
||||
|
||||
/**
|
||||
* Which of an event's two ids a mutation means.
|
||||
*
|
||||
* `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives
|
||||
* carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the
|
||||
* master it was expanded from. Sending one where the other was meant is not a
|
||||
* distinction the server will make for us:
|
||||
*
|
||||
* - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic
|
||||
* ids is not yet supported"* — so a slip was loud and arrived as a toast.
|
||||
* - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A
|
||||
* destroy that meant the series now removes one date and reports success,
|
||||
* under a dialog that said "Delete all occurrences?".
|
||||
*
|
||||
* So the choice is named and required rather than left to each caller to
|
||||
* remember a `??`. There is exactly one place that turns an event into an id,
|
||||
* and it is below.
|
||||
*/
|
||||
export type EventScope = "series" | "occurrence";
|
||||
|
||||
/**
|
||||
* The id to send for `scope`.
|
||||
*
|
||||
* `series` walks up to the master; `occurrence` sends the instance as it came.
|
||||
* A one-off is safe either way — it has a synthetic id like everything an
|
||||
* expanded query returns, and Stalwart resolves a synthetic id on a component
|
||||
* that is neither recurrent nor an override back to the base event itself.
|
||||
*/
|
||||
export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
|
||||
return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
|
||||
}
|
||||
|
||||
/** Whether this object is an expanded occurrence rather than a master. */
|
||||
export function isOccurrence(event: CalendarEvent): boolean {
|
||||
return event.baseEventId != null && event.baseEventId !== event.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `CalendarEvent/set` will not take on a single occurrence, and why the
|
||||
* client has to know rather than letting the server sort it out.
|
||||
*
|
||||
* 0.16.20's per-occurrence validator sorts properties into three groups, and
|
||||
* only one of them is honest about itself:
|
||||
*
|
||||
* - **Rejected** — `invalidProperties`, *"This property cannot be modified on a
|
||||
* single occurrence."* Loud, and fine.
|
||||
* - **Inherited** — dropped from the patch, and the response still says the
|
||||
* update succeeded. Nothing anywhere reports it.
|
||||
* - Everything else, which is applied to the override.
|
||||
*
|
||||
* The middle group is the whole problem. It is the same failure as [#26], where
|
||||
* a participant map addressed the RFC 8984 way was discarded without an error
|
||||
* and the client showed the guests as saved: a successful response is not
|
||||
* evidence that anything was written. So a per-occurrence patch is checked here
|
||||
* before it is sent — rejected properties throw, inherited ones are reported to
|
||||
* the caller — rather than being posted hopefully and believed.
|
||||
*
|
||||
* [#26]: https://github.com/Coffey-Labs/ihasmail/issues/26
|
||||
*/
|
||||
const OCCURRENCE_REJECTED = new Set([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
]);
|
||||
|
||||
/** Applied to the series and never to one date; dropped in silence if sent. */
|
||||
const OCCURRENCE_INHERITED = new Set([
|
||||
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A `notUpdated`/`notDestroyed` entry, kept whole rather than flattened.
|
||||
*
|
||||
* Some refusals are worth acting on rather than only showing: 0.16.20 will not
|
||||
* edit an occurrence that belongs to a this-and-future change, and the useful
|
||||
* response to that is to offer the series, which needs the reason and not just
|
||||
* its text.
|
||||
*/
|
||||
export class CalendarSetError extends Error {
|
||||
constructor(readonly setError: { type: string; description?: string; properties?: string[] }) {
|
||||
super(setErrorMessage(setError));
|
||||
this.name = "CalendarSetError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a refusal was "this occurrence belongs to a this-and-future change". */
|
||||
export function isThisAndFutureRefusal(err: unknown): boolean {
|
||||
return err instanceof CalendarSetError && /this-and-future/i.test(err.setError.description ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic id is only true until the next write, so an occurrence is
|
||||
* re-resolved from its `recurrenceId` immediately before it is touched.
|
||||
*
|
||||
* **Confirmed live on 0.16.20 (2026-08-31.)** Stalwart's synthetic ids encode a
|
||||
* position in the expanded series, and writing a `recurrenceOverrides` entry
|
||||
* adds a component that renumbers it. A five-week series held ids `e i m q u`
|
||||
* over 03-01…03-29; after one override was written to 03-08 the *same ids*
|
||||
* addressed 03-01, 03-15, 03-29, 03-08, 03-22. Not one of them was rejected —
|
||||
* `i` simply meant a week later than it had a moment before.
|
||||
*
|
||||
* So an id cached across a write silently points at a different date, and a
|
||||
* delete aimed at one occurrence removes another. `recurrenceId` is the stable
|
||||
* name for a slot in a series — it is the date itself — so that is what we hold
|
||||
* and what we look the current id up by.
|
||||
*/
|
||||
async function currentOccurrenceId(accountId: Id, event: CalendarEvent): Promise<Id> {
|
||||
const base = event.baseEventId;
|
||||
const rid = event.recurrenceId;
|
||||
// A one-off, or an object with nothing to re-resolve from: its own id is all
|
||||
// there is, and there is no series for a write to have renumbered.
|
||||
if (!base || !rid) return event.id;
|
||||
|
||||
const around = new Date(rid);
|
||||
if (Number.isNaN(around.getTime())) return event.id;
|
||||
const from = new Date(around.getTime() - DAY_MS);
|
||||
const to = new Date(around.getTime() + DAY_MS);
|
||||
|
||||
const res = await client.chain([
|
||||
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(from), before: toLocalDateTime(to) }, expandRecurrences: true, limit: 200 }, "q"],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: ["id", "baseEventId", "recurrenceId"] }, "g"],
|
||||
]);
|
||||
const list = (res.get("g")?.[0] as unknown as GetResponse<CalendarEvent> | undefined)?.list ?? [];
|
||||
const found = list.find((e) => e.baseEventId === base && e.recurrenceId === rid);
|
||||
if (!found) {
|
||||
// The date is gone -- already excluded, or the series no longer reaches it.
|
||||
// Better to say so than to act on an id that means something else now.
|
||||
throw new Error("That occurrence is no longer part of this series. Reload the calendar and try again.");
|
||||
}
|
||||
return found.id;
|
||||
}
|
||||
|
||||
export class OccurrenceScopeError extends Error {
|
||||
constructor(readonly property: string) {
|
||||
super(`"${property}" applies to the whole series and cannot be changed for one occurrence.`);
|
||||
this.name = "OccurrenceScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch narrowed to what one occurrence will actually accept.
|
||||
*
|
||||
* Throws `OccurrenceScopeError` on a property the server would refuse, and
|
||||
* returns the inherited ones it removed so a caller can say what it could not
|
||||
* do for this date alone instead of claiming it did.
|
||||
*
|
||||
* Patch *pointers* are judged on their first token, the way the server does:
|
||||
* `participants/{key}/participationStatus` is allowed, and
|
||||
* `participants/{key}/calendarAddress` is one of the silent drops.
|
||||
*/
|
||||
export function occurrencePatch(patch: Record<string, unknown>): { patch: Record<string, unknown>; dropped: string[] } {
|
||||
const out: Record<string, unknown> = {};
|
||||
const dropped: string[] = [];
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const [head, , third] = key.split("/");
|
||||
const root = head ?? key;
|
||||
if (OCCURRENCE_REJECTED.has(root)) throw new OccurrenceScopeError(root);
|
||||
if (OCCURRENCE_INHERITED.has(root)) { dropped.push(root); continue; }
|
||||
if (root === "participants" && third === "calendarAddress") { dropped.push(key); continue; }
|
||||
// `id` is immutable; the server errors on a value that is not the event's
|
||||
// own, and ignores one that is. Neither is worth sending.
|
||||
if (root === "id") { dropped.push(root); continue; }
|
||||
out[key] = value;
|
||||
}
|
||||
return { patch: out, dropped };
|
||||
}
|
||||
|
||||
/** A calendar somebody else shared, and the account it lives in. */
|
||||
export interface SharedCalendar {
|
||||
accountId: Id;
|
||||
accountName: string;
|
||||
calendar: Calendar;
|
||||
}
|
||||
|
||||
/** Shared events are keyed by account too: ids only differ within an account. */
|
||||
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
|
||||
|
||||
interface CalendarState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
calendars: Record<Id, Calendar>;
|
||||
/** Calendars shared with the reader, from every non-personal account. */
|
||||
sharedCalendars: SharedCalendar[];
|
||||
/** Their events, keyed by account and id. See `sharedKey`. */
|
||||
sharedEvents: Record<string, CalendarEvent>;
|
||||
/** Which shared keys each loaded window holds, alongside `ranges`. */
|
||||
sharedRanges: Record<string, string[]>;
|
||||
events: Record<Id, CalendarEvent>;
|
||||
/** Loaded ranges keyed "start|end" → event ids */
|
||||
ranges: Record<string, Id[]>;
|
||||
@@ -29,13 +244,19 @@ interface CalendarState {
|
||||
|
||||
init(): Promise<void>;
|
||||
loadCalendars(): Promise<void>;
|
||||
/** Calendars from accounts that shared with the reader, and their events. */
|
||||
loadSharedCalendars(): Promise<void>;
|
||||
loadSharedRange(start: Date, end: Date): Promise<void>;
|
||||
/** Add a shared calendar to, or remove it from, the reader's own view. */
|
||||
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
|
||||
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
|
||||
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
|
||||
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||
/** Returns the properties that had to be left to the series, if any. */
|
||||
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<string[]>;
|
||||
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
||||
destroyCalendar(id: Id): Promise<void>;
|
||||
@@ -65,6 +286,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
calendars: {},
|
||||
sharedCalendars: [],
|
||||
sharedEvents: {},
|
||||
sharedRanges: {},
|
||||
events: {},
|
||||
ranges: {},
|
||||
loading: false,
|
||||
@@ -73,12 +297,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
hidden: {},
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.calendars);
|
||||
// The reader's own: a shared calendar is shown beside theirs, not instead.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.calendars);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
|
||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||
set({ available });
|
||||
if (!available) return;
|
||||
await get().loadCalendars();
|
||||
void get().loadSharedCalendars();
|
||||
try {
|
||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
||||
set({ identities: res.list });
|
||||
@@ -87,11 +313,110 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Calendars other people shared, and the events in them.
|
||||
*
|
||||
* Kept apart from the reader's own and keyed by account, for the reason ids
|
||||
* force: they are unique only within an account. Loaded from the same window
|
||||
* the reader is looking at, so a colleague's calendar fills in beside their
|
||||
* own rather than after a separate wait.
|
||||
*
|
||||
* An account that answers with no calendars is simply not listed. Sharing a
|
||||
* file does not make somebody's calendar worth a heading.
|
||||
*/
|
||||
async loadSharedCalendars() {
|
||||
const session = useSession.getState();
|
||||
const own = session.ownAccountFor(CAP.calendars);
|
||||
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||
const found: SharedCalendar[] = [];
|
||||
for (const [accountId, account] of accounts) {
|
||||
try {
|
||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
||||
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set({ sharedCalendars: found });
|
||||
// Fill in whatever windows are already on screen.
|
||||
for (const key of Object.keys(get().ranges)) {
|
||||
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
|
||||
if (from && to) void get().loadSharedRange(from, to);
|
||||
}
|
||||
},
|
||||
|
||||
async setSharedSubscribed(accountId, calendarId, subscribed) {
|
||||
// See the note in the contacts store: subscribing writes to another
|
||||
// account, so a refusal is an ordinary answer and arrives in `notUpdated`
|
||||
// rather than as a thrown error.
|
||||
/*
|
||||
* Server first, settings when it refuses -- the same arrangement the
|
||||
* contacts store explains. Stalwart takes this write on a shared calendar
|
||||
* where it will not on a shared address book, but the difference is the
|
||||
* server's to change and not worth relying on from here.
|
||||
*/
|
||||
let stored = false;
|
||||
try {
|
||||
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } });
|
||||
const err = res.notUpdated?.[calendarId];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
stored = true;
|
||||
} catch {
|
||||
stored = false;
|
||||
}
|
||||
if (!stored) {
|
||||
const added = new Set(settings().addedShares);
|
||||
if (subscribed) added.add(sharedKey(accountId, calendarId));
|
||||
else added.delete(sharedKey(accountId, calendarId));
|
||||
useSettings.getState().update({ addedShares: [...added] });
|
||||
}
|
||||
set((s) => ({
|
||||
sharedCalendars: s.sharedCalendars.map((c) =>
|
||||
c.accountId === accountId && c.calendar.id === calendarId ? { ...c, calendar: { ...c.calendar, isSubscribed: subscribed } } : c,
|
||||
),
|
||||
}));
|
||||
// Its events are only fetched for calendars in view, so the windows on
|
||||
// screen have to be asked again either way.
|
||||
for (const key of Object.keys(get().ranges)) {
|
||||
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
|
||||
if (from && to) void get().loadSharedRange(from, to);
|
||||
}
|
||||
},
|
||||
|
||||
/** The same window, from every account that shared a calendar. */
|
||||
async loadSharedRange(start, end) {
|
||||
const shared = get().sharedCalendars;
|
||||
if (!shared.length) return;
|
||||
const key = `${start.getTime()}|${end.getTime()}`;
|
||||
const tz = settings().timeZone ?? browserTimeZone;
|
||||
const accounts = [...new Set(shared.map((c) => c.accountId))];
|
||||
const ids: string[] = [];
|
||||
const events: Record<string, CalendarEvent> = {};
|
||||
for (const accountId of accounts) {
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
|
||||
for (const e of g.list) {
|
||||
const k = sharedKey(accountId, e.id);
|
||||
events[k] = e;
|
||||
ids.push(k);
|
||||
}
|
||||
} catch {
|
||||
// One account refusing must not empty the calendar of the others.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } }));
|
||||
},
|
||||
|
||||
async loadCalendars() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
|
||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
||||
const calendars: Record<Id, Calendar> = {};
|
||||
for (const c of res.list) calendars[c.id] = c;
|
||||
set({ calendars, error: null });
|
||||
@@ -131,13 +456,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
for (const e of g.list) events[e.id] = e;
|
||||
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
|
||||
});
|
||||
void get().loadSharedRange(start, end);
|
||||
} catch (err) {
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
instancesIn(start, end) {
|
||||
const { events, ranges, calendars, hidden } = get();
|
||||
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
|
||||
const ids = new Set<Id>();
|
||||
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
||||
const out: EventInstance[] = [];
|
||||
@@ -150,6 +476,35 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
if (!inst) continue;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
/* Shared events go through the same funnel, so every view gets them
|
||||
without knowing they exist. Their calendars are looked up per account:
|
||||
a shared calendar id means nothing outside the account holding it, and
|
||||
hiding one is remembered under the same account-qualified key. */
|
||||
const sharedKeys = new Set<string>();
|
||||
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
|
||||
for (const k of sharedKeys) {
|
||||
const e = sharedEvents[k];
|
||||
if (!e) continue;
|
||||
const accountId = k.slice(0, k.length - e.id.length - 1);
|
||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||
if (calId && hidden[sharedKey(accountId, calId)]) continue;
|
||||
/* Stalwart hands back every calendar in an account the reader can reach,
|
||||
with full rights on each, whether or not anybody meant to share it --
|
||||
an account linked for its files offered its calendar too. `isSubscribed`
|
||||
is the only thing separating "shared with me" from "reachable", so
|
||||
nothing unsubscribed is drawn. */
|
||||
const added = new Set(settings().addedShares);
|
||||
const theirs: Record<Id, Calendar> = {};
|
||||
for (const c of sharedCalendars) {
|
||||
if (c.accountId !== accountId) continue;
|
||||
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
|
||||
theirs[c.calendar.id] = c.calendar;
|
||||
}
|
||||
if (calId && !theirs[calId]) continue;
|
||||
const inst = toInstance(e, theirs);
|
||||
if (!inst) continue;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
|
||||
return out;
|
||||
},
|
||||
@@ -173,39 +528,51 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return res.created!.e!.id;
|
||||
},
|
||||
|
||||
async updateEvent(id, patch, sendInvites) {
|
||||
async updateEvent(event, patch, sendInvites, scope) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
// An occurrence takes less than the series does, and says so about only
|
||||
// half of it. Narrow the patch here rather than posting it hopefully.
|
||||
const { patch: body, dropped } = scope === "occurrence" ? occurrencePatch(patch) : { patch, dropped: [] as string[] };
|
||||
if (!Object.keys(body).length) return dropped;
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: body }, sendSchedulingMessages: sendInvites });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
if (err) throw new CalendarSetError(err);
|
||||
get().invalidate();
|
||||
return dropped;
|
||||
},
|
||||
|
||||
async destroyEvent(id, sendInvites) {
|
||||
async destroyEvent(event, sendInvites, scope) {
|
||||
const accountId = get().accountId!;
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
if (err) throw new CalendarSetError(err);
|
||||
set((s) => {
|
||||
const events = { ...s.events };
|
||||
// Drop both ids: the one that was sent, and the object as the caller
|
||||
// held it. An occurrence destroy leaves the master alone on purpose.
|
||||
delete events[id];
|
||||
if (scope === "occurrence") delete events[event.id];
|
||||
return { events };
|
||||
});
|
||||
get().invalidate();
|
||||
},
|
||||
|
||||
async rsvp(id, status, comment) {
|
||||
const ev = get().events[id] ?? (await get().getEvent(id));
|
||||
if (!ev) throw new Error("Event not found");
|
||||
id = ev.baseEventId ?? id;
|
||||
const mine = myParticipantKeys(ev, get().identities);
|
||||
async rsvp(event, status, comment) {
|
||||
const mine = myParticipantKeys(event, get().identities);
|
||||
if (!mine.length) throw new Error("You are not a participant of this event");
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const k of mine) {
|
||||
patch[`participants/${k}/participationStatus`] = status;
|
||||
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
||||
}
|
||||
await get().updateEvent(id, patch, true);
|
||||
// Answering for the series, not for one date. The patch itself survives
|
||||
// either scope -- `participants/{key}/participationStatus` is one of the
|
||||
// pointers 0.16.20 allows on an occurrence -- so this would silently mean
|
||||
// "only that day" if it were aimed at an instance. Accepting an invitation
|
||||
// means accepting the series.
|
||||
await get().updateEvent(event, patch, true, "series");
|
||||
},
|
||||
|
||||
async createCalendar(data) {
|
||||
@@ -250,6 +617,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return res.list ?? [];
|
||||
},
|
||||
|
||||
/**
|
||||
* The event with this uid, as a master rather than an occurrence.
|
||||
*
|
||||
* The query deliberately omits `expandRecurrences`, so what comes back is the
|
||||
* stored event and `id` is a real id. Callers rely on that — `InviteCard`
|
||||
* removes a cancelled event by handing this straight to `destroyEvent` — so
|
||||
* it is a property of this method, not an accident of the default.
|
||||
*/
|
||||
async findByUid(uid) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return null;
|
||||
|
||||
@@ -25,6 +25,15 @@ export interface ComposeAttachment {
|
||||
abort?: AbortController;
|
||||
}
|
||||
|
||||
/** A file in Files, enough of it to attach. */
|
||||
export interface AttachableFile {
|
||||
accountId: Id;
|
||||
name: string;
|
||||
type: string | null;
|
||||
size: number | null;
|
||||
blobId: Id;
|
||||
}
|
||||
|
||||
export type Priority = "high" | "normal" | "low";
|
||||
|
||||
export interface Draft {
|
||||
@@ -76,6 +85,8 @@ interface ComposeState {
|
||||
close(key: string, opts?: { discard?: boolean }): Promise<void>;
|
||||
focus(key: string): void;
|
||||
addFiles(key: string, files: File[]): void;
|
||||
/** Attach files already in Files, by reference where the account allows it. */
|
||||
addFromFiles(key: string, nodes: AttachableFile[]): Promise<void>;
|
||||
removeAttachment(key: string, attId: string): void;
|
||||
saveDraft(key: string, opts?: { silent?: boolean }): Promise<Id | null>;
|
||||
send(key: string): Promise<void>;
|
||||
@@ -361,6 +372,47 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Attach something already in Files.
|
||||
*
|
||||
* A blob the account can already see needs no upload: an attachment carrying
|
||||
* a `blobId` is exactly what a forward produces, so the send path already
|
||||
* knows what to do with one. Attaching a 20 MB file the server is holding
|
||||
* anyway then costs nothing and takes no time.
|
||||
*
|
||||
* A file in an account somebody *shared* is a different matter. Blobs belong
|
||||
* to the account they were uploaded to, so a draft in your account cannot
|
||||
* reference one in theirs; it is fetched and uploaded to yours. Slower, and
|
||||
* unavoidable, but it happens without the reader having to know any of this.
|
||||
*/
|
||||
async addFromFiles(key, nodes) {
|
||||
const accountId = useMail.getState().accountId;
|
||||
if (!accountId || !nodes.length) return;
|
||||
const max = client.maxSizeUpload;
|
||||
const atts: ComposeAttachment[] = nodes.map((n) => ({
|
||||
id: uid("a"),
|
||||
name: n.name,
|
||||
type: n.type || "application/octet-stream",
|
||||
size: n.size ?? 0,
|
||||
blobId: n.accountId === accountId ? n.blobId : null,
|
||||
progress: n.accountId === accountId ? 100 : 0,
|
||||
error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null,
|
||||
}));
|
||||
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
|
||||
|
||||
for (const [i, a] of atts.entries()) {
|
||||
if (a.error || a.blobId) continue;
|
||||
const node = nodes[i]!;
|
||||
try {
|
||||
const blob = await client.fetchBlob(node.accountId, node.blobId, a.type);
|
||||
const up = await client.upload(accountId, blob, { type: a.type });
|
||||
patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set);
|
||||
} catch (err) {
|
||||
patchAtt(key, a.id, { error: (err as Error).message || "Could not attach" }, set);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeAttachment(key, attId) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
const a = d?.attachments.find((x) => x.id === attId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -13,6 +15,31 @@ export interface Suggestion {
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Asked for by name: `shareWith` is not returned by default.
|
||||
*
|
||||
* An `AddressBook/get` with no `properties` omits it entirely -- confirmed
|
||||
* against 0.16.19 on 2026-08-27 on a book that really was shared. See the note
|
||||
* on CALENDAR_PROPS; both had the same hole and Files did not.
|
||||
*/
|
||||
export const ADDRESS_BOOK_PROPS = ["id", "name", "description", "sortOrder", "isDefault", "isSubscribed", "shareWith", "myRights"];
|
||||
|
||||
/** A book somebody else shared, and the account it lives in. */
|
||||
export interface SharedBook {
|
||||
accountId: Id;
|
||||
accountName: string;
|
||||
book: AddressBook;
|
||||
}
|
||||
|
||||
/** Which book the contact list is showing. `accountId` null means the reader's. */
|
||||
export interface BookSelection {
|
||||
accountId: Id | null;
|
||||
bookId: Id | "all";
|
||||
}
|
||||
|
||||
/** Cards from shared accounts are keyed by account too: ids collide across them. */
|
||||
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
|
||||
|
||||
interface ContactsState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
@@ -24,12 +51,27 @@ interface ContactsState {
|
||||
principals: Principal[];
|
||||
principalsLoaded: boolean;
|
||||
recent: EmailAddress[];
|
||||
/** Address books shared with the reader, from every non-personal account. */
|
||||
sharedBooks: SharedBook[];
|
||||
/** Their cards, keyed by account and id. See `sharedKey`. */
|
||||
sharedCards: Record<string, ContactCard>;
|
||||
sharedLoaded: boolean;
|
||||
selection: BookSelection;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadBooks(): Promise<void>;
|
||||
loadAll(): Promise<void>;
|
||||
/** Books and cards from accounts that shared with the reader. */
|
||||
loadShared(): Promise<void>;
|
||||
select(selection: BookSelection): void;
|
||||
/** Add a shared address book to, or remove it from, the reader's own view. */
|
||||
setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise<void>;
|
||||
/** The account a card belongs to, null for the reader's own. */
|
||||
accountOfCard(id: Id): Id | null;
|
||||
getCard(id: Id): Promise<ContactCard | null>;
|
||||
search(text: string): ContactCard[];
|
||||
/** The search filter itself, so a shared book can be filtered the same way. */
|
||||
filterCards(cards: ContactCard[], text: string): ContactCard[];
|
||||
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
|
||||
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
|
||||
destroyCards(ids: Id[]): Promise<void>;
|
||||
@@ -57,21 +99,141 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
principals: [],
|
||||
principalsLoaded: false,
|
||||
recent: [],
|
||||
sharedBooks: [],
|
||||
sharedCards: {},
|
||||
sharedLoaded: false,
|
||||
selection: { accountId: null, bookId: "all" },
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.contacts);
|
||||
// The reader's own, not whichever account is selected: a shared address
|
||||
// book is shown beside theirs rather than instead of it, so nothing here
|
||||
// should move when the switcher does.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
|
||||
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false });
|
||||
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false, selection: { accountId: null, bookId: "all" } });
|
||||
set({ available });
|
||||
if (!available) return;
|
||||
await get().loadBooks();
|
||||
void get().loadShared();
|
||||
},
|
||||
|
||||
/*
|
||||
* Books and cards from accounts that shared with the reader.
|
||||
*
|
||||
* These are held apart from the reader's own rather than merged into them,
|
||||
* because ids are only unique within an account: two accounts each having a
|
||||
* book "ab1" is ordinary, and a flat map keyed on the bare id would have one
|
||||
* quietly replace the other. `sharedKey` keeps them apart.
|
||||
*
|
||||
* Loaded eagerly, unlike the shared folders in Files, because these are not
|
||||
* only browsed -- they have to answer when someone types a name into a To
|
||||
* field, which cannot wait for a folder to be opened first.
|
||||
*/
|
||||
async loadShared() {
|
||||
const session = useSession.getState();
|
||||
const own = session.ownAccountFor(CAP.contacts);
|
||||
const s = session.session;
|
||||
const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||
if (!accounts.length) {
|
||||
set({ sharedBooks: [], sharedCards: {}, sharedLoaded: true });
|
||||
return;
|
||||
}
|
||||
const books: SharedBook[] = [];
|
||||
const cards: Record<string, ContactCard> = {};
|
||||
for (const [accountId, account] of accounts) {
|
||||
try {
|
||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
||||
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
||||
/*
|
||||
* Cards come only from books the reader has added.
|
||||
*
|
||||
* Stalwart hands back every book in a reachable account with full
|
||||
* rights on each, shared or not -- an account linked for its files
|
||||
* offered its address book too -- so `isSubscribed` is the only thing
|
||||
* separating "shared with me" from "reachable". Loading the rest would
|
||||
* put a stranger's contacts in the To field, which is the one place
|
||||
* this must not guess.
|
||||
*/
|
||||
const added = new Set(useSettings.getState().settings.addedShares);
|
||||
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
|
||||
if (!wanted.size) continue;
|
||||
// One page. A shared book is a colleague's contacts, not an archive,
|
||||
// and the alternative is holding the reader's own list hostage to it.
|
||||
const cardsRes = await client.chain([
|
||||
["ContactCard/query", { accountId, limit: 500 }, "q"],
|
||||
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
|
||||
]);
|
||||
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
||||
for (const c of g.list) {
|
||||
if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue;
|
||||
cards[sharedKey(accountId, c.id)] = c;
|
||||
}
|
||||
} catch {
|
||||
// An account that refuses is one that shared nothing here. Not an
|
||||
// error to show: the reader did not ask for it and cannot act on it.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
||||
},
|
||||
|
||||
async setBookSubscribed(accountId, bookId, subscribed) {
|
||||
/*
|
||||
* `notUpdated` matters more here than anywhere else this pattern is used.
|
||||
* Subscribing is a write to somebody *else's* account, so it is the one
|
||||
* call in the app that a perfectly healthy server is entitled to refuse --
|
||||
* and a refusal arrives as a successful response carrying a per-object
|
||||
* failure, not as a thrown error. Ignoring it made a refused subscribe look
|
||||
* exactly like a button that does nothing.
|
||||
*/
|
||||
/*
|
||||
* Ask the server to remember it, and remember it here when it will not.
|
||||
*
|
||||
* Subscribing writes to the owner's account, and Stalwart 0.16.19 refuses
|
||||
* that for a book shared read-only -- "You are not allowed to modify this
|
||||
* address book" -- while accepting the same write on a shared calendar. The
|
||||
* server's own flag is still preferred when it takes it, because then every
|
||||
* client agrees; a refusal is an ordinary answer here rather than a
|
||||
* failure, and the preference goes in the reader's own synced settings.
|
||||
*/
|
||||
const key = sharedKey(accountId, bookId);
|
||||
let stored = false;
|
||||
try {
|
||||
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
|
||||
const err = res.notUpdated?.[bookId];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
stored = true;
|
||||
} catch {
|
||||
stored = false;
|
||||
}
|
||||
if (!stored) {
|
||||
const { settings, update } = useSettings.getState();
|
||||
const added = new Set(settings.addedShares);
|
||||
if (subscribed) added.add(key);
|
||||
else added.delete(key);
|
||||
update({ addedShares: [...added] });
|
||||
}
|
||||
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
|
||||
set({ selection: { accountId: null, bookId: "all" } });
|
||||
}
|
||||
await get().loadShared();
|
||||
},
|
||||
|
||||
select(selection) {
|
||||
set({ selection });
|
||||
},
|
||||
|
||||
accountOfCard(id) {
|
||||
if (get().cards[id]) return null;
|
||||
const hit = Object.entries(get().sharedCards).find(([key]) => key.endsWith(`:${id}`));
|
||||
return hit ? hit[0].slice(0, hit[0].length - id.length - 1) : null;
|
||||
},
|
||||
|
||||
async loadBooks() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
||||
const books: Record<Id, AddressBook> = {};
|
||||
for (const b of res.list) books[b.id] = b;
|
||||
set({ books, error: null });
|
||||
@@ -114,20 +276,23 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return c ?? null;
|
||||
},
|
||||
|
||||
search(text) {
|
||||
filterCards(cards, text) {
|
||||
const q = text.trim().toLowerCase();
|
||||
const all = Object.values(get().cards);
|
||||
const filtered = q
|
||||
? all.filter((c) => {
|
||||
? cards.filter((c) => {
|
||||
const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
: all;
|
||||
: cards;
|
||||
return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
||||
},
|
||||
|
||||
search(text) {
|
||||
return get().filterCards(Object.values(get().cards), text);
|
||||
},
|
||||
|
||||
async createCard(card, addressBookId) {
|
||||
const accountId = get().accountId!;
|
||||
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
|
||||
@@ -244,10 +409,15 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return 99;
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
for (const c of Object.values(st.cards)) {
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
// They rank a shade lower, so a name in both wins from your own book.
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc });
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
@@ -272,7 +442,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
|
||||
set({ recent: next });
|
||||
try {
|
||||
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
|
||||
saveJson(accountKey(get().accountId, "recent"), next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -280,11 +450,14 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
|
||||
lookupByEmail(email) {
|
||||
const e = email.toLowerCase();
|
||||
return Object.values(get().cards).find((c) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e));
|
||||
const match = (c: ContactCard) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e);
|
||||
// The reader's own books first: a card they wrote themselves should win
|
||||
// over a colleague's version of the same person.
|
||||
return Object.values(get().cards).find(match) ?? Object.values(get().sharedCards).find(match);
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("AddressBook")) void get().loadBooks();
|
||||
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
|
||||
if (types.has("ContactCard") && get().loaded) void get().loadAll();
|
||||
},
|
||||
}));
|
||||
@@ -294,7 +467,7 @@ useSession.subscribe((s) => {
|
||||
const accountId = s.accountFor(CAP.contacts);
|
||||
let recent: EmailAddress[] = [];
|
||||
try {
|
||||
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
|
||||
recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
||||
import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
|
||||
import { isAppFolder } from "@/lib/appFolder";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
|
||||
interface SharedAccount {
|
||||
id: Id;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FilesState {
|
||||
/**
|
||||
* The account being browsed, which is not always the reader's own.
|
||||
*
|
||||
* Files is the one module that opens somebody else's account in place: a
|
||||
* folder shared with you is reached from "Shared with me" in the tree, not by
|
||||
* switching the whole app over. So this moves and `ownAccountId` does not,
|
||||
* and anything belonging to the reader -- their settings, their signatures --
|
||||
* goes through `ownAccountFor` rather than either of them.
|
||||
*/
|
||||
accountId: Id | null;
|
||||
/** The reader's own file account, wherever they happen to be looking. */
|
||||
ownAccountId: Id | null;
|
||||
/** Accounts someone else has shared, from the session. */
|
||||
sharedAccounts: SharedAccount[];
|
||||
available: boolean;
|
||||
nodes: Record<Id, FileNode>;
|
||||
children: Record<string, Id[]>; // parentId ("root" for null) → ids
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
|
||||
dirIds: Id[];
|
||||
treeLoaded: boolean;
|
||||
/*
|
||||
* The node being dragged, if any.
|
||||
*
|
||||
* Kept here rather than in whichever pane started the drag, because a drag
|
||||
* crosses between them -- a row dragged onto the sidebar tree, a folder in
|
||||
* the tree dragged onto a row -- and every possible target has to know what
|
||||
* is in flight to say whether it will take it. Two panes each holding their
|
||||
* own copy meant the one that did not start the drag never lit up and never
|
||||
* accepted the drop.
|
||||
*
|
||||
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
|
||||
* during dragover, which is exactly when the answer is needed.
|
||||
*/
|
||||
draggingId: Id | null;
|
||||
|
||||
init(): Promise<void>;
|
||||
/** Browse an account: the reader's own, or one shared with them. */
|
||||
openAccount(accountId: Id | null): void;
|
||||
loadChildren(parentId: Id | null): Promise<void>;
|
||||
mkdir(parentId: Id | null, name: string): Promise<Id>;
|
||||
upload(parentId: Id | null, files: File[]): Promise<void>;
|
||||
rename(id: Id, name: string): Promise<void>;
|
||||
move(id: Id, parentId: Id | null): Promise<void>;
|
||||
destroy(ids: Id[]): Promise<void>;
|
||||
refresh(ids: Id[]): Promise<void>;
|
||||
setDragging(id: Id | null): void;
|
||||
/** Every directory in the account, for the tree in the sidebar. */
|
||||
loadTree(): Promise<void>;
|
||||
/** Upload a planned drop, creating the folders it needs as it goes. */
|
||||
uploadPlan(parentId: Id | null, plan: PlannedUpload[]): Promise<void>;
|
||||
pathTo(id: Id | null): FileNode[];
|
||||
applyChanges(types: Set<string>): void;
|
||||
}
|
||||
@@ -52,20 +95,113 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
|
||||
return nodes.filter((n) => !hidden.has(n.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The state that belongs to one account, emptied when the selection moves.
|
||||
*
|
||||
* Every field here describes somebody's files, so none of it survives a switch
|
||||
* to somebody else's. `treeLoaded` is the one that bites: leave it true and the
|
||||
* sidebar never asks the new account for its folders, while `dirIds` still
|
||||
* names the old account's, which no longer resolve -- so the tree is simply
|
||||
* empty, with nothing to say why. That shipped, and is what this exists to stop
|
||||
* happening again: the test asserts the whole set, so a field added to the
|
||||
* store and forgotten here fails rather than quietly persisting across
|
||||
* accounts.
|
||||
*/
|
||||
export function emptyForAccount(accountId: Id | null) {
|
||||
return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingId: null, error: null };
|
||||
}
|
||||
|
||||
export const useFiles = create<FilesState>((set, get) => ({
|
||||
accountId: null,
|
||||
ownAccountId: null,
|
||||
sharedAccounts: [],
|
||||
available: false,
|
||||
nodes: {},
|
||||
children: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
uploads: [],
|
||||
dirIds: [],
|
||||
treeLoaded: false,
|
||||
draggingId: null,
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
|
||||
if (accountId !== get().accountId) set({ accountId, nodes: {}, children: {} });
|
||||
set({ available });
|
||||
const session = useSession.getState();
|
||||
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
||||
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
|
||||
/*
|
||||
* Which accounts hold shared files cannot be worked out from capabilities:
|
||||
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
||||
* contacts and the rest -- identical to a personal one, whatever was
|
||||
* actually shared (checked on 0.16.19, 2026-08-27). So each one is asked
|
||||
* for its files, and only the ones that answer with any are listed.
|
||||
*
|
||||
* Listing them all and letting the folders speak for themselves was the
|
||||
* first attempt, and it put an account holding nothing at all under
|
||||
* "Shared with me" -- an invitation to open an empty pane, offered by 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.
|
||||
*/
|
||||
const s = session.session;
|
||||
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
||||
const sharedAccounts: SharedAccount[] = [];
|
||||
for (const [id, a] of candidates) {
|
||||
try {
|
||||
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
|
||||
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
|
||||
} catch {
|
||||
// Refused means nothing here is ours to see, which is the same answer.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Stay where the reader is if they are reading a share that still exists.
|
||||
const browsing = get().accountId;
|
||||
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
||||
if (!keep) set(emptyForAccount(ownAccountId));
|
||||
set({ available, ownAccountId, sharedAccounts });
|
||||
},
|
||||
|
||||
openAccount(accountId) {
|
||||
if (accountId === get().accountId) return;
|
||||
set(emptyForAccount(accountId));
|
||||
},
|
||||
|
||||
/*
|
||||
* The whole directory tree in one query.
|
||||
*
|
||||
* `filter: { nodeType: "directory" }` returns every folder in the account,
|
||||
* confirmed against 0.16.19 on 2026-08-27, so the sidebar tree is complete
|
||||
* from the first paint: expanding costs nothing, and a drag knows every
|
||||
* folder it could be dropped on without having opened it first.
|
||||
*
|
||||
* It is deliberately its own request rather than a call appended to another.
|
||||
* A filter Stalwart refuses fails with a request-level 400 that takes every
|
||||
* method call in the request with it -- `{ parentId: null }` does exactly
|
||||
* that -- so a tree query batched alongside the folder listing would blank
|
||||
* the whole view instead of just the sidebar.
|
||||
*/
|
||||
async loadTree() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
// Filtered again here rather than trusted: a server that ignores the
|
||||
// nodeType filter answers with files as well, and the tree would draw
|
||||
// them as folders you could open into nothing.
|
||||
const dirs = withoutAppFolder(g.list).filter((n) => n.nodeType === "directory");
|
||||
set((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const n of dirs) nodes[n.id] = n;
|
||||
return { nodes, dirIds: dirs.map((n) => n.id), treeLoaded: true };
|
||||
});
|
||||
} catch (err) {
|
||||
// The listing still works without a tree, so this must not blank the view.
|
||||
set({ error: (err as Error).message, treeLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
async loadChildren(parentId) {
|
||||
@@ -102,6 +238,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const err = res.notCreated?.d;
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await get().loadChildren(parentId);
|
||||
void get().loadTree();
|
||||
return res.created!.d!.id;
|
||||
},
|
||||
|
||||
@@ -129,12 +266,54 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
await get().loadChildren(parentId);
|
||||
},
|
||||
|
||||
/* Re-read named nodes in place. Sharing changes one property of one node and
|
||||
nothing about which folder it sits in, so reloading the level around it
|
||||
would be a bigger round trip to land in the same place. */
|
||||
setDragging(id) {
|
||||
set({ draggingId: id });
|
||||
},
|
||||
|
||||
async refresh(ids) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids, properties: fileNodeProps() });
|
||||
set((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const n of res.list) nodes[n.id] = n;
|
||||
return { nodes };
|
||||
});
|
||||
},
|
||||
|
||||
async uploadPlan(parentId, plan) {
|
||||
// Folders first, parents before children, so every file has somewhere to go.
|
||||
const dirIds = new Map<string, Id | null>([["", parentId]]);
|
||||
for (const path of foldersNeeded(plan)) {
|
||||
const parent = dirIds.get(path.slice(0, -1).join(" ")) ?? parentId;
|
||||
const name = path[path.length - 1]!;
|
||||
try {
|
||||
dirIds.set(path.join(" "), await get().mkdir(parent, name));
|
||||
} catch (err) {
|
||||
// Leave it unmapped: its files land in the nearest folder that exists
|
||||
// rather than vanishing, and the error is shown against the upload.
|
||||
set({ error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
const byFolder = new Map<string, File[]>();
|
||||
for (const item of plan) {
|
||||
const key = item.path.join(" ");
|
||||
byFolder.set(key, [...(byFolder.get(key) ?? []), item.file]);
|
||||
}
|
||||
for (const [key, files] of byFolder) await get().upload(dirIds.get(key) ?? parentId, files);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async rename(id, name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await get().loadChildren(get().nodes[id]?.parentId ?? null);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async move(id, parentId) {
|
||||
@@ -144,6 +323,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async destroy(ids) {
|
||||
@@ -153,6 +333,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const failed = Object.values(res.notDestroyed ?? {})[0];
|
||||
if (failed) throw new Error(setErrorMessage(failed));
|
||||
for (const p of parents) await get().loadChildren(p);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
pathTo(id) {
|
||||
|
||||
@@ -22,6 +22,32 @@ import { toast } from "@/ui/toast";
|
||||
import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
/*
|
||||
* Named explicitly so `shareWith` comes back, which it does not otherwise --
|
||||
* see the note on CALENDAR_PROPS and the KNOWN-ISSUES entry. Mailboxes were the
|
||||
* third and last store fetching everything by asking for nothing.
|
||||
*
|
||||
* It matters here for one narrow but real case. Sharing a mail folder is
|
||||
* withdrawn because Stalwart stores the share and never delivers it, and the
|
||||
* only way left to clear one already made is the "Stop sharing" entry, which
|
||||
* appears only when a folder looks shared. Without this it never looked shared,
|
||||
* so the escape hatch for the exact situation it was built for was invisible.
|
||||
*/
|
||||
export const MAILBOX_PROPS = [
|
||||
"id",
|
||||
"name",
|
||||
"parentId",
|
||||
"role",
|
||||
"sortOrder",
|
||||
"totalEmails",
|
||||
"unreadEmails",
|
||||
"totalThreads",
|
||||
"unreadThreads",
|
||||
"myRights",
|
||||
"isSubscribed",
|
||||
"shareWith",
|
||||
];
|
||||
|
||||
export const LIST_PROPS = [
|
||||
"id",
|
||||
"blobId",
|
||||
@@ -210,7 +236,7 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
async loadMailboxes() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null });
|
||||
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS });
|
||||
const mailboxes: Record<Id, Mailbox> = {};
|
||||
for (const m of res.list) mailboxes[m.id] = m;
|
||||
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
|
||||
@@ -833,10 +859,27 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
delete next[id];
|
||||
delete nextFull[id];
|
||||
}
|
||||
// Drop cached versions of updated emails so they're refetched lazily.
|
||||
for (const id of updated) {
|
||||
if (next[id] && nextFull[id]) delete nextFull[id];
|
||||
}
|
||||
/*
|
||||
* The full copy of an updated email is deliberately kept.
|
||||
*
|
||||
* This used to drop it so the next read would fetch it again. But
|
||||
* the reading pane renders only the emails it holds in full, so
|
||||
* dropping one took the message out of the open thread until the
|
||||
* refetch at the end of this function put it back. The pane emptied
|
||||
* and refilled -- on an HTML message, a flash to the app's own
|
||||
* background and out again, which is what was left of #100 after
|
||||
* the message view stopped rebuilding its body.
|
||||
*
|
||||
* Marking as read causes exactly this: the server echoes our own
|
||||
* change back as an update.
|
||||
*
|
||||
* Nothing is lost by keeping it. RFC 8621 makes every property of
|
||||
* an Email immutable except `keywords` and `mailboxIds` -- the id
|
||||
* is derived from the content, so a body cannot change beneath one
|
||||
* -- and both are in LIST_PROPS, which the refresh immediately
|
||||
* below merges over the cached copy. The eviction only ever cost
|
||||
* the message its place in the thread.
|
||||
*/
|
||||
return { emails: next, fullIds: nextFull, emailState: since };
|
||||
});
|
||||
// Refresh the list-level props of updated/cached emails.
|
||||
|
||||
@@ -2,9 +2,13 @@ import { create } from "zustand";
|
||||
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
||||
import type { Id, JmapSession } from "@/jmap/types";
|
||||
import { push, type PushState } from "@/jmap/push";
|
||||
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
|
||||
import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
@@ -22,8 +26,10 @@ interface SessionState {
|
||||
logout(): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
|
||||
/** The account to read and write for a capability, honouring the account switcher. */
|
||||
accountFor(cap: string): Id | null;
|
||||
/** The user's own account for a capability, whatever they are looking at. */
|
||||
ownAccountFor(cap: string): Id | null;
|
||||
}
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
@@ -77,6 +83,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stopIdleLogout();
|
||||
// Unconditional. The push subscription above is removed for exactly this
|
||||
// reason -- that a browser left holding someone's mail is somebody else's
|
||||
// problem next -- and the address book cached here is the same argument.
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null });
|
||||
},
|
||||
@@ -97,17 +108,30 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
},
|
||||
|
||||
accountFor(cap) {
|
||||
const s = get().session;
|
||||
if (!s) return null;
|
||||
const selected = get().accountId;
|
||||
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
|
||||
return s.primaryAccounts[cap] ?? selected ?? null;
|
||||
return accountForCapability(get().session, get().accountId, cap);
|
||||
},
|
||||
|
||||
ownAccountFor(cap) {
|
||||
return ownAccountForCapability(get().session, cap);
|
||||
},
|
||||
}));
|
||||
|
||||
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
client.session = s;
|
||||
setServerLocale(s.ihasmail?.userLocale);
|
||||
// `remember` is the answer to "is this device yours", given at sign-in and
|
||||
// carried on the session -- so a reload arrives at the same answer without
|
||||
// the client storing it, which on an untrusted device it could not do anyway.
|
||||
const trusted = Boolean(s.ihasmail?.remember);
|
||||
setDeviceTrusted(trusted);
|
||||
if (trusted) {
|
||||
stopIdleLogout();
|
||||
} else {
|
||||
// Residue from an earlier trusted session on this machine is exactly what
|
||||
// an untrusted sign-in is asking us not to keep.
|
||||
clearAllData();
|
||||
startIdleLogout(() => void useSession.getState().logout());
|
||||
}
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, error: null });
|
||||
}
|
||||
@@ -115,8 +139,15 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
client.onUnauthenticated(() => {
|
||||
push.stop();
|
||||
stopSettingsSync();
|
||||
stopIdleLogout();
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
useSession.setState({ status: "anonymous", session: null, accountId: null });
|
||||
// Ask before showing the sign-in form rather than after. A deploy is the
|
||||
// usual reason to be signed out here, and reloading a form someone has
|
||||
// already started typing into would throw the password away.
|
||||
void reloadIfServerRebuilt().then((reloading) => {
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null });
|
||||
});
|
||||
});
|
||||
|
||||
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
|
||||
|
||||
@@ -33,6 +33,21 @@ export interface Settings {
|
||||
showAvatars: boolean;
|
||||
pageSize: number;
|
||||
markReadDelay: number; // seconds; -1 = never auto
|
||||
/**
|
||||
* Shared calendars and address books the reader has added, as
|
||||
* `accountId:collectionId`.
|
||||
*
|
||||
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
|
||||
* still tried first -- a preference the server holds is one every client
|
||||
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
|
||||
* refuses that for an address book shared read-only: "You are not allowed to
|
||||
* modify this address book." It accepts the same write on a shared calendar,
|
||||
* which is the inconsistency this list exists to paper over.
|
||||
*
|
||||
* So where the server will not remember, ihasmail does, in the settings that
|
||||
* already follow the reader between devices.
|
||||
*/
|
||||
addedShares: string[];
|
||||
imagePolicy: ImagePolicy;
|
||||
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
|
||||
themeMessageBody: boolean;
|
||||
@@ -128,6 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
showAvatars: true,
|
||||
pageSize: 50,
|
||||
markReadDelay: 0,
|
||||
addedShares: [],
|
||||
imagePolicy: "ask",
|
||||
themeMessageBody: false,
|
||||
undoSendSeconds: 8,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
|
||||
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
|
||||
import { rulesToSieve, scriptDamage, sieveToRules, type SieveRule } from "@/lib/sieve";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export const IHASMAIL_SCRIPT = "ihasmail";
|
||||
@@ -19,7 +19,7 @@ interface SieveState {
|
||||
getContent(id: Id): Promise<string>;
|
||||
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
|
||||
/** `loaded` distinguishes "this script is hand-written" from "we could not read it". */
|
||||
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean };
|
||||
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean; damage: string | null };
|
||||
saveRules(rules: SieveRule[]): Promise<void>;
|
||||
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
|
||||
activate(id: Id | null): Promise<void>;
|
||||
@@ -91,14 +91,19 @@ export const useSieve = create<SieveState>((set, get) => ({
|
||||
rules() {
|
||||
const { scripts, contents } = get();
|
||||
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
|
||||
if (!script) return { script: null, rules: [], content: "", loaded: true };
|
||||
if (!script) return { script: null, rules: [], content: "", loaded: true, damage: null };
|
||||
const content = contents[script.id];
|
||||
// Not loaded, or the fetch failed. `null` means "cannot say", which every
|
||||
// caller already treats as "do not edit this script" -- as opposed to `[]`,
|
||||
// which means "this script genuinely has no rules" and invites a save that
|
||||
// would overwrite whatever is really in it.
|
||||
if (content === undefined) return { script, rules: null, content: "", loaded: false };
|
||||
return { script, rules: sieveToRules(content), content, loaded: true };
|
||||
if (content === undefined) return { script, rules: null, content: "", loaded: false, damage: null };
|
||||
// Read, but not all of it. Showing the rules that did parse would be the
|
||||
// most dangerous thing available: a short list that looks complete, over a
|
||||
// script that is not. Say "cannot say" here too.
|
||||
const damage = scriptDamage(content);
|
||||
if (damage) return { script, rules: null, content, loaded: true, damage };
|
||||
return { script, rules: sieveToRules(content), content, loaded: true, damage: null };
|
||||
},
|
||||
|
||||
async saveRules(rules) {
|
||||
@@ -106,9 +111,19 @@ export const useSieve = create<SieveState>((set, get) => ({
|
||||
// The last line of defence. Writing rules replaces the whole script, so
|
||||
// doing it from a baseline we never managed to read deletes whatever was
|
||||
// there. Refusing is recoverable; overwriting is not.
|
||||
if (existing && get().contents[existing.id] === undefined) {
|
||||
if (existing) {
|
||||
const content = get().contents[existing.id];
|
||||
if (content === undefined) {
|
||||
throw new Error("Your filter script could not be read, so saving would overwrite it. Reload and try again.");
|
||||
}
|
||||
// Read in full is a separate question from read at all, and the answer
|
||||
// that cost rules in #76 was "partly". A baseline missing its tail writes
|
||||
// out just as confidently as one missing entirely.
|
||||
const damage = scriptDamage(content);
|
||||
if (damage) {
|
||||
throw new Error(`Your filter script ${damage}, so saving would overwrite the rest of it. Reload and try again.`);
|
||||
}
|
||||
}
|
||||
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
|
||||
},
|
||||
|
||||
|
||||
@@ -282,6 +282,11 @@ img { max-width: 100%; }
|
||||
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: var(--radius-sm); text-align: left; color: var(--fg); white-space: nowrap; }
|
||||
.menu-item:hover, .menu-item.active { background: var(--bg-hover); }
|
||||
.menu-item:disabled { opacity: .5; cursor: default; }
|
||||
/* A menu entry that is a link still looks like a menu entry. The global rule
|
||||
for `a` would otherwise colour and underline the one item that leaves the
|
||||
app, which reads as a mistake rather than a distinction. */
|
||||
a.menu-item { text-decoration: none; color: var(--fg); cursor: pointer; }
|
||||
a.menu-item:hover { color: var(--fg); }
|
||||
.menu-item.danger { color: var(--danger); }
|
||||
.menu-item .menu-kbd { margin-left: auto; color: var(--fg-faint); font-size: .85em; }
|
||||
.menu-item svg { color: var(--fg-muted); flex: 0 0 auto; }
|
||||
@@ -305,6 +310,11 @@ img { max-width: 100%; }
|
||||
.dialog-body { padding: 8px 20px 16px; overflow: auto; }
|
||||
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
|
||||
.dialog-foot .left { margin-right: auto; }
|
||||
/* "This occurrence or the whole series" — one button per answer, stacked, so
|
||||
the destructive one is read rather than landed on by muscle memory. */
|
||||
.dialog-choices { display: flex; flex-direction: column; gap: 8px; }
|
||||
.dialog-choice { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; text-align: left; padding: 10px 12px; height: auto; }
|
||||
.dialog-choice small { font-weight: 400; opacity: 0.75; }
|
||||
|
||||
/* Toasts ----------------------------------------------------------------- */
|
||||
.toast-host { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; padding: 0 12px; width: 100%; max-width: 520px; }
|
||||
@@ -1014,3 +1024,24 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.dp-split { flex-direction: column; }
|
||||
.dp-times { flex-direction: row; overflow-x: auto; max-height: none; border-left: 0; border-top: 1px solid var(--border); padding: 6px 0 0; }
|
||||
}
|
||||
|
||||
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
|
||||
no equivalent for are here. A row in the list is a drop target the same way a
|
||||
folder in the tree is, and says so the same way. */
|
||||
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
|
||||
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.files-table tbody tr[draggable="true"] { cursor: grab; }
|
||||
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
|
||||
.sidebar .nav-item[draggable="true"] { cursor: pointer; }
|
||||
.f-name .faint { flex: none; }
|
||||
|
||||
/* The "Shared with me" header carries a refresh control, so it is a row rather
|
||||
than the plain label the other sections use. */
|
||||
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||
|
||||
/* The composer's To label doubles as the way into the address books. */
|
||||
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
||||
.composer-field label .link-btn:hover { color: var(--accent); }
|
||||
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
|
||||
@@ -86,9 +86,17 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
|
||||
|
||||
/* ---------- Imperative confirm / prompt ---------- */
|
||||
|
||||
export interface DialogChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Shown under the label, for the choice that needs the caveat. */
|
||||
hint?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmRequest {
|
||||
id: number;
|
||||
kind: "confirm" | "prompt";
|
||||
kind: "confirm" | "prompt" | "choice";
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
@@ -96,6 +104,7 @@ interface ConfirmRequest {
|
||||
danger?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
choices?: DialogChoice[];
|
||||
resolve: (v: boolean | string | null) => void;
|
||||
}
|
||||
|
||||
@@ -119,6 +128,18 @@ export function promptDialog(opts: { title: string; message?: ReactNode; default
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A question with more than two answers, which "this one or all of them" is.
|
||||
*
|
||||
* Resolves to the chosen `value`, or `null` if the dialog is dismissed —
|
||||
* dismissing is not one of the choices, so a caller cannot mistake it for one.
|
||||
*/
|
||||
export function choiceDialog(opts: { title: string; message?: ReactNode; choices: DialogChoice[]; cancelLabel?: string }): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmStore.getState().push({ id: reqId++, kind: "choice", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ConfirmHost() {
|
||||
const req = useConfirmStore((s) => s.queue[0]);
|
||||
const pop = useConfirmStore((s) => s.pop);
|
||||
@@ -132,10 +153,15 @@ export function ConfirmHost() {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done(req.kind === "prompt" ? null : false)}
|
||||
onClose={() => done(req.kind === "confirm" ? false : null)}
|
||||
title={req.title}
|
||||
size="sm"
|
||||
footer={
|
||||
req.kind === "choice" ? (
|
||||
<button className="btn" onClick={() => done(null)}>
|
||||
{req.cancelLabel ?? "Cancel"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
|
||||
{req.cancelLabel ?? "Cancel"}
|
||||
@@ -144,9 +170,20 @@ export function ConfirmHost() {
|
||||
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
|
||||
{req.kind === "choice" && (
|
||||
<div className="dialog-choices">
|
||||
{req.choices?.map((c) => (
|
||||
<button key={c.value} className={`btn dialog-choice ${c.danger ? "btn-danger" : ""}`} onClick={() => done(c.value)}>
|
||||
<span>{c.label}</span>
|
||||
{c.hint && <small>{c.hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{req.kind === "prompt" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
|
||||
@@ -120,14 +120,44 @@ export interface MenuItemProps {
|
||||
kbd?: string;
|
||||
active?: boolean;
|
||||
checked?: boolean;
|
||||
/** Renders the item as a link. An external one gets a new tab. */
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
|
||||
return (
|
||||
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
|
||||
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked, href, external }: MenuItemProps) {
|
||||
const inner = (
|
||||
<>
|
||||
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
|
||||
<span className="grow truncate">{label}</span>
|
||||
{kbd && <span className="menu-kbd">{kbd}</span>}
|
||||
</>
|
||||
);
|
||||
const className = `menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`;
|
||||
/*
|
||||
* A real anchor when there is somewhere to go, rather than a button that
|
||||
* calls window.open. The browser's own handling of a link comes with it --
|
||||
* middle-click, a modifier-click, "open in new tab", the address on hover,
|
||||
* copying it -- none of which a button offers however carefully it is
|
||||
* scripted, and all of which someone expects from a menu entry that leaves
|
||||
* the app.
|
||||
*/
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={href}
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick} disabled={disabled} role="menuitem">
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { FilesTree } from "./files/FilesTree";
|
||||
import { ContactsSidebar } from "./contacts/ContactsSidebar";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { CAP } from "@/jmap/client";
|
||||
|
||||
const PUSH_LABEL = {
|
||||
connected: "Live updates connected",
|
||||
@@ -30,8 +31,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const pushState = useSession((s) => s.pushState);
|
||||
const session = useSession((s) => s.session);
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const setAccount = useSession((s) => s.setAccount);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const acctMenu = useMenu();
|
||||
const section = location.split("/")[1] || "mail";
|
||||
@@ -53,8 +52,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [openCompose, navigate]);
|
||||
|
||||
const accounts = session ? Object.entries(session.accounts) : [];
|
||||
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
|
||||
/*
|
||||
* There is no account switcher any more.
|
||||
*
|
||||
* It existed to reach what other people shared, and was the wrong door: it
|
||||
* moved the whole app to somebody else's account, and Stalwart advertises
|
||||
* every capability on a shared account, so mail, calendar and contacts went
|
||||
* with it and were refused. Shares are listed where they belong now -- in
|
||||
* Files and in Contacts, beside the reader's own -- and found without anyone
|
||||
* having to know an account switch was involved.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -65,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<Link href="/mail" className="brand">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<span className="brand-name">
|
||||
ihasmail{mailAccounts.length > 1 ? "" : ""}
|
||||
ihasmail
|
||||
</span>
|
||||
</Link>
|
||||
<SearchBar />
|
||||
@@ -93,16 +100,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{mailAccounts.length > 1 && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Accounts</MenuTitle>
|
||||
{mailAccounts.map(([id, a]) => (
|
||||
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<BookOpen size={16} />} label="Documentation" href="https://docs.ihasmail.org" external />
|
||||
{/* The project site. It is linked from the login screen footer, which
|
||||
is a page a signed-in user never sees again -- so from inside the
|
||||
app there was no way back to it. */}
|
||||
<MenuItem icon={<Globe size={16} />} label="About ihasmail" href="https://ihasmail.org" external />
|
||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
|
||||
@@ -113,22 +116,25 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
{/* Whatever this pane is for. Files offered Compose, which wrote mail
|
||||
from the file manager and was the one thing nobody wanted there. */}
|
||||
<button
|
||||
className="compose-btn"
|
||||
onClick={() => {
|
||||
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
|
||||
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
|
||||
else if (section === "files") window.dispatchEvent(new CustomEvent("ihm:files-upload"));
|
||||
else openCompose();
|
||||
}}
|
||||
>
|
||||
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span>
|
||||
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
|
||||
</button>
|
||||
<div className="sidebar-scroll">
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
{section === "calendar" && <CalendarSidebar />}
|
||||
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
|
||||
{section === "files" && <div className="nav-section"><span>Files</span></div>}
|
||||
{section === "contacts" && <ContactsSidebar />}
|
||||
{section === "files" && <FilesTree />}
|
||||
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LoginPage() {
|
||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -34,8 +34,8 @@ export function LoginPage() {
|
||||
try {
|
||||
// No two-factor code: the field is not on this form until the flow works
|
||||
// end to end, and the server treats an absent code as none given.
|
||||
await login(username.trim(), password, "", remember);
|
||||
localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
await login(username.trim(), password, "", trustDevice);
|
||||
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "invalid_credentials") {
|
||||
@@ -74,10 +74,15 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>Keep me signed in on this device</span>
|
||||
<label className="check" style={{ marginBottom: 4 }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
<span>This is my own device</span>
|
||||
</label>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
{trustDevice
|
||||
? "Stay signed in, and keep settings and recent addresses on this computer."
|
||||
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
|
||||
</p>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar";
|
||||
import { useCalendar, isRecurring, isOccurrence, type EventInstance, type EventScope } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { formatDayMonth } from "@/lib/datetime";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
|
||||
import { toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatTime } from "@/lib/format";
|
||||
|
||||
@@ -60,20 +60,23 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
|
||||
const { inst } = ctx;
|
||||
const ev = inst.event;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
||||
const currentCat = categoryOf(ev, categories);
|
||||
const participants = Object.keys(ev.participants ?? {}).length;
|
||||
|
||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||
const scope = await askEditScope(ev);
|
||||
if (!scope) return;
|
||||
try {
|
||||
await cal.updateEvent(baseId, p, false);
|
||||
toast.success(msg);
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
|
||||
if (!dropped) return;
|
||||
// A per-occurrence change can be accepted in part. Say which part.
|
||||
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Custom colour removed");
|
||||
const setCategory = (cat: { name: string; color: string } | null) => {
|
||||
const categoriesPatch = cat ? { [cat.name]: true } : null;
|
||||
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
|
||||
@@ -89,11 +92,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
};
|
||||
const del = async () => {
|
||||
onClose();
|
||||
const recurring = isRecurring(ev);
|
||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||
let scope: EventScope | null = "series";
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
|
||||
scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants > 1);
|
||||
toast.success("Event deleted");
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -113,14 +121,17 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
))}
|
||||
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
{CALENDAR_COLORS.map((c) => (
|
||||
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
|
||||
))}
|
||||
</div>
|
||||
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
|
||||
{/*
|
||||
A colour is what a category already carries, so a second way to set
|
||||
one just made two things that could disagree. Picking a category is
|
||||
now the only way to colour an event here.
|
||||
|
||||
Clearing one stays, though, and only when there is one to clear: an
|
||||
event that already has an explicit colour — set before this, or by
|
||||
another client — would otherwise ignore its category for ever with
|
||||
nothing on the menu to say why.
|
||||
*/}
|
||||
{ev.color && <MenuItem icon={<Palette size={16} />} label="Clear custom colour" onClick={() => { onClose(); setColor(null); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, UserMinus, X } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
@@ -25,6 +25,13 @@ export function CalendarSidebar() {
|
||||
const [anchor, setAnchor] = useState(() => startOfDay(selected));
|
||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||
const menu = useMenu();
|
||||
/* Added if the server says so or the reader's settings do; Stalwart will not
|
||||
always take the flag, so the settings carry it where it refuses. */
|
||||
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
|
||||
const isAdded = (c: { accountId: string; calendar: { id: string; isSubscribed?: boolean } }) =>
|
||||
Boolean(c.calendar.isSubscribed) || addedShares.has(`${c.accountId}:${c.calendar.id}`);
|
||||
const sharedSubscribed = cal.sharedCalendars.filter(isAdded);
|
||||
const sharedAvailable = cal.sharedCalendars.filter((c) => !isAdded(c));
|
||||
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
|
||||
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
||||
const [share, setShare] = useState<Calendar | null>(null);
|
||||
@@ -59,16 +66,89 @@ export function CalendarSidebar() {
|
||||
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||
<span className="cal-name">{c.name}</span>
|
||||
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
{c.isDefault && <Star size={12} className="faint" />}
|
||||
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
{/* Calendars other people shared, split by whether the reader has added
|
||||
them. Stalwart returns every calendar in a reachable account with full
|
||||
rights, so "shared with me" and "there is an account here at all" look
|
||||
identical -- `isSubscribed` is the only thing that tells them apart,
|
||||
and adding one is a deliberate act rather than a guess on our part. */}
|
||||
{sharedSubscribed.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section"><span>Shared with me</span></div>
|
||||
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
|
||||
const key = `${accountId}:${c.id}`;
|
||||
return (
|
||||
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
|
||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||
<span className="cal-name">{c.name}</span>
|
||||
<button
|
||||
className="icon-btn xs nav-more"
|
||||
title="Remove from my calendar"
|
||||
aria-label="Remove from my calendar"
|
||||
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{sharedAvailable.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section"><span>Available to add</span></div>
|
||||
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
|
||||
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
|
||||
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
|
||||
<span className="cal-name faint">{c.name}</span>
|
||||
<button
|
||||
className="icon-btn xs nav-more"
|
||||
title="Add to my calendar"
|
||||
aria-label="Add to my calendar"
|
||||
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
||||
{menuCal && (
|
||||
<>
|
||||
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
|
||||
{/* Revoking every share at once, without walking the dialog and
|
||||
removing people one at a time. Only offered when there is
|
||||
something to revoke. */}
|
||||
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
|
||||
<MenuItem
|
||||
icon={<UserMinus size={16} />}
|
||||
label="Stop sharing"
|
||||
disabled={!menuCal.myRights.mayShare}
|
||||
onClick={async () => {
|
||||
const who = Object.keys(menuCal.shareWith ?? {}).length;
|
||||
if (!(await confirmDialog({
|
||||
title: `Stop sharing “${menuCal.name}”?`,
|
||||
message: `${who === 1 ? "One person" : `${who} people`} will lose access. Events in it are not affected.`,
|
||||
confirmLabel: "Stop sharing",
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await cal.updateCalendar(menuCal.id, { shareWith: null });
|
||||
toast.success("No longer shared");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
@@ -14,6 +14,7 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l
|
||||
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||
import { newKey } from "@/lib/contacts";
|
||||
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||
|
||||
export interface EditorInit {
|
||||
event?: CalendarEvent;
|
||||
@@ -24,25 +25,68 @@ export interface EditorInit {
|
||||
|
||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||
|
||||
/**
|
||||
* Fields this form always sends that a single occurrence will not take.
|
||||
*
|
||||
* `useDefaultAlerts` and `calendarIds` are refused with `invalidProperties`;
|
||||
* the rest are dropped from the patch while the response still reports
|
||||
* success. Both halves are reasons not to send them — the second more so,
|
||||
* because nothing would say it had happened.
|
||||
*/
|
||||
const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]);
|
||||
|
||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const session = useSession((s) => s.session);
|
||||
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
||||
const [scope, setScope] = useState<EventScope | undefined>(init.event?.baseEventId ? undefined : "series");
|
||||
const editing = Boolean(init.event);
|
||||
|
||||
// Load base event for recurring instances
|
||||
/*
|
||||
* Which event this form is even about has to be settled before it opens.
|
||||
*
|
||||
* A form populated from the master shows the series' start date, so editing
|
||||
* Wednesday's standup would offer to move Monday's — right for the series and
|
||||
* wrong for one date. So the scope is asked first, and the occurrence itself
|
||||
* is what the form loads when the answer is "this occurrence".
|
||||
*/
|
||||
/*
|
||||
* Asked once per event, and deliberately not tied to the effect's lifetime.
|
||||
*
|
||||
* Two things make the obvious version wrong. A dialog is queued in a store
|
||||
* the moment it is requested, so it outlives the effect that asked for it: a
|
||||
* re-run queues a second prompt the first answer cannot retract, and the
|
||||
* reader is asked the same question twice. And gating the *answer* on a
|
||||
* cleanup flag is worse — React's StrictMode runs mount, cleanup, mount, so
|
||||
* the flag is already set by the time anyone clicks and the editor never
|
||||
* opens at all. The ref is what makes this once; the answer is applied
|
||||
* whenever it arrives.
|
||||
*/
|
||||
const asked = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
|
||||
else if (!init.event) setBase(null);
|
||||
const ev = init.event;
|
||||
if (!ev) { setBase(null); setScope("series"); return; }
|
||||
if (!ev.baseEventId) { setBase(ev); setScope("series"); return; }
|
||||
if (asked.current === ev.id) return;
|
||||
asked.current = ev.id;
|
||||
void (async () => {
|
||||
const chosen = isRecurring(ev) && isOccurrence(ev) ? await askEditScope(ev) : "series";
|
||||
if (!chosen) { onClose(); return; }
|
||||
setScope(chosen);
|
||||
if (chosen === "occurrence") setBase(ev);
|
||||
else void cal.getEvent(ev.baseEventId!).then(setBase);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [init.event?.id]);
|
||||
|
||||
if (base === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
if (base === undefined || scope === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} scope={scope} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
}
|
||||
|
||||
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; scope: EventScope; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
/** This form is editing one date rather than the series behind it. */
|
||||
const oneDate = scope === "occurrence";
|
||||
const cal = useCalendar();
|
||||
const contacts = useContacts();
|
||||
const ev = base;
|
||||
@@ -175,11 +219,23 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
};
|
||||
const invites = sendInvites && attendees.length > 0;
|
||||
if (ev) {
|
||||
/*
|
||||
* A single occurrence takes less than the series does. Four of the
|
||||
* fields this form always sends are among them — `useDefaultAlerts`
|
||||
* and `calendarIds` are refused outright, `recurrenceRule`,
|
||||
* `privacy` and `organizerCalendarAddress` are dropped in silence —
|
||||
* so they are left out here rather than sent and believed. The store
|
||||
* still checks; this is what stops it having to complain.
|
||||
*/
|
||||
const source = oneDate
|
||||
? Object.fromEntries(Object.entries(obj).filter(([k]) => !OCCURRENCE_OMIT.has(k)))
|
||||
: obj;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
await cal.updateEvent(ev.id, patch, invites);
|
||||
toast.success("Event updated");
|
||||
for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v;
|
||||
if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s));
|
||||
if (!dropped) { setBusy(false); return; }
|
||||
toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated"));
|
||||
} else {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||
@@ -204,7 +260,13 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||
<div className="event-form">
|
||||
{ev && isRecurring(ev) && <div className="info-box mb-16">This is a recurring event — changes apply to the whole series.</div>}
|
||||
{ev && isRecurring(ev) && (
|
||||
<div className="info-box mb-16">
|
||||
{oneDate
|
||||
? `Editing ${formatNumericDate(start)} only — the rest of the series is unchanged. Repeat, calendar and privacy belong to the series and are not shown.`
|
||||
: "This is a recurring event — changes apply to the whole series."}
|
||||
</div>
|
||||
)}
|
||||
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||
<div className="time-row mb-8">
|
||||
{allDay ? (
|
||||
@@ -229,6 +291,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{!oneDate && (
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
@@ -238,8 +301,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
{!oneDate && preset === "custom" && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="row wrap" style={{ gap: 8 }}>
|
||||
<span>Repeat every</span>
|
||||
@@ -269,7 +333,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Calendar</label>
|
||||
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -327,7 +391,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
||||
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||||
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
|
||||
{!oneDate && <div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>}
|
||||
</div>
|
||||
<div className="field"><label>Category</label>
|
||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, participantEmail, type EventInstance, type EventScope } from "@/store/calendar";
|
||||
import { Popover, type Anchor } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { askDeleteScope, runScoped } from "./scope";
|
||||
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
||||
import { describeRule } from "@/lib/recurrence";
|
||||
import { useCompose } from "@/store/compose";
|
||||
@@ -22,20 +23,26 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
||||
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const location = Object.values(ev.locations ?? {})[0];
|
||||
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
||||
const alerts = Object.values(ev.alerts ?? {});
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const del = async () => {
|
||||
const recurring = isRecurring(ev);
|
||||
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
// A series asks which; anything else is a plain confirm. `askDeleteScope`
|
||||
// returns null for a dismissed dialog, which is a cancel and not a series.
|
||||
let scope: EventScope | null = "series";
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else {
|
||||
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
|
||||
if (!ok) scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants.length > 1);
|
||||
toast.success("Event deleted");
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -47,7 +54,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.rsvp(baseId, status);
|
||||
await cal.rsvp(ev, status);
|
||||
toast.success("Response sent");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { choiceDialog, confirmDialog } from "@/ui/dialog";
|
||||
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Ask which of a series a change is meant for, when there is a choice.
|
||||
*
|
||||
* There is only a choice when the object in hand is an occurrence of a real
|
||||
* series: a one-off has a synthetic id too, but its only occurrence *is* the
|
||||
* event, so asking would be a question with one true answer. `null` means the
|
||||
* dialog was dismissed, which is not the same as "the whole series" — every
|
||||
* caller has to treat it as a cancel.
|
||||
*
|
||||
* Until 0.16.20 there was nothing to ask: the server refused a write aimed at
|
||||
* an occurrence, so "the whole series" was the only thing that could happen.
|
||||
*/
|
||||
export async function askScope(
|
||||
event: CalendarEvent,
|
||||
opts: { title: string; occurrenceLabel: string; seriesLabel: string; danger?: boolean; occurrenceHint?: string; seriesHint?: string },
|
||||
): Promise<EventScope | null> {
|
||||
if (!isRecurring(event) || !isOccurrence(event)) return "series";
|
||||
const answer = await choiceDialog({
|
||||
title: opts.title,
|
||||
choices: [
|
||||
{ value: "occurrence", label: opts.occurrenceLabel, hint: opts.occurrenceHint, danger: opts.danger },
|
||||
{ value: "series", label: opts.seriesLabel, hint: opts.seriesHint, danger: opts.danger },
|
||||
],
|
||||
});
|
||||
return answer === "occurrence" || answer === "series" ? answer : null;
|
||||
}
|
||||
|
||||
/** The scope question for deleting. */
|
||||
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Delete this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Removes this date and leaves the rest of the series.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Deletes the whole series. This cannot be undone.",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
/** The scope question for editing. */
|
||||
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Change this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Applies to this date only.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Applies to every date in the series.",
|
||||
});
|
||||
|
||||
/**
|
||||
* What to say when the server kept some of a per-occurrence change for the
|
||||
* series. `dropped` comes back from `updateEvent`; an empty list says nothing.
|
||||
*/
|
||||
export function droppedMessage(dropped: string[]): string | null {
|
||||
if (!dropped.length) return null;
|
||||
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
|
||||
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run a scoped change, and offer the series if the server will not do one date.
|
||||
*
|
||||
* Stalwart refuses an occurrence that belongs to a this-and-future override —
|
||||
* *"Occurrences of a this-and-future change cannot be modified individually."*
|
||||
* Nothing ihasmail writes creates one, but an event synced from another client
|
||||
* can carry one, so the refusal is reachable and a bare error toast would leave
|
||||
* the reader with no way forward.
|
||||
*
|
||||
* The series is offered rather than silently substituted: they asked for one
|
||||
* date, and doing the larger thing without saying so is the failure this whole
|
||||
* area exists to avoid.
|
||||
*/
|
||||
export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await run(scope);
|
||||
} catch (err) {
|
||||
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: "This date cannot be changed on its own",
|
||||
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
|
||||
confirmLabel: "Apply to series",
|
||||
});
|
||||
return ok ? await run("series") : null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
@@ -12,6 +12,9 @@ import { formatSize, formatRelative } from "@/lib/format";
|
||||
import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { isValidEmail } from "@/lib/address";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { FilePicker } from "./FilePicker";
|
||||
import { RecipientPicker, type Field } from "./RecipientPicker";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
@@ -25,6 +28,10 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
const send = useCompose((s) => s.send);
|
||||
const saveDraft = useCompose((s) => s.saveDraft);
|
||||
const addFiles = useCompose((s) => s.addFiles);
|
||||
const addFromFiles = useCompose((s) => s.addFromFiles);
|
||||
const filesAvailable = useFiles((s) => s.available);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [addressBookOpen, setAddressBookOpen] = useState(false);
|
||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
@@ -169,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-to`}>To</label>
|
||||
<label htmlFor={`${key}-to`}>
|
||||
{/* Opens the address books. Autocomplete only helps someone who
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
opens it too, for anyone who tries that first. */}
|
||||
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
@@ -239,11 +254,27 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
||||
</Popover>
|
||||
{addressBookOpen && (
|
||||
<RecipientPicker
|
||||
onPick={(field: Field, addresses) => {
|
||||
// Added to whatever is already there, and the field is opened if
|
||||
// it was hidden -- picking a Bcc should not put one somewhere
|
||||
// the writer cannot see it.
|
||||
const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc;
|
||||
const merged = [...existing];
|
||||
for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a);
|
||||
patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) });
|
||||
}}
|
||||
onClose={() => setAddressBookOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
|
||||
{canSchedule && scheduleOpen && (
|
||||
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||
)}
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
{filesAvailable && <button className="icon-btn" title="Attach from Files" onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronRight, File as FileIcon, Folder, HardDrive, Users } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { useFiles } from "@/store/files";
|
||||
import type { AttachableFile } from "@/store/compose";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Pick something already in Files to attach.
|
||||
*
|
||||
* Browsing is the store's, so this shows the same folders the Files view does,
|
||||
* shared accounts included -- a file somebody shared with you is a file you can
|
||||
* send on, and having to download it first only to upload it again would be
|
||||
* the sort of detour the rest of this avoids.
|
||||
*
|
||||
* It borrows the Files store rather than keeping its own copy, which means
|
||||
* opening the picker moves where Files is browsing. Closing it puts that back:
|
||||
* a detour through somebody's shared folder to find an attachment should not
|
||||
* leave the file manager somewhere else afterwards.
|
||||
*/
|
||||
export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile[]) => void; onClose: () => void }) {
|
||||
const files = useFiles();
|
||||
const [cur, setCur] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Record<string, FileNode>>({});
|
||||
const [returnTo] = useState(() => files.accountId);
|
||||
|
||||
useEffect(() => {
|
||||
void files.loadChildren(cur);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur, files.accountId]);
|
||||
|
||||
const close = () => {
|
||||
if (files.accountId !== returnTo) files.openAccount(returnTo);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const openAccount = (accountId: string | null) => {
|
||||
files.openAccount(accountId);
|
||||
setCur(null);
|
||||
setPicked({});
|
||||
};
|
||||
|
||||
const nodes = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(cur);
|
||||
const chosen = Object.values(picked);
|
||||
const viewingShare = files.accountId !== files.ownAccountId;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
title="Attach from Files"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={close}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!chosen.length}
|
||||
onClick={() => {
|
||||
onPick(chosen.map((n) => ({ accountId: files.accountId!, name: n.name, type: n.type, size: n.size, blobId: n.blobId! })));
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{chosen.length > 1 ? `Attach ${chosen.length} files` : "Attach"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{files.sharedAccounts.length > 0 && (
|
||||
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||
<button className={`btn btn-sm ${viewingShare ? "" : "btn-primary"}`} onClick={() => openAccount(files.ownAccountId)}>
|
||||
<HardDrive size={14} /> My files
|
||||
</button>
|
||||
{files.sharedAccounts.map((a) => (
|
||||
<button key={a.id} className={`btn btn-sm ${files.accountId === a.id ? "btn-primary" : ""}`} onClick={() => openAccount(a.id)}>
|
||||
<Users size={14} /> {a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="breadcrumb mb-8">
|
||||
<button onClick={() => setCur(null)}><HardDrive size={14} /></button>
|
||||
{path.map((n) => (
|
||||
<span key={n.id} className="row gap-4">
|
||||
<ChevronRight size={12} />
|
||||
<button onClick={() => setCur(n.id)}>{n.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{files.loading && !nodes.length ? (
|
||||
<Spinner />
|
||||
) : !nodes.length ? (
|
||||
<p className="hint">This folder is empty.</p>
|
||||
) : (
|
||||
nodes.map((n) =>
|
||||
n.nodeType === "directory" ? (
|
||||
<button key={n.id} className="menu-item" onClick={() => setCur(n.id)}>
|
||||
<Folder size={16} />
|
||||
<span className="grow truncate">{n.name}</span>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<label key={n.id} className="menu-item" style={{ cursor: n.blobId ? "pointer" : "not-allowed", opacity: n.blobId ? 1 : 0.5 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!n.blobId}
|
||||
checked={Boolean(picked[n.id])}
|
||||
onChange={(e) =>
|
||||
setPicked((p) => {
|
||||
const next = { ...p };
|
||||
if (e.target.checked) next[n.id] = n;
|
||||
else delete next[n.id];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FileIcon size={16} />
|
||||
<span className="grow truncate">{n.name}</span>
|
||||
<span className="hint">{formatSize(n.size)}</span>
|
||||
</label>
|
||||
),
|
||||
)
|
||||
)}
|
||||
|
||||
{viewingShare && chosen.length > 0 && (
|
||||
// Blobs belong to the account holding them, so one from a share has to
|
||||
// be copied into yours before a draft can reference it. Worth saying,
|
||||
// because it is the difference between instant and a wait.
|
||||
<p className="hint" style={{ marginTop: 10 }}>Shared files are copied to your account when attached.</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Book, BookOpen, Search, Users, X } from "lucide-react";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
||||
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
||||
|
||||
export type Field = "to" | "cc" | "bcc";
|
||||
|
||||
/** One selectable address: a card can carry several, so the address is the unit. */
|
||||
interface Row {
|
||||
key: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
book: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose recipients by looking through the address books.
|
||||
*
|
||||
* Autocomplete answers "finish this name for me", which is only useful when the
|
||||
* writer already knows who they want. This answers the other question -- who is
|
||||
* there? -- so the books can be read rather than recalled, and several people
|
||||
* picked in one pass rather than typed one at a time.
|
||||
*
|
||||
* Each address is its own row, not each person: someone with a work address and
|
||||
* a personal one is a choice to make, and a picker that offered the card and
|
||||
* quietly took the first address would make it for them.
|
||||
*
|
||||
* Shared books are in here on the same footing as the reader's own, which is
|
||||
* the point of having added them -- with the account named, so it is never a
|
||||
* mystery whose list a name came from.
|
||||
*/
|
||||
export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) {
|
||||
const contacts = useContacts();
|
||||
const [q, setQ] = useState("");
|
||||
const [bookKey, setBookKey] = useState<string>("all");
|
||||
const [picked, setPicked] = useState<Record<string, Row>>({});
|
||||
|
||||
/*
|
||||
* Contacts are fetched on demand, and nothing had demanded them.
|
||||
*
|
||||
* `loadAll` runs when the Contacts view mounts, and `suggest` kicks it off
|
||||
* itself so autocomplete works from anywhere. This did neither, so opening a
|
||||
* composer without having visited Contacts first showed an empty picker over
|
||||
* a full address book -- "no contacts in this address book", about a book
|
||||
* with contacts in it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contacts.available, contacts.loaded]);
|
||||
|
||||
/* Added counts whether the server remembered it or the settings did --
|
||||
Stalwart refuses the flag on a book shared read-only, so for those the
|
||||
settings are the only record and filtering on `isSubscribed` alone would
|
||||
leave every shared book out of the picker. */
|
||||
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
|
||||
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || addedShares.has(`${b.accountId}:${b.book.id}`));
|
||||
const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: Row[] = [];
|
||||
const push = (card: ContactCard, book: string, keyPrefix: string) => {
|
||||
for (const a of contactEmails(card)) {
|
||||
if (!a.email) continue;
|
||||
out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book });
|
||||
}
|
||||
};
|
||||
if (bookKey === "all" || !bookKey.includes(":")) {
|
||||
for (const c of Object.values(contacts.cards)) {
|
||||
if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue;
|
||||
push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own");
|
||||
}
|
||||
}
|
||||
if (bookKey === "all" || bookKey.includes(":")) {
|
||||
for (const [key, card] of Object.entries(contacts.sharedCards)) {
|
||||
const accountId = key.slice(0, key.length - card.id.length - 1);
|
||||
const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]);
|
||||
if (!inBook) continue;
|
||||
if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue;
|
||||
push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId);
|
||||
}
|
||||
}
|
||||
const needle = q.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle))
|
||||
: out;
|
||||
return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
}, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]);
|
||||
|
||||
const chosen = Object.values(picked);
|
||||
const toggle = (r: Row) =>
|
||||
setPicked((p) => {
|
||||
const next = { ...p };
|
||||
if (next[r.key]) delete next[r.key];
|
||||
else next[r.key] = r;
|
||||
return next;
|
||||
});
|
||||
|
||||
const send = (field: Field) => {
|
||||
onPick(field, chosen.map((r) => ({ name: r.name, email: r.email })));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Choose recipients"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
|
||||
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
||||
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="row gap-8" style={{ marginBottom: 10 }}>
|
||||
{/* Same shape as the contact list's own search box. */}
|
||||
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={15} className="faint" />
|
||||
<input
|
||||
className="grow"
|
||||
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
||||
placeholder="Search names and addresses"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
|
||||
<option value="all">All address books</option>
|
||||
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
{subscribed.map((b) => (
|
||||
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
|
||||
{b.book.name} · {b.accountName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chosen.length > 0 && (
|
||||
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||
{chosen.map((r) => (
|
||||
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
|
||||
{r.name ?? r.email} <X size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
||||
{contacts.loading && !rows.length ? (
|
||||
<Spinner label="Loading contacts…" />
|
||||
) : !rows.length ? (
|
||||
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
|
||||
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
|
||||
<span className="grow truncate">
|
||||
{r.name ?? r.email}
|
||||
{r.name && <span className="hint"> · {r.email}</span>}
|
||||
</span>
|
||||
<span className="hint nowrap">{r.book}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!ownBooks.length && !subscribed.length && (
|
||||
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { AddressBook } from "@/jmap/types";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
/**
|
||||
* Re-read the session so newly shared books appear without a sign-in.
|
||||
*
|
||||
* Shared accounts arrive in the JMAP session, which is otherwise fetched once
|
||||
* and refreshed only when a state change is pushed to this tab. Opening
|
||||
* Contacts is when the answer matters, so that is when it is asked for --
|
||||
* throttled, since this is navigated to often and usually says nothing new.
|
||||
*/
|
||||
let lastRefresh = 0;
|
||||
async function refreshShares(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRefresh < 30_000) return;
|
||||
lastRefresh = now;
|
||||
try {
|
||||
await useSession.getState().refresh();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await useContacts.getState().init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Address books in the app's own left pane, the reader's above and other
|
||||
* people's below.
|
||||
*
|
||||
* The two are kept plainly apart rather than merged into one list: a book that
|
||||
* belongs to somebody else behaves differently -- you cannot add to it, and
|
||||
* what you do see depends on what they granted -- and a list that hid that
|
||||
* distinction would be lying about whose contacts these are.
|
||||
*/
|
||||
export function ContactsSidebar() {
|
||||
/* Import and export act on the list the view is showing, so they are asked
|
||||
for by event rather than reaching across into it. */
|
||||
const onImport = (file: File) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: file }));
|
||||
const onExport = () => window.dispatchEvent(new CustomEvent("ihm:contacts-export"));
|
||||
const contacts = useContacts();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
|
||||
const [share, setShare] = useState<AddressBook | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const menu = useMenu();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshShares();
|
||||
}, []);
|
||||
|
||||
if (!contacts.available) return null;
|
||||
|
||||
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
const sel = contacts.selection;
|
||||
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
|
||||
/* Added if the server says so or the reader's settings do -- Stalwart will
|
||||
not take the flag on a book shared read-only, so the settings carry it. */
|
||||
const added = new Set(settings.addedShares);
|
||||
const isAdded = (accountId: string, bookId: string) => added.has(`${accountId}:${bookId}`);
|
||||
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || isAdded(b.accountId, b.book.id));
|
||||
const available = contacts.sharedBooks.filter((b) => !(b.book.isSubscribed || isAdded(b.accountId, b.book.id)));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>Contacts</span></div>
|
||||
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
|
||||
<Users size={17} />
|
||||
<span className="grow truncate">All contacts</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-section">
|
||||
<span>My address books</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="New address book"
|
||||
aria-label="New address book"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await contacts.createBook(name.trim());
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{own.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`nav-item ${isOn(null, b.id) ? "active" : ""}`}
|
||||
onClick={() => contacts.select({ accountId: null, bookId: b.id })}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); menu.openAt(e.clientX, e.clientY); }}
|
||||
>
|
||||
<Book size={17} />
|
||||
<span className="grow truncate">{b.name}</span>
|
||||
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="nav-section">
|
||||
<span>Shared with me</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="Check for new shares"
|
||||
aria-label="Check for new shares"
|
||||
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
{subscribed.map(({ accountId, accountName, book }) => (
|
||||
<div
|
||||
key={`${accountId}:${book.id}`}
|
||||
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
||||
onClick={() => contacts.select({ accountId, bookId: book.id })}
|
||||
title={`${book.name} — shared by ${accountName}`}
|
||||
>
|
||||
<BookOpen size={17} />
|
||||
<span className="grow truncate">{book.name}</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="Remove from my contacts"
|
||||
aria-label="Remove from my contacts"
|
||||
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!subscribed.length && (
|
||||
<p className="hint" style={{ padding: "4px 12px" }}>
|
||||
{contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stalwart returns every book in a reachable account with full rights,
|
||||
shared or not, so adding one is the reader's decision rather than a
|
||||
guess made on their behalf. */}
|
||||
{available.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section"><span>Available to add</span></div>
|
||||
{available.map(({ accountId, accountName, book }) => (
|
||||
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
|
||||
<BookOpen size={17} className="faint" />
|
||||
<span className="grow truncate faint">{book.name}</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="Add to my contacts"
|
||||
aria-label="Add to my contacts"
|
||||
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Import and export lived in the pane this replaced. */}
|
||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||
<label className="btn btn-sm btn-block">
|
||||
<Upload size={14} /> Import vCard
|
||||
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> Export {sel.bookId === "all" ? "all" : "book"}</button>
|
||||
</div>
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<Pencil size={16} />}
|
||||
label="Rename"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
|
||||
if (!name?.trim() || name === menuBook.name) return;
|
||||
try {
|
||||
await contacts.updateBook(menuBook.id, { name: name.trim() });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
|
||||
{/* Revoking the lot, rather than removing people one at a time in
|
||||
the dialog. Only shown when there is something to revoke. */}
|
||||
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
|
||||
<MenuItem
|
||||
icon={<UserMinus size={16} />}
|
||||
label="Stop sharing"
|
||||
disabled={!menuBook.myRights?.mayShare}
|
||||
onClick={async () => {
|
||||
const who = Object.keys(menuBook.shareWith ?? {}).length;
|
||||
if (!(await confirmDialog({
|
||||
title: `Stop sharing “${menuBook.name}”?`,
|
||||
message: `${who === 1 ? "One person" : `${who} people`} will lose access. The contacts in it are not affected.`,
|
||||
confirmLabel: "Stop sharing",
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await contacts.updateBook(menuBook.id, { shareWith: null });
|
||||
toast.success("No longer shared");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
label="Delete"
|
||||
disabled={menuBook.isDefault}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await contacts.destroyBook(menuBook.id);
|
||||
if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ArrowLeft, Book, Download, Mail, MoreVertical, Pencil, Plus, Search, Share2, Trash2, Upload, Users, Phone, MapPin, Building2, Cake, StickyNote, Globe, Calendar as CalIcon, Star, Pin } from "lucide-react";
|
||||
import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users } from "lucide-react";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { AddressBook, ContactCard } from "@/jmap/types";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
||||
import { formatDate, formatDateLong } from "@/lib/datetime";
|
||||
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { avatarColor } from "@/lib/address";
|
||||
|
||||
export function ContactsView({ id }: { id?: string }) {
|
||||
@@ -19,11 +17,11 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
const contacts = useContacts();
|
||||
const narrow = useIsNarrow();
|
||||
const [q, setQ] = useState("");
|
||||
const [bookId, setBookId] = useState<string | "all">("all");
|
||||
/* The book being shown lives in the store, because the list that chooses it
|
||||
is the app's own sidebar rather than anything this view owns. */
|
||||
const sel = contacts.selection;
|
||||
const bookId = sel.bookId;
|
||||
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
|
||||
const [share, setShare] = useState<AddressBook | null>(null);
|
||||
const bookMenu = useMenu();
|
||||
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,16 +31,38 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
const onNew = () => setEditing({});
|
||||
const onImport = (ev: Event) => { const f = (ev as CustomEvent<File>).detail; if (f) void importFile(f); };
|
||||
const onExport = () => exportAll();
|
||||
window.addEventListener("ihm:new-contact", onNew);
|
||||
return () => window.removeEventListener("ihm:new-contact", onNew);
|
||||
}, []);
|
||||
window.addEventListener("ihm:contacts-import", onImport);
|
||||
window.addEventListener("ihm:contacts-export", onExport);
|
||||
return () => {
|
||||
window.removeEventListener("ihm:new-contact", onNew);
|
||||
window.removeEventListener("ihm:contacts-import", onImport);
|
||||
window.removeEventListener("ihm:contacts-export", onExport);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
});
|
||||
|
||||
const list = useMemo(() => {
|
||||
// A shared book lists that account's cards; anything else lists the
|
||||
// reader's own. They are never mixed: whose contacts you are looking at is
|
||||
// the one thing this view must not be vague about.
|
||||
if (sel.accountId) {
|
||||
const prefix = `${sel.accountId}:`;
|
||||
const theirs = Object.entries(contacts.sharedCards)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, c]) => c)
|
||||
.filter((c) => bookId === "all" || c.addressBookIds?.[bookId]);
|
||||
return contacts.filterCards(theirs, q);
|
||||
}
|
||||
const all = contacts.search(q);
|
||||
return bookId === "all" ? all : all.filter((c) => c.addressBookIds?.[bookId]);
|
||||
}, [contacts, q, bookId]);
|
||||
}, [contacts, q, bookId, sel.accountId]);
|
||||
|
||||
const selected = id ? contacts.cards[id] : undefined;
|
||||
const selected = id
|
||||
? contacts.cards[id] ?? Object.entries(contacts.sharedCards).find(([key]) => key.endsWith(`:${id}`))?.[1]
|
||||
: undefined;
|
||||
const books = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ letter: string; items: ContactCard[] }> = [];
|
||||
@@ -84,35 +104,6 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
|
||||
return (
|
||||
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
||||
<aside className="contacts-books">
|
||||
<button className={`nav-item ${bookId === "all" ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId("all")}>
|
||||
<Users size={18} /><span className="nav-label">All contacts</span><span className="nav-count">{Object.keys(contacts.cards).length}</span>
|
||||
</button>
|
||||
<div className="nav-section"><span>Address books</span>
|
||||
<button className="icon-btn" title="New address book" onClick={async () => { const n = await promptDialog({ title: "New address book", placeholder: "Name" }); if (n?.trim()) { try { await contacts.createBook(n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><Plus size={16} /></button>
|
||||
</div>
|
||||
{books.map((b) => (
|
||||
<button key={b.id} className={`nav-item ${bookId === b.id ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId(b.id)} onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); bookMenu.openAt(e.clientX, e.clientY); }}>
|
||||
<Book size={18} /><span className="nav-label">{b.name}</span>
|
||||
<span className="icon-btn nav-more" onClick={(e) => { e.stopPropagation(); setMenuBook(b); bookMenu.open(e); }}><MoreVertical size={16} /></span>
|
||||
</button>
|
||||
))}
|
||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||
<label className="btn btn-sm btn-block"><Upload size={14} /> Import vCard<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
|
||||
<button className="btn btn-sm btn-block" onClick={exportAll}><Download size={14} /> Export {bookId === "all" ? "all" : "book"}</button>
|
||||
</div>
|
||||
<Popover anchor={bookMenu.anchor} onClose={bookMenu.close} width={220}>
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name }); if (n?.trim()) void contacts.updateBook(menuBook.id, { name: n.trim() }).catch((err) => toast.error((err as Error).message)); }} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuBook)} />
|
||||
<MenuItem icon={<Star size={16} />} label={menuBook.isDefault ? "Default book" : "Make default"} disabled={menuBook.isDefault} onClick={() => void contacts.updateBook(menuBook.id, { isDefault: true } as Partial<AddressBook>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuBook.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "All contacts in it will be deleted.", confirmLabel: "Delete", danger: true })) void contacts.destroyBook(menuBook.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</aside>
|
||||
|
||||
<section className="contacts-list">
|
||||
<div className="list-search row">
|
||||
@@ -154,7 +145,6 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
)}
|
||||
</section>
|
||||
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
|
||||
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, RefreshCw, Share2, Trash2, Users } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
/**
|
||||
* Re-read the session, so the shared accounts on offer are current.
|
||||
*
|
||||
* Throttled because Files is navigated to often and this is a round trip that
|
||||
* tells the reader nothing new most times it runs.
|
||||
*/
|
||||
let lastShareRefresh = 0;
|
||||
async function refreshShares(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastShareRefresh < 30_000) return;
|
||||
lastShareRefresh = now;
|
||||
try {
|
||||
await useSession.getState().refresh();
|
||||
} catch {
|
||||
// The tree still lists whatever the last session said; a failed refresh is
|
||||
// not worth an error over something the reader did not ask for.
|
||||
return;
|
||||
}
|
||||
await useFiles.getState().init();
|
||||
}
|
||||
|
||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
/**
|
||||
* The folder tree beside the file list.
|
||||
*
|
||||
* Every directory in the account arrives in one query, so this never waits on
|
||||
* an expand and a drag always knows every folder it could land on -- including
|
||||
* ones the reader has never opened.
|
||||
*/
|
||||
export function FilesTree() {
|
||||
const [location, navigate] = useLocation();
|
||||
const nodes = useFiles((s) => s.nodes);
|
||||
const dirIds = useFiles((s) => s.dirIds);
|
||||
const treeLoaded = useFiles((s) => s.treeLoaded);
|
||||
const available = useFiles((s) => s.available);
|
||||
const loadTree = useFiles((s) => s.loadTree);
|
||||
const accountId = useFiles((s) => s.accountId);
|
||||
const ownAccountId = useFiles((s) => s.ownAccountId);
|
||||
const sharedAccounts = useFiles((s) => s.sharedAccounts);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const viewingShare = Boolean(accountId && accountId !== ownAccountId);
|
||||
// Kept across sessions, the way the mailbox tree keeps its own.
|
||||
const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {}));
|
||||
const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; });
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||
const [rootDrop, setRootDrop] = useState(false);
|
||||
const menu = useMenu();
|
||||
|
||||
/* Shared with the list pane: a drag starting in one has to be recognised by
|
||||
the other. See the note on `draggingId` in the store. */
|
||||
const draggingId = useFiles((s) => s.draggingId);
|
||||
const setDraggingId = useFiles((s) => s.setDragging);
|
||||
|
||||
useEffect(() => {
|
||||
if (available && !treeLoaded) void loadTree();
|
||||
}, [available, treeLoaded, loadTree]);
|
||||
|
||||
/*
|
||||
* Ask the server what is shared, on the way in.
|
||||
*
|
||||
* Shared accounts arrive in the JMAP session, which is fetched at sign-in and
|
||||
* refreshed only when a session-state change is pushed to this tab. A share
|
||||
* granted while the tab was open therefore stayed invisible until the next
|
||||
* sign-in -- and a share removed stayed on offer, which is why two browsers
|
||||
* disagreed about whether an account still existed. Opening Files is the
|
||||
* moment the answer matters, so that is when it is asked for.
|
||||
*/
|
||||
useEffect(() => {
|
||||
void refreshShares();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null;
|
||||
|
||||
// Open the branch the reader is looking at, so the current folder is visible
|
||||
// without them having to find it.
|
||||
useEffect(() => {
|
||||
if (!currentId) return;
|
||||
const open: Record<Id, boolean> = {};
|
||||
for (let id: Id | null | undefined = nodes[currentId]?.parentId; id; id = nodes[id]?.parentId) open[id] = true;
|
||||
if (Object.keys(open).length) setExpanded((x) => ({ ...x, ...open }));
|
||||
}, [currentId, nodes]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
|
||||
const canDropOn = (targetId: Id | null) => Boolean(draggingId) && canDropFileNode(nodes, draggingId!, targetId);
|
||||
|
||||
const moveTo = async (id: Id, parentId: Id | null) => {
|
||||
setDraggingId(null);
|
||||
try {
|
||||
await useFiles.getState().move(id, parentId);
|
||||
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
/** Files dropped from outside land in the folder they were dropped on. */
|
||||
const dropFiles = async (parentId: Id | null, dt: DataTransfer) => {
|
||||
const entries = entriesFromDrop(dt);
|
||||
const flat = Array.from(dt.files);
|
||||
if (entries.length && hasDirectory(entries)) {
|
||||
const plan = await planUpload(entries);
|
||||
if (plan.length) await useFiles.getState().uploadPlan(parentId, plan);
|
||||
return;
|
||||
}
|
||||
if (flat.length) await useFiles.getState().upload(parentId, flat);
|
||||
};
|
||||
|
||||
const onDrop = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRootDrop(false);
|
||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||
const id = e.dataTransfer.getData(NODE_MIME);
|
||||
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId);
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
|
||||
};
|
||||
|
||||
const onDragOver = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||
if (node ? !canDropOn(targetId) : !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||
};
|
||||
|
||||
const row = (d: FileNode, depth: number) => {
|
||||
const kids = childrenOf(d.id);
|
||||
const open = Boolean(expanded[d.id]);
|
||||
return (
|
||||
<div key={d.id}>
|
||||
<div
|
||||
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && canDropOn(d.id) ? "drop-target" : ""}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
onClick={() => navigate(`/files/${d.id}`)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onDragOver={onDragOver(d.id)}
|
||||
onDrop={onDrop(d.id)}
|
||||
>
|
||||
<button
|
||||
className="nav-twisty"
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
style={{ visibility: kids.length ? "visible" : "hidden" }}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((x) => ({ ...x, [d.id]: !open })); }}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
|
||||
<span className="grow truncate">{d.name}</span>
|
||||
{isShared(d) && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
</div>
|
||||
{open && kids.map((k) => row(k, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>{viewingShare ? "Shared folder" : "Files"}</span></div>
|
||||
<div
|
||||
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
|
||||
onClick={() => navigate("/files")}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(null); menu.openAt(e.clientX, e.clientY); }}
|
||||
onDragOver={(e) => { onDragOver(null)(e); if (!e.defaultPrevented) return; setRootDrop(true); }}
|
||||
onDragLeave={() => setRootDrop(false)}
|
||||
onDrop={onDrop(null)}
|
||||
>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<HardDrive size={17} />
|
||||
<span className="grow truncate">{viewingShare ? sharedAccounts.find((a) => a.id === accountId)?.name ?? "Shared files" : "All files"}</span>
|
||||
</div>
|
||||
{childrenOf(null).map((d) => row(d, 1))}
|
||||
{treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>{viewingShare ? "Nothing shared here." : "No folders yet."}</p>}
|
||||
|
||||
{/* Reaching a share used to mean switching the whole app to the other
|
||||
account from the profile menu, which pointed mail, calendar and
|
||||
contacts at them as well. Shared folders belong here, beside your
|
||||
own. */}
|
||||
{(viewingShare || sharedAccounts.length > 0) && (
|
||||
<>
|
||||
<div className="nav-section">
|
||||
<span>Shared with me</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="Check for new shares"
|
||||
aria-label="Check for new shares"
|
||||
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
{viewingShare && (
|
||||
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<HardDrive size={17} />
|
||||
<span className="grow truncate">Back to my files</span>
|
||||
</div>
|
||||
)}
|
||||
{sharedAccounts.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`nav-item ${accountId === a.id ? "active" : ""}`}
|
||||
onClick={() => { useFiles.getState().openAccount(a.id); navigate("/files"); }}
|
||||
>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<Users size={17} />
|
||||
<span className="grow truncate">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{!sharedAccounts.length && <p className="hint" style={{ padding: "4px 12px" }}>Nothing is shared with you.</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||
<MenuItem
|
||||
icon={<FolderPlus size={16} />}
|
||||
label="New folder"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
|
||||
if (menuNode) setExpanded((x) => ({ ...x, [menuNode.id]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{menuNode && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<Pencil size={16} />}
|
||||
label="Rename"
|
||||
disabled={!menuNode.myRights?.mayRename}
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
|
||||
if (!name?.trim() || name === menuNode.name) return;
|
||||
try {
|
||||
await useFiles.getState().rename(menuNode.id, name.trim());
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
label="Delete"
|
||||
disabled={!menuNode.myRights?.mayDelete}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await useFiles.getState().destroy([menuNode.id]);
|
||||
if (currentId === menuNode.id) navigate("/files");
|
||||
toast.success("Deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react";
|
||||
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { NODE_MIME } from "./FilesTree";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||
@@ -19,12 +23,28 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const menu = useMenu();
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
||||
recognised. See the note on `draggingId` in the store. */
|
||||
const draggingId = files.draggingId;
|
||||
const setDraggingId = files.setDragging;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.available) void files.loadChildren(parentId);
|
||||
// `accountId` is in here because opening a share changes which account the
|
||||
// same route means: at /files the parent is null before and after, so
|
||||
// without it the listing would keep showing the previous account's folder.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files.available, parentId]);
|
||||
}, [files.available, files.accountId, parentId]);
|
||||
|
||||
// The sidebar's primary button asks for an upload here, the way it asks the
|
||||
// calendar for a new event.
|
||||
useEffect(() => {
|
||||
const open = () => inputRef.current?.click();
|
||||
window.addEventListener("ihm:files-upload", open);
|
||||
return () => window.removeEventListener("ihm:files-upload", open);
|
||||
}, []);
|
||||
|
||||
// Ensure ancestors are loaded for breadcrumbs
|
||||
useEffect(() => {
|
||||
@@ -48,13 +68,37 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(parentId);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
/* A drop lands in `into`, which is the folder under the pointer when there is
|
||||
one and the folder being listed otherwise. Entries have to be read out
|
||||
before the first await -- the list is emptied the moment the handler
|
||||
returns -- so that happens here, synchronously, for every path. */
|
||||
const dropOnto = (into: string | null, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropping(false);
|
||||
const list = Array.from(e.dataTransfer.files);
|
||||
if (list.length) void files.upload(parentId, list);
|
||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||
const id = e.dataTransfer.getData(NODE_MIME);
|
||||
setDraggingId(null);
|
||||
if (id && canDropFileNode(files.nodes, id, into)) {
|
||||
void files.move(id, into).catch((err) => toast.error((err as Error).message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
const entries = entriesFromDrop(e.dataTransfer);
|
||||
const flat = Array.from(e.dataTransfer.files);
|
||||
void (async () => {
|
||||
if (entries.length && hasDirectory(entries)) {
|
||||
const plan = await planUpload(entries);
|
||||
if (plan.length) await files.uploadPlan(into, plan);
|
||||
return;
|
||||
}
|
||||
if (flat.length) await files.upload(into, flat);
|
||||
})();
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => dropOnto(parentId, e);
|
||||
|
||||
const download = (n: FileNode) => {
|
||||
if (!n.blobId) return;
|
||||
const a = document.createElement("a");
|
||||
@@ -64,7 +108,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||
<div className="files-toolbar">
|
||||
<div className="breadcrumb">
|
||||
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
||||
@@ -85,7 +129,16 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
</div>
|
||||
)}
|
||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||
<div className="files-scroll">
|
||||
<div
|
||||
className="files-scroll"
|
||||
onContextMenu={(e) => {
|
||||
// Only the empty space below the rows: a row has its own menu.
|
||||
if ((e.target as HTMLElement).closest("tr")) return;
|
||||
e.preventDefault();
|
||||
setMenuNode(null);
|
||||
menu.openAt(e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
|
||||
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
|
||||
) : (
|
||||
@@ -93,8 +146,23 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span></div></td>
|
||||
<tr
|
||||
key={n.id}
|
||||
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onDragOver={(e) => {
|
||||
if (n.nodeType !== "directory") return;
|
||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||
if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||
}}
|
||||
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
||||
onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label="Shared" />}</div></td>
|
||||
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
|
||||
@@ -105,17 +173,25 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||
{!menuNode && (
|
||||
<>
|
||||
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New folder" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
||||
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,17 +33,19 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
||||
}
|
||||
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
||||
|
||||
const { rules, loaded } = sieve.rules();
|
||||
const { rules, loaded, damage } = sieve.rules();
|
||||
if (rules === null) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
{/*
|
||||
Two different situations, and telling them apart matters: one is
|
||||
permanent and one is a reload away. Saying "written by hand" when the
|
||||
script merely failed to fetch sends someone looking for a problem
|
||||
they do not have.
|
||||
Three different situations, and telling them apart matters: one is
|
||||
permanent and two are a reload away. Saying "written by hand" when the
|
||||
script merely failed to fetch -- or arrived in part -- sends someone
|
||||
looking for a problem they do not have.
|
||||
*/}
|
||||
{loaded ? (
|
||||
{damage ? (
|
||||
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
|
||||
) : loaded ? (
|
||||
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
|
||||
) : (
|
||||
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
target = await cal.getEvent(id);
|
||||
}
|
||||
if (!target) throw new Error("Could not add the event to your calendar");
|
||||
await cal.rsvp(target.id, status);
|
||||
await cal.rsvp(target, status);
|
||||
setExisting(await cal.getEvent(target.id));
|
||||
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
||||
} catch (err) {
|
||||
@@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
)}
|
||||
{method === "CANCEL" && existing && (
|
||||
<div className="rsvp">
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{email.id}</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LabelPicker } from "./LabelPicker";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
||||
|
||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||
@@ -39,6 +40,26 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [search, mailboxId, inboxId, navigate]);
|
||||
|
||||
/*
|
||||
* A folder id this account does not have.
|
||||
*
|
||||
* It used to render the ordinary empty state -- "Nothing here. This folder is
|
||||
* empty." -- which is a claim about a folder that is not there, so a stale
|
||||
* link read as a folder that had emptied itself rather than one that was
|
||||
* gone (#111). Only reachable from outside the app: the sidebar links to ids
|
||||
* that exist.
|
||||
*
|
||||
* Inbox is the kinder landing than a dead end, but silently swapping one
|
||||
* folder for another would be its own small lie, so it says what happened.
|
||||
* `mailboxesLoaded` gates it: without that, every cold load redirects in the
|
||||
* moment before the folder list arrives.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isUnknownMailbox({ mailboxId, mailboxes, loaded: mailboxesLoaded, search }) || !inboxId) return;
|
||||
toast.show("That folder no longer exists. Showing your inbox instead.");
|
||||
navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
|
||||
|
||||
// Build & run the list query
|
||||
const listQuery = useMemo<ListQuery | null>(() => {
|
||||
if (search) {
|
||||
|
||||
@@ -292,6 +292,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
}
|
||||
|
||||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
|
||||
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||||
const [, navigate] = useLocation();
|
||||
const colors = useSettings((s) => s.settings.folderColors);
|
||||
const update = useSettings((s) => s.update);
|
||||
@@ -354,7 +355,13 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
|
||||
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||
stores the share, and it never reaches the other account -- its own
|
||||
docs list calendars, address books and files as shareable and not mail
|
||||
folders. Offering it produced shares that looked real and did nothing.
|
||||
One that already exists can still be cleared here, which is the only
|
||||
reason this entry survives at all. */}
|
||||
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
|
||||
@@ -45,6 +45,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const [showHeaders, setShowHeaders] = useState(false);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
/* Stable, so the body's click handler keeps its identity between renders.
|
||||
Passing an inline arrow here is what made the handler change on every
|
||||
render in the first place. */
|
||||
const showImages = useCallback(() => setAllowRemote(true), []);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const addrMenu = useAddressMenu();
|
||||
@@ -269,7 +273,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{icsPart && <InviteCard email={e} part={icsPart} />}
|
||||
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
||||
<div className="message-body">
|
||||
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
|
||||
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} /> : <TextBody text={textRaw ?? ""} />}
|
||||
</div>
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||
{unsubscribe && (
|
||||
@@ -405,9 +409,32 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
||||
}
|
||||
setHasQuote(found);
|
||||
setQuoteOpen(false);
|
||||
/*
|
||||
* `onClick` is deliberately not a dependency of this effect.
|
||||
*
|
||||
* This is the effect that writes the body into the shadow root, so anything
|
||||
* in its dependencies rebuilds the entire message. The click handler used
|
||||
* to be in here, and it changes identity on every render -- it closes over
|
||||
* a prop the parent recreates inline -- so every render of the message
|
||||
* threw the rendered body away and built it again. Marking as read does
|
||||
* exactly that: the store hands back a new email object, the thread
|
||||
* re-renders, and the reader watched the message vanish and come back,
|
||||
* white to dark to white on an unstyled HTML mail, half a second after they
|
||||
* started reading it (#100). The quoted-text toggle reset with it.
|
||||
*
|
||||
* The listener lives in its own effect below. It is attached to the shadow
|
||||
* root rather than to its contents, which survives this rewriting anyway,
|
||||
* so a changing handler now costs a listener swap and nothing else.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [html, bodyStyle, themed]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = hostRef.current?.shadowRoot;
|
||||
if (!root) return;
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
}, [html, bodyStyle, themed, onClick]);
|
||||
}, [onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = hostRef.current?.shadowRoot;
|
||||
|
||||
@@ -10,6 +10,10 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { client } from "@/jmap/client";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
import { threadScrollTarget } from "@/lib/threadScroll";
|
||||
|
||||
/** How long the opening scroll keeps its place while bodies and images land. */
|
||||
const HOLD_MS = 2000;
|
||||
|
||||
interface Props {
|
||||
threadId: Id;
|
||||
@@ -115,11 +119,54 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
|
||||
|
||||
// Scroll last expanded into view on load
|
||||
/*
|
||||
* Open on the first unread message rather than the newest one (#87).
|
||||
*
|
||||
* Scrolling once is not enough. Message bodies are written into shadow roots
|
||||
* by child effects, and the images in them load later still, so the pane goes
|
||||
* on growing after the scroll -- and `scrollIntoView` clamps to the scroll
|
||||
* range as it stands the moment it is called. The read-thread fallback always
|
||||
* aims at the last message, which no thread has the room to lift to the top,
|
||||
* so that clamp is the whole of the range: measuring it before the images
|
||||
* landed stopped 39px short of the bottom, every time (#89).
|
||||
*
|
||||
* So the target is held against the top of the pane while the thread settles,
|
||||
* and let go the moment the reader touches it. A pane that re-scrolls under
|
||||
* someone who has started reading is worse than one that lands short, which
|
||||
* is why the hold ends on the first sign of them rather than when the content
|
||||
* stops changing.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!messages.length || !scrollRef.current) return;
|
||||
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(lastId ?? "")}"]`);
|
||||
if (el && messages.length > 1) el.scrollIntoView({ block: "start" });
|
||||
const sc = scrollRef.current;
|
||||
if (!messages.length || !sc) return;
|
||||
const target = threadScrollTarget(messages, wasUnread);
|
||||
if (!target) return;
|
||||
|
||||
let held = true;
|
||||
const align = () => {
|
||||
if (held) sc.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(target)}"]`)?.scrollIntoView({ block: "start" });
|
||||
};
|
||||
const release = () => {
|
||||
held = false;
|
||||
};
|
||||
|
||||
align();
|
||||
|
||||
// The messages and the reply box: what grows is one of their heights.
|
||||
const ro = new ResizeObserver(align);
|
||||
for (const child of sc.children) ro.observe(child);
|
||||
// `scroll` is not in here: the aligning does that itself.
|
||||
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.addEventListener(ev, release, { passive: true });
|
||||
window.addEventListener("keydown", release);
|
||||
const settled = window.setTimeout(release, HOLD_MS);
|
||||
|
||||
return () => {
|
||||
release();
|
||||
ro.disconnect();
|
||||
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.removeEventListener(ev, release);
|
||||
window.removeEventListener("keydown", release);
|
||||
window.clearTimeout(settled);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threadId, messages.length > 0]);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AboutSettings() {
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
|
||||
<p className="hint">The middle number of ihasmail's own version is the Stalwart generation it is built for: <strong>v2.16.x</strong> targets Stalwart 0.16. The last is the pull request it was built from, and a trailing <code>+g</code> and short commit means the build is past that pull request rather than exactly it.</p>
|
||||
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>v2026.8.30+pr129</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
|
||||
<h2>Server capabilities</h2>
|
||||
<div className="row wrap gap-4">
|
||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||
|
||||
@@ -45,7 +45,7 @@ const RULE_MIME = "application/x-ihasmail-sieve-rule";
|
||||
|
||||
function RulesEditor() {
|
||||
const sieve = useSieve();
|
||||
const { script, rules, content } = sieve.rules();
|
||||
const { script, rules, content, damage } = sieve.rules();
|
||||
const [local, setLocal] = useState<SieveRule[] | null>(null);
|
||||
const [editing, setEditing] = useState<SieveRule | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -80,6 +80,19 @@ function RulesEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
// Before the hand-written branch: a script that arrived in part is not a
|
||||
// script someone chose to write themselves, and the way out of it is a reload
|
||||
// rather than the "start with rules" button below, which would write over it.
|
||||
if (damage) {
|
||||
return (
|
||||
<div className="warn-box">
|
||||
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Only part of your filter script arrived.</b></div>
|
||||
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
|
||||
<button className="btn" onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rules === null) {
|
||||
return (
|
||||
<div className="warn-box">
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FoldersSettings() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Folders</h1>
|
||||
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
|
||||
@@ -48,7 +48,7 @@ export function FoldersSettings() {
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
|
||||
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title="Stop sharing" onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
||||
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id, Principal } from "@/jmap/types";
|
||||
|
||||
type Kind = "Mailbox" | "Calendar" | "AddressBook";
|
||||
/* The JMAP type name, used verbatim as the `/set` method prefix. */
|
||||
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
|
||||
|
||||
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
|
||||
Mailbox: [
|
||||
@@ -38,15 +40,27 @@ const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
|
||||
{ key: "mayShare", label: "Share" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
],
|
||||
// Stalwart 0.16.19 returns all six on a node of your own (2026-08-27).
|
||||
FileNode: [
|
||||
{ key: "mayRead", label: "Read" },
|
||||
{ key: "mayAddChildren", label: "Add files" },
|
||||
{ key: "mayModifyContent", label: "Edit contents" },
|
||||
{ key: "mayRename", label: "Rename" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
{ key: "mayShare", label: "Share" },
|
||||
],
|
||||
};
|
||||
|
||||
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
|
||||
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
|
||||
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
|
||||
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
|
||||
// An editor can fill a folder and change what is in it, but not rename or
|
||||
// delete the folder they were given -- those stay with whoever shared it.
|
||||
FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] },
|
||||
};
|
||||
|
||||
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
|
||||
/** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */
|
||||
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
|
||||
const principals = useContacts((s) => s.principals);
|
||||
const loadPrincipals = useContacts((s) => s.loadPrincipals);
|
||||
@@ -67,7 +81,11 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
|
||||
const accountId =
|
||||
kind === "Mailbox" ? useMail.getState().accountId
|
||||
: kind === "Calendar" ? useCalendar.getState().accountId
|
||||
: kind === "FileNode" ? useFiles.getState().accountId
|
||||
: useContacts.getState().accountId;
|
||||
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
@@ -75,6 +93,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
|
||||
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
|
||||
if (kind === "AddressBook") void useContacts.getState().loadBooks();
|
||||
if (kind === "FileNode") void useFiles.getState().refresh([id]);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -85,9 +104,17 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
|
||||
{!principals.length ? (
|
||||
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
|
||||
) : (
|
||||
{/* The list of who it is shared with is rendered whether or not anybody
|
||||
can be *added*. It used to sit inside the branch below, so a server
|
||||
with directory queries switched off -- which is the default, and which
|
||||
returns no principals -- showed nothing but the hint, and an existing
|
||||
share could not be seen, let alone removed. */}
|
||||
{!principals.length && (
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.
|
||||
</p>
|
||||
)}
|
||||
{principals.length > 0 && (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
@@ -99,6 +126,8 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
|
||||
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{Object.entries(rights).map(([pid, r]) => {
|
||||
const p = principals.find((x) => x.id === pid);
|
||||
return (
|
||||
@@ -119,8 +148,6 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
);
|
||||
})}
|
||||
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||