Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa10563ed0 | ||
|
|
b7e0fc0c7d | ||
|
|
abd2269581 | ||
|
|
3530a0cc64 | ||
|
|
ba819369f4 | ||
|
|
bae29e647e | ||
|
|
4a12a898d1 | ||
|
|
aa13fcae7f | ||
|
|
55721faed2 | ||
|
|
a9dd6b3569 | ||
|
|
1fc24e6273 | ||
|
|
ea36270adf | ||
|
|
b8ab46270d | ||
|
|
d77928426a | ||
|
|
1514fd9e0a | ||
|
|
62242493f8 | ||
|
|
f7db2f23fa | ||
|
|
4a9c0c1c55 | ||
|
|
acc9df299e | ||
|
|
0dd0aed0dd | ||
|
|
ca2c4544dc | ||
|
|
a5c971ace4 | ||
|
|
aad599d802 | ||
|
|
25046e85e3 | ||
|
|
145abef3fe | ||
|
|
e29e3b35b0 | ||
|
|
99382486b8 | ||
|
|
9e2987f43c | ||
|
|
788eb0b0e1 | ||
|
|
b7f4b4e496 | ||
|
|
0fc4bd7a52 | ||
|
|
56e1f4f1be | ||
|
|
a9231a335c | ||
|
|
00868f053c | ||
|
|
ea55b1b2aa | ||
|
|
a027b312c5 | ||
|
|
db6afb72ff | ||
|
|
047a0029bf | ||
|
|
b0be465e50 | ||
|
|
fe9285062c | ||
|
|
9a2f355a4a | ||
|
|
4740ca46eb | ||
|
|
c04bc7a31b | ||
|
|
377e3aba2a | ||
|
|
27dbc8ac41 | ||
|
|
cc073693f4 | ||
|
|
a9ff405135 | ||
|
|
65443a235a | ||
|
|
0cbebed645 | ||
|
|
16dd867707 | ||
|
|
9a7f37b540 | ||
|
|
9a8c7516d7 | ||
|
|
6170fc3944 | ||
|
|
ee998eff46 | ||
|
|
d4d218f078 | ||
|
|
e9c183f2a5 | ||
|
|
be46c0c3c9 | ||
|
|
05c9bd46c2 | ||
|
|
d1d3041ce6 | ||
|
|
b0944b2f42 | ||
|
|
ca2c71e858 | ||
|
|
2f8af1ce94 | ||
|
|
6127a77458 | ||
|
|
afecc7d1dc | ||
|
|
d756d3ca68 | ||
|
|
f1f762c228 | ||
|
|
232e518d55 | ||
|
|
287af22ef8 | ||
|
|
409e5578a0 | ||
|
|
f1a2972d3a | ||
|
|
3521487f6c | ||
|
|
f5373c6fcd | ||
|
|
c4b9741c6d | ||
|
|
1aee40a169 | ||
|
|
d5468277d6 | ||
|
|
0a49c914eb | ||
|
|
57ff18bb7f | ||
|
|
b8263aa785 | ||
|
|
ee7542fd86 | ||
|
|
c92a68aba1 | ||
|
|
41f4cc7f8c | ||
|
|
c13a5375ab | ||
|
|
4025812c5d | ||
|
|
ad0b913efb | ||
|
|
337c46ebda | ||
|
|
c55b54163f | ||
|
|
3621e81d0c | ||
|
|
c4731fc8e0 | ||
|
|
0db2acb52b | ||
|
|
15d64c6320 | ||
|
|
e87ba09d70 | ||
|
|
54c256e69e | ||
|
|
98edc18570 | ||
|
|
9cdf84203b | ||
|
|
24fac8204a | ||
|
|
eadec49b4f | ||
|
|
95e442b2dd | ||
|
|
3ec4dc44fb | ||
|
|
7caa847737 | ||
|
|
0b278ca3c0 | ||
|
|
860cda22ab | ||
|
|
ecbcd76372 | ||
|
|
4846b5515c | ||
|
|
b870ee1910 | ||
|
|
36c19d639b | ||
|
|
d0828d67ed | ||
|
|
be893ef482 | ||
|
|
86660497b1 | ||
|
|
626a48e678 | ||
|
|
49c06e6efe | ||
|
|
487da2fbca | ||
|
|
c17887e48e |
@@ -3,14 +3,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
# Lets CI be run by hand against any ref, including a specific commit.
|
|
||||||
# Without this there is no way to re-run a check that never started: a run
|
|
||||||
# GitHub queues and then orphans -- as it did to every run created during the
|
|
||||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
|
||||||
# nor cancelled ("already completed"), and the workflow has no other trigger
|
|
||||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
|
||||||
# change, without pushing an empty commit to move it.
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+1
-15
@@ -1,12 +1,5 @@
|
|||||||
# ---- build stage ----
|
# ---- build stage ----
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
|
||||||
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
|
||||||
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
|
||||||
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
|
|
||||||
# Left empty, the build falls back to the base version from package.json.
|
|
||||||
ARG IHASMAIL_VERSION=""
|
|
||||||
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY server/package.json server/
|
COPY server/package.json server/
|
||||||
@@ -17,21 +10,14 @@ RUN npm run build
|
|||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
# Re-declared: an ARG does not cross stages.
|
|
||||||
ARG IHASMAIL_VERSION=""
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
STATIC_DIR=/app/web/dist \
|
STATIC_DIR=/app/web/dist \
|
||||||
SESSION_FILE=/data/sessions.json \
|
SESSION_FILE=/data/sessions.json
|
||||||
IHASMAIL_VERSION=$IHASMAIL_VERSION
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server/package.json server/
|
COPY server/package.json server/
|
||||||
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
|
||||||
# set it never looks further; without it, it falls back to package.json rather
|
|
||||||
# than failing, since there is no git in here to ask.
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
COPY --from=build /app/web/dist ./web/dist
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Known issues and pending QA
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
capability looked for in the wrong place meant it had never run at all.
|
|
||||||
|
|
||||||
Some entries record what a live **0.15.5** proved before that server was
|
|
||||||
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).
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
- **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.
|
|
||||||
- **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).
|
|
||||||
- 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,8 +4,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<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="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="Tested against Stalwart 0.16.19 and 0.15.5" src="https://img.shields.io/badge/Stalwart-0.16.19%20%7C%200.15.5-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://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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -13,56 +12,97 @@
|
|||||||
|
|
||||||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
||||||
|
|
||||||
Mail, calendars, contacts, files and filters in a responsive single-page app
|
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, 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.
|
||||||
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.
|
|
||||||
|
|
||||||
| | |
|
> Status: 2.0 rewrite, in QA against a live Stalwart server — **0.16.19**
|
||||||
| --- | --- |
|
> since 2026-08-25, 0.15.5 before that. The previous FastAPI/HTMX prototype
|
||||||
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
|
> has been removed entirely (only the logo survived, and it has since lost
|
||||||
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
|
> the `.com` wordmark it used to carry — ihasmail is the software, not the
|
||||||
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
> hosted instance).
|
||||||
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
|
||||||
|
|
||||||
This file is for people working *on* ihasmail. Everything about running it
|
ihasmail supports both generations of Stalwart, which are less alike than the
|
||||||
lives in the docs.
|
version numbers suggest: 0.16 replaced the REST management API with JMAP
|
||||||
|
registry objects, changed the shape of `FileNode`, split its rights up, and
|
||||||
|
moved configuration into the store. Where the two differ, ihasmail detects
|
||||||
|
which it is talking to rather than assuming — see [Known issues / pending
|
||||||
|
QA](#known-issues--pending-qa) for what is verified on which.
|
||||||
|
|
||||||
|
The live instance was moved from 0.15.5 to 0.16.19 with
|
||||||
|
[stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator), a
|
||||||
|
companion project: an in-place upgrade tool that checkpoints every phase,
|
||||||
|
refuses to start on the things that cannot be fixed mid-migration, and
|
||||||
|
validates the server afterwards. The upgrade is genuinely treacherous by hand
|
||||||
|
— the store is migrated in place with no way back, and Stalwart's own
|
||||||
|
converter drops settings without saying so — and that migration took eight
|
||||||
|
seconds of downtime with nothing lost.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
| **Inbox & conversation view (dark)**  | **Inbox & conversation view (light)**  |
|
||||||
| **Composer**  | **Calendar**  |
|
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
||||||
| **Contacts**  | **Sieve filter builder**  |
|
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
||||||
|
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
||||||
|
|
||||||
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
## Features
|
||||||
|
|
||||||
## What's in it
|
**Mail**
|
||||||
|
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
|
||||||
|
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
|
||||||
|
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
|
||||||
|
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
|
||||||
|
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
|
||||||
|
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
|
||||||
|
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
|
||||||
|
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
|
||||||
|
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
|
||||||
|
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
|
||||||
|
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||||
|
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||||
|
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||||
|
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||||||
|
|
||||||
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
|
**Calendar** (JMAP Calendars / JSCalendar)
|
||||||
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
||||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour
|
||||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability`
|
||||||
- **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
|
- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week)
|
||||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync
|
||||||
|
|
||||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
**Contacts** (JMAP Contacts / JSContact)
|
||||||
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
|
- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact
|
||||||
|
|
||||||
## Requires Stalwart 0.16 or newer
|
**Files** (JMAP FileNode)
|
||||||
|
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||||
|
|
||||||
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
|
**Settings**
|
||||||
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
|
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||||
and moved configuration into the store; supporting both generations meant a
|
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
|
||||||
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||||||
reached production. With one supported generation a wrong guess is a loud error
|
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||||
on the first call.
|
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
|
||||||
|
|
||||||
- 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).
|
**Platform**
|
||||||
- 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.
|
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||||||
|
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||||||
|
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
|
||||||
|
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||||
|
React SPA • session cookie ⇄ Basic auth
|
||||||
|
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||||
|
```
|
||||||
|
|
||||||
|
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
|
||||||
|
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
||||||
|
|
||||||
|
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
|
||||||
|
|
||||||
## Quick start (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
@@ -73,32 +113,7 @@ docker compose up --build -d
|
|||||||
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||||
```
|
```
|
||||||
|
|
||||||
Users sign in with their Stalwart mailbox credentials. **An account with
|
Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`).
|
||||||
two-factor authentication needs an app password**, created in Stalwart's own
|
|
||||||
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
|
|
||||||
password grant, so no client holding a username and password can exchange them
|
|
||||||
plus a code for a token.
|
|
||||||
|
|
||||||
Full instructions, TLS, and every environment variable:
|
|
||||||
[Installing](https://docs.ihasmail.org/install/) ·
|
|
||||||
[Configuring](https://docs.ihasmail.org/configure/).
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
|
||||||
React SPA • session cookie ⇄ Basic auth
|
|
||||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
|
||||||
```
|
|
||||||
|
|
||||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
|
|
||||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
|
||||||
|
|
||||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
|
||||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
|
||||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
|
||||||
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
|
|
||||||
missing.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -107,9 +122,18 @@ Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
# against a real Stalwart (set STALWART_URL in .env or the environment)
|
||||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
|
||||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
|
||||||
|
# against the built-in mock Stalwart ([email protected] / demo) — no real mailbox needed
|
||||||
|
npm run dev:mock # mock on :8788, server on :8080, Vite on :5173
|
||||||
|
|
||||||
|
# the same, with the mock impersonating Stalwart 0.15 instead of 0.16
|
||||||
|
npm run dev:mock:legacy
|
||||||
|
|
||||||
|
# the same, with the mock advertising FUTURERELEASE but dropping every hold —
|
||||||
|
# the shape of a real server whose `futureRelease` setting was never turned on
|
||||||
|
npm run dev:mock:no-future-release
|
||||||
|
|
||||||
npm run typecheck # tsc for both packages
|
npm run typecheck # tsc for both packages
|
||||||
npm test # vitest (web) + node:test (server)
|
npm test # vitest (web) + node:test (server)
|
||||||
@@ -117,71 +141,107 @@ npm run build # web/dist + server/dist
|
|||||||
npm start # serve the production build
|
npm start # serve the production build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
Open http://localhost:5173 in dev (or http://localhost:8080 for the production build).
|
||||||
build. Running it for real is covered in
|
|
||||||
[Installing](https://docs.ihasmail.org/install/) and
|
|
||||||
[Configuring](https://docs.ihasmail.org/configure/).
|
|
||||||
|
|
||||||
### The mock
|
### The mock, and which Stalwart it pretends to be
|
||||||
|
|
||||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or
|
||||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
`npm run mock:legacy`) impersonates the generation before the registry. The
|
||||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
older mode is not a smaller mock — it reproduces the specific ways that
|
||||||
**per-account** rather than session-level, identity signatures capped at 2047
|
generation differs, none of which the server reports as an error:
|
||||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
|
||||||
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
|
||||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
|
||||||
the sign-in refusal can be tested.
|
|
||||||
|
|
||||||
### Version numbers
|
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||||
|
parse fails the **whole request**, not the one call that wanted it. On 0.16
|
||||||
|
it *is* known — but advertised per-account, in `primaryAccounts` and each
|
||||||
|
account's `accountCapabilities`, never in the session-level `capabilities`.
|
||||||
|
Stalwart validates `using` by parsing the urn rather than looking it up in
|
||||||
|
the session, so naming it works regardless; a client that tests for it in
|
||||||
|
the obvious place, though, mistakes every 0.16 server for an older one
|
||||||
|
- `x:` methods do not exist, so the registry — credentials, account settings —
|
||||||
|
is unreachable, and self-service credentials live at `POST /api/account/auth`
|
||||||
|
- `FileNode/query` masks its results to non-containers, so it returns files and
|
||||||
|
**never folders**, silently; `FileNode/get` has no such mask
|
||||||
|
- FileNode has no `nodeType` (a directory is a node with no file properties),
|
||||||
|
and rights are only `mayRead`/`mayWrite`/`mayShare`
|
||||||
|
|
||||||
`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
Both modes enforce the 2047-**byte** cap on identity signatures. Every one of
|
||||||
this build targets, `84` the pull request the commit came from. The first two
|
these cost a live debugging session against a real 0.15.5 server, because the
|
||||||
live in the root `package.json`; the third comes from git at build time, since
|
0.16-shaped mock could not express them; `server/src/account-legacy.test.ts`
|
||||||
it does not exist until the PR has merged. A commit that did not arrive through
|
now pins them.
|
||||||
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
|
||||||
|
|
||||||
```bash
|
## Configuration
|
||||||
node scripts/version.mjs # the version for the current checkout
|
|
||||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
|
|
||||||
```
|
|
||||||
|
|
||||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
All configuration is via environment variables (see `.env.example`):
|
||||||
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.
|
|
||||||
|
|
||||||
### Deploying
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` |
|
||||||
|
| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys |
|
||||||
|
| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |
|
||||||
|
| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*`, but only from a peer listed in `TRUSTED_PROXIES` |
|
||||||
|
| `TRUSTED_PROXIES` | *(loopback + private ranges)* | Comma-separated CIDRs or addresses whose forwarding headers are believed. Anything else is attributed by its socket address, whatever it claims |
|
||||||
|
| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev |
|
||||||
|
| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" |
|
||||||
|
| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) |
|
||||||
|
| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy |
|
||||||
|
| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) |
|
||||||
|
| `APP_NAME` | `ihasmail` | Branding |
|
||||||
|
|
||||||
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
## Keyboard shortcuts
|
||||||
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
|
|
||||||
introduced and asks, rebuilds with the right version baked in, replaces the
|
|
||||||
container, waits for healthy, then prunes all but the newest
|
|
||||||
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
|
|
||||||
|
|
||||||
```bash
|
Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send.
|
||||||
./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)
|
|
||||||
```
|
|
||||||
|
|
||||||
`--yes` does not override a hold; clearing one means deleting its line.
|
## Known issues / pending QA
|
||||||
|
|
||||||
## Contributing
|
The live instance ran **0.15.5** until 2026-08-25 and runs **0.16.19** now,
|
||||||
|
so both generations have been exercised against a real server. Everything
|
||||||
|
below says which.
|
||||||
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
Verified against a live **0.15.5**: the mail flows, self-service credentials
|
||||||
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
over the REST path, Files, and signatures.
|
||||||
|
|
||||||
|
The 0.16 registry path was previously recorded here as verified live. That
|
||||||
|
was wrong, and the entry below says why: ihasmail looked for
|
||||||
|
`urn:stalwart:jmap` in the session-level capabilities, where Stalwart has
|
||||||
|
never put it, so **every** real 0.16 server was taken for a pre-0.16 one.
|
||||||
|
Self-service credentials went to a REST endpoint 0.16 had removed, About
|
||||||
|
reported the wrong generation, and Files ran on the older code path. The mock
|
||||||
|
advertised the capability in the wrong place too, which is why nothing caught
|
||||||
|
it. Fixed, and the mock now advertises it where the real server does — but
|
||||||
|
the registry path is **awaiting live re-verification**.
|
||||||
|
|
||||||
|
- **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`); **not yet exercised against the live server**.
|
||||||
|
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. 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 pre-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 pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled.
|
||||||
|
- **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.
|
||||||
|
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
|
||||||
|
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 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 mock enforces the same rules either way (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. What is still mock-only is the rest of the journey: the **Scheduled** folder reconciling on the way in, and a hold actually expiring and being delivered.
|
||||||
|
- **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).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Roadmap / not yet
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
|
Copyright (C) 2026 LINUXexpert.org
|
||||||
[LICENSE](LICENSE).
|
|
||||||
|
|
||||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
ihasmail is free software: you can redistribute it and/or modify it under the
|
||||||
nearly always run as a network service rather than handed to anyone as a binary,
|
terms of the GNU Affero General Public License as published by the Free
|
||||||
and the AGPL's section 13 closes that gap.
|
Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
later version. See [LICENSE](LICENSE) for the full text.
|
||||||
|
|
||||||
|
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25. Webmail is
|
||||||
|
nearly always run as a network service rather than handed to anyone as a
|
||||||
|
binary, and the AGPL's section 13 closes that gap: anyone running a modified
|
||||||
|
ihasmail for other people has to offer them its source, which the GPL alone
|
||||||
|
does not require.
|
||||||
|
|
||||||
That offer has to point at *your* source, not this one. If you run a modified
|
That offer has to point at *your* source, not this one. If you run a modified
|
||||||
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
|
ihasmail, set `SOURCE_URL` to your own repository: the sign-in page and
|
||||||
Settings › About both show it. See
|
Settings › About both show it, so the people using your instance are told where
|
||||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
the code they are actually running can be found.
|
||||||
|
|||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
# 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".
|
|
||||||
|
|
||||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
|
|
||||||
#
|
|
||||||
# Copy it, or run it as-is and set the variables below in the environment.
|
|
||||||
# Nothing here is specific to any one host: the defaults describe the shape of
|
|
||||||
# a deployment rather than anyone's particular one.
|
|
||||||
#
|
|
||||||
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
|
|
||||||
#
|
|
||||||
# Three guards stand between a careless run and production:
|
|
||||||
#
|
|
||||||
# .deploy-hold commits that must not reach prod yet, one per line. If the
|
|
||||||
# target contains one that is not already deployed, the deploy
|
|
||||||
# is refused outright -- `--yes` does not override it. Clearing
|
|
||||||
# a hold means deleting its line, which is a deliberate edit.
|
|
||||||
#
|
|
||||||
# confirmation anything introducing new commits is listed first and has to
|
|
||||||
# be confirmed. Over SSH, where there is no terminal to answer
|
|
||||||
# on, that means passing --yes: a bare `deploy.sh` cannot ship
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
# environment file is never read here, only handed to Docker.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --- what to deploy, and where ----------------------------------------------
|
|
||||||
# The checkout to deploy from. It must be a git clone: the version number is
|
|
||||||
# read from its history (see scripts/version.mjs).
|
|
||||||
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
|
|
||||||
# Environment file passed to the container. Keep it outside the repo's tracked
|
|
||||||
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
|
|
||||||
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
|
|
||||||
# Commits held back from production, one per line; blank or missing is fine.
|
|
||||||
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
|
|
||||||
# Container name, and where to publish it. The default binds to loopback only,
|
|
||||||
# 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).
|
|
||||||
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
|
||||||
# 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}"
|
|
||||||
# How long to wait for the new container to report healthy, in seconds.
|
|
||||||
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
|
|
||||||
# How many past versions to keep as images, for rolling back to. Each is around
|
|
||||||
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
|
|
||||||
# couple of releases -- and `docker image prune` will not touch them, because
|
|
||||||
# they are tagged. 0 keeps every version.
|
|
||||||
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
|
|
||||||
|
|
||||||
# --- run from a copy, if this script lives in the checkout it resets ---------
|
|
||||||
# `git reset --hard` below rewrites the working tree, and this script may be
|
|
||||||
# part of it. Bash does not read a script all at once -- it reads as it goes,
|
|
||||||
# by byte offset -- so a file replaced underneath it makes the shell stop
|
|
||||||
# wherever it had reached. Silently, and with exit status 0: a deploy that
|
|
||||||
# stopped halfway would report success. Re-exec from a copy outside the tree so
|
|
||||||
# the file being run cannot change while it runs.
|
|
||||||
SELF="$(readlink -f "$0")"
|
|
||||||
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
|
|
||||||
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
|
|
||||||
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
|
|
||||||
cat "$SELF" > "$COPY"
|
|
||||||
chmod +x "$COPY"
|
|
||||||
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
|
|
||||||
fi
|
|
||||||
# The copy has served its purpose once we exit; the shell has finished reading
|
|
||||||
# it by then.
|
|
||||||
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
|
|
||||||
trap 'rm -f "$SELF"' EXIT
|
|
||||||
fi
|
|
||||||
|
|
||||||
REF=""
|
|
||||||
ASSUME_YES=0
|
|
||||||
DRY_RUN=0
|
|
||||||
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 ;;
|
|
||||||
-*) 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
|
|
||||||
REF="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
REF="${REF:-origin/main}"
|
|
||||||
|
|
||||||
cd "$APP"
|
|
||||||
git fetch --quiet origin
|
|
||||||
|
|
||||||
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
|
|
||||||
echo "!! no such commit: $REF" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
CURRENT=$(git rev-parse --verify HEAD)
|
|
||||||
|
|
||||||
# --- guard 1: commits held back from production -----------------------------
|
|
||||||
if [ -f "$HOLD" ]; then
|
|
||||||
blocked=""
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
line="${line%%#*}"
|
|
||||||
line="$(printf '%s' "$line" | tr -d '[:space:]')"
|
|
||||||
[ -z "$line" ] && continue
|
|
||||||
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
|
|
||||||
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# Only a problem if the target carries it and production does not already.
|
|
||||||
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
|
|
||||||
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
|
|
||||||
fi
|
|
||||||
done < "$HOLD"
|
|
||||||
if [ -n "$blocked" ]; then
|
|
||||||
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
|
|
||||||
printf '%s' "$blocked" >&2
|
|
||||||
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- guard 2: say what is being introduced, and get a yes --------------------
|
|
||||||
NEW=$(git log --oneline "$CURRENT..$TARGET")
|
|
||||||
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
|
|
||||||
if [ -t 0 ]; then
|
|
||||||
read -r -p "deploy these to production? [y/N] " reply
|
|
||||||
case "$reply" in
|
|
||||||
y|Y|yes|YES) ;;
|
|
||||||
*) echo "aborted."; exit 1 ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
|
|
||||||
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"
|
|
||||||
|
|
||||||
# The version is worked out here, from the checkout, because the image build
|
|
||||||
# cannot: .dockerignore keeps .git out of the build context. Without this the
|
|
||||||
# build falls back to the base version in package.json and every deployment
|
|
||||||
# reports the same number -- see "Version numbers" in the README.
|
|
||||||
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
|
|
||||||
#
|
|
||||||
# Only ever runs after the new container reports healthy, so a rollback target
|
|
||||||
# is never removed while the thing replacing it is still unproven. The image in
|
|
||||||
# use is excluded outright rather than relied on to sort newest -- docker
|
|
||||||
# refuses to remove an image a container is using, but being refused is not the
|
|
||||||
# same as not having tried.
|
|
||||||
prune_old_images() {
|
|
||||||
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
|
|
||||||
local in_use stale
|
|
||||||
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
|
||||||
# Newest first, tags only, skipping the moving ":current" pointer.
|
|
||||||
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
|
|
||||||
| grep -v ":current" \
|
|
||||||
| sort -k2 -r \
|
|
||||||
| cut -f1 \
|
|
||||||
| grep -vxF "$in_use" \
|
|
||||||
| tail -n +"$((KEEP_VERSIONS + 1))")"
|
|
||||||
[ -n "$stale" ] || return 0
|
|
||||||
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
|
|
||||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
TAG="${VERSION//+/-}"
|
|
||||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
|
||||||
docker build \
|
|
||||||
--build-arg IHASMAIL_VERSION="$VERSION" \
|
|
||||||
-t "$IMAGE_REPO:$TAG" \
|
|
||||||
-t "$IMAGE_REPO:current" \
|
|
||||||
.
|
|
||||||
|
|
||||||
echo "==> restarting container"
|
|
||||||
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
|
|
||||||
|
|
||||||
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
|
|
||||||
if health=$(curl -sf "http://$BIND/api/health"); then
|
|
||||||
echo "==> healthy: $health"
|
|
||||||
prune_old_images
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
|
|
||||||
docker logs "$NAME" 2>&1 | tail -20 >&2
|
|
||||||
echo "!! the previous image is still tagged, if you need it back:" >&2
|
|
||||||
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
|
|
||||||
exit 1
|
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ihasmail",
|
"name": "ihasmail",
|
||||||
"version": "2.16.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"lint": "npm run typecheck",
|
"lint": "npm run typecheck",
|
||||||
"mock": "npm run mock -w server",
|
"mock": "npm run mock -w server",
|
||||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
|
"dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
|
||||||
export function baseVersion(): string;
|
|
||||||
export function versionFromGit(): string | null;
|
|
||||||
export function resolveVersion(): string;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Work out this build's version: `2.16.57`.
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* `.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.
|
|
||||||
*/
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function git(...args) {
|
|
||||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function versionFromGit() {
|
|
||||||
let head;
|
|
||||||
try {
|
|
||||||
head = git("rev-parse", "--short", "HEAD");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const base = baseVersion();
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* a shallow clone, or no history to read */
|
|
||||||
}
|
|
||||||
return `${base}.0+g${head}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whatever the environment was told, else git, else just the base. */
|
|
||||||
export function resolveVersion() {
|
|
||||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
|
||||||
if (fromEnv) return fromEnv;
|
|
||||||
return versionFromGit() ?? `${baseVersion()}.0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
|
||||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
||||||
process.stdout.write(resolveVersion() + "\n");
|
|
||||||
}
|
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ihasmail/server",
|
"name": "@ihasmail/server",
|
||||||
"version": "2.16.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
||||||
"mock": "tsx src/mock/index.ts",
|
"mock": "tsx src/mock/index.ts",
|
||||||
|
"mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts",
|
||||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same self-service flows, against a mock impersonating Stalwart 0.15.
|
||||||
|
*
|
||||||
|
* That generation has no registry: credentials live behind a REST endpoint,
|
||||||
|
* `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||||
|
* parse fails the whole request. Until now this adapter had no coverage at all
|
||||||
|
* — it was the least-tested code in the project, verified only by hand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = 18799;
|
||||||
|
process.env.MOCK_PORT = String(PORT);
|
||||||
|
process.env.MOCK_STALWART = "0.15";
|
||||||
|
process.env.MOCK_USER = "[email protected]";
|
||||||
|
process.env.MOCK_PASS = "demo-password";
|
||||||
|
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
process.env.APP_SECRET = "test-secret-for-legacy-flows";
|
||||||
|
|
||||||
|
const mock = await import("./mock/index.js");
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
let cookie = "";
|
||||||
|
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||||
|
|
||||||
|
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
...init,
|
||||||
|
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
|
||||||
|
});
|
||||||
|
const setCookie = res.headers.get("set-cookie");
|
||||||
|
if (setCookie) cookie = setCookie.split(";")[0]!;
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
|
||||||
|
assert.equal(res.status, 200, "login should succeed against the legacy mock");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
(mock as { server?: { close(): void } }).server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the older server is recognised, and reported as such", async () => {
|
||||||
|
const res = await call("/api/auth/session");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.ihasmail.server.generation, "pre-0.16");
|
||||||
|
assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16");
|
||||||
|
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials fall back to the REST endpoint", async () => {
|
||||||
|
const res = await call("/api/account/security");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.backend, "legacy");
|
||||||
|
assert.equal(res.body.otpEnabled, false);
|
||||||
|
assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app passwords round-trip, keyed by their name", async () => {
|
||||||
|
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||||
|
assert.equal(created.status, 200);
|
||||||
|
assert.ok(created.body.secret, "a secret is generated for the user to copy");
|
||||||
|
assert.equal(created.body.id, "Thunderbird", "the name is the identifier here");
|
||||||
|
|
||||||
|
const listed = await call("/api/account/security");
|
||||||
|
assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]);
|
||||||
|
|
||||||
|
await post("/api/account/app-passwords/revoke", { id: "Thunderbird" });
|
||||||
|
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the current password is verified before it is changed", async () => {
|
||||||
|
// The REST endpoint would take our word for it, so ihasmail proves it first.
|
||||||
|
const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
||||||
|
assert.equal(wrong.status, 403);
|
||||||
|
assert.match(wrong.body.message, /incorrect/i);
|
||||||
|
assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing the password keeps this session working", async () => {
|
||||||
|
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password");
|
||||||
|
assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2FA is enabled with a code proved against the new secret", async () => {
|
||||||
|
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||||
|
const begin = await post("/api/account/2fa/begin", {});
|
||||||
|
const params = parseOtpauthUrl(begin.body.url);
|
||||||
|
assert.ok(params);
|
||||||
|
|
||||||
|
const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored");
|
||||||
|
|
||||||
|
const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" });
|
||||||
|
assert.equal(good.status, 200);
|
||||||
|
assert.equal(good.body.sessionKept, true, "the session moved onto an app password");
|
||||||
|
assert.equal((await call("/api/account/security")).body.otpEnabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2FA is switched off again", async () => {
|
||||||
|
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||||
|
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||||
|
const params = parseOtpauthUrl(stored!);
|
||||||
|
assert.ok(params);
|
||||||
|
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mock is only worth having if it is faithful, so these pin the specific
|
||||||
|
* behaviours that cost us a live debugging session each. Every one of them was
|
||||||
|
* invisible to the 0.16 mock, which is how the bugs shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls });
|
||||||
|
const CORE = "urn:ietf:params:jmap:core";
|
||||||
|
const MAIL = "urn:ietf:params:jmap:mail";
|
||||||
|
const FILES = "urn:ietf:params:jmap:filenode";
|
||||||
|
|
||||||
|
test("naming a capability it cannot parse fails the whole request", async () => {
|
||||||
|
const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||||
|
assert.notEqual(res.status, 200, "not one failed call - the entire request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("x: methods do not exist, so they come back unknownMethod", async () => {
|
||||||
|
const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.methodResponses[0][0], "error");
|
||||||
|
assert.equal(res.body.methodResponses[0][1].type, "unknownMethod");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("FileNode/set refuses nodeType by name", async () => {
|
||||||
|
const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]);
|
||||||
|
const set = res.body.methodResponses[0][1];
|
||||||
|
assert.equal(set.notCreated.d.type, "invalidProperties");
|
||||||
|
assert.deepEqual(set.notCreated.d.properties, ["nodeType"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a directory is a node with no file properties, and query cannot see it", async () => {
|
||||||
|
const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]);
|
||||||
|
const id = made.body.methodResponses[0][1].created.d.id;
|
||||||
|
assert.ok(id);
|
||||||
|
|
||||||
|
const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]);
|
||||||
|
assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers");
|
||||||
|
|
||||||
|
// get carries no such mask, which is the only way to find a folder here.
|
||||||
|
const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||||
|
const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record<string, boolean> }[];
|
||||||
|
const dir = list.find((n) => n.id === id);
|
||||||
|
assert.ok(dir, "get returns the directory");
|
||||||
|
assert.equal(dir!.nodeType, undefined, "nodeType is not a property here");
|
||||||
|
assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("FileNode/query refuses the filters and sorts this generation lacks", async () => {
|
||||||
|
const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]);
|
||||||
|
assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter");
|
||||||
|
const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]);
|
||||||
|
assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an identity signature is capped in bytes, not characters", async () => {
|
||||||
|
// 1200 CJK characters: comfortably under 2047 counted as characters, and
|
||||||
|
// 3600 bytes once encoded.
|
||||||
|
const tooBig = "日".repeat(1200);
|
||||||
|
assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047);
|
||||||
|
const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]);
|
||||||
|
const set = res.body.methodResponses[0][1];
|
||||||
|
assert.equal(set.notUpdated.i1.type, "invalidProperties");
|
||||||
|
assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]);
|
||||||
|
});
|
||||||
@@ -47,25 +47,27 @@ after(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
|
||||||
* session-level capabilities. Looking for it at the top level alone reported
|
* per-account, so a session that looks for it at the top level reports a real
|
||||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
|
||||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
* the removed REST endpoint.
|
||||||
* everyone out rather than merely misroute credentials.
|
|
||||||
*/
|
*/
|
||||||
test("the session is accepted on a server that advertises the registry per-account", async () => {
|
test("the session reports the 0.16 generation the server actually is", async () => {
|
||||||
const res = await call("/api/auth/session");
|
const res = await call("/api/auth/session");
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.ihasmail.server.generation, "0.16+");
|
||||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the registry reports an account with nothing set up yet", async () => {
|
test("the 0.16 registry backend is detected and reported empty", async () => {
|
||||||
const res = await call("/api/account/security");
|
const res = await call("/api/account/security");
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.backend, "registry");
|
||||||
assert.equal(res.body.otpEnabled, false);
|
assert.equal(res.body.otpEnabled, false);
|
||||||
assert.deepEqual(res.body.appPasswords, []);
|
assert.deepEqual(res.body.appPasswords, []);
|
||||||
|
assert.equal(res.body.appPasswordsKeyedByName, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||||
@@ -174,33 +176,3 @@ test("credential endpoints reject unauthenticated callers", async () => {
|
|||||||
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
||||||
cookie = saved;
|
cookie = saved;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* A sign-in carrying a two-factor code that the server rejects is almost never
|
|
||||||
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
|
|
||||||
* no password grant, so the concatenated form ihasmail sends cannot work — and
|
|
||||||
* saying "invalid credentials" sends the user to check a password that is fine.
|
|
||||||
*
|
|
||||||
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
|
|
||||||
* worked, which is Stalwart's documented route and gave no hint of itself.
|
|
||||||
*/
|
|
||||||
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
|
|
||||||
const saved = cookie;
|
|
||||||
cookie = "";
|
|
||||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
|
|
||||||
cookie = saved;
|
|
||||||
assert.equal(res.status, 401);
|
|
||||||
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
|
|
||||||
assert.match(res.body.message, /app password/i, "points at the route that does work");
|
|
||||||
assert.match(res.body.message, /probably fine/i, "does not blame the password");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rejected sign-in without a code is still a plain credential failure", async () => {
|
|
||||||
// The explanation must not leak onto ordinary typos.
|
|
||||||
const saved = cookie;
|
|
||||||
cookie = "";
|
|
||||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
|
|
||||||
cookie = saved;
|
|
||||||
assert.equal(res.status, 401);
|
|
||||||
assert.equal(res.body.error, "invalid_credentials");
|
|
||||||
});
|
|
||||||
|
|||||||
+222
-44
@@ -1,15 +1,17 @@
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-service credential management, over Stalwart's JMAP registry:
|
* Self-service credential management, across two incompatible Stalwart APIs.
|
||||||
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
|
||||||
* and `x:AppPassword`.
|
|
||||||
*
|
*
|
||||||
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
|
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
|
||||||
* supports. Sign-in refuses anything older, so by the time any of this runs
|
* password and the otpauth URL) and x:AppPassword.
|
||||||
* the registry is known to be there.
|
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
|
||||||
|
*
|
||||||
|
* The registry crate does not exist before 0.16 and the REST endpoint is gone
|
||||||
|
* after it, so which one answers is the only reliable way to tell them apart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STALWART_CAP = "urn:stalwart:jmap";
|
const STALWART_CAP = "urn:stalwart:jmap";
|
||||||
@@ -19,7 +21,10 @@ const SINGLETON = "singleton";
|
|||||||
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
||||||
const MASKED = "[********]";
|
const MASKED = "[********]";
|
||||||
|
|
||||||
|
export type Backend = "registry" | "legacy";
|
||||||
|
|
||||||
export interface AppPasswordRow {
|
export interface AppPasswordRow {
|
||||||
|
/** Registry object id, or the name itself on legacy servers. */
|
||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
@@ -27,8 +32,14 @@ export interface AppPasswordRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SecurityState {
|
export interface SecurityState {
|
||||||
|
backend: Backend;
|
||||||
otpEnabled: boolean;
|
otpEnabled: boolean;
|
||||||
appPasswords: AppPasswordRow[];
|
appPasswords: AppPasswordRow[];
|
||||||
|
/**
|
||||||
|
* Legacy servers key app passwords by name and hand back nothing else, so
|
||||||
|
* the UI must keep names unique and cannot show when one was created.
|
||||||
|
*/
|
||||||
|
appPasswordsKeyedByName: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An error with a message meant for the person using the app. */
|
/** An error with a message meant for the person using the app. */
|
||||||
@@ -50,7 +61,49 @@ interface Ctx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Transport */
|
/* Backend detection */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||||
|
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
export function forgetBackend(sessionId: string): void {
|
||||||
|
backendCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||||
|
const cached = backendCache.get(sessionId);
|
||||||
|
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||||
|
const backend = await probeBackend(ctx);
|
||||||
|
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||||
|
// A server with the registry answers x:AccountPassword/get; one without it
|
||||||
|
// fails to parse the method name at all and returns unknownMethod.
|
||||||
|
if (hasStalwartRegistry(ctx.session)) {
|
||||||
|
try {
|
||||||
|
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||||
|
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||||
|
if (name && name !== "error") return "registry";
|
||||||
|
const type = (args as { type?: string } | undefined)?.type;
|
||||||
|
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||||
|
} catch {
|
||||||
|
// The capability already told us this server has the registry, so a
|
||||||
|
// request we could not read is a fault to surface, not evidence of an
|
||||||
|
// older server. Falling back here would post the user's password to a
|
||||||
|
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||||
|
return "registry";
|
||||||
|
}
|
||||||
|
// It named the capability and then disowned the method: nothing else to try.
|
||||||
|
return "registry";
|
||||||
|
}
|
||||||
|
return "legacy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Transports */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function accountId(ctx: Ctx): string {
|
function accountId(ctx: Ctx): string {
|
||||||
@@ -76,6 +129,29 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
|||||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||||
|
...init,
|
||||||
|
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||||
|
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||||
|
} catch {
|
||||||
|
/* fall through to the generic message */
|
||||||
|
}
|
||||||
|
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||||
|
}
|
||||||
|
return ((await res.json()) as { data: T }).data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||||
* into one error carrying whatever the server was willing to explain.
|
* into one error carrying whatever the server was willing to explain.
|
||||||
@@ -116,7 +192,17 @@ function describeSetError(err: { type?: string; description?: string; properties
|
|||||||
/* Operations */
|
/* Operations */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function getState(ctx: Ctx): Promise<SecurityState> {
|
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||||
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
|
if (backend === "legacy") {
|
||||||
|
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||||
|
return {
|
||||||
|
backend,
|
||||||
|
otpEnabled: Boolean(data.otpEnabled),
|
||||||
|
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||||
|
appPasswordsKeyedByName: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
const id = accountId(ctx);
|
const id = accountId(ctx);
|
||||||
const res = await jmap(ctx, [
|
const res = await jmap(ctx, [
|
||||||
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
||||||
@@ -125,6 +211,7 @@ export async function getState(ctx: Ctx): Promise<SecurityState> {
|
|||||||
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
||||||
const apps = listOf(res, "a");
|
const apps = listOf(res, "a");
|
||||||
return {
|
return {
|
||||||
|
backend,
|
||||||
// The URL itself is masked; its presence is what tells us 2FA is on.
|
// The URL itself is masked; its presence is what tells us 2FA is on.
|
||||||
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
||||||
appPasswords: apps.map((a) => ({
|
appPasswords: apps.map((a) => ({
|
||||||
@@ -133,6 +220,7 @@ export async function getState(ctx: Ctx): Promise<SecurityState> {
|
|||||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||||
})),
|
})),
|
||||||
|
appPasswordsKeyedByName: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,25 +235,56 @@ function firstListItem(res: { methodResponses?: [string, unknown, string][] }, c
|
|||||||
return listOf(res, callId)[0] ?? null;
|
return listOf(res, callId)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
|
export async function changePassword(
|
||||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
sessionId: string,
|
||||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
ctx: Ctx,
|
||||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
opts: { current: string; next: string; otpCode?: string },
|
||||||
setResult(res, "updated");
|
): Promise<void> {
|
||||||
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
|
if (backend === "registry") {
|
||||||
|
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||||
|
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||||
|
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||||
|
setResult(res, "updated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The legacy endpoint changes the password without asking for the old one,
|
||||||
|
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||||
|
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||||
|
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
export async function createAppPassword(
|
||||||
|
sessionId: string,
|
||||||
|
ctx: Ctx,
|
||||||
|
opts: { description: string },
|
||||||
|
): Promise<{ id: string; secret: string }> {
|
||||||
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
const description = opts.description.trim() || "App password";
|
const description = opts.description.trim() || "App password";
|
||||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
if (backend === "registry") {
|
||||||
const created = setResult(res, "created");
|
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
const created = setResult(res, "created");
|
||||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||||
return { id: String(created?.id ?? description), secret };
|
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||||
|
return { id: String(created?.id ?? description), secret };
|
||||||
|
}
|
||||||
|
// Legacy servers take a secret of our choosing and key it by name.
|
||||||
|
const secret = readableSecret();
|
||||||
|
await legacy<unknown>(ctx, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||||
|
});
|
||||||
|
return { id: description, secret };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
setResult(res, "destroyed");
|
if (backend === "registry") {
|
||||||
|
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||||
|
setResult(res, "destroyed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,30 +311,89 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
export async function enableOtp(
|
||||||
|
sessionId: string,
|
||||||
|
ctx: Ctx,
|
||||||
|
opts: { url: string; code: string; current: string },
|
||||||
|
): Promise<void> {
|
||||||
assertEnrolmentCode(opts.url, opts.code);
|
assertEnrolmentCode(opts.url, opts.code);
|
||||||
const res = await jmap(ctx, [
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
[
|
if (backend === "registry") {
|
||||||
"x:AccountPassword/set",
|
const res = await jmap(ctx, [
|
||||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
[
|
||||||
"s",
|
"x:AccountPassword/set",
|
||||||
],
|
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||||
]);
|
"s",
|
||||||
setResult(res, "updated");
|
],
|
||||||
|
]);
|
||||||
|
setResult(res, "updated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assertCurrentPassword(ctx, opts.current);
|
||||||
|
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
|
export async function disableOtp(
|
||||||
const res = await jmap(ctx, [
|
sessionId: string,
|
||||||
[
|
ctx: Ctx,
|
||||||
"x:AccountPassword/set",
|
opts: { current: string; code: string },
|
||||||
{
|
): Promise<void> {
|
||||||
accountId: accountId(ctx),
|
const backend = await detectBackend(sessionId, ctx);
|
||||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
if (backend === "registry") {
|
||||||
},
|
const res = await jmap(ctx, [
|
||||||
"s",
|
[
|
||||||
],
|
"x:AccountPassword/set",
|
||||||
]);
|
{
|
||||||
setResult(res, "updated");
|
accountId: accountId(ctx),
|
||||||
|
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||||
|
},
|
||||||
|
"s",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
setResult(res, "updated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||||
|
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||||
|
* would otherwise take our word for it.
|
||||||
|
*/
|
||||||
|
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||||
|
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||||
|
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||||
|
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||||
|
headers: { authorization, accept: "application/json" },
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A legacy app password a person can read off a screen and type.
|
||||||
|
*
|
||||||
|
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||||
|
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||||
|
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||||
|
*/
|
||||||
|
export function readableSecret(): string {
|
||||||
|
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||||
|
const limit = 256 - (256 % alphabet.length);
|
||||||
|
const chars: string[] = [];
|
||||||
|
while (chars.length < 20) {
|
||||||
|
for (const b of randomBytes(32)) {
|
||||||
|
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||||
|
chars.push(alphabet[b % alphabet.length]!);
|
||||||
|
if (chars.length === 20) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MASKED };
|
export { MASKED };
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./ups
|
|||||||
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
||||||
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
||||||
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
||||||
* so both are asked for and whichever answers wins. Both are 0.16 methods:
|
* so both are asked for and whichever answers wins.
|
||||||
* this is a permissions fallback, not a version one.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Responses = [string, Record<string, unknown>, string][];
|
type Responses = [string, Record<string, unknown>, string][];
|
||||||
@@ -20,6 +19,7 @@ const failed = (id: string, type: string): Responses[number] => ["error", { type
|
|||||||
test("prefers the locale a regular user is allowed to read", () => {
|
test("prefers the locale a regular user is allowed to read", () => {
|
||||||
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
||||||
assert.equal(info.locale, "de-DE");
|
assert.equal(info.locale, "de-DE");
|
||||||
|
assert.equal(info.generation, "0.16+");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to x:Account when the settings object is forbidden", () => {
|
test("falls back to x:Account when the settings object is forbidden", () => {
|
||||||
@@ -27,14 +27,22 @@ test("falls back to x:Account when the settings object is forbidden", () => {
|
|||||||
assert.equal(info.locale, "sr-Latn-RS");
|
assert.equal(info.locale, "sr-Latn-RS");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an account with no locale set yields none, rather than a guess", () => {
|
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
|
||||||
|
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
|
||||||
|
assert.equal(info.generation, "pre-0.16");
|
||||||
|
assert.equal(info.locale, "en-GB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server answering the new method is 0.16+ even with no locale set", () => {
|
||||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||||
|
assert.equal(info.generation, "0.16+");
|
||||||
assert.equal(info.locale, null);
|
assert.equal(info.locale, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("neither answering leaves the locale unknown", () => {
|
test("neither answering leaves everything unknown rather than guessing", () => {
|
||||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
|
||||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
assert.deepEqual(info, { locale: null, generation: null, edition: null });
|
||||||
|
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locales that carry no language are dropped, not passed through", () => {
|
test("locales that carry no language are dropped, not passed through", () => {
|
||||||
@@ -42,18 +50,20 @@ test("locales that carry no language are dropped, not passed through", () => {
|
|||||||
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a server without the registry is not asked for anything", async () => {
|
test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => {
|
||||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
|
||||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
// so its absence is the answer - and asking anyway would fail the whole
|
||||||
// fails the whole request rather than the one call.
|
// request on those servers. This is what the live 0.15.5 box hits.
|
||||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
|
||||||
assert.deepEqual(info, { locale: null, edition: null });
|
assert.equal(info.generation, "pre-0.16");
|
||||||
|
assert.equal(info.locale, null);
|
||||||
|
assert.equal(info.edition, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no capabilities at all is treated the same way", async () => {
|
test("no capabilities at all leaves the generation unknown", async () => {
|
||||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||||
assert.equal(info.locale, null);
|
assert.equal(info.generation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +73,9 @@ test("no capabilities at all is treated the same way", async () => {
|
|||||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||||
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
||||||
* account's `accountCapabilities`. Looking only at the session level called
|
* account's `accountCapabilities`. Looking only at the session level called
|
||||||
* every real 0.16 server too old, which sent self-service credentials to a
|
* every real 0.16 server pre-0.16, which sent self-service credentials to a
|
||||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||||
* thing.
|
* generation.
|
||||||
*
|
|
||||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
|
||||||
* wrong would lock every user out of a perfectly good server.
|
|
||||||
*/
|
*/
|
||||||
const STALWART = "urn:stalwart:jmap";
|
const STALWART = "urn:stalwart:jmap";
|
||||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||||
@@ -95,7 +102,7 @@ test("the session level still counts, for a server that ever advertises it there
|
|||||||
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a server that advertises it nowhere is one we do not support", () => {
|
test("a server that advertises it nowhere is pre-0.16", () => {
|
||||||
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
||||||
assert.equal(hasStalwartRegistry(undefined), false);
|
assert.equal(hasStalwartRegistry(undefined), false);
|
||||||
});
|
});
|
||||||
@@ -110,3 +117,15 @@ test("a shared account carrying the capability is enough to recognise the server
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a locale request that fails does not talk us out of a generation we proved", () => {
|
||||||
|
// The capability settled it. A forbidden reply costs the locale, nothing more.
|
||||||
|
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
|
||||||
|
assert.equal(info.generation, "0.16+");
|
||||||
|
assert.equal(info.locale, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server that disowns the method is still older, whatever we came in believing", () => {
|
||||||
|
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
|
||||||
|
assert.equal(info.generation, "pre-0.16");
|
||||||
|
});
|
||||||
|
|||||||
+18
-56
@@ -12,7 +12,6 @@ import {
|
|||||||
absoluteUpstream,
|
absoluteUpstream,
|
||||||
expandTemplate,
|
expandTemplate,
|
||||||
fetchUpstreamSession,
|
fetchUpstreamSession,
|
||||||
hasStalwartRegistry,
|
|
||||||
forgetUpstreamSession,
|
forgetUpstreamSession,
|
||||||
getAccountInfo,
|
getAccountInfo,
|
||||||
getUpstreamSession,
|
getUpstreamSession,
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
createAppPassword,
|
createAppPassword,
|
||||||
disableOtp,
|
disableOtp,
|
||||||
enableOtp,
|
enableOtp,
|
||||||
|
forgetBackend,
|
||||||
getState,
|
getState,
|
||||||
revokeAppPassword,
|
revokeAppPassword,
|
||||||
} from "./account.js";
|
} from "./account.js";
|
||||||
@@ -143,7 +143,7 @@ export function createApp(): Hono<Env> {
|
|||||||
const api = new Hono<Env>();
|
const api = new Hono<Env>();
|
||||||
api.use("*", csrfGuard);
|
api.use("*", csrfGuard);
|
||||||
|
|
||||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
|
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" }));
|
||||||
|
|
||||||
api.get("/config", (c) =>
|
api.get("/config", (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
@@ -180,20 +180,6 @@ export function createApp(): Hono<Env> {
|
|||||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||||
try {
|
try {
|
||||||
const upstream = await fetchUpstreamSession(authorization);
|
const upstream = await fetchUpstreamSession(authorization);
|
||||||
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
|
||||||
// clearly, rather than signing someone in and letting Files, the account
|
|
||||||
// locale and self-service credentials each fail in their own way with
|
|
||||||
// nothing to connect them. The credentials were good, so say so.
|
|
||||||
if (!hasStalwartRegistry(upstream)) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: "unsupported_server",
|
|
||||||
message:
|
|
||||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
|
||||||
},
|
|
||||||
501,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
loginLimiter.reset(limitKey);
|
loginLimiter.reset(limitKey);
|
||||||
const { cookie, session } = sessions.create({
|
const { cookie, session } = sessions.create({
|
||||||
username,
|
username,
|
||||||
@@ -206,32 +192,6 @@ export function createApp(): Hono<Env> {
|
|||||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A rejected sign-in that carried a two-factor code is worth explaining
|
|
||||||
// rather than calling "invalid credentials", because the credentials are
|
|
||||||
// very likely fine.
|
|
||||||
//
|
|
||||||
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
|
|
||||||
// interface is an OAuth client, which is why signing in there works. It
|
|
||||||
// offers no password grant, so a client holding a username and password
|
|
||||||
// cannot exchange them plus a code for a token, and the concatenated
|
|
||||||
// `password$code` form ihasmail sent is not a route the server has. Its
|
|
||||||
// documented answer for clients like this one is an app password, which
|
|
||||||
// bypasses TOTP entirely.
|
|
||||||
//
|
|
||||||
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
|
|
||||||
// app password and moves the session onto it, precisely because a plain
|
|
||||||
// password stops working from that moment. The sign-in page was the one
|
|
||||||
// place still pretending otherwise.
|
|
||||||
if (totp && err instanceof UpstreamError && err.status === 401) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: "totp_unsupported",
|
|
||||||
message:
|
|
||||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -276,8 +236,9 @@ export function createApp(): Hono<Env> {
|
|||||||
// ---------- Self-service credentials ----------
|
// ---------- Self-service credentials ----------
|
||||||
/**
|
/**
|
||||||
* Password, app passwords and 2FA. These live on the server rather than in
|
* Password, app passwords and 2FA. These live on the server rather than in
|
||||||
* the browser because changing a credential means re-sealing the session
|
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
|
||||||
* cookie that holds it, and because the browser only ever sees /api/jmap.
|
* only ever sees /api/jmap), and because changing a credential means
|
||||||
|
* re-sealing the session cookie that holds it.
|
||||||
*/
|
*/
|
||||||
const accountCtx = async (c: Context<Env>) => {
|
const accountCtx = async (c: Context<Env>) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
@@ -303,7 +264,7 @@ export function createApp(): Hono<Env> {
|
|||||||
api.get("/account/security", requireSession, async (c) => {
|
api.get("/account/security", requireSession, async (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
try {
|
try {
|
||||||
return c.json(await getState(await accountCtx(c)));
|
return c.json(await getState(session.id, await accountCtx(c)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -323,7 +284,7 @@ export function createApp(): Hono<Env> {
|
|||||||
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -339,8 +300,8 @@ export function createApp(): Hono<Env> {
|
|||||||
api.get("/account/app-passwords", requireSession, async (c) => {
|
api.get("/account/app-passwords", requireSession, async (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
try {
|
try {
|
||||||
const state = await getState(await accountCtx(c));
|
const state = await getState(session.id, await accountCtx(c));
|
||||||
return c.json({ appPasswords: state.appPasswords });
|
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -353,7 +314,7 @@ export function createApp(): Hono<Env> {
|
|||||||
const description = (body.description ?? "").trim().slice(0, 120);
|
const description = (body.description ?? "").trim().slice(0, 120);
|
||||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||||
try {
|
try {
|
||||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -364,7 +325,7 @@ export function createApp(): Hono<Env> {
|
|||||||
const body = await readJson<{ id?: string }>(c);
|
const body = await readJson<{ id?: string }>(c);
|
||||||
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
||||||
try {
|
try {
|
||||||
await revokeAppPassword(await accountCtx(c), body.id);
|
await revokeAppPassword(session.id, await accountCtx(c), body.id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
@@ -405,18 +366,18 @@ export function createApp(): Hono<Env> {
|
|||||||
}
|
}
|
||||||
let app: { id: string; secret: string } | null = null;
|
let app: { id: string; secret: string } | null = null;
|
||||||
try {
|
try {
|
||||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Out of app-password quota, say. 2FA is still worth having; the user
|
// Out of app-password quota, say. 2FA is still worth having; the user
|
||||||
// just has to sign in again afterwards.
|
// just has to sign in again afterwards.
|
||||||
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (app) {
|
if (app) {
|
||||||
// Don't leave a credential behind for a change that never happened.
|
// Don't leave a credential behind for a change that never happened.
|
||||||
await revokeAppPassword(ctx, app.id).catch(() => {});
|
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
|
||||||
}
|
}
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -437,7 +398,7 @@ export function createApp(): Hono<Env> {
|
|||||||
const body = await readJson<{ current?: string; code?: string }>(c);
|
const body = await readJson<{ current?: string; code?: string }>(c);
|
||||||
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
||||||
try {
|
try {
|
||||||
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
|
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -445,6 +406,7 @@ export function createApp(): Hono<Env> {
|
|||||||
// the plain password works again now, so put it back.
|
// the plain password works again now, so put it back.
|
||||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||||
forgetUpstreamSession(session.id);
|
forgetUpstreamSession(session.id);
|
||||||
|
forgetBackend(session.id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -616,7 +578,7 @@ function appPasswordName(c: Context): string {
|
|||||||
return `${config.appName} (${browser})`;
|
return `${config.appName} (${browser})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) {
|
||||||
return {
|
return {
|
||||||
ihasmail: {
|
ihasmail: {
|
||||||
appName: config.appName,
|
appName: config.appName,
|
||||||
@@ -629,7 +591,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||||
userLocale: info.locale,
|
userLocale: info.locale,
|
||||||
/** What the upstream server would tell us about itself. */
|
/** What the upstream server would tell us about itself. */
|
||||||
server: { edition: info.edition },
|
server: { generation: info.generation, edition: info.edition },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { resolveVersion } from "../../scripts/version.mjs";
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -61,13 +60,6 @@ const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+
|
|||||||
export const config = {
|
export const config = {
|
||||||
isProd,
|
isProd,
|
||||||
appName: env("APP_NAME", "ihasmail"),
|
appName: env("APP_NAME", "ihasmail"),
|
||||||
/**
|
|
||||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
|
||||||
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
|
|
||||||
* the build context and nothing in there could work it out. A dev checkout
|
|
||||||
* has git, so it falls back to asking; see `scripts/version.mjs`.
|
|
||||||
*/
|
|
||||||
version: resolveVersion(),
|
|
||||||
/**
|
/**
|
||||||
* Where this instance's source can be had, shown to everyone who reaches it.
|
* Where this instance's source can be had, shown to everyone who reaches it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { test, before, after } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
|
|
||||||
* and it matters that it is enforced *there*: the alternative is signing
|
|
||||||
* someone in and letting Files, the account locale and self-service
|
|
||||||
* credentials each fail in their own way, with nothing to connect the three or
|
|
||||||
* to say what the real problem is.
|
|
||||||
*
|
|
||||||
* The refusal also has to keep two things apart that look the same from the
|
|
||||||
* outside. Bad credentials are a 401 the user can fix by typing again; an
|
|
||||||
* unsupported server is not, and telling someone their password is wrong when
|
|
||||||
* it is not would send them round in circles.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PORT = 18799;
|
|
||||||
process.env.MOCK_PORT = String(PORT);
|
|
||||||
process.env.MOCK_USER = "[email protected]";
|
|
||||||
process.env.MOCK_PASS = "demo-password";
|
|
||||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
|
||||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
|
||||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
|
||||||
|
|
||||||
const mock = await import("./mock/index.js");
|
|
||||||
const { createApp } = await import("./app.js");
|
|
||||||
|
|
||||||
const app = createApp();
|
|
||||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
|
||||||
|
|
||||||
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
|
|
||||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
|
|
||||||
const text = await res.text();
|
|
||||||
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
|
|
||||||
}
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
(mock as { server?: { close(): void } }).server?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a server without the registry is refused, with good credentials", async () => {
|
|
||||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
|
||||||
assert.equal(res.status, 501);
|
|
||||||
assert.equal(res.body.error, "unsupported_server");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the message says the credentials were fine, and names the way out", async () => {
|
|
||||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
|
||||||
// Someone hitting this has typed a correct password. Saying so is the
|
|
||||||
// difference between "upgrade your server" and "try your password again".
|
|
||||||
assert.match(body.message, /credentials are fine/i);
|
|
||||||
assert.match(body.message, /0\.16/);
|
|
||||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no session is minted for a server we cannot talk to", async () => {
|
|
||||||
// A cookie here would leave a signed-in session against a server every
|
|
||||||
// other request is going to fail on.
|
|
||||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
|
||||||
assert.equal(res.setCookie, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bad credentials on such a server are still a 401, not the server error", async () => {
|
|
||||||
// The upstream session request fails first, and that answer is the honest
|
|
||||||
// one: we never got far enough to learn what the server supports.
|
|
||||||
const res = await login({ username: "[email protected]", password: "wrong-password" });
|
|
||||||
assert.equal(res.status, 401);
|
|
||||||
assert.notEqual(res.body.error, "unsupported_server");
|
|
||||||
});
|
|
||||||
+72
-109
@@ -10,12 +10,14 @@ import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
|||||||
|
|
||||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||||
/**
|
/**
|
||||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
* Which Stalwart generation to impersonate. "0.16" (the default) has the
|
||||||
* against a server ihasmail does not support. This is only that: the rest of
|
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
|
||||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
* rights. "0.15" is the older shape, and differs in ways that mostly do not
|
||||||
* support for it.
|
* announce themselves: its FileNode/query cannot see directories at all, it
|
||||||
|
* refuses a `using` naming a capability it does not know, and self-service
|
||||||
|
* credentials live behind a REST endpoint instead.
|
||||||
*/
|
*/
|
||||||
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
const LEGACY = process.env.MOCK_STALWART === "0.15";
|
||||||
/**
|
/**
|
||||||
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
||||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||||
@@ -44,24 +46,12 @@ const state = { n: 1 };
|
|||||||
const nextState = () => String(state.n++);
|
const nextState = () => String(state.n++);
|
||||||
|
|
||||||
/* ---------- data ---------- */
|
/* ---------- data ---------- */
|
||||||
/*
|
|
||||||
* The names are Stalwart's own defaults, which follow the Exchange convention:
|
|
||||||
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
|
|
||||||
* short forms, so anything built from a folder's name read differently here
|
|
||||||
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
|
|
||||||
* against a real server -- and every screenshot in the README showed a folder
|
|
||||||
* list no user has. The role is what the client branches on; the name is only
|
|
||||||
* ever displayed, which is exactly why it has to look right.
|
|
||||||
*/
|
|
||||||
/** Push subscriptions, as a fresh account has none. */
|
|
||||||
const pushSubscriptions: Obj[] = [];
|
|
||||||
|
|
||||||
const mailboxes: Obj[] = [
|
const mailboxes: Obj[] = [
|
||||||
mb("inbox", "Inbox", "inbox"),
|
mb("inbox", "Inbox", "inbox"),
|
||||||
mb("drafts", "Drafts", "drafts"),
|
mb("drafts", "Drafts", "drafts"),
|
||||||
mb("sent", "Sent Items", "sent"),
|
mb("sent", "Sent", "sent"),
|
||||||
mb("junk", "Junk Mail", "junk"),
|
mb("junk", "Junk Mail", "junk"),
|
||||||
mb("trash", "Deleted Items", "trash"),
|
mb("trash", "Trash", "trash"),
|
||||||
mb("archive", "Archive", "archive"),
|
mb("archive", "Archive", "archive"),
|
||||||
mb("work", "Work", null),
|
mb("work", "Work", null),
|
||||||
mb("work-inv", "Invoices", null, "work"),
|
mb("work-inv", "Invoices", null, "work"),
|
||||||
@@ -179,7 +169,10 @@ const fileNodes: Obj[] = [
|
|||||||
{ 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: "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() },
|
||||||
];
|
];
|
||||||
function fr() {
|
function fr() {
|
||||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
// 0.16 split what used to be a single mayWrite into four.
|
||||||
|
return LEGACY
|
||||||
|
? { mayRead: true, mayWrite: true, mayShare: true }
|
||||||
|
: { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function recount() {
|
function recount() {
|
||||||
@@ -479,85 +472,6 @@ const handlers: Record<string, Handler> = {
|
|||||||
state.n++;
|
state.n++;
|
||||||
return setResp({ updated: { singleton: null } });
|
return setResp({ updated: { singleton: null } });
|
||||||
},
|
},
|
||||||
/*
|
|
||||||
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
|
||||||
* runs through the browser vendor's real push service, so nothing local will
|
|
||||||
* ever make a notification appear.
|
|
||||||
*
|
|
||||||
* What is worth reproducing is the handshake, because it is the part that
|
|
||||||
* fails quietly: a subscription is created unverified and stays silent until
|
|
||||||
* the client echoes back a code the server pushed. A mock that marked one
|
|
||||||
* verified on creation would let a client ship without ever implementing
|
|
||||||
* that, and the symptom in production is "registered, and no notifications".
|
|
||||||
*/
|
|
||||||
"PushSubscription/get": (a) => {
|
|
||||||
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
|
||||||
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
|
||||||
// `keys` is write-only in JMAP: the server never hands it back.
|
|
||||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
|
||||||
},
|
|
||||||
"PushSubscription/set": (a) => {
|
|
||||||
const created: Obj = {};
|
|
||||||
const notCreated: Obj = {};
|
|
||||||
const updated: Obj = {};
|
|
||||||
const notUpdated: Obj = {};
|
|
||||||
const destroyed: string[] = [];
|
|
||||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
|
||||||
const o = obj as Obj;
|
|
||||||
const keys = (o.keys ?? {}) as Obj;
|
|
||||||
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
|
||||||
// API produces; padding it would be the client inventing a shape.
|
|
||||||
for (const k of ["p256dh", "auth"]) {
|
|
||||||
const v = String(keys[k] ?? "");
|
|
||||||
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
|
||||||
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
|
||||||
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (notCreated[cid]) continue;
|
|
||||||
if (!String(o.url ?? "").startsWith("https://")) {
|
|
||||||
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A filter condition with a null value is not a filter -- the real server
|
|
||||||
// answers "Invalid filter" and refuses the whole subscription. ihasmail
|
|
||||||
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
|
|
||||||
// all here, and the mock accepted it happily. It does not any more.
|
|
||||||
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
|
|
||||||
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
|
|
||||||
return Object.values(f).some((v) => v === null || v === undefined);
|
|
||||||
});
|
|
||||||
if (badFilter) {
|
|
||||||
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// One per device: re-subscribing replaces rather than accumulates.
|
|
||||||
const deviceId = String(o.deviceClientId ?? "");
|
|
||||||
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
|
||||||
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
|
||||||
const id = `ps${randomUUID().slice(0, 6)}`;
|
|
||||||
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
|
||||||
created[cid] = { id, expires: null };
|
|
||||||
state.n++;
|
|
||||||
}
|
|
||||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
|
||||||
const s = pushSubscriptions.find((x) => x.id === id);
|
|
||||||
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
|
||||||
const code = (patch as Obj).verificationCode;
|
|
||||||
if (code !== undefined) {
|
|
||||||
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
|
||||||
s.verified = true;
|
|
||||||
}
|
|
||||||
updated[id] = null;
|
|
||||||
state.n++;
|
|
||||||
}
|
|
||||||
for (const id of (a.destroy as string[]) ?? []) {
|
|
||||||
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
|
||||||
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
|
||||||
}
|
|
||||||
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
|
||||||
},
|
|
||||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||||
"x:AppPassword/set": (a) => {
|
"x:AppPassword/set": (a) => {
|
||||||
const created: Obj = {};
|
const created: Obj = {};
|
||||||
@@ -702,11 +616,32 @@ const handlers: Record<string, Handler> = {
|
|||||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
"FileNode/query": (a) => {
|
"FileNode/query": (a) => {
|
||||||
const f = (a.filter as Obj) ?? {};
|
const f = (a.filter as Obj) ?? {};
|
||||||
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
if (LEGACY) {
|
||||||
|
// Sorting is refused outright, and isTopLevel / nodeType are not filters
|
||||||
|
// this generation knows.
|
||||||
|
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
|
||||||
|
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
|
||||||
|
}
|
||||||
|
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||||
|
// The pre-0.16 query masks its results to non-containers, so a directory
|
||||||
|
// never comes back — with nothing to say it was left out.
|
||||||
|
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
|
||||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
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) => {
|
||||||
|
const res = genericGet(fileNodes)(a);
|
||||||
|
// nodeType does not exist before 0.16; the shape is all the client gets.
|
||||||
|
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
|
||||||
|
return res;
|
||||||
|
},
|
||||||
"FileNode/set": (a) => {
|
"FileNode/set": (a) => {
|
||||||
|
if (LEGACY) {
|
||||||
|
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
|
||||||
|
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
|
||||||
|
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return genericSet(fileNodes, "f", (o) => {
|
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 });
|
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 });
|
||||||
// Without nodeType, a node is a directory precisely when it carries no
|
// Without nodeType, a node is a directory precisely when it carries no
|
||||||
@@ -749,11 +684,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = () => ({
|
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" },
|
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: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": {} },
|
||||||
"urn:ietf:params:jmap:emailpush": {},
|
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": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||||
"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": {} },
|
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||||
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": {} }) } } },
|
|
||||||
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,
|
username: USER,
|
||||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||||
@@ -776,8 +709,37 @@ export const server = createServer(async (req, res) => {
|
|||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end(JSON.stringify(session()));
|
return res.end(JSON.stringify(session()));
|
||||||
}
|
}
|
||||||
// The account info endpoint; the only place a server reports its edition.
|
// Before 0.16, self-service credentials are a REST endpoint rather than
|
||||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
// registry objects: GET reports the state, POST takes a list of actions.
|
||||||
|
if (LEGACY && url.pathname === "/api/account/auth") {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
|
||||||
|
}
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
|
||||||
|
// Password and OTP changes are only accepted over Basic auth.
|
||||||
|
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
|
||||||
|
res.writeHead(400, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
|
||||||
|
}
|
||||||
|
for (const a of actions) {
|
||||||
|
if (a.type === "setPassword") account.password = a.password ?? account.password;
|
||||||
|
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
|
||||||
|
else if (a.type === "disableOtpAuth") account.otpUrl = null;
|
||||||
|
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
|
||||||
|
else if (a.type === "removeAppPassword") {
|
||||||
|
const i = account.appPasswords.findIndex((p) => p.description === a.name);
|
||||||
|
if (i >= 0) account.appPasswords.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ data: null }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.16's account info endpoint; the only place a server reports its edition.
|
||||||
|
if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||||
}
|
}
|
||||||
@@ -801,7 +763,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
for (const [name, rawArgs, id] of body.methodCalls) {
|
for (const [name, rawArgs, id] of body.methodCalls) {
|
||||||
const h = handlers[name];
|
const h = handlers[name];
|
||||||
// The registry, and every x: method with it, arrived in 0.16.
|
// The registry, and every x: method with it, arrived in 0.16.
|
||||||
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||||
try {
|
try {
|
||||||
const args = resolveRefs(rawArgs, responses, creations);
|
const args = resolveRefs(rawArgs, responses, creations);
|
||||||
enforceLimits(name, args);
|
enforceLimits(name, args);
|
||||||
@@ -848,6 +810,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
res.end(JSON.stringify({ error: "not found" }));
|
res.end(JSON.stringify({ error: "not found" }));
|
||||||
}).listen(PORT, "127.0.0.1", () => {
|
}).listen(PORT, "127.0.0.1", () => {
|
||||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||||
|
console.log(`[mock-stalwart] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
|
||||||
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,37 @@ test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
|||||||
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||||
assert.equal(normalizeLocale("../etc/passwd"), null);
|
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("generated app passwords are unbiased and long enough", async () => {
|
||||||
|
const { readableSecret } = await import("./account.js");
|
||||||
|
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789";
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
let samples = 0;
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const secret = readableSecret();
|
||||||
|
assert.match(secret, /^[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}$/, secret);
|
||||||
|
for (const ch of secret.replace(/-/g, "")) {
|
||||||
|
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
||||||
|
samples++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(samples, 2000 * 20);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `% 33` over a byte maps 25 characters onto 8 values each and the last 8
|
||||||
|
* onto 7, so the digits — the tail of the alphabet — would come up about
|
||||||
|
* 7/8 as often as they should. Testing each character on its own cannot see
|
||||||
|
* a skew that size against the noise, so weigh the whole tail at once:
|
||||||
|
* uniform puts 8/33 of the draw there, the biased version 7/8 of that, and
|
||||||
|
* over 40,000 draws the two are more than four standard deviations apart.
|
||||||
|
*/
|
||||||
|
const tail = alphabet.slice(25); // "23456789"
|
||||||
|
const tailSeen = [...tail].reduce((n, ch) => n + (counts.get(ch) ?? 0), 0);
|
||||||
|
const p = tail.length / alphabet.length;
|
||||||
|
const expected = samples * p;
|
||||||
|
const sigma = Math.sqrt(samples * p * (1 - p));
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(tailSeen - expected) < 4 * sigma,
|
||||||
|
`digits appeared ${tailSeen} times, expected ~${Math.round(expected)} (sigma ${sigma.toFixed(1)}) - modulo bias?`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+41
-20
@@ -78,14 +78,10 @@ const JMAP_CORE = "urn:ietf:params:jmap:core";
|
|||||||
* builds that list from a fixed set that has never included this capability;
|
* builds that list from a fixed set that has never included this capability;
|
||||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||||
* in each account's `accountCapabilities`. Checking only the session level
|
* in each account's `accountCapabilities`. Checking only the session level
|
||||||
* therefore reported every real 0.16 server as older than 0.16 — which routed
|
* therefore reports every real 0.16 server as pre-0.16 — which routed
|
||||||
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
||||||
* About page the wrong thing. The session level is still checked last, in case
|
* About page the wrong thing. The session level is still checked last, in case
|
||||||
* a later release advertises it there as well.
|
* a later release advertises it there as well.
|
||||||
*
|
|
||||||
* This is now what sign-in tests to decide whether a server is supported at
|
|
||||||
* all, so the same mistake would lock every user out of a working server
|
|
||||||
* rather than merely misroute them.
|
|
||||||
*/
|
*/
|
||||||
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
@@ -100,13 +96,22 @@ export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities
|
|||||||
export interface AccountInfo {
|
export interface AccountInfo {
|
||||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||||
locale: string | null;
|
locale: string | null;
|
||||||
|
/**
|
||||||
|
* Which generation of Stalwart's API answered: "0.16+" has the registry
|
||||||
|
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
|
||||||
|
* server is not Stalwart or told us nothing.
|
||||||
|
*/
|
||||||
|
generation: "0.16+" | "pre-0.16" | null;
|
||||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||||
edition: string | null;
|
edition: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
const INFO_CACHE_MS = 30 * 60_000;
|
const INFO_CACHE_MS = 30 * 60_000;
|
||||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
|
||||||
|
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||||
|
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||||
|
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||||
@@ -160,9 +165,13 @@ export function normalizeLocale(raw: unknown): string | null {
|
|||||||
* tells us which generation we are talking to.
|
* tells us which generation we are talking to.
|
||||||
*/
|
*/
|
||||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||||
// Sign-in refuses a server without the registry, so this should not happen —
|
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
|
||||||
// but a session we cannot read capabilities from is not one to ask.
|
// at all, so its absence already answers the question — and asking anyway
|
||||||
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
|
// would fail the whole request, since those servers reject a `using` naming
|
||||||
|
// a capability they cannot parse.
|
||||||
|
// A session with no capabilities at all is not one we can read anything from.
|
||||||
|
if (!session.capabilities) return EMPTY_INFO;
|
||||||
|
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
|
||||||
const accountId =
|
const accountId =
|
||||||
session.primaryAccounts?.[STALWART_CAP] ??
|
session.primaryAccounts?.[STALWART_CAP] ??
|
||||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||||
@@ -180,23 +189,35 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
|||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
// A locale request that fails — a permission we lack, a hiccup upstream —
|
// The registry capability already settled the generation. A locale request
|
||||||
// costs us the locale and nothing else.
|
// that fails — a permission we lack, a hiccup upstream — can only cost us the
|
||||||
if (!res.ok) return EMPTY_INFO;
|
// locale; it must not talk us out of what we know.
|
||||||
|
if (!res.ok) return REGISTRY_INFO;
|
||||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||||
return interpretAccountInfo(body.methodResponses ?? []);
|
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
|
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
|
||||||
* permission the built-in user role has, and fall back to `x:Account` for the
|
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||||
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
|
* and note which generation answered.
|
||||||
* methods; this is a permissions fallback, not a version one.
|
|
||||||
*/
|
*/
|
||||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
export function interpretAccountInfo(
|
||||||
|
responses: [string, Record<string, unknown>, string][],
|
||||||
|
known: AccountInfo["generation"] = null,
|
||||||
|
): AccountInfo {
|
||||||
const settings = responses.find((r) => r[2] === "s");
|
const settings = responses.find((r) => r[2] === "s");
|
||||||
const account = responses.find((r) => r[2] === "a");
|
const account = responses.find((r) => r[2] === "a");
|
||||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||||
|
// `known` is what the session capability already proved, and outranks a reply
|
||||||
|
// that merely refused us.
|
||||||
|
const generation: AccountInfo["generation"] =
|
||||||
|
settings && settings[0] !== "error"
|
||||||
|
? "0.16+"
|
||||||
|
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||||
|
? "pre-0.16"
|
||||||
|
: known;
|
||||||
|
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||||
@@ -230,7 +251,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
|||||||
let info = EMPTY_INFO;
|
let info = EMPTY_INFO;
|
||||||
try {
|
try {
|
||||||
info = await fetchAccountInfo(authorization, session);
|
info = await fetchAccountInfo(authorization, session);
|
||||||
info = { ...info, edition: await fetchEdition(authorization) };
|
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
|
||||||
} catch {
|
} catch {
|
||||||
/* all of this is a nicety - never fail the session over it */
|
/* all of this is a nicety - never fail the session over it */
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -4,17 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<!--
|
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||||
One tag, no media query: applyTheme() keeps it in step with the chosen
|
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
|
||||||
not what the user picked here. There used to be two, both with media
|
|
||||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
|
||||||
neither and the colour never moved off whatever the OS implied.
|
|
||||||
|
|
||||||
The initial value is the default theme's background, so the browser chrome
|
|
||||||
is right from the first paint rather than only once JS has run.
|
|
||||||
-->
|
|
||||||
<meta name="theme-color" content="#0d2430" />
|
|
||||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ihasmail/web",
|
"name": "@ihasmail/web",
|
||||||
"version": "2.16.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+2
-96
@@ -1,7 +1,5 @@
|
|||||||
/* ihasmail service worker.
|
/* ihasmail service worker: app-shell caching for installability & fast loads.
|
||||||
Two jobs: app-shell caching for installability and fast loads (API requests
|
API requests are never cached. */
|
||||||
are never cached), and Web Push, which is the only part of ihasmail that runs
|
|
||||||
when no tab is open. */
|
|
||||||
const VERSION = "ihasmail-v2";
|
const VERSION = "ihasmail-v2";
|
||||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||||
|
|
||||||
@@ -41,95 +39,3 @@ self.addEventListener("fetch", (event) => {
|
|||||||
}
|
}
|
||||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Web Push */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
|
||||||
* nothing here talks to ihasmail's server. The payload is an EmailPush object
|
|
||||||
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
|
|
||||||
* notification without a round-trip — which matters, because when this fires
|
|
||||||
* there may be no session to make one with.
|
|
||||||
*
|
|
||||||
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
|
||||||
* until the client echoes its code back. That cannot be done from here (no
|
|
||||||
* credentials), so it is stashed for a tab to collect and confirm.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const VERIFY_KEY = "ihasmail-push-verification";
|
|
||||||
|
|
||||||
function textOf(email) {
|
|
||||||
const from = email?.from?.[0];
|
|
||||||
const who = from?.name || from?.email || "New message";
|
|
||||||
const what = email?.subject || "(no subject)";
|
|
||||||
return { title: who, body: what, preview: email?.preview || "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
data = event.data ? event.data.json() : null;
|
|
||||||
} catch {
|
|
||||||
/* not JSON: fall through to the generic notification below */
|
|
||||||
}
|
|
||||||
|
|
||||||
// The verification handshake. No credentials here, so hand it to a tab —
|
|
||||||
// an open one now, or the next one to start.
|
|
||||||
if (data && data["@type"] === "PushVerification") {
|
|
||||||
event.waitUntil((async () => {
|
|
||||||
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
|
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
||||||
if (clients.length) {
|
|
||||||
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
|
|
||||||
} else {
|
|
||||||
const cache = await caches.open(VERSION);
|
|
||||||
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
|
|
||||||
}
|
|
||||||
})());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
|
||||||
event.waitUntil((async () => {
|
|
||||||
if (!emails.length) {
|
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
|
||||||
// something true rather than inventing a sender.
|
|
||||||
await self.registration.showNotification("New mail", {
|
|
||||||
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// One notification per message, collapsing repeats of the same message by
|
|
||||||
// tag so a re-push does not stack.
|
|
||||||
for (const email of emails.slice(0, 5)) {
|
|
||||||
const { title, body, preview } = textOf(email);
|
|
||||||
await self.registration.showNotification(title, {
|
|
||||||
body: preview ? `${body}\n${preview}` : body,
|
|
||||||
icon: "/img/icon-192.png",
|
|
||||||
badge: "/img/favicon-64.png",
|
|
||||||
tag: `ihasmail-${email.id || body}`,
|
|
||||||
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})());
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const url = event.notification.data?.url || "/mail";
|
|
||||||
event.waitUntil((async () => {
|
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
|
||||||
// Reuse a tab if one is open rather than piling up windows.
|
|
||||||
for (const c of clients) {
|
|
||||||
if (new URL(c.url).origin === self.location.origin) {
|
|
||||||
await c.focus();
|
|
||||||
if ("navigate" in c) await c.navigate(url).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self.clients.openWindow(url);
|
|
||||||
})());
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
|||||||
import { setUnreadBadge } from "@/lib/notify";
|
import { setUnreadBadge } from "@/lib/notify";
|
||||||
import { useSettings, syncedPart } from "@/store/settings";
|
import { useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { listenForVerification } from "@/lib/webpushEnable";
|
|
||||||
|
|
||||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||||
@@ -88,9 +87,6 @@ function AuthedApp() {
|
|||||||
void useFiles.getState().init();
|
void useFiles.getState().init();
|
||||||
void useSieve.getState().init();
|
void useSieve.getState().init();
|
||||||
push.start();
|
push.start();
|
||||||
// A push subscription stays silent until its verification code is echoed
|
|
||||||
// back, and the code may have arrived while no tab was open.
|
|
||||||
listenForVerification();
|
|
||||||
const pending = new Map<string, Set<string>>();
|
const pending = new Map<string, Set<string>>();
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
const unsub = push.subscribe((acct, type) => {
|
const unsub = push.subscribe((acct, type) => {
|
||||||
|
|||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The build's version string, substituted by Vite at build time — there is no
|
|
||||||
* git to ask from inside a browser, or inside the Docker build. See
|
|
||||||
* `scripts/version.mjs`.
|
|
||||||
*/
|
|
||||||
declare const __IHASMAIL_VERSION__: string;
|
|
||||||
@@ -36,7 +36,8 @@ export interface JmapSession {
|
|||||||
userLocale?: string | null;
|
userLocale?: string | null;
|
||||||
/** What the upstream server was willing to say about itself. */
|
/** What the upstream server was willing to say about itself. */
|
||||||
server?: {
|
server?: {
|
||||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
/** Which API generation answered: Stalwart publishes no version number. */
|
||||||
|
generation?: "0.16+" | "pre-0.16" | null;
|
||||||
edition?: string | null;
|
edition?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { canEmpty, emptyLabel } from "@/lib/emptyFolder";
|
|
||||||
import type { MailboxRole } from "@/jmap/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emptying destroys everything in a folder in one action, with no undo and no
|
|
||||||
* trip through Deleted Items. Which folders may be emptied is therefore a
|
|
||||||
* safety property, not a presentation one — the store enforces it too, and
|
|
||||||
* these pin the half the menus decide.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe("which folders may be emptied", () => {
|
|
||||||
it("allows exactly Deleted Items and Junk Mail", () => {
|
|
||||||
expect(canEmpty("trash")).toBe(true);
|
|
||||||
expect(canEmpty("junk")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses folders holding mail someone meant to keep", () => {
|
|
||||||
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
|
|
||||||
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a plain folder, which has no role at all", () => {
|
|
||||||
expect(canEmpty(null)).toBe(false);
|
|
||||||
expect(canEmpty(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("what the action is called", () => {
|
|
||||||
it("says what it does to spam, rather than naming the folder", () => {
|
|
||||||
// "Delete all spam" is what this is called everywhere else; "Empty Junk
|
|
||||||
// Mail" would be accurate and still leave people hunting for it.
|
|
||||||
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
|
|
||||||
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the folder for Deleted Items, whatever the server calls it", () => {
|
|
||||||
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
|
|
||||||
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
|
||||||
|
import type { FileNode, JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the
|
||||||
|
* whole create with `invalidProperties (nodeType)` — which is what uploading a
|
||||||
|
* file or making a folder hit on the live 0.15.5 box. Those servers tell a file
|
||||||
|
* from a directory by whether it carries file properties at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function session(caps: string[]): JmapSession {
|
||||||
|
return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"];
|
||||||
|
const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
|
||||||
|
* per-account and never appears in the session-level capabilities, so a client
|
||||||
|
* that only checks there drops every 0.16 server onto the older code path.
|
||||||
|
*/
|
||||||
|
function realStalwartSession(): JmapSession {
|
||||||
|
return {
|
||||||
|
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
|
||||||
|
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
|
||||||
|
primaryAccounts: { "urn:stalwart:jmap": "a1" },
|
||||||
|
state: "s",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
client.session = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on Stalwart 0.16 and newer", () => {
|
||||||
|
it("uses nodeType everywhere", () => {
|
||||||
|
client.session = session(NEW_SERVER);
|
||||||
|
expect(supportsNodeType()).toBe(true);
|
||||||
|
expect(fileNodeProps()).toContain("nodeType");
|
||||||
|
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||||
|
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves what the server reported alone", () => {
|
||||||
|
client.session = session(NEW_SERVER);
|
||||||
|
const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial<FileNode>[];
|
||||||
|
expect(normalizeFileNodes(nodes)).toEqual(nodes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on a real 0.16 session, which advertises per-account only", () => {
|
||||||
|
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
|
||||||
|
client.session = realStalwartSession();
|
||||||
|
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
|
||||||
|
expect(supportsNodeType()).toBe(true);
|
||||||
|
expect(queryOmitsDirectories()).toBe(false);
|
||||||
|
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on Stalwart before 0.16", () => {
|
||||||
|
it("never mentions nodeType, in creates or in requested properties", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
expect(supportsNodeType()).toBe(false);
|
||||||
|
expect(fileNodeProps()).not.toContain("nodeType");
|
||||||
|
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" });
|
||||||
|
expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a directory free of file properties, which is what makes it one", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const dir = directoryCreate(null, "ihasmail");
|
||||||
|
// Setting blobId, size or type — even to null — would make this a file.
|
||||||
|
expect(dir).not.toHaveProperty("blobId");
|
||||||
|
expect(dir).not.toHaveProperty("size");
|
||||||
|
expect(dir).not.toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sends what a file needs", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works out nodeType from the file properties, so folders stay folders", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const out = normalizeFileNodes([
|
||||||
|
{ id: "1", name: "Documents", blobId: null, size: null, type: null },
|
||||||
|
{ id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" },
|
||||||
|
{ id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null },
|
||||||
|
] as Partial<FileNode>[]);
|
||||||
|
expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite a nodeType that did come back", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial<FileNode>[]);
|
||||||
|
expect(out[0]!.nodeType).toBe("symlink");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assumes the older shape when there is no session yet", () => {
|
||||||
|
client.session = null;
|
||||||
|
expect(supportsNodeType()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rights were split up in 0.16. Before that a node carried mayRead / mayWrite /
|
||||||
|
* mayShare, with mayWrite covering everything the newer release names
|
||||||
|
* separately — so Rename and Delete sat permanently greyed out, doing nothing
|
||||||
|
* and saying nothing.
|
||||||
|
*/
|
||||||
|
describe("rights on a pre-0.16 server", () => {
|
||||||
|
const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false });
|
||||||
|
|
||||||
|
it("widens mayWrite into the rights the UI gates on", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial<FileNode>[]);
|
||||||
|
expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand out rights the server withheld", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial<FileNode>[]);
|
||||||
|
expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves rights that already use the newer names untouched", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true };
|
||||||
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial<FileNode>[]);
|
||||||
|
expect(node!.myRights).toEqual(newer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copes with a node that reported no rights at all", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial<FileNode>[]);
|
||||||
|
expect(node!.myRights).toBeUndefined();
|
||||||
|
expect(node!.nodeType).toBe("directory");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
|
||||||
|
* only resources that are *not* containers. It therefore returns files and
|
||||||
|
* never folders, with no error to explain the omission: a folder created there
|
||||||
|
* exists but never comes back in a listing. FileNode/get carries no such mask.
|
||||||
|
*/
|
||||||
|
describe("directory-blind query", () => {
|
||||||
|
it("is worked around on older servers", () => {
|
||||||
|
client.session = session(OLD_SERVER);
|
||||||
|
expect(queryOmitsDirectories()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not worked around where query can see folders", () => {
|
||||||
|
client.session = session(NEW_SERVER);
|
||||||
|
expect(queryOmitsDirectories()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #71, both halves of it, reduced to the arithmetic they turn on.
|
|
||||||
*
|
|
||||||
* After deleting a row from the keyboard, `focusId` used to keep pointing at
|
|
||||||
* the row that had gone. Two things fell out of that:
|
|
||||||
*
|
|
||||||
* - `targetIds()` falls back to the focused id, so the next `#` re-targeted
|
|
||||||
* the deleted message. The optimistic update had already moved it into
|
|
||||||
* Deleted Items, so it looked like a permanent delete and raised a
|
|
||||||
* confirmation the user had switched off.
|
|
||||||
* - `moveFocus` read `ids.indexOf(focusId)` as -1 and treated that as
|
|
||||||
* "before the first row", so `k` clamped to the top of the list.
|
|
||||||
*
|
|
||||||
* Clicking was unaffected: it sets focus to a row that exists. That is why it
|
|
||||||
* only ever happened from the keyboard.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Where focus lands after the row at `wasAt` is removed. */
|
|
||||||
function focusAfterRemove(freshIds: string[], wasAt: number, autoAdvance: "newer" | "older" | "list"): string | null {
|
|
||||||
if (!freshIds.length) return null;
|
|
||||||
if (wasAt < 0) return undefined as unknown as string;
|
|
||||||
const want = autoAdvance === "newer" ? wasAt - 1 : wasAt;
|
|
||||||
return freshIds[Math.max(0, Math.min(want, freshIds.length - 1))] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What moveFocus resolves to, given a focus id that may no longer exist. */
|
|
||||||
function nextIndex(ids: string[], focus: string | null, listIndex: number, delta: number): number {
|
|
||||||
const fromFocus = focus ? ids.indexOf(focus) : -1;
|
|
||||||
const cur = fromFocus >= 0 ? fromFocus : listIndex;
|
|
||||||
return Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("focus after deleting a row", () => {
|
|
||||||
const after = ["b", "c", "d"]; // "a" was at 0 and has gone
|
|
||||||
|
|
||||||
it("lands on the row that slid into the gap", () => {
|
|
||||||
expect(focusAfterRemove(after, 0, "older")).toBe("b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lands on the row above when auto-advance is set to newer", () => {
|
|
||||||
// deleted "c" at index 2; newer means the one before it
|
|
||||||
expect(focusAfterRemove(["a", "b", "d"], 2, "newer")).toBe("b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not run off the end when the last row was deleted", () => {
|
|
||||||
expect(focusAfterRemove(["a", "b"], 2, "older")).toBe("b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears focus when the list is now empty", () => {
|
|
||||||
expect(focusAfterRemove([], 0, "older")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("moving focus when the focused row has gone", () => {
|
|
||||||
const ids = ["b", "c", "d"];
|
|
||||||
|
|
||||||
it("no longer sends k to the top of the list", () => {
|
|
||||||
// The regression: focus is on the deleted "a", the list says we were at 1.
|
|
||||||
expect(nextIndex(ids, "a", 1, -1)).toBe(0);
|
|
||||||
// …and with focus repaired to a real row, k moves by one as it should.
|
|
||||||
expect(ids[nextIndex(ids, "c", 1, -1)]).toBe("b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves by one from a row that exists, in both directions", () => {
|
|
||||||
expect(ids[nextIndex(ids, "c", 1, 1)]).toBe("d");
|
|
||||||
expect(ids[nextIndex(ids, "b", 0, 1)]).toBe("c");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops at the ends rather than wrapping", () => {
|
|
||||||
expect(ids[nextIndex(ids, "b", 0, -1)]).toBe("b");
|
|
||||||
expect(ids[nextIndex(ids, "d", 2, 1)]).toBe("d");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isAlwaysVisible, visibleIdentities } from "@/lib/identityVisibility";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #73: a unique address per service, on a server with an alias domain,
|
|
||||||
* gives every local part twice and a compose picker nobody can use — while only
|
|
||||||
* a handful are ever sent from.
|
|
||||||
*
|
|
||||||
* The interesting cases are not the hiding. They are the three refusals, all of
|
|
||||||
* which exist because a sender picker with nothing usable in it is worse than a
|
|
||||||
* cluttered one.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ids = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `i${i + 1}`, email: `a${i + 1}@example.com` }));
|
|
||||||
|
|
||||||
describe("hiding identities from the picker", () => {
|
|
||||||
it("removes the hidden ones", () => {
|
|
||||||
expect(visibleIdentities(ids(4), ["i2", "i4"]).map((i) => i.id)).toEqual(["i1", "i3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("changes nothing when none are hidden", () => {
|
|
||||||
const all = ids(3);
|
|
||||||
expect(visibleIdentities(all, [])).toBe(all);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("what it refuses to hide", () => {
|
|
||||||
it("keeps the identity the draft is already using", () => {
|
|
||||||
// Otherwise the select has no matching option and the From line moves
|
|
||||||
// under the writer.
|
|
||||||
expect(visibleIdentities(ids(3), ["i2"], ["i2"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the default, which a new draft starts on", () => {
|
|
||||||
expect(visibleIdentities(ids(3), ["i1", "i3"], [null, "i1"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows everything rather than nothing when all are hidden", () => {
|
|
||||||
const all = ids(3);
|
|
||||||
expect(visibleIdentities(all, ["i1", "i2", "i3"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores an id for an identity that no longer exists", () => {
|
|
||||||
// A deleted identity leaves its id behind in the setting; it must not
|
|
||||||
// silently hide anything else or empty the list.
|
|
||||||
expect(visibleIdentities(ids(2), ["gone"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tolerates nulls among the ids to keep", () => {
|
|
||||||
expect(visibleIdentities(ids(2), ["i1"], [null, undefined]).map((i) => i.id)).toEqual(["i2"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("what the settings row may offer", () => {
|
|
||||||
it("refuses to offer hiding for an always-visible identity", () => {
|
|
||||||
expect(isAlwaysVisible("i1", ["i1"])).toBe(true);
|
|
||||||
expect(isAlwaysVisible("i2", ["i1"])).toBe(false);
|
|
||||||
expect(isAlwaysVisible("i2", [null, undefined])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
|
||||||
* asks "is this dark?" has to say yes for it — the top-bar toggle picks its
|
|
||||||
* icon from the answer, and the message frame decides whether mail sits on a
|
|
||||||
* light card or follows the app. A theme that painted dark while reporting
|
|
||||||
* light would show a sun icon on a dark screen and light-card mail on it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe("which themes paint dark", () => {
|
|
||||||
it("counts ihasmail as dark, regardless of the OS", () => {
|
|
||||||
expect(isDarkTheme("ihasmail", false)).toBe(true);
|
|
||||||
expect(isDarkTheme("ihasmail", true)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still resolves the ordinary three the way it always did", () => {
|
|
||||||
expect(isDarkTheme("dark", false)).toBe(true);
|
|
||||||
expect(isDarkTheme("light", true)).toBe(false);
|
|
||||||
expect(isDarkTheme("system", true)).toBe(true);
|
|
||||||
expect(isDarkTheme("system", false)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a missing OS preference as light, not as unknown", () => {
|
|
||||||
// matchMedia is absent in some embeddings; the default must not read dark.
|
|
||||||
expect(isDarkTheme("system")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has an answer for every theme there is", () => {
|
|
||||||
// A theme added later without a branch here would silently paint light.
|
|
||||||
const all: Theme[] = ["system", "light", "dark", "ihasmail"];
|
|
||||||
for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the default theme", () => {
|
|
||||||
it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
|
|
||||||
expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The guarantee that matters when a default changes: it moves nobody who
|
|
||||||
* already has a theme stored — which is everyone using ihasmail today, since
|
|
||||||
* the setting is saved whether or not they deliberately picked it.
|
|
||||||
*
|
|
||||||
* `localStorage` is not available in this environment, and `saveJson`
|
|
||||||
* swallows that, so a plain round-trip here would pass for the wrong reason:
|
|
||||||
* both sides would be the fallback. Stub it, so what is under test is
|
|
||||||
* `loadJson`'s merge rather than the environment.
|
|
||||||
*/
|
|
||||||
const withStorage = (fn: () => void) => {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
Object.defineProperty(globalThis, "localStorage", {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
getItem: (k: string) => store.get(k) ?? null,
|
|
||||||
setItem: (k: string, v: string) => void store.set(k, v),
|
|
||||||
removeItem: (k: string) => void store.delete(k),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
} finally {
|
|
||||||
Reflect.deleteProperty(globalThis, "localStorage");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
it("is only a default — a stored theme wins", () => {
|
|
||||||
withStorage(() => {
|
|
||||||
saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
|
|
||||||
expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills in from the default only for keys the stored settings lack", () => {
|
|
||||||
withStorage(() => {
|
|
||||||
// An older settings blob that predates a key must not lose the new one.
|
|
||||||
saveJson("theme-test-partial", { theme: "dark" });
|
|
||||||
const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
|
|
||||||
expect(loaded.theme).toBe("dark");
|
|
||||||
expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the default when nothing is stored", () => {
|
|
||||||
withStorage(() => {
|
|
||||||
expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the top-bar toggle", () => {
|
|
||||||
it("goes to light from anything dark", () => {
|
|
||||||
expect(toggleTarget("dark", "ihasmail")).toBe("light");
|
|
||||||
expect(toggleTarget("dark", "dark")).toBe("light");
|
|
||||||
expect(toggleTarget("dark", "system")).toBe("light");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("comes back to the theme you were actually on", () => {
|
|
||||||
// The whole point: two clicks from ihasmail must return to ihasmail, not
|
|
||||||
// deposit you on plain dark.
|
|
||||||
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
|
|
||||||
expect(toggleTarget("light", "dark")).toBe("dark");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can bring back \"match system\", which the toggle used to strand", () => {
|
|
||||||
expect(toggleTarget("light", "system")).toBe("system");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips every dark theme there is", () => {
|
|
||||||
for (const t of ["dark", "ihasmail", "system"] as const) {
|
|
||||||
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
|
|
||||||
expect(toggleTarget("light", t), t).toBe(t);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remembering which dark theme you were on", () => {
|
|
||||||
const setTheme = (t: Theme) => {
|
|
||||||
useSettings.getState().update({ theme: t });
|
|
||||||
return useSettings.getState().settings;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("records a dark theme chosen from Settings, not just from the toggle", () => {
|
|
||||||
// update() is the single path every way of choosing a theme goes through,
|
|
||||||
// which is why the remembering lives there rather than at the call sites.
|
|
||||||
expect(setTheme("dark").lastDarkTheme).toBe("dark");
|
|
||||||
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
|
|
||||||
expect(setTheme("system").lastDarkTheme).toBe("system");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not let light overwrite it — that is the theme being toggled away from", () => {
|
|
||||||
setTheme("ihasmail");
|
|
||||||
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("survives a there-and-back through the toggle", () => {
|
|
||||||
setTheme("ihasmail");
|
|
||||||
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
|
|
||||||
expect(away.theme).toBe("light");
|
|
||||||
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
|
|
||||||
expect(back.theme).toBe("ihasmail");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("where the theme settings live", () => {
|
|
||||||
it("follows the account, not the browser", () => {
|
|
||||||
// Both of these ride in the account's settings.json, so a theme chosen on
|
|
||||||
// one machine — and the toggle's way back to it — are the same everywhere.
|
|
||||||
// Named explicitly rather than derived from DEVICE_KEYS: the test that
|
|
||||||
// does derive it would still pass if one of these were moved there, since
|
|
||||||
// its expectation would move too.
|
|
||||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
|
||||||
expect(synced).toHaveProperty("theme");
|
|
||||||
expect(synced).toHaveProperty("lastDarkTheme");
|
|
||||||
expect(DEVICE_KEYS.has("theme")).toBe(false);
|
|
||||||
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is applied from a settings file another device wrote", () => {
|
|
||||||
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { client } from "@/jmap/client";
|
|
||||||
import {
|
|
||||||
applicationServerKey,
|
|
||||||
decodeApplicationServerKey,
|
|
||||||
encodeKey,
|
|
||||||
subscriptionPayload,
|
|
||||||
supportsEmailPush,
|
|
||||||
webPushAvailable,
|
|
||||||
} from "@/lib/webpush";
|
|
||||||
import type { JmapSession } from "@/jmap/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The key encoding is where this breaks silently. `subscribe()` fails with an
|
|
||||||
* opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to
|
|
||||||
* accept the *unpadded* base64url the W3C Push API produces — so re-padding on
|
|
||||||
* the way out would be sending a shape the server has not been tested against.
|
|
||||||
*
|
|
||||||
* The real key from the live 0.16.19 is used below rather than a made-up one:
|
|
||||||
* its length is what exercises the padding arithmetic.
|
|
||||||
*/
|
|
||||||
const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
|
|
||||||
|
|
||||||
function session(caps: Record<string, unknown>): JmapSession {
|
|
||||||
return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
client.session = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the VAPID key", () => {
|
|
||||||
it("is read from the capability the server publishes", () => {
|
|
||||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
|
||||||
expect(applicationServerKey()).toBe(LIVE_KEY);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is null when the server does not do Web Push, rather than an empty string", () => {
|
|
||||||
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
|
||||||
expect(applicationServerKey()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes to the 65 bytes of an uncompressed P-256 point", () => {
|
|
||||||
const buf = decodeApplicationServerKey(LIVE_KEY);
|
|
||||||
expect(buf.byteLength).toBe(65);
|
|
||||||
// 0x04 marks an uncompressed EC point; the Push API rejects anything else.
|
|
||||||
expect(new Uint8Array(buf)[0]).toBe(0x04);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles base64url without padding, which is how it arrives", () => {
|
|
||||||
expect(LIVE_KEY).not.toContain("=");
|
|
||||||
expect(LIVE_KEY).toMatch(/[-_]/);
|
|
||||||
expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an ArrayBuffer, which is what subscribe() accepts", () => {
|
|
||||||
expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("encoding keys for the server", () => {
|
|
||||||
it("produces unpadded base64url, the form Stalwart was fixed to accept", () => {
|
|
||||||
// 5 bytes: a length that would be padded with "===" in standard base64.
|
|
||||||
const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer;
|
|
||||||
const out = encodeKey(buf);
|
|
||||||
expect(out).not.toContain("=");
|
|
||||||
expect(out).not.toContain("+");
|
|
||||||
expect(out).not.toContain("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips through the decoder", () => {
|
|
||||||
const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]);
|
|
||||||
expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives an empty string rather than throwing on a missing key", () => {
|
|
||||||
expect(encodeKey(null)).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("what gets registered", () => {
|
|
||||||
const fakeSub = {
|
|
||||||
endpoint: "https://push.example/abc",
|
|
||||||
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
|
|
||||||
getKey: () => null,
|
|
||||||
} as unknown as PushSubscription;
|
|
||||||
|
|
||||||
it("asks for the message itself when the server supports emailpush", () => {
|
|
||||||
client.session = session({
|
|
||||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
|
||||||
"urn:ietf:params:jmap:emailpush": {},
|
|
||||||
});
|
|
||||||
const body = subscriptionPayload(fakeSub, "a1") as Record<string, any>;
|
|
||||||
expect(body.url).toBe("https://push.example/abc");
|
|
||||||
expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" });
|
|
||||||
expect(body.emailPush.a1.properties).toContain("subject");
|
|
||||||
expect(body.emailPush.a1.properties).toContain("from");
|
|
||||||
// Order is priority: the server drops from the end when the payload is
|
|
||||||
// too large, so the sender must outrank the preview.
|
|
||||||
const props: string[] = body.emailPush.a1.properties;
|
|
||||||
expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits emailPush entirely when the server does not support it", () => {
|
|
||||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
|
||||||
expect(supportsEmailPush()).toBe(false);
|
|
||||||
expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits emailPush when there is no account to scope it to", () => {
|
|
||||||
client.session = session({
|
|
||||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
|
||||||
"urn:ietf:params:jmap:emailpush": {},
|
|
||||||
});
|
|
||||||
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
|
|
||||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
|
||||||
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("availability", () => {
|
|
||||||
it("is false without a push key, however capable the browser", () => {
|
|
||||||
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
|
||||||
expect(webPushAvailable()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the emailPush filter", () => {
|
|
||||||
/**
|
|
||||||
* This is the bug that reached production: `inMailbox: null` read as "the
|
|
||||||
* inbox" and meant nothing to the server, which answered "Invalid filter"
|
|
||||||
* and refused the subscription outright. The original tests checked the
|
|
||||||
* property ordering and never looked at the filter at all.
|
|
||||||
*/
|
|
||||||
const fakeSub = {
|
|
||||||
endpoint: "https://push.example/abc",
|
|
||||||
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
|
|
||||||
getKey: () => null,
|
|
||||||
} as unknown as PushSubscription;
|
|
||||||
|
|
||||||
const withEmailPush = () => {
|
|
||||||
client.session = session({
|
|
||||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
|
||||||
"urn:ietf:params:jmap:emailpush": {},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
it("never sends a condition with a null or undefined value", () => {
|
|
||||||
withEmailPush();
|
|
||||||
for (const inbox of ["mb1", null]) {
|
|
||||||
const body = subscriptionPayload(fakeSub, "a1", inbox) as Record<string, any>;
|
|
||||||
const filter = body.emailPush.a1.filter as Record<string, unknown>;
|
|
||||||
for (const [k, v] of Object.entries(filter)) {
|
|
||||||
expect(v, `${k} was ${String(v)} with inbox=${String(inbox)}`).not.toBeNull();
|
|
||||||
expect(v, k).not.toBeUndefined();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the real mailbox id when it knows one", () => {
|
|
||||||
withEmailPush();
|
|
||||||
const body = subscriptionPayload(fakeSub, "a1", "mbInbox") as Record<string, any>;
|
|
||||||
expect(body.emailPush.a1.filter.inMailbox).toBe("mbInbox");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves inMailbox out entirely when it does not, rather than sending null", () => {
|
|
||||||
withEmailPush();
|
|
||||||
const filter = (subscriptionPayload(fakeSub, "a1", null) as Record<string, any>).emailPush.a1.filter;
|
|
||||||
expect(filter).not.toHaveProperty("inMailbox");
|
|
||||||
// Still narrowed to unread: notifying more widely beats not notifying.
|
|
||||||
expect(filter.notKeyword).toBe("$seen");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+45
-23
@@ -7,39 +7,53 @@
|
|||||||
* is what makes this state travel between devices without ihasmail storing
|
* is what makes this state travel between devices without ihasmail storing
|
||||||
* anything server-side of its own — but it is housekeeping rather than
|
* anything server-side of its own — but it is housekeeping rather than
|
||||||
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
|
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
|
||||||
*
|
|
||||||
* Both lookups below filter on `parentId`/`isTopLevel` alone and match the name
|
|
||||||
* here rather than asking the server to. Those are the filters Files itself
|
|
||||||
* relies on; `name` is not one Stalwart is known to implement, and a filter it
|
|
||||||
* does not know fails the whole query rather than being ignored.
|
|
||||||
*/
|
*/
|
||||||
import { client, setErrorMessage } from "@/jmap/client";
|
import { client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
|
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||||
import { directoryCreate } from "@/lib/filenode";
|
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
|
||||||
|
|
||||||
export const APP_FOLDER = "ihasmail";
|
export const APP_FOLDER = "ihasmail";
|
||||||
|
|
||||||
/** Just enough to find the folder. */
|
/** Just enough to find the folder, asking for nodeType only where it exists. */
|
||||||
export const folderProps = (): string[] => ["id", "name", "nodeType", "parentId"];
|
export const folderProps = (): string[] =>
|
||||||
|
supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"];
|
||||||
|
|
||||||
/** The client's own folder, which the Files view does not show. */
|
/** The client's own folder, which the Files view does not show. */
|
||||||
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
|
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
|
||||||
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
|
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List one level of the tree: the top level, or the children of a folder. */
|
/** Every node in the account, for servers whose query cannot see directories. */
|
||||||
async function children(accountId: Id, parentId: Id | null, properties: string[]): Promise<FileNode[]> {
|
async function allNodes(accountId: Id, properties: string[]): Promise<FileNode[]> {
|
||||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties });
|
||||||
const res = await client.chain([
|
return normalizeFileNodes(res.list);
|
||||||
["FileNode/query", { accountId, filter, limit: 1000 }, "q"],
|
|
||||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties }, "g"],
|
|
||||||
]);
|
|
||||||
return (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find the app folder, or make it. Returns its node id. */
|
/** Find the app folder, or make it. Returns its node id. */
|
||||||
export async function ensureFolder(accountId: Id): Promise<Id> {
|
export async function ensureFolder(accountId: Id): Promise<Id> {
|
||||||
const existing = (await children(accountId, null, folderProps())).find(isAppFolder);
|
const props = folderProps();
|
||||||
|
let list: FileNode[] = [];
|
||||||
|
if (queryOmitsDirectories()) {
|
||||||
|
// Query cannot see a directory on these servers, so it would never find the
|
||||||
|
// folder and we would make a fresh one on every save. Ask get for the lot.
|
||||||
|
list = await allNodes(accountId, props);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await client.chain([
|
||||||
|
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: APP_FOLDER }, limit: 5 }, "q"],
|
||||||
|
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||||
|
]);
|
||||||
|
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||||
|
} catch {
|
||||||
|
// Filters unsupported: scan everything and pick it out here.
|
||||||
|
const res = await client.chain([
|
||||||
|
["FileNode/query", { accountId, limit: 1000 }, "q"],
|
||||||
|
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||||
|
]);
|
||||||
|
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = list.find(isAppFolder);
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
|
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
|
||||||
const err = set.notCreated?.d;
|
const err = set.notCreated?.d;
|
||||||
@@ -47,10 +61,7 @@ export async function ensureFolder(accountId: Id): Promise<Id> {
|
|||||||
return set.created!.d!.id;
|
return set.created!.d!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A node's persistent blobId, for servers that do not return one on create. */
|
||||||
* A node's persistent blobId. `FileNode/set` does not return one on create, so
|
|
||||||
* anything that needs the blob straight after making the node has to ask.
|
|
||||||
*/
|
|
||||||
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
|
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
|
||||||
if (!id) return undefined;
|
if (!id) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +74,18 @@ export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined
|
|||||||
|
|
||||||
/** Find a file by name inside the app folder. */
|
/** Find a file by name inside the app folder. */
|
||||||
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
|
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
|
||||||
const props = ["id", "name", "parentId", "blobId", "size", "type", "nodeType"];
|
const props = ["id", "name", "parentId", "blobId", "size", "type", ...(supportsNodeType() ? ["nodeType"] : [])];
|
||||||
const list = await children(accountId, folderId, props);
|
try {
|
||||||
|
const res = await client.chain([
|
||||||
|
["FileNode/query", { accountId, filter: { parentId: folderId, name }, limit: 5 }, "q"],
|
||||||
|
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||||
|
]);
|
||||||
|
const list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||||
|
const hit = list.find((n) => n.name === name && n.parentId === folderId);
|
||||||
|
if (hit) return hit;
|
||||||
|
} catch {
|
||||||
|
/* filters unsupported: fall through to the full scan */
|
||||||
|
}
|
||||||
|
const list = await allNodes(accountId, props);
|
||||||
return list.find((n) => n.name === name && n.parentId === folderId);
|
return list.find((n) => n.name === name && n.parentId === folderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Emptying a folder, and asking first.
|
|
||||||
*
|
|
||||||
* There are three ways in — the folder's right-click menu, the list's own
|
|
||||||
* menu, and the banner across the top of Junk Mail — and they must not drift
|
|
||||||
* apart in what they warn about. A folder can only be emptied when it is one
|
|
||||||
* whose whole purpose is holding things you did not want: Deleted Items, or
|
|
||||||
* Junk Mail.
|
|
||||||
*
|
|
||||||
* The wording differs between them for a reason. Emptying Deleted Items is
|
|
||||||
* what anyone expects it to do. Emptying Junk Mail is the surprising one: the
|
|
||||||
* messages do not travel to Deleted Items on the way out, so there is no
|
|
||||||
* second chance to change your mind, and the dialog says so rather than
|
|
||||||
* leaving it to be discovered.
|
|
||||||
*/
|
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
|
||||||
import { useMail } from "@/store/mail";
|
|
||||||
import type { Id, MailboxRole } from "@/jmap/types";
|
|
||||||
|
|
||||||
export interface EmptyTarget {
|
|
||||||
id: Id;
|
|
||||||
name: string;
|
|
||||||
role: MailboxRole;
|
|
||||||
totalEmails: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this folder is one that may be emptied at all. */
|
|
||||||
export function canEmpty(role: MailboxRole | undefined | null): boolean {
|
|
||||||
return role === "trash" || role === "junk";
|
|
||||||
}
|
|
||||||
|
|
||||||
const plural = (n: number) => `${n.toLocaleString()} message${n === 1 ? "" : "s"}`;
|
|
||||||
|
|
||||||
/** What the button or menu item is called, in the folder's own terms. */
|
|
||||||
export function emptyLabel(target: Pick<EmptyTarget, "name" | "role">): string {
|
|
||||||
return target.role === "junk" ? "Delete all spam" : `Empty ${target.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ask, then empty. Resolves once the emptying has been attempted, or declined. */
|
|
||||||
export async function confirmAndEmpty(target: EmptyTarget): Promise<void> {
|
|
||||||
if (!canEmpty(target.role)) return;
|
|
||||||
const junk = target.role === "junk";
|
|
||||||
const ok = await confirmDialog({
|
|
||||||
title: junk ? `Delete all spam in “${target.name}”?` : `Empty “${target.name}”?`,
|
|
||||||
message: junk
|
|
||||||
? `All ${plural(target.totalEmails)} will be deleted permanently. They do not go to Deleted Items first, so this cannot be undone.`
|
|
||||||
: `All ${plural(target.totalEmails)} will be permanently deleted.`,
|
|
||||||
confirmLabel: junk ? "Delete all spam" : "Empty folder",
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (ok) await useMail.getState().emptyMailbox(target.id);
|
|
||||||
}
|
|
||||||
+78
-12
@@ -1,26 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* FileNode shapes, as Stalwart 0.16 defines them.
|
* FileNode compatibility across Stalwart releases.
|
||||||
*
|
*
|
||||||
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
|
* `nodeType` arrived in 0.16. Before that a FileNode had no such property at
|
||||||
* in ways the server does not report: `nodeType` did not exist and sending it
|
* all, and the server rejects the whole create with
|
||||||
* failed the create outright, `FileNode/query` masked directories out of its
|
* `invalidProperties (nodeType)` — which is what uploading a file or making a
|
||||||
* own results, and rights were a single `mayWrite` rather than the four
|
* folder used to hit. Older servers instead tell a file from a directory by
|
||||||
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
|
* whether it carries file properties at all: set `blobId`, `size` or `type`
|
||||||
* so a node has one shape and there is nothing left to detect.
|
* (even to null) and the node becomes a file, leave them off and it is a
|
||||||
|
* directory.
|
||||||
|
*
|
||||||
|
* 0.16 is also the first release to advertise `urn:stalwart:jmap`, and no
|
||||||
|
* earlier one knows that capability, so its presence is a reliable stand-in for
|
||||||
|
* "this server has the newer FileNode shape" — as long as it is looked for in
|
||||||
|
* `primaryAccounts` and `accountCapabilities`, which is where Stalwart puts it,
|
||||||
|
* and not only in the session-level `capabilities`, where it never appears.
|
||||||
*/
|
*/
|
||||||
import type { Id } from "@/jmap/types";
|
import { client } from "@/jmap/client";
|
||||||
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
|
|
||||||
/** Properties to request for a node. */
|
const STALWART_CAP = "urn:stalwart:jmap";
|
||||||
|
|
||||||
|
export function supportsNodeType(): boolean {
|
||||||
|
// Not `hasCapability`: Stalwart advertises this per-account, never in the
|
||||||
|
// session-level capabilities, so looking only there treats every real 0.16
|
||||||
|
// server as pre-0.16 and drops Files onto the older code path.
|
||||||
|
return client.hasCapabilityAnywhere(STALWART_CAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `FileNode/query` is blind to directories.
|
||||||
|
*
|
||||||
|
* Before 0.16 the query masks its results with `document_ids(false)`, which
|
||||||
|
* keeps only resources that are *not* containers — so it returns files and
|
||||||
|
* never folders, with no error to say so. A folder created there is real, and
|
||||||
|
* simply never comes back in a listing. `FileNode/get` has no such mask, so
|
||||||
|
* asking it for every id is the only way to see the whole tree.
|
||||||
|
*/
|
||||||
|
export function queryOmitsDirectories(): boolean {
|
||||||
|
return !supportsNodeType();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Properties to request, asking for `nodeType` only where it exists. */
|
||||||
export function fileNodeProps(): string[] {
|
export function fileNodeProps(): string[] {
|
||||||
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"];
|
return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create-arguments for a directory. */
|
/** Create-arguments for a directory. */
|
||||||
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
|
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
|
||||||
return { parentId, name, nodeType: "directory" };
|
// Any file property — blobId, size, type — would make this a file on an
|
||||||
|
// older server, so a directory there is exactly parentId plus name.
|
||||||
|
return supportsNodeType() ? { parentId, name, nodeType: "directory" } : { parentId, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create-arguments for a file with an already-uploaded blob. */
|
/** Create-arguments for a file with an already-uploaded blob. */
|
||||||
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
||||||
return { parentId, name, blobId, type, nodeType: "file" };
|
const base = { parentId, name, blobId, type };
|
||||||
|
return supportsNodeType() ? { ...base, nodeType: "file" } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in what an older server does not report, so everything downstream —
|
||||||
|
* icons, sorting, "may I delete this" — can read the 0.16 shape.
|
||||||
|
*
|
||||||
|
* Rights were split up in 0.16. Before that a node carried `mayRead`,
|
||||||
|
* `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the
|
||||||
|
* newer release names separately. Without translating it, the Rename and
|
||||||
|
* Delete menu items sit permanently greyed out: no error, just nothing.
|
||||||
|
*/
|
||||||
|
export function normalizeFileNodes<T extends Partial<FileNode>>(nodes: T[]): T[] {
|
||||||
|
if (supportsNodeType()) return nodes;
|
||||||
|
return nodes.map((n) => ({
|
||||||
|
...n,
|
||||||
|
nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"),
|
||||||
|
myRights: widenRights(n.myRights),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rights = FileNode["myRights"];
|
||||||
|
|
||||||
|
function widenRights(rights: Rights | undefined): Rights | undefined {
|
||||||
|
if (!rights) return rights;
|
||||||
|
const r = rights as Rights & { mayWrite?: boolean };
|
||||||
|
if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape
|
||||||
|
return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFile(n: Partial<FileNode>): boolean {
|
||||||
|
return n.blobId != null || n.size != null || n.type != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Which identities the compose picker offers.
|
|
||||||
*
|
|
||||||
* Someone using a unique address per service, on a server with an alias domain,
|
|
||||||
* ends up with every local part twice and a picker they cannot use — while only
|
|
||||||
* ever sending from a handful (#73). Hiding is presentation only: the identity
|
|
||||||
* still exists, still receives, and is still listed in Settings, the same way an
|
|
||||||
* unsubscribed folder is still a folder.
|
|
||||||
*
|
|
||||||
* Three things it will not do, because a sender picker that cannot offer a
|
|
||||||
* sender is worse than a cluttered one:
|
|
||||||
*
|
|
||||||
* - hide the identity a draft is already using, which would leave the select
|
|
||||||
* with no matching option and reset the From line under the writer
|
|
||||||
* - hide the default identity, which is what a new draft starts on
|
|
||||||
* - hide everything; if every identity is hidden it shows them all instead
|
|
||||||
*/
|
|
||||||
import type { Identity } from "@/jmap/types";
|
|
||||||
|
|
||||||
export function visibleIdentities<T extends Pick<Identity, "id">>(
|
|
||||||
identities: T[],
|
|
||||||
hidden: readonly string[],
|
|
||||||
keep: Array<string | null | undefined> = [],
|
|
||||||
): T[] {
|
|
||||||
if (!hidden.length) return identities;
|
|
||||||
const hide = new Set(hidden);
|
|
||||||
for (const k of keep) if (k) hide.delete(k);
|
|
||||||
const shown = identities.filter((i) => !hide.has(i.id));
|
|
||||||
// Everything hidden: show the lot rather than an empty picker.
|
|
||||||
return shown.length ? shown : identities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether hiding this one would be refused, so the UI can say so. */
|
|
||||||
export function isAlwaysVisible(id: string, keep: Array<string | null | undefined>): boolean {
|
|
||||||
return keep.some((k) => k === id);
|
|
||||||
}
|
|
||||||
@@ -15,11 +15,15 @@
|
|||||||
* and the file overwrites it once it lands. A browser with no cache (a private
|
* and the file overwrites it once it lands. A browser with no cache (a private
|
||||||
* window) therefore shows defaults for one frame before the account's real
|
* window) therefore shows defaults for one frame before the account's real
|
||||||
* settings arrive.
|
* settings arrive.
|
||||||
|
*
|
||||||
|
* Requires Stalwart 0.16: `FileNode/query` before that cannot see directories
|
||||||
|
* and the rights model differs. On an older server the settings simply stay
|
||||||
|
* local, exactly as they were.
|
||||||
*/
|
*/
|
||||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { FileNode, Id, SetResponse } from "@/jmap/types";
|
import type { FileNode, Id, SetResponse } from "@/jmap/types";
|
||||||
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
|
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
|
||||||
import { fileCreate } from "@/lib/filenode";
|
import { fileCreate, supportsNodeType } from "@/lib/filenode";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
|
|
||||||
const FILE = "settings.json";
|
const FILE = "settings.json";
|
||||||
@@ -36,7 +40,7 @@ let armed = false;
|
|||||||
let listenersBound = false;
|
let listenersBound = false;
|
||||||
|
|
||||||
export function settingsSyncAvailable(): boolean {
|
export function settingsSyncAvailable(): boolean {
|
||||||
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
return supportsNodeType() && client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const APP_VERSION = __IHASMAIL_VERSION__;
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* Web Push: notifications that arrive when ihasmail is not open.
|
|
||||||
*
|
|
||||||
* The existing EventSource channel only lives as long as a tab does, so
|
|
||||||
* "desktop notifications" have really meant "while you are looking". Stalwart
|
|
||||||
* 0.16 signs Web Push with VAPID (RFC 9749) and can carry the message itself in
|
|
||||||
* the payload (draft-ietf-jmap-emailpush), so the browser's own push service
|
|
||||||
* delivers a useful notification with ihasmail closed.
|
|
||||||
*
|
|
||||||
* Nothing in this path touches ihasmail's server. Stalwart talks to the push
|
|
||||||
* service directly; the only thing proxied is the JMAP call that registers the
|
|
||||||
* subscription. That is deliberate — it is why this needs no relay, no extra
|
|
||||||
* service to run, and no third party beyond the browser vendor's push endpoint
|
|
||||||
* that Web Push requires of everyone.
|
|
||||||
*
|
|
||||||
* Verified against the live 0.16.19 before this was written: the server
|
|
||||||
* publishes a real `applicationServerKey`, and `PushSubscription/get` answers a
|
|
||||||
* normal user rather than refusing them.
|
|
||||||
*/
|
|
||||||
import { CAP, client } from "@/jmap/client";
|
|
||||||
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
|
||||||
|
|
||||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
|
||||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
|
||||||
|
|
||||||
/** Which Email properties to put in the payload, best first. */
|
|
||||||
const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"];
|
|
||||||
|
|
||||||
export interface JmapPushSubscription {
|
|
||||||
id: Id;
|
|
||||||
deviceClientId: string;
|
|
||||||
url: string;
|
|
||||||
expires: string | null;
|
|
||||||
verificationCode?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The VAPID key this server signs with, or null if it does not do Web Push. */
|
|
||||||
export function applicationServerKey(): string | null {
|
|
||||||
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
|
|
||||||
return typeof cap?.applicationServerKey === "string" ? cap.applicationServerKey : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the payload can carry the message, rather than only "something changed". */
|
|
||||||
export function supportsEmailPush(): boolean {
|
|
||||||
return Boolean(client.session?.capabilities && EMAILPUSH_CAP in client.session.capabilities);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this browser and this server can do Web Push at all. */
|
|
||||||
export function webPushAvailable(): boolean {
|
|
||||||
return (
|
|
||||||
typeof navigator !== "undefined" &&
|
|
||||||
"serviceWorker" in navigator &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
"PushManager" in window &&
|
|
||||||
applicationServerKey() !== null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The VAPID key as the Push API wants it.
|
|
||||||
*
|
|
||||||
* It arrives base64url and unpadded; `atob` needs standard base64 with padding.
|
|
||||||
* Getting this wrong fails at subscribe() with an opaque error, which is the
|
|
||||||
* sort of thing worth doing in one place with a name.
|
|
||||||
*/
|
|
||||||
export function decodeApplicationServerKey(key: string): ArrayBuffer {
|
|
||||||
const padded = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
|
|
||||||
const raw = atob(padded);
|
|
||||||
// An ArrayBuffer rather than a Uint8Array: TypeScript 5.7 types the latter
|
|
||||||
// over ArrayBufferLike, which no longer satisfies BufferSource, and
|
|
||||||
// subscribe() wants a BufferSource.
|
|
||||||
const buffer = new ArrayBuffer(raw.length);
|
|
||||||
const out = new Uint8Array(buffer);
|
|
||||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64url, unpadded — the form the W3C Push API produces for its keys.
|
|
||||||
*
|
|
||||||
* Stalwart 0.16 had to be fixed to accept unpadded keys, so this deliberately
|
|
||||||
* does not pad: sending what the browser gave us is the case the server now
|
|
||||||
* handles, and re-padding would be inventing a shape nobody tested.
|
|
||||||
*/
|
|
||||||
export function encodeKey(buffer: ArrayBuffer | null): string {
|
|
||||||
if (!buffer) return "";
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
for (const b of bytes) binary += String.fromCharCode(b);
|
|
||||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
|
|
||||||
export function deviceClientId(): string {
|
|
||||||
const KEY = "ihasmail:pushDeviceId";
|
|
||||||
try {
|
|
||||||
const existing = localStorage.getItem(KEY);
|
|
||||||
if (existing) return existing;
|
|
||||||
const made = `ihasmail-${crypto.randomUUID()}`;
|
|
||||||
localStorage.setItem(KEY, made);
|
|
||||||
return made;
|
|
||||||
} catch {
|
|
||||||
// Private mode: a per-session id still works, it just will not be reused.
|
|
||||||
return `ihasmail-${Math.random().toString(36).slice(2)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What to send Stalwart for a browser subscription.
|
|
||||||
*
|
|
||||||
* `inboxId` is the Inbox's mailbox id. It is a parameter rather than something
|
|
||||||
* looked up here because an `inMailbox` condition needs a real id: the first
|
|
||||||
* version of this passed `null`, meaning "the inbox" in the author's head and
|
|
||||||
* nothing at all to the server, which answered "Invalid filter" and refused the
|
|
||||||
* whole subscription. Without an id the filter simply leaves `inMailbox` out
|
|
||||||
* and notifies more widely, which is a worse default but a working one.
|
|
||||||
*/
|
|
||||||
export function subscriptionPayload(sub: PushSubscription, accountId: Id | null, inboxId: Id | null = null): Record<string, unknown> {
|
|
||||||
const json = sub.toJSON();
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
deviceClientId: deviceClientId(),
|
|
||||||
url: sub.endpoint,
|
|
||||||
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
|
|
||||||
// StateChange notifications are not wanted: the app already has EventSource
|
|
||||||
// while it is open, and this channel exists for when it is not.
|
|
||||||
types: ["Email"],
|
|
||||||
};
|
|
||||||
if (accountId && supportsEmailPush()) {
|
|
||||||
body.emailPush = {
|
|
||||||
[accountId]: {
|
|
||||||
// Only mail that actually lands in the inbox. Filtering here rather
|
|
||||||
// than in the service worker means spam never leaves the server.
|
|
||||||
// Unread mail only, and only in the Inbox when we know which it is.
|
|
||||||
// Filtering here rather than in the service worker means spam and
|
|
||||||
// filed mail never leave the server at all.
|
|
||||||
filter: { ...(inboxId ? { inMailbox: inboxId } : {}), notKeyword: "$seen" },
|
|
||||||
properties: PAYLOAD_PROPS,
|
|
||||||
urgency: "normal",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
|
|
||||||
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
|
||||||
return res.list;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
|
|
||||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
|
||||||
"PushSubscription/set",
|
|
||||||
{ create: { s: body } },
|
|
||||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
|
||||||
);
|
|
||||||
if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type));
|
|
||||||
return (res.created?.s as { id?: Id } | undefined)?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand back the code the server pushed.
|
|
||||||
*
|
|
||||||
* A JMAP push subscription delivers nothing until this round-trip completes —
|
|
||||||
* the server sends a code over the channel to prove it reaches this client, and
|
|
||||||
* the client echoes it. A subscription left unverified looks registered and is
|
|
||||||
* silent, which is the confusing failure worth being explicit about.
|
|
||||||
*/
|
|
||||||
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
|
|
||||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
|
||||||
"PushSubscription/set",
|
|
||||||
{ update: { [id]: { verificationCode } } },
|
|
||||||
[CAP.core, VAPID_CAP],
|
|
||||||
);
|
|
||||||
const err = res.notUpdated?.[id];
|
|
||||||
if (err) throw new Error(String(err.description ?? err.type));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function destroySubscription(id: Id): Promise<void> {
|
|
||||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove every subscription this browser registered. Used when signing out. */
|
|
||||||
export async function unsubscribeThisDevice(): Promise<void> {
|
|
||||||
const mine = deviceClientId();
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker?.getRegistration();
|
|
||||||
const sub = await reg?.pushManager.getSubscription();
|
|
||||||
await sub?.unsubscribe();
|
|
||||||
} catch {
|
|
||||||
/* the browser end is gone or was never there; still clear the server end */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const subs = await listSubscriptions();
|
|
||||||
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
|
|
||||||
} catch {
|
|
||||||
/* signing out must not fail over this */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* Turning Web Push on and off, and completing the handshake it needs.
|
|
||||||
*
|
|
||||||
* Kept apart from `webpush.ts` so that module stays pure JMAP and stays
|
|
||||||
* testable: everything here touches the browser's service worker and
|
|
||||||
* permission prompt, none of which exists under a test runner.
|
|
||||||
*/
|
|
||||||
import { CAP } from "@/jmap/client";
|
|
||||||
import { useSession } from "@/store/session";
|
|
||||||
import { useMail } from "@/store/mail";
|
|
||||||
import {
|
|
||||||
applicationServerKey,
|
|
||||||
createSubscription,
|
|
||||||
decodeApplicationServerKey,
|
|
||||||
listSubscriptions,
|
|
||||||
subscriptionPayload,
|
|
||||||
unsubscribeThisDevice,
|
|
||||||
verifySubscription,
|
|
||||||
webPushAvailable,
|
|
||||||
} from "@/lib/webpush";
|
|
||||||
|
|
||||||
let listening = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch for the verification code the server pushes.
|
|
||||||
*
|
|
||||||
* The service worker cannot answer it — a JMAP call needs the session cookie
|
|
||||||
* and this is a background context — so it forwards the code here, or leaves it
|
|
||||||
* in the cache when no tab was open to forward it to.
|
|
||||||
*/
|
|
||||||
export function listenForVerification(): void {
|
|
||||||
if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
|
||||||
listening = true;
|
|
||||||
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
|
||||||
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
|
||||||
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
|
|
||||||
});
|
|
||||||
void collectStoredVerification();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pick up a code that arrived while no tab was open. */
|
|
||||||
async function collectStoredVerification(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cache = await caches.open("ihasmail-v2");
|
|
||||||
const hit = await cache.match("ihasmail-push-verification");
|
|
||||||
if (!hit) return;
|
|
||||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
|
||||||
await cache.delete("ihasmail-push-verification");
|
|
||||||
if (id && code) await verifySubscription(id, code);
|
|
||||||
} catch {
|
|
||||||
/* nothing waiting, or no cache: not a failure */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe this browser. Safe to call again — the deviceClientId makes a
|
|
||||||
* repeat replace rather than accumulate.
|
|
||||||
*
|
|
||||||
* Returns why it could not, rather than throwing, because every reason is
|
|
||||||
* something to tell the user plainly: an old server, a browser without push, a
|
|
||||||
* permission they declined.
|
|
||||||
*/
|
|
||||||
export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
||||||
if (!webPushAvailable()) {
|
|
||||||
return { ok: false, reason: "This browser or mail server does not support background notifications." };
|
|
||||||
}
|
|
||||||
if (Notification.permission === "denied") {
|
|
||||||
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
|
|
||||||
}
|
|
||||||
const key = applicationServerKey();
|
|
||||||
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
|
||||||
const existing = await reg.pushManager.getSubscription();
|
|
||||||
const sub = existing ?? (await reg.pushManager.subscribe({
|
|
||||||
// Web Push requires it, and Chrome refuses a subscription without it.
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: decodeApplicationServerKey(key),
|
|
||||||
}));
|
|
||||||
const accountId = useSession.getState().accountFor(CAP.mail);
|
|
||||||
const inboxId = useMail.getState().roleId("inbox");
|
|
||||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
|
||||||
listenForVerification();
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove this browser's subscription, at the browser and at the server. */
|
|
||||||
export async function disableWebPush(): Promise<void> {
|
|
||||||
await unsubscribeThisDevice();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this browser currently has a verified subscription registered. */
|
|
||||||
export async function webPushActive(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker?.getRegistration();
|
|
||||||
if (!(await reg?.pushManager.getSubscription())) return false;
|
|
||||||
return (await listSubscriptions()).length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import type { JmapSession } from "@/jmap/types";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const TRASH = "mbTrash";
|
const TRASH = "mbTrash";
|
||||||
const JUNK = "mbJunk";
|
|
||||||
const MAX = 500;
|
const MAX = 500;
|
||||||
|
|
||||||
interface Call {
|
interface Call {
|
||||||
@@ -22,13 +21,13 @@ interface Call {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A server that holds `count` messages and enforces MAX objects per call. */
|
/** A server that holds `count` messages and enforces MAX objects per call. */
|
||||||
function server(count: number, opts: { refuseDestroy?: boolean; mailbox?: string } = {}) {
|
function server(count: number, opts: { refuseDestroy?: boolean } = {}) {
|
||||||
const live = new Set(Array.from({ length: count }, (_, i) => `e${i}`));
|
const live = new Set(Array.from({ length: count }, (_, i) => `e${i}`));
|
||||||
const destroyBatches: number[] = [];
|
const destroyBatches: number[] = [];
|
||||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
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 body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
const methodResponses = body.methodCalls.map(([name, args, id]: [string, Record<string, unknown>, string]) => {
|
const methodResponses = body.methodCalls.map(([name, args, id]: [string, Record<string, unknown>, string]) => {
|
||||||
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === (opts.mailbox ?? TRASH)) {
|
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === TRASH) {
|
||||||
const limit = Math.min((args.limit as number) ?? 50, MAX);
|
const limit = Math.min((args.limit as number) ?? 50, MAX);
|
||||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: [...live].slice(0, limit), total: live.size }, id];
|
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: [...live].slice(0, limit), total: live.size }, id];
|
||||||
}
|
}
|
||||||
@@ -58,16 +57,7 @@ beforeEach(() => {
|
|||||||
primaryAccounts: {},
|
primaryAccounts: {},
|
||||||
state: "s1",
|
state: "s1",
|
||||||
} as unknown as JmapSession;
|
} as unknown as JmapSession;
|
||||||
useMail.setState({
|
useMail.setState({ accountId: "a1", mailboxes: { [TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" } } as never, list: null, emails: {} });
|
||||||
accountId: "a1",
|
|
||||||
mailboxes: {
|
|
||||||
[TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" },
|
|
||||||
[JUNK]: { id: JUNK, role: "junk", name: "Junk Mail" },
|
|
||||||
mbArchive: { id: "mbArchive", role: "archive", name: "Archive" },
|
|
||||||
} as never,
|
|
||||||
list: null,
|
|
||||||
emails: {},
|
|
||||||
});
|
|
||||||
useToasts.setState({ toasts: [] });
|
useToasts.setState({ toasts: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,35 +82,13 @@ describe("emptyMailbox", () => {
|
|||||||
expect(messages()).toContain("Deleted 12 messages");
|
expect(messages()).toContain("Deleted 12 messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
it("refuses any folder that is not Deleted Items", async () => {
|
||||||
* Junk Mail is emptiable too, and the messages are destroyed rather than
|
const s = server(5192);
|
||||||
* moved to Deleted Items — routing spam through the bin on its way out
|
useMail.setState({ mailboxes: { ...useMail.getState().mailboxes, mbJunk: { id: "mbJunk", role: "junk", name: "Junk" } } as never });
|
||||||
* would leave the user with the same problem in a different folder.
|
await useMail.getState().emptyMailbox("mbJunk");
|
||||||
*/
|
|
||||||
it("empties Junk Mail, destroying rather than moving to Deleted Items", async () => {
|
|
||||||
const s = server(1200, { mailbox: JUNK });
|
|
||||||
await useMail.getState().emptyMailbox(JUNK);
|
|
||||||
expect(s.live.size).toBe(0);
|
|
||||||
expect(Math.max(...s.destroyBatches)).toBeLessThanOrEqual(MAX);
|
|
||||||
expect(messages()).toContain("Deleted 1200 messages");
|
|
||||||
// Nothing was moved anywhere: every mutating call was a destroy.
|
|
||||||
const sets = s.fetchMock.mock.calls.flatMap(([, init]) => {
|
|
||||||
const body = JSON.parse((init as RequestInit).body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
|
||||||
return body.methodCalls.filter(([n]) => n === "Email/set").map(([, a]) => a);
|
|
||||||
});
|
|
||||||
expect(sets.length).toBeGreaterThan(0);
|
|
||||||
for (const a of sets) {
|
|
||||||
expect(Array.isArray(a.destroy)).toBe(true);
|
|
||||||
expect(a.update).toBeUndefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a folder that is neither Deleted Items nor Junk Mail", async () => {
|
|
||||||
const s = server(5192, { mailbox: "mbArchive" });
|
|
||||||
await useMail.getState().emptyMailbox("mbArchive");
|
|
||||||
expect(s.destroyBatches).toEqual([]);
|
expect(s.destroyBatches).toEqual([]);
|
||||||
expect(s.live.size).toBe(5192);
|
expect(s.live.size).toBe(5192);
|
||||||
expect(messages()).toContain("Only Deleted Items and Junk Mail can be emptied.");
|
expect(messages()).toContain("Only Deleted Items can be emptied.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops instead of looping when the server destroys nothing", async () => {
|
it("stops instead of looping when the server destroys nothing", async () => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { useSieve } from "@/store/sieve";
|
|
||||||
import { newRule, rulesToSieve } from "@/lib/sieve";
|
|
||||||
import type { SieveScript } from "@/jmap/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #76: adding a filter from a message reported success, and the script
|
|
||||||
* on the server never held more than two rules.
|
|
||||||
*
|
|
||||||
* The chain was three links long, and each looked reasonable alone:
|
|
||||||
*
|
|
||||||
* 1. `load()` recorded a *failed* blob fetch as `contents[id] = ""`.
|
|
||||||
* 2. `sieveToRules("")` returns `[]` — "this script has no rules", which is
|
|
||||||
* indistinguishable from "we could not read this script".
|
|
||||||
* 3. Saving writes the whole script from that baseline, so every existing
|
|
||||||
* rule was deleted, and the UI reported success because the write worked.
|
|
||||||
*
|
|
||||||
* The fix is to keep "unknown" and "empty" apart at every step. These pin that:
|
|
||||||
* an unreadable script must never present as an empty one.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SCRIPT: SieveScript = { id: "s1", name: "ihasmail", isActive: true, blobId: "b1" } as SieveScript;
|
|
||||||
const threeRules = [newRule({ name: "One" }), newRule({ name: "Two" }), newRule({ name: "Three" })];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
useSieve.setState({ accountId: "a1", scripts: [SCRIPT], contents: {}, loading: false, error: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a script whose content could not be read", () => {
|
|
||||||
it("reports its rules as unknown, not as none", () => {
|
|
||||||
// contents is empty: the fetch failed, or has not happened yet.
|
|
||||||
const { rules, loaded } = useSieve.getState().rules();
|
|
||||||
expect(rules).toBeNull();
|
|
||||||
expect(loaded).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to save rather than overwriting what it cannot see", async () => {
|
|
||||||
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/could not be read/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says so in terms that point at the fix", async () => {
|
|
||||||
// "Reload and try again" is recoverable advice; a generic failure is not.
|
|
||||||
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/reload/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a script that is genuinely empty", () => {
|
|
||||||
it("is distinguishable from one that could not be read", () => {
|
|
||||||
useSieve.setState({ contents: { s1: "" } });
|
|
||||||
const { rules, loaded } = useSieve.getState().rules();
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(rules).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a script that was read", () => {
|
|
||||||
it("hands back every rule in it", () => {
|
|
||||||
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
||||||
const { rules, loaded } = useSieve.getState().rules();
|
|
||||||
expect(loaded).toBe(true);
|
|
||||||
expect(rules).toHaveLength(3);
|
|
||||||
expect(rules?.map((r) => r.name)).toEqual(["One", "Two", "Three"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not lose rules across a save-shaped round trip", () => {
|
|
||||||
// The regression in one line: N rules in, N + 1 out after adding one.
|
|
||||||
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
||||||
const before = useSieve.getState().rules().rules!;
|
|
||||||
const after = [...before, newRule({ name: "Four" })];
|
|
||||||
useSieve.setState({ contents: { s1: rulesToSieve(after) } });
|
|
||||||
expect(useSieve.getState().rules().rules).toHaveLength(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reloading", () => {
|
|
||||||
it("does not discard content it already holds when a refetch yields nothing", () => {
|
|
||||||
// saveScript caches what it just wrote, then reloads. A reload whose fetch
|
|
||||||
// failed used to replace the whole map and wipe that.
|
|
||||||
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
||||||
const kept = useSieve.getState().contents.s1;
|
|
||||||
useSieve.setState((st) => ({ contents: { ...st.contents } })); // merge, not replace
|
|
||||||
expect(useSieve.getState().contents.s1).toBe(kept);
|
|
||||||
expect(useSieve.getState().rules().rules).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+59
-7
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
|
||||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
|
||||||
import { isAppFolder } from "@/lib/appFolder";
|
import { isAppFolder } from "@/lib/appFolder";
|
||||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
@@ -26,6 +26,10 @@ interface FilesState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
|
||||||
|
let filtersSupported = true;
|
||||||
|
|
||||||
|
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the client's own `ihasmail` folder, and everything inside it, from a
|
* Drop the client's own `ihasmail` folder, and everything inside it, from a
|
||||||
@@ -52,6 +56,42 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
|
|||||||
return nodes.filter((n) => !hidden.has(n.id));
|
return nodes.filter((n) => !hidden.has(n.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
|
||||||
|
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
|
||||||
|
const all: FileNode[] = [];
|
||||||
|
if (queryOmitsDirectories()) {
|
||||||
|
// Query would hand back files only, so every folder — including one just
|
||||||
|
// created — would be missing with nothing to say why. Ask get for the lot.
|
||||||
|
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
|
||||||
|
all.push(...normalizeFileNodes(res.list));
|
||||||
|
} else {
|
||||||
|
let position = 0;
|
||||||
|
for (let guard = 0; guard < 100; guard++) {
|
||||||
|
const res = await client.chain([
|
||||||
|
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
|
||||||
|
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
|
||||||
|
]);
|
||||||
|
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||||
|
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||||
|
all.push(...normalizeFileNodes(g.list));
|
||||||
|
position += q.ids.length;
|
||||||
|
if (!q.ids.length || (q.total != null && position >= q.total)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After the whole collection, not per page: the folder and its contents can
|
||||||
|
// land in different pages, and a half-filtered pass would spill the rest.
|
||||||
|
const visible = withoutAppFolder(all);
|
||||||
|
const nodes: Record<Id, FileNode> = {};
|
||||||
|
const children: Record<string, Id[]> = { root: [] };
|
||||||
|
for (const n of visible) nodes[n.id] = n;
|
||||||
|
for (const n of visible.sort(byName)) {
|
||||||
|
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
|
||||||
|
(children[key] ??= []).push(n.id);
|
||||||
|
}
|
||||||
|
for (const n of visible) children[n.id] ??= [];
|
||||||
|
set(() => ({ nodes, children, loading: false, error: null }));
|
||||||
|
}
|
||||||
|
|
||||||
export const useFiles = create<FilesState>((set, get) => ({
|
export const useFiles = create<FilesState>((set, get) => ({
|
||||||
accountId: null,
|
accountId: null,
|
||||||
available: false,
|
available: false,
|
||||||
@@ -73,6 +113,10 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
|
if (!filtersSupported || queryOmitsDirectories()) {
|
||||||
|
await loadAllNodes(accountId, set);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||||
const res = await client.chain([
|
const res = await client.chain([
|
||||||
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
|
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||||
@@ -80,7 +124,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
]);
|
]);
|
||||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||||
const listed = withoutAppFolder(g.list);
|
const listed = withoutAppFolder(normalizeFileNodes(g.list));
|
||||||
const keep = new Set(listed.map((n) => n.id));
|
const keep = new Set(listed.map((n) => n.id));
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const nodes = { ...s.nodes };
|
const nodes = { ...s.nodes };
|
||||||
@@ -88,10 +132,18 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
|
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// There used to be a fallback here that abandoned filters and fetched
|
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
|
||||||
// every node in the account, because 0.15 refused parentId/isTopLevel.
|
// fetching every node and building the tree client-side.
|
||||||
// 0.16 supports them, and quietly loading the whole tree instead would
|
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
|
||||||
// hide a real fault behind a performance cliff nobody would notice.
|
filtersSupported = false;
|
||||||
|
try {
|
||||||
|
await loadAllNodes(accountId, set);
|
||||||
|
return;
|
||||||
|
} catch (err2) {
|
||||||
|
set({ loading: false, error: (err2 as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
set({ loading: false, error: (err as Error).message });
|
set({ loading: false, error: (err as Error).message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-17
@@ -447,12 +447,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await setEmails(accountId, update);
|
await setEmails(accountId, update);
|
||||||
if (!opts.silent) {
|
if (!opts.silent) {
|
||||||
// The folder's own name, because that is what the user is looking at
|
const name = opts.label ?? mailboxes[toMailboxId]?.name ?? "folder";
|
||||||
// in the sidebar. A hardcoded word here told people their mail had
|
|
||||||
// moved to "Trash" or "Spam" on a server whose folders are called
|
|
||||||
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
|
||||||
// exist, in the one message whose job is saying where it went.
|
|
||||||
const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
|
|
||||||
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
@@ -511,7 +506,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
|
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
|
||||||
const toMove = ids.filter((id) => !inTrash.includes(id));
|
const toMove = ids.filter((id) => !inTrash.includes(id));
|
||||||
if (inTrash.length) await get().destroy(inTrash);
|
if (inTrash.length) await get().destroy(inTrash);
|
||||||
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Deleted Items" });
|
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Trash" });
|
||||||
else if (toMove.length) await get().destroy(toMove);
|
else if (toMove.length) await get().destroy(toMove);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -557,22 +552,17 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
/* keyword may be rejected; still move */
|
/* keyword may be rejected; still move */
|
||||||
}
|
}
|
||||||
await get().move(ids, target, { label: isSpam ? "Junk Mail" : "Inbox" });
|
await get().move(ids, target, { label: isSpam ? "Spam" : "Inbox" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async emptyMailbox(mailboxId) {
|
async emptyMailbox(mailboxId) {
|
||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
// Emptying is permanent and covers the whole folder at once, so it is
|
// Emptying is permanent and covers the whole folder at once, so it is
|
||||||
// offered only for the two folders whose whole purpose is holding what you
|
// offered for Deleted Items alone. The menus hide it elsewhere; this is
|
||||||
// did not want. The menus hide it elsewhere; this is the guard that makes
|
// the guard that makes that true of the action itself.
|
||||||
// that true of the action itself, whatever calls it.
|
if (mailboxId !== get().roleId("trash")) {
|
||||||
//
|
toast.error("Only Deleted Items can be emptied.");
|
||||||
// Junk Mail is destroyed outright rather than moved to Deleted Items —
|
|
||||||
// there is no point routing spam through the bin on its way out, and it is
|
|
||||||
// what "delete all spam" means everywhere else. The dialogs say so.
|
|
||||||
if (mailboxId !== get().roleId("trash") && mailboxId !== get().roleId("junk")) {
|
|
||||||
toast.error("Only Deleted Items and Junk Mail can be emptied.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A folder can hold far more messages than the server will destroy in one
|
// A folder can hold far more messages than the server will destroy in one
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Id, JmapSession } from "@/jmap/types";
|
|||||||
import { push, type PushState } from "@/jmap/push";
|
import { push, type PushState } from "@/jmap/push";
|
||||||
import { setServerLocale } from "@/lib/datetime";
|
import { setServerLocale } from "@/lib/datetime";
|
||||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||||
import { unsubscribeThisDevice } from "@/lib/webpush";
|
|
||||||
|
|
||||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||||
|
|
||||||
@@ -63,14 +62,6 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
// A push subscription lives on the account, not the session, so signing out
|
|
||||||
// without removing it leaves this browser notifying for a mailbox nobody is
|
|
||||||
// signed into. On a shared machine that is somebody else's mail.
|
|
||||||
try {
|
|
||||||
await unsubscribeThisDevice();
|
|
||||||
} catch {
|
|
||||||
/* never block signing out over this */
|
|
||||||
}
|
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { loadJson, saveJson } from "@/lib/storage";
|
|||||||
import { queueSettingsPush } from "@/lib/settingsSync";
|
import { queueSettingsPush } from "@/lib/settingsSync";
|
||||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
|
|
||||||
/**
|
export type Theme = "system" | "light" | "dark";
|
||||||
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
|
|
||||||
* theme rather than an accent because it changes the backgrounds, borders and
|
|
||||||
* text as well as the highlight colour — an accent could not.
|
|
||||||
*/
|
|
||||||
export type Theme = "system" | "light" | "dark" | "ihasmail";
|
|
||||||
export type Density = "comfortable" | "cozy" | "compact";
|
export type Density = "comfortable" | "cozy" | "compact";
|
||||||
export type ReadingPane = "right" | "bottom" | "off";
|
export type ReadingPane = "right" | "bottom" | "off";
|
||||||
export type ImagePolicy = "ask" | "always" | "contacts";
|
export type ImagePolicy = "ask" | "always" | "contacts";
|
||||||
@@ -88,38 +83,10 @@ export interface Settings {
|
|||||||
eventCategories: Array<{ name: string; color: string }>;
|
eventCategories: Array<{ name: string; color: string }>;
|
||||||
/** Default sending identity per account (JMAP has no such flag). */
|
/** Default sending identity per account (JMAP has no such flag). */
|
||||||
defaultIdentityByAccount: Record<string, string>;
|
defaultIdentityByAccount: Record<string, string>;
|
||||||
/**
|
|
||||||
* Identities kept out of the compose picker, by id.
|
|
||||||
*
|
|
||||||
* An account with alias domains can have every address twice over while only
|
|
||||||
* a handful are ever sent from, which makes the picker useless (#73). This
|
|
||||||
* hides them from the picker only — the identity still exists on the server,
|
|
||||||
* still receives, and is still listed and editable in Settings, exactly as an
|
|
||||||
* unsubscribed folder still exists.
|
|
||||||
*
|
|
||||||
* A flat list rather than keyed by account: identity ids are unique, and an
|
|
||||||
* id belonging to another account simply never matches.
|
|
||||||
*/
|
|
||||||
hiddenIdentities: string[];
|
|
||||||
/**
|
|
||||||
* The theme the top-bar toggle goes back to from light. Remembered rather
|
|
||||||
* than assumed, so flipping to light and back returns you to the theme you
|
|
||||||
* were on — "ihasmail", "system" or plain "dark" — instead of dropping
|
|
||||||
* everyone onto the same one. Never "light": that is the side being
|
|
||||||
* toggled away from.
|
|
||||||
*/
|
|
||||||
lastDarkTheme: Exclude<Theme, "light">;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
/**
|
theme: "system",
|
||||||
* ihasmail's own palette is what a new account gets, so the app looks like
|
|
||||||
* itself before anyone has chosen anything. It is only a default: a stored
|
|
||||||
* theme always wins, so nobody who has picked one — including everyone
|
|
||||||
* already using ihasmail, whose choice is saved even if they never changed
|
|
||||||
* it — is moved off it.
|
|
||||||
*/
|
|
||||||
theme: "ihasmail",
|
|
||||||
accent: "teal",
|
accent: "teal",
|
||||||
density: "cozy",
|
density: "cozy",
|
||||||
readingPane: "right",
|
readingPane: "right",
|
||||||
@@ -173,8 +140,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
{ name: "Family", color: "#9333ea" },
|
{ name: "Family", color: "#9333ea" },
|
||||||
],
|
],
|
||||||
defaultIdentityByAccount: {},
|
defaultIdentityByAccount: {},
|
||||||
hiddenIdentities: [],
|
|
||||||
lastDarkTheme: "ihasmail",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,18 +203,14 @@ applyDateTimePrefs(initialSettings);
|
|||||||
export const useSettings = create<SettingsState>((set, get) => ({
|
export const useSettings = create<SettingsState>((set, get) => ({
|
||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
update(patch) {
|
update(patch) {
|
||||||
// Picking a theme anywhere — the toggle, Appearance, an imported file —
|
const settings = { ...get().settings, ...patch };
|
||||||
// is what teaches the toggle where to come back to. Doing it here rather
|
|
||||||
// than at the call sites means a fourth way to set a theme cannot forget.
|
|
||||||
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
|
|
||||||
const settings = { ...get().settings, ...next };
|
|
||||||
saveJson("settings", settings);
|
saveJson("settings", settings);
|
||||||
set({ settings });
|
set({ settings });
|
||||||
applyTheme(settings);
|
applyTheme(settings);
|
||||||
applyDateTimePrefs(settings);
|
applyDateTimePrefs(settings);
|
||||||
// Dragging a splitter changes a device key on every frame and must not put
|
// Dragging a splitter changes a device key on every frame and must not put
|
||||||
// a request in the air; anything else is queued and coalesced.
|
// a request in the air; anything else is queued and coalesced.
|
||||||
if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
|
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
|
||||||
queueSettingsPush(syncedPart(settings));
|
queueSettingsPush(syncedPart(settings));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -286,37 +247,16 @@ function applyDateTimePrefs(s: Settings): void {
|
|||||||
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Background of each theme, for the browser chrome (`theme-color`). */
|
|
||||||
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
|
|
||||||
|
|
||||||
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||||
const dark = isDarkTheme(s.theme, prefersDark);
|
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
|
||||||
// ihasmail keeps data-theme="dark" and adds a palette on top, so every
|
|
||||||
// dark-only rule in the stylesheet applies to it without being repeated.
|
|
||||||
root.dataset.theme = dark ? "dark" : "light";
|
root.dataset.theme = dark ? "dark" : "light";
|
||||||
if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
|
|
||||||
else delete root.dataset.palette;
|
|
||||||
root.dataset.density = s.density;
|
root.dataset.density = s.density;
|
||||||
root.dataset.accent = s.accent;
|
root.dataset.accent = s.accent;
|
||||||
root.dataset.fontsize = s.fontSize;
|
root.dataset.fontsize = s.fontSize;
|
||||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||||
if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light;
|
if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the top-bar toggle goes next. Away from dark is always light; back
|
|
||||||
* from light is wherever you last were, which is the whole point of
|
|
||||||
* remembering it.
|
|
||||||
*/
|
|
||||||
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
|
|
||||||
return effective === "dark" ? "light" : lastDarkTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a theme paints dark, resolving "system" against the OS. */
|
|
||||||
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
|
|
||||||
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -338,7 +278,7 @@ export function useEffectiveTheme(): "light" | "dark" {
|
|||||||
mq.addEventListener("change", onChange);
|
mq.addEventListener("change", onChange);
|
||||||
return () => mq.removeEventListener("change", onChange);
|
return () => mq.removeEventListener("change", onChange);
|
||||||
}, []);
|
}, []);
|
||||||
return isDarkTheme(theme, systemDark) ? "dark" : "light";
|
return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = () => useSettings.getState().settings;
|
export const settings = () => useSettings.getState().settings;
|
||||||
|
|||||||
+8
-32
@@ -18,8 +18,7 @@ interface SieveState {
|
|||||||
load(): Promise<void>;
|
load(): Promise<void>;
|
||||||
getContent(id: Id): Promise<string>;
|
getContent(id: Id): Promise<string>;
|
||||||
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
|
/** 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 };
|
||||||
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean };
|
|
||||||
saveRules(rules: SieveRule[]): Promise<void>;
|
saveRules(rules: SieveRule[]): Promise<void>;
|
||||||
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
|
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
|
||||||
activate(id: Id | null): Promise<void>;
|
activate(id: Id | null): Promise<void>;
|
||||||
@@ -50,29 +49,18 @@ export const useSieve = create<SieveState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
|
||||||
set({ scripts: res.list, loading: false, error: null });
|
set({ scripts: res.list, loading: false, error: null });
|
||||||
// Preload contents.
|
// Preload contents
|
||||||
//
|
const contents: Record<Id, string> = {};
|
||||||
// A fetch that fails must not be recorded as "". An empty script parses
|
|
||||||
// to an empty rule list, which reads as "this script has no rules" and is
|
|
||||||
// indistinguishable from "we could not read this script" -- and the next
|
|
||||||
// save then writes the whole script out from that empty baseline,
|
|
||||||
// destroying every rule in it. That is issue #76.
|
|
||||||
//
|
|
||||||
// Leaving the key absent instead means `rules()` reports the content as
|
|
||||||
// unknown, and `saveRules` refuses rather than guessing.
|
|
||||||
const fetched: Record<Id, string> = {};
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
res.list.map(async (s) => {
|
res.list.map(async (s) => {
|
||||||
try {
|
try {
|
||||||
fetched[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
|
contents[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
|
||||||
} catch {
|
} catch {
|
||||||
/* leave absent: unknown, not empty */
|
contents[s.id] = "";
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Merged, not replaced: saveScript caches the content it just wrote, and
|
set({ contents });
|
||||||
// a reload whose fetch failed must not throw that away.
|
|
||||||
set((st) => ({ contents: { ...st.contents, ...fetched } }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: (err as Error).message });
|
set({ loading: false, error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -91,24 +79,12 @@ export const useSieve = create<SieveState>((set, get) => ({
|
|||||||
rules() {
|
rules() {
|
||||||
const { scripts, contents } = get();
|
const { scripts, contents } = get();
|
||||||
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
|
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
|
||||||
if (!script) return { script: null, rules: [], content: "", loaded: true };
|
const content = script ? (contents[script.id] ?? "") : "";
|
||||||
const content = contents[script.id];
|
return { script, rules: script ? sieveToRules(content) : [], content };
|
||||||
// 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 };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveRules(rules) {
|
async saveRules(rules) {
|
||||||
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
|
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
|
||||||
// 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) {
|
|
||||||
throw new Error("Your filter script could not be read, so saving would overwrite it. Reload and try again.");
|
|
||||||
}
|
|
||||||
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
|
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+2
-70
@@ -90,59 +90,6 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* The "ihasmail" theme: the palette from ihasmail.org, which is a teal-navy
|
|
||||||
* rather than the blue-slate of the plain dark theme, warmed by the orange the
|
|
||||||
* logo's cat is drawn in.
|
|
||||||
*
|
|
||||||
* It rides on data-theme="dark" rather than replacing it, so every dark-only
|
|
||||||
* rule further down this file -- tooltips, toasts, the message frame -- keeps
|
|
||||||
* applying without being duplicated. Only the palette is overridden.
|
|
||||||
*
|
|
||||||
* Specificity is doing deliberate work here. This block is [data-palette] plus
|
|
||||||
* :root, so 0,2,0; the accent variants below are :root[data-theme][data-accent],
|
|
||||||
* so 0,3,0 and they win. That is what makes the accent swatches keep working on
|
|
||||||
* top of this theme -- and because the default accent ("teal") has no rule of
|
|
||||||
* its own, ihasmail.org's own accent is what shows until someone picks another.
|
|
||||||
*/
|
|
||||||
:root[data-palette="ihasmail"] {
|
|
||||||
--bg: #0d2430;
|
|
||||||
--bg-elev: #12303e;
|
|
||||||
--bg-sunken: #0a1c26;
|
|
||||||
--bg-hover: rgba(70, 202, 195, 0.10);
|
|
||||||
--bg-active: rgba(70, 202, 195, 0.16);
|
|
||||||
--fg: #eaf6f6;
|
|
||||||
--fg-muted: #a3c3cb;
|
|
||||||
--fg-faint: #86aab4;
|
|
||||||
--border: #21505f;
|
|
||||||
--border-strong: #2e6a7a;
|
|
||||||
--accent: #46cac3;
|
|
||||||
--accent-fg: #062028;
|
|
||||||
--accent-soft: rgba(70, 202, 195, 0.16);
|
|
||||||
--accent-soft-fg: #9fe6e2;
|
|
||||||
--danger: #f87171;
|
|
||||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
|
||||||
--warn: #f9a34b;
|
|
||||||
--warn-soft: rgba(249, 163, 75, 0.14);
|
|
||||||
--success: #4ade80;
|
|
||||||
--success-soft: rgba(74, 222, 128, 0.15);
|
|
||||||
--link: #6fdcd6;
|
|
||||||
--unread-bg: #163a4a;
|
|
||||||
--read-bg: #12303e;
|
|
||||||
--selected-bg: rgba(70, 202, 195, 0.18);
|
|
||||||
--focus-ring: 0 0 0 3px rgba(70, 202, 195, 0.4);
|
|
||||||
/* The cat is orange; so is the star. */
|
|
||||||
--star: #f9a34b;
|
|
||||||
--q1: #6fdcd6;
|
|
||||||
--q2: #4ade80;
|
|
||||||
--q3: #c084fc;
|
|
||||||
--scrollbar: rgba(163, 195, 203, 0.3);
|
|
||||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
||||||
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
|
|
||||||
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent variants */
|
/* Accent variants */
|
||||||
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
|
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
|
||||||
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
|
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
|
||||||
@@ -504,7 +451,7 @@ img { max-width: 100%; }
|
|||||||
.msg-row .msg-important { color: var(--warn); }
|
.msg-row .msg-important { color: var(--warn); }
|
||||||
.list-footer { padding: 12px; text-align: center; color: var(--fg-muted); font-size: .9em; }
|
.list-footer { padding: 12px; text-align: center; color: var(--fg-muted); font-size: .9em; }
|
||||||
.list-hint { padding: 8px 12px; font-size: .85em; color: var(--fg-muted); background: var(--bg-sunken); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
.list-hint { padding: 8px 12px; font-size: .85em; color: var(--fg-muted); background: var(--bg-sunken); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
||||||
.list-hint button { color: var(--link); font-weight: 600; white-space: nowrap; }
|
.list-hint button { color: var(--link); font-weight: 600; }
|
||||||
.drag-ghost { position: fixed; top: -1000px; left: -1000px; padding: 8px 12px; background: var(--accent); color: var(--accent-fg); border-radius: 999px; font-weight: 600; box-shadow: var(--shadow-2); pointer-events: none; z-index: 5000; }
|
.drag-ghost { position: fixed; top: -1000px; left: -1000px; padding: 8px 12px; background: var(--accent); color: var(--accent-fg); border-radius: 999px; font-weight: 600; box-shadow: var(--shadow-2); pointer-events: none; z-index: 5000; }
|
||||||
|
|
||||||
/* Splitter between list and reading pane */
|
/* Splitter between list and reading pane */
|
||||||
@@ -626,22 +573,7 @@ img { max-width: 100%; }
|
|||||||
.composer-field .field-extra button { color: var(--fg-muted); padding: 2px 6px; border-radius: 4px; }
|
.composer-field .field-extra button { color: var(--fg-muted); padding: 2px 6px; border-radius: 4px; }
|
||||||
.composer-field .field-extra button:hover { background: var(--bg-hover); color: var(--fg); }
|
.composer-field .field-extra button:hover { background: var(--bg-hover); color: var(--fg); }
|
||||||
.composer-field input.plain { flex: 1; border: 0; background: transparent; outline: none; min-width: 80px; height: 30px; }
|
.composer-field input.plain { flex: 1; border: 0; background: transparent; outline: none; min-width: 80px; height: 30px; }
|
||||||
.composer-field .from-select { flex: 1; border: 0; background: transparent; color: var(--fg); padding: 0; height: 30px; cursor: pointer; }
|
.composer-field .from-select { flex: 1; border: 0; background: transparent; padding: 0; height: 30px; cursor: pointer; }
|
||||||
|
|
||||||
/*
|
|
||||||
* A native <select>'s dropdown is painted by the browser from the element's own
|
|
||||||
* colours, not the page's. `.from-select` is deliberately transparent so it
|
|
||||||
* sits flush in the composer's From line -- which left its popup with no
|
|
||||||
* background of its own, so the browser drew a light one while the text kept
|
|
||||||
* the app's light foreground: light on light, unreadable in any dark theme.
|
|
||||||
*
|
|
||||||
* Styling `option` fixes the popup without giving the closed control a box.
|
|
||||||
* Scoped to every select rather than this one, because nothing else in the app
|
|
||||||
* styled options either -- the next transparent select would have arrived with
|
|
||||||
* the same bug.
|
|
||||||
*/
|
|
||||||
select option,
|
|
||||||
select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|
||||||
.recipients { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; position: relative; }
|
.recipients { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; position: relative; }
|
||||||
.recipients .chip { height: 24px; }
|
.recipients .chip { height: 24px; }
|
||||||
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
|
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import { Link, useLocation } from "wouter";
|
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 { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||||
@@ -205,31 +205,22 @@ function QuotaBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flip to light and back from the top bar.
|
* Flip between light and dark from the top bar.
|
||||||
*
|
*
|
||||||
* The setting has four values and only two of them are "light", so the button
|
* The stored setting has a third value, "system", so the button acts on what
|
||||||
* acts on what is actually on screen rather than on the setting: if you can
|
* is actually on screen rather than on the setting: whichever theme you can
|
||||||
* see a dark theme, one click gives you light.
|
* see, one click gives you the other one. Choosing "match system" again lives
|
||||||
*
|
* in Settings › Appearance, where the three-way choice belongs.
|
||||||
* Coming back is the part that needs remembering. There is more than one way
|
|
||||||
* to be dark — "dark", "ihasmail", or "system" while the OS is — so the way
|
|
||||||
* back is whichever you were on, kept in `lastDarkTheme`, rather than plain
|
|
||||||
* "dark" for everyone. Without that, two clicks would quietly move an
|
|
||||||
* ihasmail user onto a theme they never chose.
|
|
||||||
*/
|
*/
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const effective = useEffectiveTheme();
|
const effective = useEffectiveTheme();
|
||||||
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
|
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const next = toggleTarget(effective, lastDarkTheme);
|
const next = effective === "dark" ? "light" : "dark";
|
||||||
// The label names where you are going, and going back is not always "dark"
|
|
||||||
// any more -- it is whichever theme you were on before flipping to light.
|
|
||||||
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label={`Switch to ${label}`}
|
aria-label={`Switch to ${next} mode`}
|
||||||
title={`Switch to ${label}`}
|
title={`Switch to ${next} mode`}
|
||||||
onClick={() => update({ theme: next })}
|
onClick={() => update({ theme: next })}
|
||||||
>
|
>
|
||||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||||
|
|||||||
+18
-19
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { Eye, EyeOff, LogIn } from "lucide-react";
|
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { ApiError } from "@/jmap/client";
|
import { ApiError } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const login = useSession((s) => s.login);
|
const login = useSession((s) => s.login);
|
||||||
@@ -21,6 +20,8 @@ export function LoginPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [totp, setTotp] = useState("");
|
||||||
|
const [showTotp, setShowTotp] = useState(false);
|
||||||
const [showPw, setShowPw] = useState(false);
|
const [showPw, setShowPw] = useState(false);
|
||||||
const [remember, setRemember] = useState(true);
|
const [remember, setRemember] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -32,14 +33,13 @@ export function LoginPage() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// No two-factor code: the field is not on this form until the flow works
|
await login(username.trim(), password, totp.trim(), remember);
|
||||||
// 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());
|
localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
if (err.code === "invalid_credentials") {
|
if (err.code === "invalid_credentials") {
|
||||||
setError("Invalid username or password.");
|
setError(showTotp ? "Invalid credentials or verification code." : "Invalid username or password.");
|
||||||
|
if (!showTotp && password) setShowTotp(true);
|
||||||
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
|
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
|
||||||
else setError(err.message || "Could not sign in.");
|
else setError(err.message || "Could not sign in.");
|
||||||
} else setError("Network error. Please check your connection.");
|
} else setError("Network error. Please check your connection.");
|
||||||
@@ -74,6 +74,17 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showTotp ? (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t">Two-factor code</label>
|
||||||
|
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
|
||||||
|
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
|
||||||
|
<ShieldCheck size={16} /> I have a two-factor code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<label className="check" style={{ marginBottom: 12 }}>
|
<label className="check" style={{ marginBottom: 12 }}>
|
||||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||||
<span>Keep me signed in on this device</span>
|
<span>Keep me signed in on this device</span>
|
||||||
@@ -83,19 +94,7 @@ export function LoginPage() {
|
|||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
<p className="foot">
|
<p className="foot">
|
||||||
{/*
|
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
|
||||||
The version sits directly above the source link on purpose: the
|
|
||||||
AGPL's offer is for the source of *this* build, and naming the
|
|
||||||
build is what makes that offer something a person can act on. It
|
|
||||||
also means a bug report can name the build without anyone having
|
|
||||||
to sign in to find it.
|
|
||||||
|
|
||||||
One <p> with a break rather than two: .foot carries a 20px
|
|
||||||
margin-top, which a second paragraph would repeat as a gap.
|
|
||||||
*/}
|
|
||||||
ihasmail v{APP_VERSION}
|
|
||||||
<br />
|
|
||||||
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>
|
|
||||||
{" · "}
|
{" · "}
|
||||||
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
|
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, More
|
|||||||
import { useCompose, type Draft } from "@/store/compose";
|
import { useCompose, type Draft } from "@/store/compose";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { visibleIdentities } from "@/lib/identityVisibility";
|
|
||||||
import { RecipientInput } from "./RecipientInput";
|
import { RecipientInput } from "./RecipientInput";
|
||||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||||
@@ -29,10 +28,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
const setIdentity = useCompose((s) => s.setIdentity);
|
const setIdentity = useCompose((s) => s.setIdentity);
|
||||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||||
const focus = useCompose((s) => s.focus);
|
const focus = useCompose((s) => s.focus);
|
||||||
const allIdentities = useMail((s) => s.identities);
|
const identities = useMail((s) => s.identities);
|
||||||
const mailAccountId = useMail((s) => s.accountId);
|
|
||||||
const hiddenIdentities = useSettings((s) => s.settings.hiddenIdentities);
|
|
||||||
const defaultIdentityId = useSettings((s) => (mailAccountId ? s.settings.defaultIdentityByAccount[mailAccountId] : undefined));
|
|
||||||
const settings = useSettings((s) => s.settings);
|
const settings = useSettings((s) => s.settings);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
const updateSettings = useSettings((s) => s.update);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -122,16 +118,6 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
if (files.length) addFiles(key, files);
|
if (files.length) addFiles(key, files);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
* The picker offers the visible identities, plus two that can never be
|
|
||||||
* hidden from it: the one this draft is already using, and the default a new
|
|
||||||
* draft starts on. Hiding either would leave the select with no matching
|
|
||||||
* option and silently move the From line. See lib/identityVisibility.
|
|
||||||
*/
|
|
||||||
const identities = useMemo(
|
|
||||||
() => visibleIdentities(allIdentities, hiddenIdentities, [d.identityId, defaultIdentityId]),
|
|
||||||
[allIdentities, hiddenIdentities, d.identityId, defaultIdentityId],
|
|
||||||
);
|
|
||||||
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
||||||
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
||||||
|
|||||||
@@ -33,21 +33,11 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
|||||||
}
|
}
|
||||||
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
||||||
|
|
||||||
const { rules, loaded } = sieve.rules();
|
const { rules } = sieve.rules();
|
||||||
if (rules === null) {
|
if (rules === null) {
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||||
{/*
|
<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>
|
||||||
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.
|
|
||||||
*/}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,37 +115,6 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
(removed: boolean) => {
|
(removed: boolean) => {
|
||||||
useMail.getState().clearSelection();
|
useMail.getState().clearSelection();
|
||||||
if (!removed) return;
|
if (!removed) return;
|
||||||
|
|
||||||
/*
|
|
||||||
* Move the focused row off the message that just went away.
|
|
||||||
*
|
|
||||||
* Nothing did this before, so `focusId` kept pointing at a row that was
|
|
||||||
* no longer in the list, and two separate complaints in #71 fell out of
|
|
||||||
* it. `targetIds()` falls back to the focused id, so the next `#`
|
|
||||||
* re-targeted the deleted message -- which the optimistic update had
|
|
||||||
* already marked as being in Deleted Items, making it look like a
|
|
||||||
* permanent delete and raising a confirmation the setting had turned
|
|
||||||
* off. And `moveFocus` reads `ids.indexOf(focusId)`, which was -1, which
|
|
||||||
* it treats as "before the start" -- so `k` clamped to the top of the
|
|
||||||
* list.
|
|
||||||
*
|
|
||||||
* Clicking a row was unaffected, because that sets focus to a row that
|
|
||||||
* exists, which is why it only ever happened from the keyboard.
|
|
||||||
*
|
|
||||||
* `currentRowIndex` here is the value from the render that started this
|
|
||||||
* action, so it is the index the message had *before* it was removed.
|
|
||||||
* The row that slid into that slot is the one to focus.
|
|
||||||
*/
|
|
||||||
const wasAt = currentRowIndex;
|
|
||||||
const freshIds = useMail.getState().list?.ids ?? [];
|
|
||||||
if (!freshIds.length) {
|
|
||||||
setFocusId(null);
|
|
||||||
} else if (wasAt >= 0) {
|
|
||||||
const want = settings.autoAdvance === "newer" ? wasAt - 1 : wasAt;
|
|
||||||
const next = freshIds[Math.max(0, Math.min(want, freshIds.length - 1))];
|
|
||||||
if (next) setFocusId(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// auto-advance
|
// auto-advance
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
const idx = currentRowIndex;
|
const idx = currentRowIndex;
|
||||||
@@ -159,7 +128,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread, setFocusId],
|
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread],
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
@@ -222,11 +191,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
focusRef.current = focusId;
|
focusRef.current = focusId;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const moveFocus = (delta: number) => {
|
const moveFocus = (delta: number) => {
|
||||||
// A focused id that is no longer in the list gives -1, which must not be
|
const cur = focusRef.current ? ids.indexOf(focusRef.current) : currentRowIndex;
|
||||||
// read as "just before the first row" -- that is what sent `k` to the
|
|
||||||
// top. Fall back to where the list thinks we are instead.
|
|
||||||
const fromFocus = focusRef.current ? ids.indexOf(focusRef.current) : -1;
|
|
||||||
const cur = fromFocus >= 0 ? fromFocus : currentRowIndex;
|
|
||||||
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
||||||
const id = ids[next];
|
const id = ids[next];
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
|
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
|
||||||
import { isScheduledMailbox } from "@/store/scheduled";
|
import { isScheduledMailbox } from "@/store/scheduled";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
@@ -329,7 +328,10 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
|
const empty = async () => {
|
||||||
|
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
|
||||||
|
if (ok) await useMail.getState().emptyMailbox(m.id);
|
||||||
|
};
|
||||||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||||
const color = folderColor(colors, m.id);
|
const color = folderColor(colors, m.id);
|
||||||
const setColor = (c: string | null) => {
|
const setColor = (c: string | null) => {
|
||||||
@@ -370,7 +372,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
</div>
|
</div>
|
||||||
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
|
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
{m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
|
||||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { useMail, type ListState } from "@/store/mail";
|
|||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import type { Email, Id } from "@/jmap/types";
|
import type { Email, Id } from "@/jmap/types";
|
||||||
import { formatListDate } from "@/lib/format";
|
import { formatListDate } from "@/lib/format";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
|
||||||
import { displayName, shortName } from "@/lib/address";
|
import { displayName, shortName } from "@/lib/address";
|
||||||
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
|
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||||
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||||
|
|
||||||
@@ -74,6 +74,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
const selCount = Object.keys(selected).length;
|
const selCount = Object.keys(selected).length;
|
||||||
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
|
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||||
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
|
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
|
||||||
|
// Emptying in one action is for Deleted Items only; Junk is cleared by hand.
|
||||||
|
const isTrash = mailbox?.role === "trash";
|
||||||
const isDrafts = mailbox?.role === "drafts";
|
const isDrafts = mailbox?.role === "drafts";
|
||||||
|
|
||||||
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
|
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
|
||||||
@@ -198,15 +200,16 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
||||||
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||||
{mailbox && canEmpty(mailbox.role) && (
|
{isTrash && (
|
||||||
<>
|
<>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon={<Eraser size={16} />}
|
icon={<Eraser size={16} />}
|
||||||
label={emptyLabel(mailbox)}
|
label={`Empty ${mailbox?.name}`}
|
||||||
disabled={!mailbox.totalEmails}
|
onClick={async () => {
|
||||||
onClick={() => void confirmAndEmpty(mailbox)}
|
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -220,23 +223,6 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
<button onClick={() => void doRefresh()}>Retry</button>
|
<button onClick={() => void doRefresh()}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/*
|
|
||||||
Junk Mail's own banner, the way every other mail client offers it:
|
|
||||||
clearing spam is the one thing people come to this folder to do, and
|
|
||||||
making them find it in a menu is making them hunt for it.
|
|
||||||
|
|
||||||
Only here, and only with something to delete. It says "permanently"
|
|
||||||
because that is the part worth knowing before clicking — these do not
|
|
||||||
pass through Deleted Items on the way out.
|
|
||||||
*/}
|
|
||||||
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
|
|
||||||
<div className="list-hint">
|
|
||||||
<span className="grow">
|
|
||||||
Deleting spam is permanent — it does not go to Deleted Items first.
|
|
||||||
</span>
|
|
||||||
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
|
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
|
||||||
{list?.loading && ids.length === 0 ? (
|
{list?.loading && ids.length === 0 ? (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ import { sendReadReceipt } from "@/store/mdn";
|
|||||||
interface Props {
|
interface Props {
|
||||||
email: Email;
|
email: Email;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
/** Unread when the conversation was opened, which is what the bar marks. */
|
|
||||||
wasUnread?: boolean;
|
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
actions: ListActions;
|
actions: ListActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
|
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) {
|
||||||
const accountId = useMail((s) => s.accountId)!;
|
const accountId = useMail((s) => s.accountId)!;
|
||||||
const settings = useSettings((s) => s.settings);
|
const settings = useSettings((s) => s.settings);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
const updateSettings = useSettings((s) => s.update);
|
||||||
@@ -136,10 +134,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* `wasUnread` rather than `$seen`: the bar marks what was unread when the
|
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
|
||||||
conversation was opened, and keeps marking it after the auto-mark-read
|
|
||||||
timer has told the server otherwise. Losing it mid-read was half of #69. */
|
|
||||||
<article className={`message ${expanded ? "" : "collapsed"} ${wasUnread ?? !e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
|
|
||||||
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
||||||
<Avatar who={from ?? null} />
|
<Avatar who={from ?? null} />
|
||||||
<div className="who">
|
<div className="who">
|
||||||
|
|||||||
@@ -65,41 +65,15 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
||||||
}, [thread, emails, fullIds, mailboxId]);
|
}, [thread, emails, fullIds, mailboxId]);
|
||||||
|
|
||||||
/*
|
// Default expansion: unread + last message expanded, others collapsed
|
||||||
* Which messages were unread when this conversation was opened.
|
|
||||||
*
|
|
||||||
* Expansion and the unread bar used to read `$seen` directly, so the moment
|
|
||||||
* the auto-mark-read timer fired, every message expanded *because* it was
|
|
||||||
* unread collapsed again -- all but the last -- and the only record of which
|
|
||||||
* ones they were disappeared with them (#69). Opening a thread with several
|
|
||||||
* unread messages gave you a few seconds before the view rearranged itself
|
|
||||||
* underneath you.
|
|
||||||
*
|
|
||||||
* Marking read on the server is still right: opening the thread is the signal
|
|
||||||
* that you are reading it. What was wrong was letting that change the shape
|
|
||||||
* of what you are looking at. The set only ever grows while a thread is open
|
|
||||||
* -- a message that arrives unread joins it -- and is discarded on the way to
|
|
||||||
* another thread.
|
|
||||||
*
|
|
||||||
* Accumulated during render rather than in an effect because it is derived
|
|
||||||
* purely from `messages`, and adding an id twice does nothing. An effect
|
|
||||||
* would repaint a frame later, which is the flicker this exists to remove.
|
|
||||||
*/
|
|
||||||
const threadKey = thread?.id ?? null;
|
|
||||||
const unreadAtOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
|
|
||||||
if (unreadAtOpen.current.key !== threadKey) unreadAtOpen.current = { key: threadKey, ids: new Set() };
|
|
||||||
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
|
|
||||||
const wasUnread = unreadAtOpen.current.ids;
|
|
||||||
|
|
||||||
// Default expansion: unread when opened + last message expanded, others collapsed
|
|
||||||
const lastId = messages[messages.length - 1]?.id;
|
const lastId = messages[messages.length - 1]?.id;
|
||||||
const isExpanded = useCallback(
|
const isExpanded = useCallback(
|
||||||
(e: Email) => {
|
(e: Email) => {
|
||||||
if (e.id in expanded) return expanded[e.id]!;
|
if (e.id in expanded) return expanded[e.id]!;
|
||||||
if (allExpanded) return true;
|
if (allExpanded) return true;
|
||||||
return wasUnread.has(e.id) || e.id === lastId || messages.length === 1;
|
return !e.keywords.$seen || e.id === lastId || messages.length === 1;
|
||||||
},
|
},
|
||||||
[expanded, allExpanded, lastId, messages.length, wasUnread],
|
[expanded, allExpanded, lastId, messages.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as read after delay
|
// Mark as read after delay
|
||||||
@@ -216,7 +190,6 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
key={e.id}
|
key={e.id}
|
||||||
email={e}
|
email={e}
|
||||||
expanded={isExpanded(e)}
|
expanded={isExpanded(e)}
|
||||||
wasUnread={wasUnread.has(e.id)}
|
|
||||||
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
|
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
|
||||||
isLast={i === messages.length - 1}
|
isLast={i === messages.length - 1}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
|
||||||
|
|
||||||
export function AboutSettings() {
|
export function AboutSettings() {
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
@@ -15,7 +14,7 @@ export function AboutSettings() {
|
|||||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||||
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
|
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
|
||||||
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
|
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,8 +28,7 @@ export function AboutSettings() {
|
|||||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.</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>
|
|
||||||
<h2>Server capabilities</h2>
|
<h2>Server capabilities</h2>
|
||||||
<div className="row wrap gap-4">
|
<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>)}
|
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||||
@@ -41,11 +39,12 @@ export function AboutSettings() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
||||||
* "1.0.0" wherever it publishes one at all), so the edition is all there is to
|
* "1.0.0" wherever it publishes one at all), so the most honest thing we can
|
||||||
* show. The generation used to be reported here too, back when ihasmail spoke
|
* show is which generation of its API answered us, plus the edition where the
|
||||||
* to both 0.15 and 0.16; it requires 0.16 now, so signing in at all is the
|
* server reports it.
|
||||||
* answer to that question.
|
|
||||||
*/
|
*/
|
||||||
function describeServer(server: { edition?: string | null } | undefined): string {
|
function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string {
|
||||||
return server?.edition ? `0.16 or newer (${server.edition})` : "0.16 or newer";
|
if (!server?.generation) return "not detected";
|
||||||
|
const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16";
|
||||||
|
return server.edition ? `${generation} (${server.edition})` : generation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
|
|
||||||
/**
|
|
||||||
* The theme cards, each previewing the background it actually paints. Kept as
|
|
||||||
* data rather than three inline ternaries so a fourth does not mean editing a
|
|
||||||
* conditional in three places.
|
|
||||||
*/
|
|
||||||
const THEMES = [
|
|
||||||
{ id: "system", label: "Match system", preview: "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" },
|
|
||||||
{ id: "light", label: "Light", preview: "#f6f8fa" },
|
|
||||||
{ id: "dark", label: "Dark", preview: "#0b1220" },
|
|
||||||
// The ihasmail.org palette: its background, with its teal and the logo's
|
|
||||||
// orange showing, so the card looks like what picking it does.
|
|
||||||
{ id: "ihasmail", label: "ihasmail", preview: "linear-gradient(135deg,#0d2430 0%,#12303e 55%,#46cac3 55%,#46cac3 78%,#f9a34b 78%)" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ACCENTS = [
|
const ACCENTS = [
|
||||||
{ id: "teal", color: "#0f766e" },
|
{ id: "teal", color: "#0f766e" },
|
||||||
{ id: "blue", color: "#2563eb" },
|
{ id: "blue", color: "#2563eb" },
|
||||||
@@ -33,16 +19,13 @@ export function AppearanceSettings() {
|
|||||||
<p className="lead">Make ihasmail yours.</p>
|
<p className="lead">Make ihasmail yours.</p>
|
||||||
<h2>Theme</h2>
|
<h2>Theme</h2>
|
||||||
<div className="theme-grid">
|
<div className="theme-grid">
|
||||||
{THEMES.map((t) => (
|
{(["system", "light", "dark"] as const).map((t) => (
|
||||||
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
|
||||||
<div className="preview" style={{ background: t.preview }} />
|
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
|
||||||
{t.label}
|
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="hint" style={{ marginTop: 10 }}>
|
|
||||||
<strong>ihasmail</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
|
|
||||||
</p>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={s.themeMessageBody}
|
checked={s.themeMessageBody}
|
||||||
onChange={(v) => update({ themeMessageBody: v })}
|
onChange={(v) => update({ themeMessageBody: v })}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
|
import { Plus, Trash2, Star } from "lucide-react";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import type { Identity } from "@/jmap/types";
|
import type { Identity } from "@/jmap/types";
|
||||||
import { isAlwaysVisible } from "@/lib/identityVisibility";
|
|
||||||
import { Dialog, confirmDialog } from "@/ui/dialog";
|
import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -20,10 +19,6 @@ export function IdentitiesSettings() {
|
|||||||
const setDefault = useMail((s) => s.setDefaultIdentity);
|
const setDefault = useMail((s) => s.setDefaultIdentity);
|
||||||
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
||||||
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
||||||
const hidden = useSettings((s) => s.settings.hiddenIdentities);
|
|
||||||
const updateSettings = useSettings((s) => s.update);
|
|
||||||
const toggleHidden = (id: string) =>
|
|
||||||
updateSettings({ hiddenIdentities: hidden.includes(id) ? hidden.filter((x) => x !== id) : [...hidden, id] });
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -39,36 +34,16 @@ export function IdentitiesSettings() {
|
|||||||
{i.id !== defaultId && (
|
{i.id !== defaultId && (
|
||||||
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
||||||
)}
|
)}
|
||||||
{/*
|
|
||||||
Hiding is presentation only -- the identity still exists and still
|
|
||||||
receives, like an unsubscribed folder. The default cannot be
|
|
||||||
hidden, because it is what a new draft starts on.
|
|
||||||
*/}
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-ghost"
|
|
||||||
disabled={isAlwaysVisible(i.id, [defaultId])}
|
|
||||||
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
|
|
||||||
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
|
|
||||||
>
|
|
||||||
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
|
|
||||||
</button>
|
|
||||||
{i.mayDelete && (
|
{i.mayDelete && (
|
||||||
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
|
|
||||||
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
||||||
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
||||||
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
|
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
|
||||||
{hidden.length > 0 && (
|
|
||||||
<p className="hint">
|
|
||||||
{hidden.length} {hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to
|
|
||||||
choose from, so in that case they are all offered again.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,21 +3,12 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
|
|
||||||
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
|
||||||
import { toast } from "@/ui/toast";
|
|
||||||
|
|
||||||
export function NotificationsSettings() {
|
export function NotificationsSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
const pushConnected = useSession((st) => st.pushConnected);
|
const pushConnected = useSession((st) => st.pushConnected);
|
||||||
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
||||||
const [background, setBackground] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const canBackground = webPushAvailable();
|
|
||||||
useEffect(() => {
|
|
||||||
void webPushActive().then(setBackground);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ("Notification" in window) setPerm(Notification.permission);
|
if ("Notification" in window) setPerm(Notification.permission);
|
||||||
}, [s.desktopNotifications]);
|
}, [s.desktopNotifications]);
|
||||||
@@ -35,46 +26,10 @@ export function NotificationsSettings() {
|
|||||||
}
|
}
|
||||||
update({ desktopNotifications: v });
|
update({ desktopNotifications: v });
|
||||||
}}
|
}}
|
||||||
label="Desktop notifications while ihasmail is open"
|
label="Desktop notifications for new mail"
|
||||||
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
||||||
disabled={perm === "denied" || perm === "unsupported"}
|
disabled={perm === "denied" || perm === "unsupported"}
|
||||||
/>
|
/>
|
||||||
{/*
|
|
||||||
The distinction worth drawing for the user: the switch above needs a tab
|
|
||||||
open, this one does not. Everything before this shipped only the first
|
|
||||||
kind, while calling it "desktop notifications".
|
|
||||||
*/}
|
|
||||||
<Switch
|
|
||||||
checked={background}
|
|
||||||
disabled={!canBackground || busy || perm === "denied"}
|
|
||||||
onChange={async (v) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
if (v) {
|
|
||||||
const p = await requestNotificationPermission();
|
|
||||||
setPerm(p);
|
|
||||||
if (p !== "granted") return;
|
|
||||||
const res = await enableWebPush();
|
|
||||||
if (!res.ok) { toast.error(res.reason); return; }
|
|
||||||
setBackground(true);
|
|
||||||
toast.success("Background notifications are on");
|
|
||||||
} else {
|
|
||||||
await disableWebPush();
|
|
||||||
setBackground(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label="Notify me even when ihasmail is closed"
|
|
||||||
hint={
|
|
||||||
!canBackground
|
|
||||||
? "Needs a browser with the Push API and a mail server that publishes a push key."
|
|
||||||
: supportsEmailPush()
|
|
||||||
? "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again."
|
|
||||||
: "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
||||||
<div className="row mt-16">
|
<div className="row mt-16">
|
||||||
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSession } from "@/store/session";
|
|||||||
import { formatFullDate } from "@/lib/format";
|
import { formatFullDate } from "@/lib/format";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||||
|
import { QrCode } from "@/ui/qrcode";
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,15 +26,17 @@ interface AppPasswordRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SecurityState {
|
interface SecurityState {
|
||||||
|
backend: "registry" | "legacy";
|
||||||
otpEnabled: boolean;
|
otpEnabled: boolean;
|
||||||
appPasswords: AppPasswordRow[];
|
appPasswords: AppPasswordRow[];
|
||||||
|
appPasswordsKeyedByName: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecuritySettings() {
|
export function SecuritySettings() {
|
||||||
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||||
const [current, setCurrent] = useState<string>("");
|
const [current, setCurrent] = useState<string>("");
|
||||||
const [state, setState] = useState<SecurityState | null>(null);
|
const [state, setState] = useState<SecurityState | null>(null);
|
||||||
/** Set when the server has no self-service API at all (a proxy, say). */
|
/** Set when the server has no self-service API at all (pre-0.15 or a proxy). */
|
||||||
const [unsupported, setUnsupported] = useState<string | null>(null);
|
const [unsupported, setUnsupported] = useState<string | null>(null);
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
const logout = useSession((s) => s.logout);
|
const logout = useSession((s) => s.logout);
|
||||||
@@ -67,11 +70,11 @@ export function SecuritySettings() {
|
|||||||
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
|
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!unsupported && state?.otpEnabled && (
|
<h2>Two-factor authentication</h2>
|
||||||
<>
|
{unsupported ? (
|
||||||
<h2>Two-factor authentication</h2>
|
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
|
||||||
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
|
) : (
|
||||||
</>
|
<TwoFactor state={state} reload={async () => { await loadSecurity(); await load(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2>App passwords</h2>
|
<h2>App passwords</h2>
|
||||||
@@ -167,20 +170,46 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
|
||||||
* Only the way *out*. Setting two-factor authentication up is gone until
|
const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null);
|
||||||
* signing in with a code works: Stalwart takes a TOTP code through an OAuth
|
|
||||||
* flow alone and offers no password grant, so ihasmail has nowhere to send one
|
|
||||||
* (#75). Turning it on here would lock the account out of webmail on its next
|
|
||||||
* sign-in. Turning it off is a plain registry write, works today, and has to
|
|
||||||
* stay — whoever is already enrolled needs a way back.
|
|
||||||
*/
|
|
||||||
function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [disabling, setDisabling] = useState(false);
|
const [disabling, setDisabling] = useState(false);
|
||||||
|
|
||||||
|
if (!state) return <p className="hint">Loading…</p>;
|
||||||
|
|
||||||
|
const begin = async () => {
|
||||||
|
try {
|
||||||
|
setSetup(await apiFetch<{ secret: string; url: string }>("/api/account/2fa/begin", { method: "POST", body: "{}" }));
|
||||||
|
setCode(""); setPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enable = async () => {
|
||||||
|
if (!setup) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ sessionKept: boolean }>("/api/account/2fa/enable", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url: setup.url, code, current: password }),
|
||||||
|
});
|
||||||
|
setSetup(null);
|
||||||
|
await reload();
|
||||||
|
if (res.sessionKept) {
|
||||||
|
toast.success("Two-factor authentication is on. This browser stays signed in.");
|
||||||
|
} else {
|
||||||
|
toast.success("Two-factor authentication is on. You'll need to sign in again with a code.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const disable = async () => {
|
const disable = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -198,15 +227,51 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<p className="hint" style={{ marginBottom: 12 }}>
|
||||||
This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another
|
{state.otpEnabled
|
||||||
device needs an app password — or you can turn two-factor authentication off here.
|
? "Signing in requires a code from your authenticator app as well as your password."
|
||||||
|
: "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."}
|
||||||
</p>
|
</p>
|
||||||
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
||||||
<ShieldCheck size={18} />
|
<ShieldCheck size={18} className={state.otpEnabled ? "" : "muted"} />
|
||||||
<b>Enabled</b>
|
<b>{state.otpEnabled ? "Enabled" : "Not enabled"}</b>
|
||||||
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
|
{state.otpEnabled
|
||||||
|
? <button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
|
||||||
|
: <button className="btn btn-sm btn-primary" onClick={() => void begin()}>Set up</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(setup)} onClose={() => setSetup(null)} title="Set up two-factor authentication" size="md"
|
||||||
|
footer={<>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setSetup(null)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" disabled={busy || code.length < 6 || !password} onClick={() => void enable()}>{busy ? "Verifying…" : "Turn on"}</button>
|
||||||
|
</>}>
|
||||||
|
{setup && (
|
||||||
|
<div>
|
||||||
|
<ol style={{ paddingLeft: 18, marginTop: 0 }}>
|
||||||
|
<li>Scan this with your authenticator app.</li>
|
||||||
|
<li>Enter the six-digit code it shows, and your password.</li>
|
||||||
|
</ol>
|
||||||
|
<div className="row" style={{ gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||||||
|
<QrCode value={setup.url} size={188} title="Two-factor setup code" />
|
||||||
|
<div style={{ minWidth: 220, flex: 1 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Can't scan? Enter this key by hand</label>
|
||||||
|
<CopyableSecret value={setup.secret} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="tfa-code">Code from the app</label>
|
||||||
|
<input id="tfa-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="tfa-pw">Your password</label>
|
||||||
|
<input id="tfa-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="hint">Codes are checked before anything is saved, so a mistyped key can't lock you out.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
|
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
|
||||||
footer={<>
|
footer={<>
|
||||||
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
|
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
|
||||||
@@ -277,12 +342,12 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
</p>
|
</p>
|
||||||
{state.appPasswords.length > 0 && (
|
{state.appPasswords.length > 0 && (
|
||||||
<table className="sessions-table">
|
<table className="sessions-table">
|
||||||
<thead><tr><th>Name</th><th>Created</th><th /></tr></thead>
|
<thead><tr><th>Name</th>{!state.appPasswordsKeyedByName && <th>Created</th>}<th /></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{state.appPasswords.map((row) => (
|
{state.appPasswords.map((row) => (
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
|
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
|
||||||
<td>{row.createdAt ? formatFullDate(row.createdAt) : "—"}</td>
|
{!state.appPasswordsKeyedByName && <td>{row.createdAt ? formatFullDate(row.createdAt) : "—"}</td>}
|
||||||
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
|
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -296,6 +361,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
</div>
|
</div>
|
||||||
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
|
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{state.appPasswordsKeyedByName && <p className="hint mt-8">This mail server identifies app passwords by name, so give each one a different name.</p>}
|
||||||
|
|
||||||
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
|
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
|
||||||
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
|
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import { resolveVersion } from "../scripts/version.mjs";
|
|
||||||
|
|
||||||
// Resolved here, at build time: the browser has no git to ask, and neither does
|
|
||||||
// the Docker build, which is handed the answer as IHASMAIL_VERSION instead.
|
|
||||||
const version = resolveVersion();
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user