Compare commits

..
Author SHA1 Message Date
jcoffey 3c4f6a9f8e Merge pull request #328 from Coffey-Labs/i18n-scripts-typescript7
Give the i18n scripts a parser again
2026-09-10 14:24:06 -07:00
jcoffey 2a323d6270 Merge pull request #327 from Coffey-Labs/single-message-view
Let conversation view off mean off
2026-09-10 14:23:52 -07:00
jcoffey-dev d282813bc5 Give the i18n scripts a parser again
TypeScript 7 is the native port: the package ships a `tsc` shim over a Go
binary, and `typescript` now exports `version` and `versionMajorMinor` and no
compiler API. Every `ts.createSourceFile` in scripts/ has been throwing
"Cannot read properties of undefined (reading 'Latest')" since the 5.9.3 → 7.0.2
bump -- four of the five i18n scripts dead, only i18n-extract still running.

Nothing noticed because no workflow runs them. The catalogue gate for nine
languages has been dark, and the only signal was running it by hand.

There is no official TS7 API package (@typescript/ast and @typescript/api are
both 404), and the alternative was rewriting 493 lines and 25 distinct AST
calls, including the JSX guards, against a different tree -- in tooling with no
tests of its own. So `typescript-ast` is an npm alias for the last TypeScript
carrying the JS API. It parses; `typescript` still type-checks and builds. Two
entries, two jobs, said so in each script so the next reader does not delete
one as a leftover.

What the gate says now it can speak: catalogues are green and coverage is 100%.
The "16 falling back to English" it reports in every locale are placeholders,
example domains, a product name, a licence id and the quote glyph -- strings
that should stay English. The 41 stale keys per locale are real dead weight and
are left for their own change.
2026-09-10 14:21:42 -07:00
jcoffey-dev d599e7404f Let conversation view off mean off
The setting reached only as far as the query. It set `collapseThreads`, so the
list correctly showed individual messages -- and then everything downstream
carried on working in threads. Opening one message highlighted every row of its
thread and filled the reading pane with the whole conversation, which is the
grouping the setting was turned off to avoid. The empty pane went on offering
"62 conversations" either way.

Three places had to learn about it, and the two rules behind them now live
together in lib/openMessage.ts:

- the row highlight matched on threadId, so siblings lit up
- ThreadView rendered every message the thread held
- the empty state named conversations regardless

The thread id stays in the path and loading is unchanged; the opened message
rides in `m`. Keeping it in the URL rather than in memory is what makes a
reload or a shared link come back to the same message, and an id that names
nothing in the thread falls back to the conversation -- which is what a link
from somebody with the setting on looks like, and what a stale parameter looks
like after switching back. Better a conversation than an empty pane.

Nine catalogues gain "No message selected" and "Select a message to read it
here"; "{n} messages" was already there, plural forms and all.
2026-09-10 14:15:50 -07:00
jcoffey 9b497af756 Merge pull request #326 from Coffey-Labs/plain-text-line-breaks
Render plain-text mail with its line breaks
2026-09-10 13:39:12 -07:00
jcoffey-dev 73a1bad29f Render plain-text mail with its line breaks
`htmlBody` is a derived list, not a filter: RFC 8621 §4.1.4 gives a message
with no HTML alternative one anyway, holding the text/plain part. Testing
`Boolean(htmlRaw)` therefore answered "this is HTML" for every plain-text
mail, sending it to HtmlBody and `.ihm-email-root`, which is
`white-space: normal` and collapses every line break. Hard-wrapped mail
arrived as a single paragraph with the signature and the quoted reply run
into the prose.

Confirmed live against Stalwart 0.16.21 (2026-09-10): a plain-text message
comes back with `htmlBody` and `textBody` naming the same part, typed
text/plain, while a real multipart/alternative names two different parts.
`type` was already in BODY_PROPS; nothing looked at it.

TextBody was written for exactly these messages and was simply unreachable,
so this also restores what it does -- pre-wrap, quote-depth colouring and the
collapsible quoted block, none of which had ever fired on plain-text mail.
2026-09-10 13:32:37 -07:00
jcoffey a9923c48d9 Merge pull request #325 from Coffey-Labs/funding-username-jcoffey-dev
Point the Sponsor button at the current GitHub username
2026-09-10 09:19:28 -07:00
jcoffey-dev 5b21720312 Point the Sponsor button at the current GitHub username
The account behind it was renamed from LINUXexpert-org to jcoffey-dev,
and GitHub does not redirect the old name: github.com/sponsors/
LINUXexpert-org answers 404 while the new one answers 200. So the
Sponsor button on this repository has been leading nowhere.

Worth fixing rather than leaving to redirect, because a released
username can be registered by anyone -- a stale link stops being a dead
end and starts being someone else's page.
2026-09-10 09:17:02 -07:00
Coffey Labs f2aaa9cea4 Merge pull request #324 from Coffey-Labs/node-26
Move CI, the image and the Node types to 26 together
2026-09-10 08:50:27 -07:00
jcoffey-dev 6632231815 Move CI, the image and the Node types to 26 together
Three pins and a types package all described Node 22, and moving any one
of them alone puts the build somewhere the others are not: @types/node
on its own would typecheck against APIs the runtime does not have, and
the base image on its own would ship a major CI never exercised. So
ci.yml, publish.yml, release.yml, both Dockerfile stages and
@types/node move in one change.

Worth knowing before this is deployed: 26 is Current, not LTS. node:26-
alpine reports lts=none, where 24-alpine is Krypton and the 22-alpine we
are leaving is Jod. 26 is due to become Active LTS in October. Nothing
here needs 26 over 24 -- the pins are a single number if the LTS line is
preferred.

engines stays at >=20.19, which is the floor for running ihasmail rather
than the version we build it on; the README's recommendation follows CI
to 26.

Checked on the runtime, not just in CI: the image builds on 26-alpine,
starts, and answers /api/health, and the login, SSE and body-carrying
POST checks from the node-server upgrade pass against a server on
26.8.1.
2026-09-10 08:44:55 -07:00
Coffey Labs 7d9d5b005c Merge pull request #323 from Coffey-Labs/hono-node-server-2
Take @hono/node-server to 2.1.1
2026-09-10 08:40:20 -07:00
jcoffey-dev 23738501c7 Take @hono/node-server to 2.1.1
All three entry points we import survive the major unchanged: `serve`
keeps its `(options, listeningListener)` signature and still accepts
`fetch`, `hostname` and `port`; `RESPONSE_ALREADY_SENT` is still exported
from `utils/response`; `getConnInfo` is still on `conninfo`. The peer is
hono ^4 and the engine >=20, both of which we already meet.

What v2 adds is two defaults worth knowing about. `overrideGlobalObjects`
swaps in a lighter Request/Response, and `autoCleanupIncoming` destroys
an incoming request the app never finished reading -- which is the
behaviour you want behind a proxy, and is on by default.

Neither is something the unit tests would notice, so this was run rather
than reasoned about. Against the mock: login, an /api/events stream, and
a POST carrying a body through to upstream. The SSE path is the one that
matters, since it writes to the raw ServerResponse and hands back
RESPONSE_ALREADY_SENT; it answers with the same headers, the same
chunked encoding and the same bytes as 1.19.17 does on the same script.
2026-09-10 08:28:21 -07:00
Coffey Labs 91dda348bc Merge pull request #318 from Coffey-Labs/dependabot/npm_and_yarn/typescript-7.0.2
Bump typescript from 5.9.3 to 7.0.2
2026-09-10 08:19:18 -07:00
dependabot[bot] 3240c56e84 Bump typescript from 5.9.3 to 7.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 15:16:28 +00:00
Coffey Labs 136c754bcd Merge pull request #322 from Coffey-Labs/vite-8
Move the build to vite 8, and chunk the way rolldown wants
2026-09-10 08:14:33 -07:00
Coffey Labs aa0d594666 Merge pull request #321 from Coffey-Labs/tsconfig-relative-paths
Point the @ alias at a relative path, and drop baseUrl
2026-09-10 08:14:25 -07:00
jcoffey-dev 53ccaad468 Move the build to vite 8, and chunk the way rolldown wants
@vitejs/plugin-react 6 peers on vite ^8 and nothing lower, so the build
had to move before the plugin could. vite 8 bundles with rolldown rather
than rollup, which is most of what is here.

The object form of `manualChunks` -- a chunk name against the list of
packages in it -- is gone; rolldown takes groups tested against module
paths instead. Same two chunks come out, `vendor` and `icons`, with the
same contents; `icons` is tried first because the first matching group
wins. `rollupOptions` is now a deprecated alias, so it is spelled
`rolldownOptions`.

The lockfile is regenerated rather than patched. vitest depends on vite
itself, and an incremental install was happy to leave 6.4.3 hoisted for
vitest while web built against 8.3.0 -- two majors in one tree, which is
not a state to ship. A clean install collapses to one.

vite 8 wants Node ^20.19 || >=22.12, above the >=20.10 the README and
engines promised, so both say 20.19 now. CI and the image are on 22 and
were never affected.

Rolldown reports two modules that are imported both statically and
dynamically, so the dynamic import cannot split them out. That is true
of the source either way -- store/sieve.ts has three static importers
and one dynamic -- and is left alone here.
2026-09-10 08:03:40 -07:00
jcoffey-dev d51d523ce1 Point the @ alias at a relative path, and drop baseUrl
TypeScript 7 removes `baseUrl` outright and refuses a non-relative entry
in `paths`, so the typecheck stops on tsconfig.json before it reaches a
line of our code. A leading `./` says the same thing without it: paths
resolve against the tsconfig's own directory, which is what `baseUrl:
"."` was there to arrange.

Nothing here waits for the upgrade. Relative paths without a baseUrl
have been the supported spelling since 4.4, so this typechecks the same
under 5.9.3 today as it will under 7. Vite resolves `@` from its own
alias in vite.config.ts and never read this.

With this in, 7.0.2 typechecks both workspaces clean -- the two
tsconfig errors were all that stood in the way, not the first two of
many.
2026-09-10 07:57:57 -07:00
Coffey Labs d90a1cef93 Merge pull request #314 from Coffey-Labs/dependabot/npm_and_yarn/minor-and-patch-897b8e30fb
Bump the minor-and-patch group across 1 directory with 4 updates
2026-09-10 07:02:31 -07:00
Coffey Labs 829c5ab14d Merge pull request #316 from Coffey-Labs/dependabot/github_actions/actions-819308dff6
Bump the actions group with 7 updates
2026-09-10 07:02:23 -07:00
dependabot[bot] 1b9058fccb Bump the minor-and-patch group across 1 directory with 4 updates
Bumps the minor-and-patch group with 4 updates in the / directory: [tsx](https://github.com/privatenumber/tsx), [dompurify](https://github.com/cure53/DOMPurify), [wouter](https://github.com/molefrog/wouter) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `tsx` from 4.23.12 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.12...v4.23.13)

Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `wouter` from 3.10.0 to 3.11.0
- [Release notes](https://github.com/molefrog/wouter/releases)
- [Commits](https://github.com/molefrog/wouter/commits/v3.11.0)

Updates `@types/react-dom` from 19.2.4 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: wouter
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 13:54:14 +00:00
Coffey Labs a2ae868f28 Merge pull request #320 from Coffey-Labs/deps-vitest-4
Take vitest to 4.1.11, and stop two suites leaking their spies
2026-09-10 06:51:36 -07:00
jcoffey-dev 520de85d12 Take vitest to 4.1.11, and stop two suites leaking their spies
GHSA-82fw-gwwq-j7x9 -- arbitrary file read through @vitest/mocker's
redirect mock -- has no fix in the 3.x line. The patched versions are
4.1.11 and 5.0.0-rc.2, so clearing it means the major. vite stays at
6.4.3: vitest 4 accepts ^6, and nothing outside devDependencies moves.

The bump surfaced a bug of ours rather than one of vitest's. vi.spyOn
now hands back the spy already installed on a method instead of wrapping
it in a fresh one, so a spy installed in beforeEach keeps its call count
across tests. compose-from-share expected two uploads and saw three: its
own two, plus the one from the test before it. The assertion was only
ever passing because each test happened to get a new spy.

Both suites now restore between tests, which is what the other five
spying suites already do. webpush had the same leak with no assertion
close enough to catch it.
2026-09-10 06:41:19 -07:00
dependabot[bot] 36ad85feef Bump the actions group with 7 updates
Bumps the actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-10 13:26:33 +00:00
Coffey Labs 9418d3f935 Merge pull request #312 from Coffey-Labs/deps-hono-4.13.7-dependabot-config
Take hono to 4.13.7 and let Dependabot open the next one
2026-09-10 06:25:00 -07:00
jcoffey-dev 3e8b1ebb38 Take hono to 4.13.7 and let Dependabot open the next one
Three medium advisories land on hono before 4.13.5: a toSSG() path
escape, a query parser that reads parameters past the URL fragment, and
unbounded dot-notation nesting in parseBody(). Only the second one
touches this server -- c.req.query() is read in imageproxy, icsproxy and
app -- and even there safeFetch validates the value it actually fetches
rather than a separate pre-check, so there was nothing to desync. toSSG
and parseBody are never called. The bump is still worth taking on its
own: it is a patch release with no API change.

The declared range moves with it, from ^4.7.4 to ^4.13.7, so the
security floor is recorded in server/package.json and not only in the
lockfile.

The dependabot.yml is the actual fix for how these were found. There was
no config, so nothing opened a PR and the alerts sat on a dashboard
until someone thought to look. Routine updates now group into one PR a
week; majors stay separate, because they are migrations.
2026-09-10 06:21:56 -07:00
Coffey Labs 38fb78a095 Merge pull request #311 from Coffey-Labs/theme-nested-light-panels
Neutralise light panels nested inside dark painted cards
2026-09-08 21:28:52 -07:00
jcoffey-dev 6f3aba06a6 Neutralise light panels nested inside dark painted cards
Closes #310.

A dark campaign rendered with beige cards inside it. markKeptSurfaces
marks any element whose declared background is below the luminance
threshold, with no area cap, so a 600px layout card is marked exactly
like a button. The CSS then exempted the marked element and its whole
subtree via [data-ihm-keep] *, so a light table nested in that card was
never touched. In the reported specimen 14 of 21 light panels survived.

The rule now is that being inside a painted surface is not inherited past
a sheet. The walk tracks that state and emits a second mark,
data-ihm-in-keep, for elements sitting on paint with no background of
their own; the CSS exempts those explicitly instead of exempting every
descendant. A nested light sheet ends the protection, and paint resumes
below it, so a button inside such a sheet is still kept whole.

The alternatives in the report were not taken. Dropping the descendant
half of the selector outright puts back what #294 fixed: a nested label
on a coloured cell loses its colour. An area threshold is a magic number
that misfires on both a legitimate hero banner and a small dark panel
with a light chip in it.

The tests assert against the neutraliser selector lifted out of
EMAIL_BASE_CSS rather than against the marks. The first draft of them
checked which attributes were set and passed against the unfixed code,
which proved nothing: the bug was in the rule that reads the marks, not
in the marking. All four fail without this change.
2026-09-08 19:32:09 -07:00
Coffey Labs a389b9e8c5 Merge pull request #309 from Coffey-Labs/sw-must-not-be-cached
Never serve the service worker from a cache
2026-09-07 23:04:59 -07:00
jcoffey-dev 0ad19802f8 Never serve the service worker from a cache
The deploy on 2026-09-08 went out at the origin and did not arrive.
Cloudflare went on handing out the previous `sw.js` -- `cf-cache-status:
HIT`, with an edge TTL of four hours, longer than the hour we asked for
-- because the file is neither a hashed asset nor HTML and so fell into
the ordinary `max-age=3600` case.

That is not a freshness preference. The service worker is the app's whole
update mechanism: a browser holding the old one goes on being served the
shell that worker knows and never learns a deploy happened, so the deploy
simply does not land. The manifest matters for a second reason -- the two
have to agree. A fresh manifest advertising a share target, answered by a
worker that has never heard of one, sends the share to the server for a
405. Either being old is survivable; disagreeing is not.

`no-cache` rather than `no-store`: both may still keep a copy, they just
have to revalidate it, which is a 304 and costs nothing. Neither gets to
answer with its own copy without asking.

Narrow on purpose -- two files, named, rather than a policy that quietly
stops the icons and fonts being cached as well.
2026-09-07 23:02:19 -07:00
Coffey Labs 1592f38515 Merge pull request #308 from Coffey-Labs/notification-actions
Archive and mark read from the notification itself
2026-09-07 22:59:14 -07:00
jcoffey-dev acc50f009c Archive and mark read from the notification itself
Both happen in the background. The phone stays where it is.

This was twice described as impossible, here and in FEATURES.md: the
service worker was said to have no session, so anything touching mail had
to open the app. That is wrong, and checking it rather than repeating it
is the whole of this change. ihasmail's session is an httpOnly cookie
against its own origin and the only other thing the API asks for is a
fixed `x-requested-with` header, which is not a secret and is not held
anywhere. A same-origin fetch from the worker carries the cookie like any
other. Confirmed against the mock: logging in with curl and then issuing
`Email/set` with nothing but that cookie and the static headers marked a
message read and moved it to Archive, HTTP 200. Nothing the tab holds in
memory is involved, because the API asks for none of it.

Two actions, because `maxActions` is two on Android and anything past it
is dropped without a word. Archive and Mark as read are the two worth
having: they are what somebody does to a notification they have already
read the whole of. Reply is not among them -- it would have to open the
app, which is what tapping the notification does already.

The worker still cannot reach a catalogue. It is plain JavaScript copied
into the build, outside the bundle, with no i18n and no idea which
mailbox is the archive. So the app writes both down in the same cache it
already uses for handoffs, and rewrites them whenever the language, the
account or the folder list changes. Where there is no such note -- between
installing this worker and next opening ihasmail -- the notification
appears with no buttons at all, rather than English ones over a mailbox
guessed by name. That also fixes two strings the worker had always shown
in English regardless: "New mail" and "(no subject)".

A session can be gone by the time a button is pressed. That comes back as
a refusal and the notification says so, rather than vanishing as though
it had worked. It does not open the app to recover: being interrupted is
what the button existed to avoid.

The two claims that were wrong are corrected rather than quietly deleted,
including the one about push renewal -- which still needs a tab, but for
a different reason than the one given. The reason is when the worker
runs, not what it may do: it wakes only for a push, and the push stops
when the subscription lapses.

Two new strings, in all nine catalogues.
2026-09-07 22:55:55 -07:00
Coffey Labs f42fb014c8 Merge pull request #307 from Coffey-Labs/share-target
Be somewhere a phone can share to
2026-09-07 22:46:24 -07:00
jcoffey-dev a73525f425 Build the shared file from its bytes, not from a Blob
CI caught this on Node 22 while it passed here on 26. `new File([blob],
…)` only puts the blob's contents in the file where that implementation
recognises a Blob as a part; where it does not, it stringifies it, and
the file contains the thirteen characters "[object Blob]". No error
anywhere -- the name, the type and the attachment are all correct and
the contents are gone.

A browser would not have done this. It is worth not relying on that: an
ArrayBuffer is a part on every implementation, and the whole file is in
memory a moment later regardless, since it is about to be uploaded.
2026-09-07 22:43:30 -07:00
jcoffey-dev 82470e8db0 Be somewhere a phone can share to
ihasmail could hand a file to the share sheet as of #306, and was still
not in it. Share a photo from the gallery, a link from the browser or a
document from a file manager and ihasmail was not among the places it
could go, which is the one piece of operating-system integration a mail
app is expected to have.

A share is a POST that navigates, and there is nothing on this side that
can answer one: the app is a client-side router with no endpoint at that
address, and the server behind it would need a route that understood the
composer. So the service worker intercepts it, takes the form body, puts
the files and text in its cache, and redirects to the app -- which finds
them on start and opens a draft holding them. The subject is the shared
title, the text and the link become the body, and files are attached and
begin uploading. Nothing is addressed: a share says what to send, never
who to.

The body is pushed in above the signature rather than passed to open(),
because open() only fits a signature when it is given no body at all --
the obvious version drops the signature from every message that started
as a share, and nothing about the draft looks wrong afterwards.

Collected on every start rather than when the launch URL says so. A share
to a signed-out ihasmail lands on the sign-in page, and there is no
account to attach to until it is done, so the payload has to outlive a
redirect and a login -- which the query string does not. What that costs
is a stash nobody came back for, so it carries a timestamp and expires
after ten minutes.

`accept` names wildcard families and explicit types and extensions both.
A mail client attaches anything, but wildcards are not in the
specification and operating systems differ over which form they match on,
so the explicit list is what holds if the families are ignored.

The cache name the worker and the app have to agree on now has one home
on the app side. It was written out twice, and a drift would not fail --
a push verification would simply never complete and a share would arrive
at an empty composer.

One case is deliberately left to fail loudly: an app still installed
whose worker has been cleared away POSTs to the server, which answers
405. A server route would trade a plain error for a silent nothing, and
the payload is gone in both -- it only ever existed in that request body.

Verified by test, not on a device: Android is the only place this exists
at all, and the extension driving Chrome is not connected here. The
handoff is pinned from the tab's side against a cache shaped exactly as
the worker leaves it, since the two files never see each other.
2026-09-07 22:40:44 -07:00
Coffey Labs f39d6ac30c Merge pull request #306 from Coffey-Labs/mobile-share-and-app-badge
Badge the installed icon, and share to the phone rather than to Downloads
2026-09-07 22:32:39 -07:00
jcoffey-dev 4e61adfe80 Badge the installed icon, and share to the phone rather than to Downloads
Three things an installed ihasmail did not do that a phone user expects,
and all three are about the app once it is off the browser tab.

The unread count was painted into the tab title and the favicon, neither
of which exists in `display: standalone` -- so putting ihasmail on a home
screen threw the count away entirely. It goes to the Badging API as well
now. Web Push marks the icon while the app is closed, and marks it with a
dot rather than a figure: the service worker has no session to ask how
many messages are unread, and a push carries the new mail rather than a
total, so counting the payload would badge "2" over an inbox holding
forty. The next tab to open writes the real count over it.

Sharing is new. Everything that left ihasmail left as a download, which
on a phone is close to a dead end -- the file lands in Downloads and
whoever meant to send it somewhere goes looking for it in a file manager.
The share sheet is now on the message menu, on each attachment row, and
in the file viewer, which is where an attachment is already open and
where both callers meet. A message shares as text rather than as the
.eml beside it: a share sheet is aimed at everything that is not a mail
client, and an .eml in a chat app is an attachment nobody can open.

Every control feature-detects, and sharing a file is a separate question
from sharing at all -- desktop Linux and Firefox have neither, and not
every browser with `share` takes files. Anything that fails, including
the transient activation running out while a large attachment is fetched,
falls through to the download the button sits beside, so the worst case
costs a tap rather than the file. `NotAllowedError` is reported as
unsupported for that reason: it cannot be told apart from a refusal, and
a toast about activation is not something a reader can act on.

The share strings are contextual keys rather than the existing "Share…".
That one means granting another account access, and several languages use
a different verb for it -- German had "Freigeben" where the sheet wants
"Teilen". Three new strings, in all nine catalogues.

The manifest gains `launch_handler: navigate-existing`, so a mailto:, a
shortcut or a notification tapped while ihasmail is running arrives in
the copy that is running: two windows on one inbox disagree about what
has been read. `focus-existing` would have been wrong -- it only focuses
and leaves the target URL to launchQueue, which nothing here consumes, so
it would swallow the mailto. There is deliberately still no `id`, and the
manifest now says why: it is the one member resolved against the origin
of start_url rather than against the manifest's own address, so no
relative form can name a subpath mount, and the default id already is
start_url -- writing one now would give every installed copy a new
identity and orphan it as a second app.

Verified by test rather than on a device: the extension driving Chrome
was not connected, and Chrome on Linux has no Web Share to drive anyway.
The preview dialog is covered by a component test that stubs the browser
both ways.
2026-09-07 22:28:17 -07:00
Coffey Labs b65732ea04 Merge pull request #304 from Coffey-Labs/settings-ownership-wording
Say who owns settings.json, not just where it lives
2026-09-07 18:07:30 -07:00
jcoffey-dev 166a04f578 Say who owns settings.json, not just where it lives
The bullet asserted that ihasmail stays stateless without saying what
that is scoped to, which reads as a claim about the file rather than
about the process. Name both halves: the format is ihasmail's, the file
is the account's.
2026-09-07 18:05:16 -07:00
Coffey Labs 79d891c623 Merge pull request #303 from Coffey-Labs/contributor-notes-in-contributing
Move the translation and UI-verification notes into CONTRIBUTING
2026-09-07 16:49:29 -07:00
jcoffey-dev d13cf6ed6b Move the translation and UI-verification notes into CONTRIBUTING
The plural-key gotcha and the reasons store tests miss visible bugs are
contributor guidance, not a side file: they belong next to the rest of
the pull-request checklist where anybody sending a change will read them.
2026-09-07 16:47:12 -07:00
Coffey Labs b56ffdf268 Merge pull request #302 from Coffey-Labs/claude-md-scope
Limit CLAUDE.md to translations and verifying UI work
2026-09-07 16:43:35 -07:00
jcoffey-dev 0ebdb38a98 Limit CLAUDE.md to translations and verifying UI work
The file was added without being asked for. It stays, but with its scope
stated at the top so it does not grow into a second contributor guide:
the nine catalogues and what it takes to confirm a visible change works,
and nothing else.
2026-09-07 16:41:10 -07:00
Coffey Labs 35935f2d3c Merge pull request #301 from Coffey-Labs/calendar-reimport-updates
Update a re-imported event rather than skipping it
2026-09-07 13:07:29 -07:00
jcoffey-dev 8173e22ccb Update a re-imported event rather than skipping it
Contacts and calendars disagreed on a re-import: a vCard or LDIF entry
whose identity a book already held overwrote the card there (#242, #274),
while an event whose UID a calendar held was counted and thrown away
(#222). The asymmetry was never decided -- it was where each half stopped.

Decided on #279: calendars update too, with two properties held back.
`participants` carries every attendee's accepted/declined and
`recurrenceOverrides` holds every "just this Wednesday" edit made here.
Both are decisions taken after the file was written, and a file that
mentions them at all describes them as they were at export, so writing
either one over would destroy work silently and return no error. A
corrected export now fixes the time, the title and the location, and
leaves who said yes alone. `uid` is held back with them: it is what the
two were matched on, so it is already equal.

The scan returns uid -> id rather than a set of UIDs, since updating
needs something to address, and creates and updates now share one
`maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new
and 300 changed batched separately would be two calls of 300, neither
over a ceiling of 500 and both refused. Counts become created/updated,
reported as the contacts import reports them.

Still no scheduling messages, on an update as much as on a create. That
is a real cost -- an event a re-import moves is moved here and nowhere
else -- and it is the lesser one: an import is not the place to start
mailing a room full of people who never asked for it.

Driven against the mock end to end: a second file with the same UID
updated the event in place, took the file's title, start and location,
and left an accepted RSVP and a per-occurrence override untouched even
though the file carried participants of its own.
2026-09-07 12:56:36 -07:00
63 changed files with 3499 additions and 1953 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Funding platforms shown behind the repository's Sponsor button.
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: LINUXexpert-org
github: jcoffey-dev
+44
View File
@@ -0,0 +1,44 @@
version: 2
updates:
# The npm entry sits at the root because that is where the single lockfile
# is: root, server and web are one npm workspace, so one entry covers all
# three. Pointing entries at server/ or web/ would find package.json files
# with no lockfile beside them and update nothing.
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
# Everything routine arrives as one PR a week, so the dashboard is not
# the only place these get noticed. Majors are deliberately left out of
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
# and each deserves its own PR and its own CI run.
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The runtime and build stages both pin node:22-alpine, so this is what
# keeps the published container images off a stale base between the weekly
# releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+3 -3
View File
@@ -15,10 +15,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
node-version: 26
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck
+11 -11
View File
@@ -76,13 +76,13 @@ jobs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
node-version: 26
- id: v
run: |
V="$(node scripts/version.mjs)"
@@ -108,18 +108,18 @@ jobs:
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
@@ -140,7 +140,7 @@ jobs:
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
@@ -157,13 +157,13 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
+4 -4
View File
@@ -46,13 +46,13 @@ jobs:
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
node-version: 26
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -122,7 +122,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
-2
View File
@@ -7,5 +7,3 @@ server/data/
.vite/
coverage/
# Worktrees used by parallel agents; never part of a commit.
.claude/worktrees/
-54
View File
@@ -1,54 +0,0 @@
# Notes for Claude
Things that are true of this repository and cost somebody a round trip to
find out. Not a style guide — `CONTRIBUTING.md` is that.
## Translations
Nine languages ship alongside English: German, Spanish, French, Dutch,
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
`web/src/locales/`. A missing key renders its English source rather than
failing, so an untranslated string is invisible until somebody reading that
language finds it.
**Any change that adds or alters a user-visible string adds work in all nine
catalogues.** Say so explicitly when reporting the change — how many keys, and
the fallback count before and after — and say so just as explicitly when a
change adds none, so it is never left to be inferred.
### The catalogue key for a plural is the `other` form
`plural()` looks the entry up by `forms.other`, so a call site written as
```ts
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
```
is keyed on **`"Deleted {n} contacts"`**. Keying the catalogue on the `one`
form type-checks, builds, passes every test, and silently falls back to English
in all nine languages. Nothing errors. The only signal is the fallback count
going up, so read it:
```sh
npm run i18n:check # literals wrapped, and catalogue health
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
```
Compare the "falling back to English" number against `main` before and after.
It should not rise. Do not read the percentage instead — adding keys moves the
denominator, so it can hold steady while new strings go untranslated.
Plural forms are per language, from `Intl.PluralRules`: `one`/`other` for most,
`one`/`few`/`many`/`other` for Russian and Ukrainian, `other` alone for Japanese
and Chinese. Supplying a form a language does not draw is inventing a
distinction, not being thorough.
## Verifying UI work
Store tests do not exercise the component. At least one bug in this repo's
history — a shift-click range measured inside a `setState` updater, which React
runs after the anchor ref has already moved — passed every store assertion and
failed the moment the built app was driven. If a change is visible on screen,
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then
drive the real thing. Add a component test for what you find; there are
examples in `web/src/views/*/__tests__/`.
+54 -6
View File
@@ -48,12 +48,10 @@ For larger changes, please open an issue to discuss the approach **before** subm
- Related issue number(s), if any
- Screenshots/GIFs for UI changes
- Any manual testing you performed
8. **Add translations** for any new user-visible string. Nine languages ship
alongside English in `web/src/locales/`, and a missing key renders its
English source rather than failing — so an untranslated string is invisible
until somebody reading that language finds it. `npm run i18n:check` and
`node scripts/i18n-catalog-check.mjs` report where you stand; the catalogue
key for a plural is the `other` form. See [CLAUDE.md](CLAUDE.md).
8. **Add translations** for any new user-visible string — see
[Translations](#translations) below — and **drive the built app** for any
change that is visible on screen, as described in
[Verifying UI work](#verifying-ui-work).
`main` is protected. A change reaches it through a pull request whose **build**
check has passed — not afterwards — and the branch cannot be force-pushed or
@@ -67,6 +65,56 @@ waiting for one.
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
### Translations
Nine languages ship alongside English: German, Spanish, French, Dutch,
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
`web/src/locales/`. A missing key renders its English source rather than
failing, so an untranslated string is invisible until somebody reading that
language finds it.
**Any change that adds or alters a user-visible string adds work in all nine
catalogues.** Say so explicitly in the PR — how many keys, and the fallback
count before and after — and say so just as explicitly when a change adds none,
so it is never left to be inferred.
#### The catalogue key for a plural is the `other` form
`plural()` looks the entry up by `forms.other`, so a call site written as
```ts
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
```
is keyed on **`"Deleted {n} contacts"`**. Keying the catalogue on the `one`
form type-checks, builds, passes every test, and silently falls back to English
in all nine languages. Nothing errors. The only signal is the fallback count
going up, so read it:
```sh
npm run i18n:check # literals wrapped, and catalogue health
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
```
Compare the "falling back to English" number against `main` before and after.
It should not rise. Do not read the percentage instead — adding keys moves the
denominator, so it can hold steady while new strings go untranslated.
Plural forms are per language, from `Intl.PluralRules`: `one`/`other` for most,
`one`/`few`/`many`/`other` for Russian and Ukrainian, `other` alone for Japanese
and Chinese. Supplying a form a language does not draw is inventing a
distinction, not being thorough.
### Verifying UI work
Store tests do not exercise the component. At least one bug in this repo's
history — a shift-click range measured inside a `setState` updater, which React
runs after the anchor ref has already moved — passed every store assertion and
failed the moment the built app was driven. If a change is visible on screen,
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then
drive the real thing. Add a component test for what you find; there are
examples in `web/src/views/*/__tests__/`.
### Development Setup
1. Clone your fork:
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- build stage ----
FROM node:22-alpine AS build
FROM node:26-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`
@@ -25,7 +25,7 @@ COPY . .
RUN npm run build
# ---- runtime stage ----
FROM node:22-alpine AS runtime
FROM node:26-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ARG BASE_PATH=""
+96 -3
View File
@@ -546,6 +546,24 @@ nothing for anybody else.
- **iCal import** through `CalendarEvent/parse` (a file of any number of
events), from the calendar's own menu, into that calendar. The events are
filed rather than scheduled: no invitations go out to anyone named in them.
- **Re-importing updates rather than duplicates**, as a contacts import does.
An event is recognised by its UID, per calendar, and what the file carries
wins -- so a corrected export corrects what the first attempt got wrong.
Two things are deliberately left alone: **who accepted**, and **edits to a
single occurrence**. Both are answers and decisions taken here after the file
was written, and a file that mentions them at all describes them as they were
at export, so writing either one over would throw away work silently and
return no error anywhere. A corrected export therefore fixes the time, the
title and the location, and leaves the RSVPs and the "just this Wednesday"
changes where they are.
The cost runs both ways and is worth knowing. An attendee added at the source
since the last import does not arrive, because nothing here can tell that
apart from an answer given in ihasmail. And an import still sends no
scheduling messages, so an event a re-import moves is moved *here* --
everybody else's copy still says the old time until whoever is organising
sends the update from the event itself.
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
Added in Settings Calendar & contacts, read-only, and shown beside your own
with their own colour.
@@ -1101,9 +1119,14 @@ needed nothing in either half.
when the open page happened to be the root.
- **The subscription is renewed on every app start**, because a JMAP push
subscription expires — seven days is the ceiling — and re-registering before
it lapses is the client's job. Renewal can only happen with a page open:
registering is a JMAP call and the service worker has no session to make one
with. So the guarantee is that background notifications keep working as long
it lapses is the client's job. Renewal happens with a page open, and the
reason is *when* the service worker runs rather than what it is allowed to
do: it only wakes for an event, and the event that would wake it is a push
that stops arriving the moment the subscription lapses. A renewal that can
only run while renewal is still unnecessary is no schedule at all. (This
page previously said the worker had no session to register with. That was
wrong — see **Acting on a notification** below.) So the guarantee is that
background notifications keep working as long
as ihasmail is opened now and again, and the two-day renewal window means
once a week is enough. A browser that dropped or rotated its subscription on
its own is re-subscribed at the same moment, rather than left with a switch
@@ -1123,6 +1146,76 @@ needed nothing in either half.
installability and fast loads, API requests never are, and navigations are
network-first with the shell as fallback.
- **Manifest shortcuts** for Compose, Calendar and Contacts.
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
notification opened while ihasmail is already running arrives in the copy
that is running. Two windows on the same inbox disagree about what has been
read, and only one of them is where the half-written reply is.
- **The unread count on the installed app's icon.** The tab title and the
painted favicon are the same idea for a browser tab, and an installed app has
neither -- in `display: standalone` there is no tab strip and no favicon on
screen, so a home-screen ihasmail showed nothing at all. Web Push marks the
icon while the app is closed, with a dot rather than a figure: the service
worker is not told how many messages are unread — a push carries the new mail
rather than a total, so counting the payload would badge "2" over an inbox
holding forty. The next tab to open writes the real count over it. It could
now ask, which is a change since this was written; whether a badge is worth a
request on every push is a separate question and has not been answered yet.
Unsupported browsers show nothing, as does iOS until notification permission
has been granted, which is that platform's condition for a badge.
- **In the share sheet** — share a photo, a link or a file from any other app
and ihasmail is one of the places it can go, opening a draft that holds it.
The subject comes from the shared title, the text and the link become the
body above your signature, and files are attached and start uploading. It
addresses nothing: a share says what to send, never who to.
A share is a POST, which is not something a client-side router can answer, so
the service worker takes the body, leaves it where a tab can collect it and
redirects to the app. That indirection is also what lets a share to a
signed-out ihasmail work — it waits through the sign-in page and opens after,
which the query string could not have survived. One nobody comes back for
expires after ten minutes rather than opening a composer full of a forgotten
photo the next time you look. Android and Chromium only; iOS does not
implement share targets.
- **Acting on a notification.** Archive and Mark as read sit on the
notification itself, and both happen where you are — the phone stays in your
hand, or in your pocket. They are the two a phone shows: `maxActions` is two
on Android, and anything past it is dropped silently, so these are the two
worth having rather than the two that came first. Reply is deliberately not
among them, because it would have to open the app, and tapping the
notification already does that.
This was described here as impossible, and it is worth saying why it was not.
ihasmail's session is an httpOnly cookie against its own origin, and the only
other thing the API asks for is a fixed header that is not a secret. A
same-origin request from the service worker carries the cookie like any
other, so `Email/set` from a notification is an ordinary call. What the
worker genuinely cannot reach is anything a *tab* holds in memory — and the
API asks for none of it.
What it cannot reach is a catalogue. The worker is plain JavaScript outside
the bundle, with no i18n and no idea which mailbox is the archive, so the app
writes both down for it whenever the language, the account or the folder list
changes. Where there is no such note — between installing a new worker and
next opening ihasmail — the notification appears with no action buttons at
all rather than English ones over a guessed mailbox.
A session can still be gone by the time a button is pressed: expired, signed
out, or a cookie that did not outlive the browser. That comes back as a
refusal, and the notification says so rather than disappearing as though it
had worked. It does not open the app to recover — being interrupted is the
thing the button existed to avoid.
- **Share** — a message, or one attachment, handed to the operating system's
share sheet instead of to the filesystem. On a phone a download is close to a
dead end: the file lands in Downloads and whoever wanted to send it somewhere
goes hunting for it in a file manager. The sheet is on the message menu, on
each attachment row, and in the file viewer, which is where an attachment is
already open. A message shares as text rather than as the `.eml` beside it,
because a share sheet is aimed at everything that is not a mail client and an
`.eml` in a chat app is an attachment nobody can open. Every one of those
controls is drawn only where the browser has Web Share -- absent on desktop
Linux and in Firefox -- and sharing a file is asked about separately from
sharing at all. Where the share cannot be made, the download it sits beside
happens instead, so the worst case costs a tap rather than the file.
- **`mailto:` handler** — registered from Settings General for the browser
(needs HTTPS; Safari does not support it), and declared in the manifest so an
installed ihasmail is offered by the operating system wherever something asks
+2 -2
View File
@@ -68,7 +68,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
- **Signature checking** — S/MIME signed mail is verified as you read it, and the signer is remembered: a later message from the same address signed by somebody else is called out loudly. No certificate authority is involved and none is bundled, so ihasmail never claims more than it can show — see [Checking a signature](FEATURES.md#checking-a-signature)
- **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
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files. The format is ihasmail's; the file is the account's, under its quota, and outlives any container that read it. ihasmail holds none of it
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
@@ -347,7 +347,7 @@ missing.
## Development
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
```bash
npm install
+1109 -1712
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -10,7 +10,7 @@
"web"
],
"engines": {
"node": ">=20.10"
"node": ">=20.19"
},
"scripts": {
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
@@ -28,6 +28,7 @@
},
"devDependencies": {
"concurrently": "^9.1.2",
"typescript": "^5.7.3"
"typescript": "^7.0.2",
"typescript-ast": "npm:typescript@^5.9.3"
}
}
+15 -1
View File
@@ -12,7 +12,21 @@
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalogue is only ever read by key.
*/
import ts from "typescript";
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
const wanted = new Set();
+15 -1
View File
@@ -11,7 +11,21 @@
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
import ts from "typescript";
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/** Attributes a person reads. `className` and `key` are not among them. */
+15 -1
View File
@@ -12,7 +12,21 @@
* node scripts/i18n-extract.mjs <file...> rewrite in place
* node scripts/i18n-extract.mjs --dry <file...>
*/
import ts from "typescript";
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, writeFileSync } from "node:fs";
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
+15 -1
View File
@@ -18,7 +18,21 @@
* string that is neither -- one no catalogue has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
import ts from "typescript";
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/* Where a string literal in this position is shown to somebody. */
+15 -1
View File
@@ -9,7 +9,21 @@
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.
*/
import ts from "typescript";
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
const strings = new Set();
+5 -5
View File
@@ -16,12 +16,12 @@
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4"
"@hono/node-server": "^2.1.1",
"hono": "^4.13.7"
},
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
"@types/node": "^26.5.1",
"tsx": "^4.23.13",
"typescript": "^7.0.2"
}
}
+30 -1
View File
@@ -5,6 +5,35 @@ import { Readable } from "node:stream";
import type { Context, Handler } from "hono";
import { stripBasePath } from "../../scripts/basePath.mjs";
/*
* Files that must not be served from anybody's cache, the way index.html is
* not.
*
* They went out with `max-age=3600` because they are neither hashed assets nor
* HTML, and an hour looks harmless. It is not, for two of them, and a CDN in
* front makes it worse: on a deploy the origin had the new build while
* Cloudflare went on handing out the previous `sw.js` for hours, with
* `cf-cache-status: HIT` and an edge TTL of its own that was longer than what
* we asked for. Caught on the 2026-09-08 deploy, where the new worker was live
* at the origin and the old one was still being installed by every browser
* that asked.
*
* What that costs is specific rather than general. The service worker is the
* app's whole update mechanism: a stale one keeps serving the shell it knows
* and never learns there is a newer build, so the deploy simply does not
* arrive. And a manifest and a worker that disagree is worse than either being
* old -- a fresh manifest advertising a share target to the operating system,
* answered by a worker that has never heard of one, sends the share to the
* server for a 405.
*
* `no-cache` does not mean "do not store": the browser and the CDN may both
* keep it and revalidate, which is a 304 and costs nothing. It means neither
* gets to serve it without asking first, which is the whole requirement.
*/
function isNeverStale(rel: string, ext: string): boolean {
return ext === ".webmanifest" || rel === "/sw.js" || rel === "sw.js";
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
@@ -116,7 +145,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
c.header("Content-Length", String(st.size));
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
c.header("Cache-Control", "public, max-age=31536000, immutable");
} else if (ext === ".html") {
} else if (ext === ".html" || isNeverStale(rel, ext)) {
c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
} else {
+81
View File
@@ -0,0 +1,81 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/*
* What may be served stale, and what may not.
*
* This is not a preference about freshness. The service worker is the app's
* whole update mechanism: a browser holding an old one goes on being served
* the shell that worker knows and never finds out a deploy happened. On
* 2026-09-08 the origin had the new build while Cloudflare handed out the
* previous `sw.js` for hours, because it was neither a hashed asset nor HTML
* and so went out with an hour's max-age that the CDN then extended.
*
* A static root of our own, since CI runs the tests before the build and
* `web/dist` does not exist yet.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-cache-"));
mkdirSync(join(root, "assets"));
writeFileSync(join(root, "assets", "app-a1b2c3.js"), "console.log(1)\n");
writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "manifest.webmanifest"), `{"name":"ihasmail"}`);
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
writeFileSync(join(root, "img.png"), "not really a png");
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const cacheControl = async (path: string) => {
const res = await createApp().request(path);
assert.equal(res.status, 200, `${path} should be served`);
return res.headers.get("cache-control") ?? "";
};
test("the service worker is never served from a cache without asking", async () => {
// `no-cache` permits storing it and requires revalidating it, which is a 304
// and costs nothing. What it forbids is a browser or a CDN answering with
// its own copy, which is the whole failure.
assert.match(await cacheControl("/sw.js"), /no-cache/);
});
test("nor is the manifest, which the worker has to agree with", async () => {
// A fresh manifest advertising a share target, answered by a worker that has
// never heard of one, sends the share to the server for a 405. Either being
// old is survivable; the two disagreeing is not.
assert.match(await cacheControl("/manifest.webmanifest"), /no-cache/);
});
test("the manifest is still served as a manifest", async () => {
const res = await createApp().request("/manifest.webmanifest");
assert.match(res.headers.get("content-type") ?? "", /application\/manifest\+json/);
});
test("index.html was already revalidated, and still is", async () => {
assert.match(await cacheControl("/"), /no-cache/);
});
test("hashed assets are still immutable for a year", async () => {
// The name changes when the bytes do, so there is nothing to go stale --
// and this is the caching that makes the app load quickly at all.
const cc = await cacheControl("/assets/app-a1b2c3.js");
assert.match(cc, /immutable/);
assert.match(cc, /max-age=31536000/);
});
test("everything else keeps its ordinary hour", async () => {
// The rule is narrow on purpose: two files, named, rather than a policy that
// quietly stops the icons and fonts being cached too.
assert.match(await cacheControl("/img.png"), /max-age=3600/);
});
test("under a prefix, the worker is still the worker", async () => {
// The mount comes off before the path is matched, so this has to hold for a
// subpath deployment as well -- where a stale worker is exactly as bad.
const res = await createApp("/mail").request("/mail/sw.js");
assert.equal(res.status, 200);
assert.match(res.headers.get("cache-control") ?? "", /no-cache/);
});
+7 -7
View File
@@ -13,22 +13,22 @@
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4",
"dompurify": "^3.4.15",
"lucide-react": "^0.477.0",
"marked": "^18.0.11",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"wouter": "^3.6.0",
"wouter": "^3.11.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.2.0",
"vitest": "^3.0.8"
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vitest": "^4.1.11"
}
}
+34
View File
@@ -3,8 +3,42 @@
"short_name": "ihasmail",
"description": "Fast, friendly JMAP webmail for Stalwart",
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
"start_url": "mail",
"scope": "./",
"categories": ["productivity", "utilities"],
"_comment_launch": "One window, not one per launch. A `mailto:` link, a manifest shortcut or a notification tapped while ihasmail is already open should arrive in the copy that is running rather than beside it -- two windows on the same inbox disagree about what has been read. `navigate-existing` rather than `focus-existing` because the latter only focuses and leaves the app to handle the target URL through launchQueue, which nothing here consumes: it would swallow the mailto entirely. The navigation goes through the same beforeunload guard as a reload, so an unsent draft still stops it and asks.",
"launch_handler": {
"client_mode": "navigate-existing"
},
"_comment_share_target": "Being in the operating system's share sheet, which is the other half of the Share this app now offers. `action` is relative like everything else here, so it follows the mount; it has to sit inside `scope`, and `./` covers it. POST with multipart because a share can carry files, and a POST to a page is not something the app can answer -- the service worker intercepts it, puts the payload where a tab can collect it, and redirects. `accept` names wildcard families AND explicit types and extensions on purpose: a mail client attaches anything, but wildcard support is not in the specification and operating systems differ over which form they match on, so the explicit list is what holds if the families are ignored. Android and Chromium only -- iOS does not implement share targets at all.",
"share_target": {
"action": "share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": [
"image/*", "video/*", "audio/*", "text/*",
"application/pdf", "application/zip", "application/json",
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet",
"message/rfc822", "text/calendar", "text/vcard",
".pdf", ".zip", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".odt", ".ods", ".csv", ".txt", ".md", ".eml", ".ics", ".vcf",
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mp3"
]
}
]
}
},
"protocol_handlers": [
{
"protocol": "mailto",
+227 -13
View File
@@ -30,8 +30,74 @@ self.addEventListener("activate", (event) => {
);
});
/*
* Where a share from the operating system is left for a tab to collect.
*
* Absolute and anchored to the mount, for the same reason the verification key
* below is: a relative key is resolved against the URL of whoever asks, and the
* worker and a tab deep in `/mail/inbox/…` are not at the same place.
*
* The files go in one entry each and the rest in a JSON index beside them,
* because the Cache API stores Responses and a File is already one body.
*/
const SHARE_KEY = `${BASE}/ihasmail-share`;
const SHARE_MAX_FILES = 20;
/*
* Take delivery of a share.
*
* This is a POST that navigates: the operating system submits a form at the
* app and expects a page back. Nothing in ihasmail can answer it directly --
* the app is a client-side router with no endpoint at that address, and the
* server behind it would have to grow one that understood the composer. So the
* worker takes the body, puts it where a tab can find it, and redirects to the
* app, which then opens a draft holding it.
*
* The redirect happens whatever went wrong. A share that fails to stash costs
* whatever was being shared, which is bad; a share that fails to *respond*
* costs that and leaves the reader looking at a browser error page where they
* expected their mail, which is worse.
*
* There is one case this cannot cover, and the server is deliberately not
* taught to: an app still installed whose worker has been cleared away. The
* POST then reaches the server, which answers 405, and the share is lost
* either way -- the payload only ever existed in that request body. A server
* route would trade a plain error for a silent nothing, and a share that
* vanishes without saying so is the harder of the two to notice.
*/
async function stashShare(request) {
try {
const form = await request.formData();
const cache = await caches.open(VERSION);
const meta = {
at: Date.now(),
title: String(form.get("title") ?? ""),
text: String(form.get("text") ?? ""),
url: String(form.get("url") ?? ""),
files: [],
};
const files = form.getAll("files").filter((f) => f && typeof f === "object" && "name" in f && f.size > 0);
for (const [i, f] of files.slice(0, SHARE_MAX_FILES).entries()) {
const key = `${SHARE_KEY}/${i}`;
await cache.put(key, new Response(f, { headers: { "content-type": f.type || "application/octet-stream" } }));
meta.files.push({ key, name: f.name || `file-${i + 1}`, type: f.type || "application/octet-stream" });
}
await cache.put(SHARE_KEY, new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } }));
} catch {
/* nothing to hand on: the app opens on an empty inbox rather than an error */
}
// Absolute, because `Response.redirect` rejects a bare path outright rather
// than resolving it -- so `${BASE}/mail` would throw here and the share
// would end at a browser error page instead of the inbox.
return Response.redirect(new URL(`${BASE}/mail?share=1`, self.location.origin).href, 303);
}
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method === "POST" && new URL(req.url).pathname === `${BASE}/share`) {
event.respondWith(stashShare(req));
return;
}
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
@@ -64,14 +130,23 @@ self.addEventListener("fetch", (event) => {
/*
* 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.
* nothing here talks to ihasmail's server on the way in. 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 is what lets a
* notification appear immediately rather than after a request.
*
* This file used to say that a round-trip was impossible here, and it was
* wrong: see the note on `jmap()`. What it can do is ask; what it cannot do is
* be sure of an answer, since the session may be gone by the time it does. So
* the payload still carries the message and the request is only made when
* somebody presses something.
*
* 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.
* until the client echoes its code back. It is stashed for a tab to confirm
* rather than answered here on the same reasoning, and because a
* verification that failed silently would leave push looking broken with
* nothing to show for it. Answering it directly is now possible and is worth
* revisiting.
*/
/*
@@ -87,13 +162,90 @@ self.addEventListener("fetch", (event) => {
*/
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
function textOf(email) {
/*
* What a tab wrote down for this worker: the account, which mailbox is the
* archive, and the worker's own text in the reader's language. See
* `lib/swFacts.ts` for why any of that has to be handed over rather than
* worked out here.
*
* Everything that depends on it is skipped when it is missing, which is the
* state between installing this worker and next opening the app. An action
* button with no label, or one that files mail into a mailbox guessed by name,
* is worse than the notification that was here before.
*/
const FACTS_KEY = `${BASE}/ihasmail-worker-facts`;
async function readFacts() {
try {
const hit = await (await caches.open(VERSION)).match(FACTS_KEY);
return hit ? await hit.json() : null;
} catch {
return null;
}
}
/*
* A JMAP call, made as the reader.
*
* This worker was written believing it could not do this -- that acting on
* mail needed a session it had no way to hold. It does not: ihasmail's session
* is an httpOnly cookie against its own origin, and the only other thing the
* API asks for is a fixed `x-requested-with` header that is not a secret and
* is not held anywhere. A same-origin fetch from here carries the cookie like
* any other, so `Email/set` from a notification is an ordinary request.
*
* What is genuinely not available is anything the *tab* holds in memory, and
* the answer is that the API asks for none of it.
*
* The session can still be gone -- expired, signed out, or a cookie that did
* not survive the browser closing -- which arrives as a 401 and is reported
* rather than swallowed. A tap that silently does nothing is the failure worth
* avoiding here: the reader has already put the phone down.
*/
async function jmap(methodCalls) {
const res = await fetch(`${BASE}/api/jmap`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
// A JMAP method can fail inside a 200. Treat that as a failure too, rather
// than reporting success because the transport was fine.
const first = body?.methodResponses?.[0];
if (!first || first[0] === "error") throw new Error(first?.[1]?.type || "error");
const notUpdated = first[1]?.notUpdated;
if (notUpdated && Object.keys(notUpdated).length) throw new Error("notUpdated");
return body;
}
function textOf(email, strings) {
const from = email?.from?.[0];
const who = from?.name || from?.email || "New message";
const what = email?.subject || "(no subject)";
const who = from?.name || from?.email || strings.newMessage;
const what = email?.subject || strings.noSubject;
return { title: who, body: what, preview: email?.preview || "" };
}
/*
* Two, because that is what a phone shows. `Notification.maxActions` is 2 on
* Android Chrome, and anything past it is dropped silently -- so these are the
* two worth having rather than the two that happened to come first. Both are
* triage: they are what somebody does to a notification they have read the
* whole of on the lock screen and does not need to open.
*
* Reply is deliberately not among them. It cannot be done from here, so it
* would have to open the app -- and an action that opens the app is what
* tapping the notification already does.
*/
function actionsFor(facts) {
if (!facts) return [];
const actions = [];
if (facts.archiveId) actions.push({ action: "archive", title: facts.strings.archive });
actions.push({ action: "read", title: facts.strings.markRead });
return actions;
}
self.addEventListener("push", (event) => {
let data = null;
try {
@@ -120,10 +272,24 @@ self.addEventListener("push", (event) => {
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => {
const facts = await readFacts();
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
/*
* Mark the app icon, without claiming a number.
*
* `setAppBadge()` with no count shows a dot rather than a figure, which is
* the only honest thing to show from here: this worker has no session, so
* it cannot ask how many messages are unread, and a push carries the new
* mail rather than a total. Counting the payload would badge "2" over an
* inbox holding forty. The next time a tab opens, `setUnreadBadge` writes
* the real count over the dot.
*/
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
if (!emails.length) {
// A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender.
await self.registration.showNotification("New mail", {
await self.registration.showNotification(strings.newMail, {
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
});
return;
@@ -131,21 +297,69 @@ self.addEventListener("push", (event) => {
// 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);
const { title, body, preview } = textOf(email, strings);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
icon: `${BASE}/img/icon-192.png`,
badge: `${BASE}/img/favicon-64.png`,
tag: `ihasmail-${email.id || body}`,
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
// Only where there is a message to act on: a payload without an id can
// be shown but not archived, and a button that cannot work should not
// be drawn.
actions: email.id ? actionsFor(facts) : [],
data: {
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
id: email.id || null,
title,
accountId: facts?.accountId ?? null,
archiveId: facts?.archiveId ?? null,
failed: strings.failed ?? null,
},
});
}
})());
});
/*
* Do what the button said, without opening anything.
*
* The whole point of an action is that the phone goes back in the pocket, so
* this must not fall back to opening the app when the call fails -- that is
* the same interruption the action existed to avoid. It re-notifies instead,
* saying it did not happen, and leaves opening ihasmail to the reader.
*
* Archiving replaces the mailbox set rather than adding to it, which is what
* archiving is: the message leaves the inbox. Marking read is a keyword and
* touches nothing else.
*/
async function runAction(action, data) {
const { id, accountId, archiveId } = data;
if (!id || !accountId) return;
const patch = action === "archive"
? { mailboxIds: { [archiveId]: true } }
: { "keywords/$seen": true };
try {
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
} catch {
await self.registration.showNotification(data.title || "ihasmail", {
body: data.failed || "Could not do that — open ihasmail and try again",
icon: `${BASE}/img/icon-192.png`,
badge: `${BASE}/img/favicon-64.png`,
tag: `ihasmail-failed-${id}`,
data: { url: data.url },
});
}
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || `${BASE}/mail`;
const data = event.notification.data || {};
if (event.action === "archive" || event.action === "read") {
event.waitUntil(runAction(event.action, data));
return;
}
const url = data.url || `${BASE}/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. Same origin is
+16
View File
@@ -17,6 +17,7 @@ import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { publishWorkerFacts } from "@/lib/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
@@ -263,6 +264,21 @@ function AuthedApp() {
});
}, [inboxUnread, appName]);
/*
* Leave the service worker its briefing.
*
* Written from here rather than once at startup because everything in it can
* change while the app is open -- the language from Settings, the archive
* folder from the mailbox list arriving -- and what is written is what the
* worker will still be reading a week from now, with no tab to correct it.
* See lib/swFacts.ts.
*/
const archiveId = useMail((s) => s.roleId("archive"));
const languageVersion = useLanguageVersion();
useEffect(() => {
void publishWorkerFacts(accountId, archiveId);
}, [accountId, archiveId, languageVersion]);
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => {
+99 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => {
@@ -96,6 +96,24 @@ describe("markKeptSurfaces", () => {
return d;
};
/*
* Marking is only half of it the other half is the rule in EMAIL_BASE_CSS
* that reads the marks, and #310 was a bug in that half rather than in the
* marking. So these assert what the reader actually sees: does the
* neutraliser hit this element? The selector is lifted out of the stylesheet
* rather than copied, so a test cannot quietly drift from the rule it checks.
*/
const NEUTRALISER = (() => {
const m = EMAIL_BASE_CSS.match(
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
);
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
return m[1]!.trim();
})();
/** True when the theme is forced onto this element rather than leaving it alone. */
const neutralised = (el: Element) => el.matches(NEUTRALISER);
it("keeps a coloured button and drops the white sheet around it", () => {
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
@@ -103,9 +121,87 @@ describe("markKeptSurfaces", () => {
expect(markKeptSurfaces(d)).toBe(1);
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
// The label is not marked itself; the CSS keeps it because it is inside
// something that is, which is what stops white-on-blue turning unreadable.
// The label is not a painted surface itself. It is marked as sitting on
// one, which is what stops white-on-blue turning unreadable.
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
});
it("neutralises a light panel nested inside a dark painted card", () => {
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
// are dark enough to be marked, with light content tables inside them.
// Those tables used to inherit the card's exemption and render as beige
// sheets in an otherwise themed message.
const d = frag(
'<div style="background-color:#e7e5e2">' +
'<div style="background-color:#2b2b2b">' +
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
'</div>' +
'</div>',
);
expect(markKeptSurfaces(d)).toBe(1);
const divs = Array.from(d.querySelectorAll("div"));
const surround = divs[0]!;
const card = divs[1]!;
const nested = d.querySelector("table")!;
// The page surround is a sheet and always was.
expect(surround.hasAttribute("data-ihm-keep")).toBe(false);
// The card is paint and stays paint.
expect(card.hasAttribute("data-ihm-keep")).toBe(true);
// The fix, stated the way the reader experiences it: the nested sheet is
// themed, and so is the copy inside it. Before #310 both were exempt for
// being descendants of the card.
expect(neutralised(nested)).toBe(true);
expect(neutralised(d.querySelector("td")!)).toBe(true);
// The card itself is still left alone, and the page surround still goes.
expect(neutralised(card)).toBe(false);
expect(neutralised(surround)).toBe(true);
});
it("still keeps a button that sits inside a nested light panel", () => {
// Paint resumes below a sheet, however deep it is: the fix must not cost
// a call to action its label just because a sheet came between it and the
// card it is on.
const d = frag(
'<div style="background-color:#2b2b2b">' +
'<table style="background-color:#ffffff"><tr>' +
'<td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td>' +
'</tr></table>' +
'</div>',
);
expect(markKeptSurfaces(d)).toBe(2);
expect(neutralised(d.querySelector("table")!)).toBe(true);
expect(neutralised(d.querySelector("td")!)).toBe(false);
// The label keeps its white, which is the thing #294 bought and this must
// not spend.
expect(neutralised(d.querySelector("a")!)).toBe(false);
});
it("leaves no light panel exempt across the whole reported specimen", () => {
// #310 as reported: a dark campaign with no bgcolor attributes, 21 light
// panels, 14 of them nested inside dark 600px cards. Those fourteen were
// the ones rendering as beige sheets.
let cards = "";
for (let i = 0; i < 7; i++) {
cards +=
'<div style="background-color:#2b2b2b">' +
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
'<table style="background-color:#e7e5e2"><tr><td>more</td></tr></table>' +
"</div>";
}
let loose = "";
for (let i = 0; i < 7; i++) {
loose += '<table style="background-color:#e7e5e2"><tr><td>loose</td></tr></table>';
}
const d = frag('<div style="background-color:#e7e5e2">' + cards + loose + "</div>");
const panels = Array.from(d.querySelectorAll<HTMLElement>("table"));
expect(panels.length).toBe(21);
expect(markKeptSurfaces(d)).toBe(7);
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
});
it("reads an inline background as well as the attribute", () => {
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { hasHtmlAlternative } from "../html";
/*
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
* part's own type says whether there is an HTML alternative to render.
*
* The shapes below are what Stalwart 0.16.21 actually returned for one thread
* on 2026-09-10, read back through `Email/get` with
* `bodyProperties: ["partId", "type"]`. A plain-text message named the *same*
* part in both lists; a message with a real alternative named two.
*
* Getting this wrong is not a rendering nicety. Plain text went to the HTML
* path, which places the body under `white-space: normal`, so every line break
* collapsed: hard-wrapped mail arrived as one paragraph, and the signature and
* the quoted reply ran into the prose.
*/
describe("deciding whether a message has an HTML alternative", () => {
it("says no to a plain-text message, whose htmlBody holds the text part", () => {
// As returned for [email protected]: htmlBody[0] and textBody[0] are the
// same part, typed text/plain.
expect(hasHtmlAlternative({ type: "text/plain" }, "Hey,\n\nmy earlier response was before\n")).toBe(false);
});
it("says yes to a real multipart/alternative", () => {
expect(hasHtmlAlternative({ type: "text/html" }, "<p>Hello</p>")).toBe(true);
});
it("keeps the parameters that follow a media type", () => {
// `type` arrives bare in practice, but a charset must not turn a real HTML
// part into a plain-text one.
expect(hasHtmlAlternative({ type: "text/html; charset=utf-8" }, "<p>Hi</p>")).toBe(true);
});
it("is not fooled by a type that merely starts with the right letters", () => {
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
});
it("matches the type case-insensitively, since a header may be capitalised", () => {
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
});
it("says no when the part is HTML but its value never arrived", () => {
// maxBodyValueBytes can leave a part named with nothing fetched; falling
// through to the text body is the useful answer, not an empty pane.
expect(hasHtmlAlternative({ type: "text/html" }, undefined)).toBe(false);
expect(hasHtmlAlternative({ type: "text/html" }, "")).toBe(false);
});
it("says no when there is no part at all", () => {
expect(hasHtmlAlternative(undefined, undefined)).toBe(false);
expect(hasHtmlAlternative({}, "something")).toBe(false);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { rowIsOpen, visibleMessages } from "../openMessage";
/*
* Reported from the inbox: with conversation view off, the list showed the two
* messages of a thread as separate rows -- correctly -- but clicking either one
* highlighted *both* and filled the reading pane with all five messages of the
* conversation.
*
* The setting reached only as far as `collapseThreads` on the query. These are
* the two rules that were missing downstream.
*/
const msg = (id: string) => ({ id });
describe("which row is drawn as open", () => {
it("marks only the opened message, not its siblings", () => {
// The reported case: two rows, one thread, one of them opened.
expect(rowIsOpen("m1", "t1", "m1", "t1")).toBe(true);
expect(rowIsOpen("m2", "t1", "m1", "t1")).toBe(false);
});
it("still marks the whole thread when conversation view is on", () => {
// No message singled out: every row of the open thread is part of what the
// reading pane is showing, so every one of them is open.
expect(rowIsOpen("m1", "t1", null, "t1")).toBe(true);
expect(rowIsOpen("m2", "t1", null, "t1")).toBe(true);
expect(rowIsOpen("m3", "t2", null, "t1")).toBe(false);
});
it("marks nothing when nothing is open", () => {
expect(rowIsOpen("m1", "t1", null, null)).toBe(false);
});
it("does not mark a row whose thread is unknown", () => {
// A row whose email has not loaded yet has no thread id; `undefined` must
// not match a null openThreadId and light the row up.
expect(rowIsOpen("m1", undefined, null, null)).toBe(false);
});
});
describe("which messages the reading pane shows", () => {
const thread = [msg("a"), msg("b"), msg("c")];
it("shows just the opened message", () => {
expect(visibleMessages(thread, "b")).toEqual([msg("b")]);
});
it("shows the whole thread when none is singled out", () => {
expect(visibleMessages(thread, null)).toEqual(thread);
});
it("falls back to the thread when the id names nothing in it", () => {
/*
* Two ways to arrive here: a link shared by somebody whose conversation
* view is on, and an `m` parameter left in the URL when the setting is
* switched back. A conversation is a better answer to both than an empty
* pane, which is what filtering to nothing would produce.
*/
expect(visibleMessages(thread, "zzz")).toEqual(thread);
});
it("leaves an empty thread empty rather than inventing a message", () => {
expect(visibleMessages([], "b")).toEqual([]);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { canShare, canShareFiles, resetShareSupport, shareFile, shareText } from "@/lib/share";
/**
* The outcomes are the whole of this module: what the callers do next is
* decided entirely by which of the three comes back, and two of the three are
* reached through an exception rather than a return.
*
* `unsupported` is the one worth guarding. It is the instruction to download
* instead, and it has to cover the browser that cannot share files *and* the
* share that was refused because the tap's activation ran out while the
* attachment was fetched -- which arrives as an error indistinguishable from a
* permissions refusal, and would otherwise reach the reader as a toast about
* something they cannot act on.
*/
function stubNavigator(nav: Partial<Navigator>) {
vi.stubGlobal("navigator", nav as Navigator);
resetShareSupport();
}
const aFile = () => new File(["x"], "note.txt", { type: "text/plain" });
afterEach(() => {
vi.unstubAllGlobals();
resetShareSupport();
});
describe("share availability", () => {
it("is absent where the browser has no Web Share", () => {
stubNavigator({});
expect(canShare()).toBe(false);
expect(canShareFiles()).toBe(false);
});
it("asks about files separately from sharing at all", () => {
// Every iOS and Android browser shares text; not all of them take files,
// and a Share button that turns out to be a download is worse than none.
stubNavigator({ share: vi.fn(), canShare: () => false });
expect(canShare()).toBe(true);
expect(canShareFiles()).toBe(false);
});
it("probes with a real file, since canShare() cannot answer without one", () => {
const canShareFn = vi.fn(() => true);
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
expect(canShareFiles()).toBe(true);
const probe = (canShareFn.mock.calls[0] as unknown as [ShareData])[0];
expect(probe.files?.[0]).toBeInstanceOf(File);
expect(probe.files?.[0]?.size).toBeGreaterThan(0);
});
it("asks once and remembers, because the answer is about the browser", () => {
const canShareFn = vi.fn(() => true);
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
canShareFiles();
canShareFiles();
canShareFiles();
expect(canShareFn).toHaveBeenCalledTimes(1);
});
});
describe("sharing text", () => {
it("hands the data straight to the sheet", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share });
await expect(shareText({ title: "Lunch", text: "One o'clock?" })).resolves.toBe("shared");
expect(share).toHaveBeenCalledWith({ title: "Lunch", text: "One o'clock?" });
});
it("reports unsupported rather than throwing where there is no share", async () => {
stubNavigator({});
await expect(shareText({ text: "hello" })).resolves.toBe("unsupported");
});
});
describe("sharing a file", () => {
it("passes the file through when the browser takes it", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => true) as unknown as Navigator["canShare"] });
const f = aFile();
await expect(shareFile(f, { title: "note.txt" })).resolves.toBe("shared");
expect(share).toHaveBeenCalledWith({ title: "note.txt", files: [f] });
});
it("does not call share at all when this file is not shareable", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => false) as unknown as Navigator["canShare"] });
await expect(shareFile(aFile())).resolves.toBe("unsupported");
expect(share).not.toHaveBeenCalled();
});
it("treats a closed sheet as a decision, not a failure", async () => {
stubNavigator({
share: vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).resolves.toBe("dismissed");
});
it("falls back rather than reporting an error when the gesture has expired", async () => {
// What NotAllowedError means here is that fetching the attachment outlived
// the tap that asked for it. The caller downloads; the reader sees a file
// rather than a message about transient activation.
stubNavigator({
share: vi.fn(async () => { throw new DOMException("no activation", "NotAllowedError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).resolves.toBe("unsupported");
});
it("raises anything it does not recognise, so a real fault is still reported", async () => {
stubNavigator({
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).rejects.toThrow("boom");
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/swCache";
/**
* The handoff, from the tab's side. The worker's half cannot be exercised here
* -- sw.js is copied to the build rather than imported, and there is no service
* worker under a test runner -- so what is stood up below is the cache it
* writes into, keyed and shaped exactly as `stashShare` leaves it.
*
* That shape is the contract between two files that never see each other, and
* it is the thing worth pinning: a drift on either side is silent. Nothing
* errors, a share simply arrives at an empty composer.
*/
interface Entry {
body: BodyInit;
type: string;
}
function fakeCaches(entries: Record<string, Entry>) {
const store = new Map(Object.entries(entries));
const cache = {
match: vi.fn(async (key: string) => {
const e = store.get(key);
return e ? new Response(e.body, { headers: { "content-type": e.type } }) : undefined;
}),
delete: vi.fn(async (key: string) => store.delete(key)),
put: vi.fn(async () => undefined),
};
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : { match: async () => undefined })) });
return { cache, store };
}
/** What the worker writes, at the keys it writes them under. */
function stash(meta: Record<string, unknown>, files: { name: string; type: string; body: string }[] = []) {
const entries: Record<string, Entry> = {};
const index = files.map((f, i) => ({ key: `/ihasmail-share/${i}`, name: f.name, type: f.type }));
entries["/ihasmail-share"] = { body: JSON.stringify({ at: Date.now(), files: index, ...meta }), type: "application/json" };
for (const [i, f] of files.entries()) entries[`/ihasmail-share/${i}`] = { body: f.body, type: f.type };
return entries;
}
beforeEach(() => vi.unstubAllGlobals());
afterEach(() => vi.unstubAllGlobals());
describe("collecting a share", () => {
it("finds nothing on an ordinary start, which is almost every start", async () => {
fakeCaches({});
await expect(collectShare()).resolves.toBeNull();
});
it("survives a browser with no cache storage at all", async () => {
vi.stubGlobal("caches", undefined);
await expect(collectShare()).resolves.toBeNull();
});
it("rebuilds the files, with their names and types intact", async () => {
fakeCaches(stash({ title: "Holiday", text: "", url: "" }, [
{ name: "beach.png", type: "image/png", body: "pixels" },
{ name: "notes.txt", type: "text/plain", body: "later" },
]));
const share = await collectShare();
expect(share?.title).toBe("Holiday");
expect(share?.files.map((f) => [f.name, f.type])).toEqual([["beach.png", "image/png"], ["notes.txt", "text/plain"]]);
// The bytes made the trip, not just the index entry describing them.
expect(share!.files[0]!.size).toBe("pixels".length);
});
it("leaves nothing behind, so it cannot be collected twice", async () => {
const { store } = fakeCaches(stash({ text: "hello" }, [{ name: "a.txt", type: "text/plain", body: "x" }]));
await collectShare();
expect(store.size).toBe(0);
});
it("ignores one nobody came back for, and still clears it", async () => {
// A share to a signed-out ihasmail waits through the sign-in page, so it
// cannot expire quickly -- but it must expire, or it opens a composer full
// of a forgotten photo on some unrelated morning.
const { store } = fakeCaches(stash({ at: Date.now() - SHARE_MAX_AGE_MS - 1000, text: "stale" }));
await expect(collectShare()).resolves.toBeNull();
expect(store.size).toBe(0);
});
it("treats an empty share as no share", async () => {
fakeCaches(stash({ title: "", text: "", url: "" }));
await expect(collectShare()).resolves.toBeNull();
});
it("does not throw on a stash it cannot read", async () => {
fakeCaches({ "/ihasmail-share": { body: "not json", type: "application/json" } });
await expect(collectShare()).resolves.toBeNull();
});
});
describe("the body a share turns into", () => {
it("keeps the link when the text does not already carry it", () => {
expect(shareBody({ text: "Look at this", url: "https://example.com/a" })).toBe("Look at this\n\nhttps://example.com/a");
});
it("does not repeat a link the sharing app already put in the text", () => {
// Which field a link arrives in is up to whatever shared it, and they do
// not agree. Appending unconditionally would double it more often than not.
expect(shareBody({ text: "https://example.com/a", url: "https://example.com/a" })).toBe("https://example.com/a");
});
it("is just the link when that is all there was", () => {
expect(shareBody({ text: "", url: "https://example.com/a" })).toBe("https://example.com/a");
});
it("is just the text when there was no link", () => {
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
import { SW_CACHE_NAME } from "@/lib/swCache";
import { setCatalog } from "@/lib/i18n";
import { catalog as de } from "@/locales/de";
/**
* The briefing is the only thing standing between a notification action and a
* button labelled in a language the reader does not use the worker is plain
* JavaScript outside the bundle and cannot reach a catalogue.
*
* It is also the only place the archive mailbox is named, and getting that
* wrong does not fail visibly: a message would be filed somewhere, just not
* where Archive means.
*/
function fakeCaches() {
const store = new Map<string, string>();
const cache = {
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
delete: vi.fn(async () => true),
};
// Only the worker's own cache: a briefing put anywhere else is one the
// worker will never read.
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
return { store, cache };
}
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
afterEach(() => {
vi.unstubAllGlobals();
setCatalog("en", { strings: {}, plurals: {} });
});
describe("the worker's briefing", () => {
it("names the account and the archive mailbox", async () => {
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.accountId).toBe("a1");
expect(facts.archiveId).toBe("mb-archive");
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalogue. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
setCatalog("de", de);
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.strings.archive).toBe("Archivieren");
expect(facts.strings.markRead).toBe("Als gelesen markieren");
expect(facts.strings.newMail).toBe("Neue E-Mail");
expect(facts.strings.noSubject).toBe("(kein Betreff)");
expect(facts.strings.failed).not.toBe("");
});
it("says so when there is no archive folder, rather than inventing one", async () => {
// The worker draws no Archive button on a null. An account without an
// archive is not a reason to file mail somewhere else.
const { store } = fakeCaches();
await publishWorkerFacts("a1", null);
expect(written(store).archiveId).toBeNull();
});
it("writes nothing before there is an account", async () => {
const { cache } = fakeCaches();
await publishWorkerFacts(null, null);
expect(cache.put).not.toHaveBeenCalled();
});
it("does not throw where the browser has no cache storage", async () => {
vi.stubGlobal("caches", undefined);
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
});
it("carries every string the worker looks up", async () => {
// The worker reads these by name and shows `undefined` for a missing one,
// which is the kind of thing that only appears on somebody's lock screen.
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
}
});
});
+1
View File
@@ -36,6 +36,7 @@ function session(caps: Record<string, unknown>): JmapSession {
afterEach(() => {
client.session = null;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("the VAPID key", () => {
+81 -16
View File
@@ -194,13 +194,14 @@ export const EMAIL_BASE_CSS = `
/* "Even mail that styles itself" the second, opt-in switch, applied on top of
.themed. Everything the sender coloured is neutralised except the surfaces
marked by markKeptSurfaces() and their contents, so a white wrapper table
marked by markKeptSurfaces() and what it marked as sitting on them, so a
white wrapper table
stops being a bright card while a blue button keeps its white label. The
sender's markup is untouched; this is all cascade, so the switch is
reversible and print still pins the tokens to ink on white. */
.ihm-email-root.forced { color: var(--fg, #1f2937) !important; background: var(--bg-elev, #fff) !important; }
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-keep] *) { color: inherit !important; background-color: transparent !important; }
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-keep] *) { color: var(--link, #0f766e) !important; }
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: inherit !important; background-color: transparent !important; }
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: var(--link, #0f766e) !important; }
`;
/**
@@ -275,6 +276,13 @@ export function relativeLuminance(color: string): number | null {
*/
export const LIGHT_SURFACE_LUMINANCE = 0.5;
/** The background an element declares itself, or null if it declares none we can read. */
function declaredLuminance(el: HTMLElement): number | null {
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
if (!declared) return null;
return relativeLuminance(declared);
}
/**
* Mark the surfaces that must survive being themed, and count them.
*
@@ -285,26 +293,83 @@ export const LIGHT_SURFACE_LUMINANCE = 0.5;
* a **painted surface** a button, a banner which is kept whole so its
* label stays legible on it.
*
* Only the second is marked, with `data-ihm-keep`, and one CSS rule in
* EMAIL_BASE_CSS neutralises everything that is not marked or inside something
* marked. Nothing the sender wrote is removed, so turning the switch off puts
* the message back exactly as it was and a colour that arrived from a
* `<style>` block rather than an attribute is covered too, which is most of
* them in modern templates.
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
* keeps its own colours. `data-ihm-in-keep` is an element sitting on one with
* no background of its own, whose colour is left alone so a white label on a
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralises
* everything else.
*
* The distinction that matters is that being *inside* a painted surface is not
* inherited past a sheet. A light table nested in a dark 600px card is still a
* sheet and is still neutralised that is issue #310, where a dark campaign
* rendered with beige cards inside it because the exemption used to be
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
* below it: a dark button inside that nested table is kept as usual.
*
* Nothing the sender wrote is removed, so turning the switch off puts the
* message back exactly as it was and a colour that arrived from a `<style>`
* block rather than an attribute is covered too, which is most of them in
* modern templates.
*/
export function markKeptSurfaces(root: ParentNode): number {
let kept = 0;
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
if (!declared) continue;
const lum = relativeLuminance(declared);
if (lum === null || lum >= LIGHT_SURFACE_LUMINANCE) continue;
el.setAttribute("data-ihm-keep", "");
kept++;
// An explicit stack rather than recursion: this walks untrusted mail, and
// deeply nested tables are exactly what old newsletter HTML is made of.
const stack: Array<{ el: HTMLElement; onPaint: boolean }> = [];
const push = (parent: ParentNode, onPaint: boolean) => {
for (const child of Array.from(parent.children)) {
stack.push({ el: child as HTMLElement, onPaint });
}
};
push(root, false);
while (stack.length) {
const { el, onPaint } = stack.pop()!;
const lum = declaredLuminance(el);
let childrenOnPaint = onPaint;
if (lum !== null && lum < LIGHT_SURFACE_LUMINANCE) {
// Painted: keep it whole, and anything on it inherits that protection.
el.setAttribute("data-ihm-keep", "");
kept++;
childrenOnPaint = true;
} else if (lum !== null) {
// A sheet, wherever it sits. Left unmarked so it neutralises, and it
// ends the protection rather than passing it on.
childrenOnPaint = false;
} else if (onPaint) {
// No background of its own, sitting on paint: leave its colour alone.
el.setAttribute("data-ihm-in-keep", "");
}
push(el, childrenOnPaint);
}
return kept;
}
/**
* Whether a message really has an HTML alternative to render.
*
* `htmlBody` is a *derived* list, not a filter: RFC 8621 §4.1.4 says a message
* with no HTML alternative still gets one, and it holds the text/plain part.
* Confirmed live against Stalwart 0.16.21 (2026-09-10) -- a plain-text mail
* comes back with `htmlBody` and `textBody` naming the same part, typed
* `text/plain`, while a real multipart/alternative names two different parts.
*
* So "is there a body value under htmlBody" is not the question; the part's own
* type is. Answering the first one sent every plain-text message down the HTML
* path, where the body is placed in `.ihm-email-root` under
* `white-space: normal` and every line break collapses -- hard-wrapped mail
* arrived as a single paragraph with the signature and the quoted reply run
* into the prose.
*/
export function hasHtmlAlternative(part: { type?: string } | undefined, value: string | undefined): boolean {
return /^text\/html\b/i.test(part?.type ?? "") && Boolean(value);
}
export const TEXT_EMAIL_CSS = `
:host { display:block; }
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
+25 -1
View File
@@ -8,9 +8,33 @@ export function setBaseTitle(t: string) {
baseTitle = t;
}
/** Update document title and favicon badge with unread count. */
/*
* The unread count on the installed app's icon.
*
* The title and the favicon below are the same idea for a tab, and an
* installed app has neither: in `display: standalone` there is no tab strip
* and no favicon anywhere on screen, so everything this file did for the
* unread count vanished at exactly the moment somebody put ihasmail on a home
* screen. The Badging API is where the count goes instead, and it is the one
* thing every phone user expects a mail icon to do.
*
* Silently nothing where it is unsupported, and silently nothing on iOS until
* notification permission has been granted, which is that platform's condition
* for showing a badge at all. Neither is worth reporting: a count that does not
* appear is not a failure anybody can act on.
*/
function setIconBadge(count: number): void {
if (!("setAppBadge" in navigator)) return;
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
void done.catch(() => {
/* unsupported, or not permitted on this platform */
});
}
/** Update document title, favicon and app icon badge with unread count. */
export function setUnreadBadge(count: number): void {
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
setIconBadge(count);
try {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
if (!link) return;
+37
View File
@@ -0,0 +1,37 @@
/**
* What "open" means when conversation view is off.
*
* The setting used to reach only as far as the query -- it set `collapseThreads`
* and nothing else -- so the list showed individual messages while everything
* downstream still worked in threads. Opening one message highlighted every row
* in its thread and filled the reading pane with the whole conversation, which
* is exactly the grouping the setting was turned off to avoid.
*
* Both halves are the same question asked in two places, so they live together.
*/
import type { Id } from "@/jmap/types";
/**
* Whether a list row should be drawn as the open one.
*
* With a message singled out the row must match it exactly. Matching on the
* thread is what lit up every sibling.
*/
export function rowIsOpen(rowId: Id, rowThreadId: Id | undefined, openMessageId: Id | null, openThreadId: Id | null): boolean {
if (openMessageId) return rowId === openMessageId;
return Boolean(openThreadId) && rowThreadId === openThreadId;
}
/**
* The messages the reading pane should render.
*
* Falls back to the whole thread when the id names nothing in it. That is what
* a link from somebody with conversation view *on* looks like, and what a
* lingering `m` parameter looks like after the setting is switched back -- a
* conversation is a better answer to both than an empty pane.
*/
export function visibleMessages<T extends { id: Id }>(messages: T[], openMessageId: Id | null): T[] {
if (!openMessageId) return messages;
const single = messages.filter((m) => m.id === openMessageId);
return single.length ? single : messages;
}
+102
View File
@@ -0,0 +1,102 @@
/*
* The operating system's own share sheet.
*
* Everything that leaves ihasmail today leaves as a download, and on a phone a
* download is close to a dead end: the file lands in Downloads and the person
* who wanted to send it somewhere goes hunting for it in a file manager. Web
* Share hands the bytes straight to whatever they meant to send them to, which
* is the thing they were actually trying to do.
*
* Every entry point feature-detects and disappears where the API is not there
* rather than failing at the tap: `navigator.share` is absent on desktop Linux
* and in Firefox, exists on iOS and Android and on Windows and macOS Chrome,
* and file sharing is a separate question from sharing at all.
*/
/**
* What became of a share.
*
* `unsupported` is the interesting one: it says the share did not happen and
* the caller should do whatever it did before for an attachment, download
* it. It covers both "this browser cannot" and "this browser could not this
* time", because to the caller those are the same instruction.
*/
export type ShareOutcome = "shared" | "dismissed" | "unsupported";
/** Whether the browser can share at all. */
export function canShare(): boolean {
return typeof navigator !== "undefined" && typeof navigator.share === "function";
}
/*
* Whether it can share *files*, asked once and remembered.
*
* `canShare()` needs a real File to answer, and the answer is about the
* browser rather than about any particular file, so a one-byte probe settles
* it for the session. It has to be asked before there is anything to share:
* this is what decides whether a Share button is drawn at all, and drawing one
* that turns out to be a download in disguise is worse than not drawing it.
*
* A byte rather than an empty file on purpose an implementation is entitled
* to refuse a zero-length one, and being told "no" by the probe would hide the
* button everywhere.
*/
let fileShareSupported: boolean | null = null;
export function canShareFiles(): boolean {
if (fileShareSupported === null) {
try {
fileShareSupported =
canShare() &&
typeof navigator.canShare === "function" &&
navigator.canShare({ files: [new File(["x"], "probe.txt", { type: "text/plain" })] });
} catch {
fileShareSupported = false;
}
}
return fileShareSupported;
}
/** Reset the remembered probe. Tests only. */
export function resetShareSupport(): void {
fileShareSupported = null;
}
/** Share text, a title, a URL, or any combination the browser accepts. */
export async function shareText(data: { title?: string; text?: string; url?: string }): Promise<ShareOutcome> {
if (!canShare()) return "unsupported";
return await run(data);
}
/**
* Share one file. `unsupported` means nothing happened and the caller should
* fall back to a download.
*/
export async function shareFile(file: File, extra: { title?: string; text?: string } = {}): Promise<ShareOutcome> {
if (!canShare() || !navigator.canShare?.({ files: [file] })) return "unsupported";
return await run({ ...extra, files: [file] });
}
async function run(data: ShareData): Promise<ShareOutcome> {
try {
await navigator.share(data);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
// The sheet opened and was closed again. That is a decision, not a fault,
// and a toast for it would be scolding somebody for changing their mind.
if (name === "AbortError") return "dismissed";
/*
* `NotAllowedError` is reported as unsupported rather than raised, because
* what it nearly always means here is that the tap's transient activation
* ran out while the attachment downloaded. `share()` takes files and not a
* promise of them, so there is no way to open the sheet first and fill it
* afterwards the fetch has to happen inside the gesture's window, and on
* a slow connection and a large attachment it will sometimes not fit.
*
* The caller's fallback is a download, which is exactly what the button
* did before this existed, so the failure costs a tap rather than the file.
*/
if (name === "NotAllowedError") return "unsupported";
throw err;
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
* Collecting a share the operating system sent us.
*
* The other end of `share_target` in the manifest: the system POSTs a form at
* `<base>/share`, the service worker takes the body and stashes it, and this
* is the tab picking it up. See the note on `stashShare` in sw.js for why the
* worker answers that request rather than the app or the server.
*
* The handoff goes through the cache rather than postMessage because a share
* usually launches the app: there is no tab to message at the moment it
* arrives, and the one that appears a second later is a different context that
* has to find the payload lying somewhere.
*/
import { withBase } from "./basePath";
import { SW_CACHE_NAME } from "./swCache";
export interface SharedContent {
title: string;
text: string;
url: string;
files: File[];
}
/** The worker writes here; both sides name it absolutely. */
const SHARE_KEY = "/ihasmail-share";
/*
* How long a share is worth acting on.
*
* It is collected on every app start rather than only when the launch URL says
* so, because the launch may not survive the trip: a share to a signed-out
* ihasmail lands on the sign-in page, and the composer can only open once
* there is an account to open it in. Waiting for that means the payload has to
* outlive a redirect and a login, which the query string does not.
*
* What that costs is the possibility of a stash nobody ever came back for, so
* it expires. Ten minutes is long enough for signing in -- password manager,
* app password, a second device -- and short enough that a share abandoned
* this morning does not open a composer full of a forgotten photo tonight.
*/
export const SHARE_MAX_AGE_MS = 10 * 60_000;
interface StashedFile {
key: string;
name: string;
type: string;
}
/**
* Take whatever the worker left, and leave nothing behind.
*
* Returns null when there is nothing waiting, which is almost every start.
* The entries are deleted whether or not the share is still worth opening: a
* stash that stayed would be collected on the next start instead, which is the
* expiry doing nothing.
*/
export async function collectShare(): Promise<SharedContent | null> {
if (typeof caches === "undefined") return null;
try {
const cache = await caches.open(SW_CACHE_NAME);
const key = withBase(SHARE_KEY);
const hit = await cache.match(key);
if (!hit) return null;
const meta = (await hit.json()) as Partial<SharedContent> & { at?: number; files?: StashedFile[] };
await cache.delete(key);
const files: File[] = [];
for (const f of meta.files ?? []) {
const res = await cache.match(f.key);
await cache.delete(f.key);
if (!res) continue;
/*
* The bytes, rather than the Blob holding them.
*
* `new File([blob], …)` is correct and works in a browser, but a Blob
* only counts as a part where the File constructor recognises it as one
* -- and where it does not, it is stringified instead, producing a file
* containing the thirteen characters "[object Blob]" and no error
* anywhere. That is exactly what CI caught on Node 22 while it passed
* here on 26. An ArrayBuffer is a part on any implementation, and this
* has the whole file in memory a moment later regardless: it is about to
* be uploaded as an attachment.
*/
files.push(new File([await res.arrayBuffer()], f.name, { type: f.type }));
}
if (typeof meta.at === "number" && Date.now() - meta.at > SHARE_MAX_AGE_MS) return null;
const share: SharedContent = {
title: meta.title ?? "",
text: meta.text ?? "",
url: meta.url ?? "",
files,
};
// A share with nothing in it is a share that went wrong upstream. Opening
// an empty composer over the inbox would be a worse account of that than
// opening nothing.
return share.title || share.text || share.url || files.length ? share : null;
} catch {
/* no cache, or nothing waiting: not a failure */
return null;
}
}
/**
* The shared text and the shared link as one body.
*
* What arrives in which field is up to whatever did the sharing, and they do
* not agree: a link from Chrome comes as a title and a `url`, from other apps
* as `text` that already *is* the link, and from a few as both. Appending it
* unconditionally would put the same URL in twice as often as not.
*/
export function shareBody(share: Pick<SharedContent, "text" | "url">): string {
const text = share.text.trim();
const url = share.url.trim();
if (!url || text.includes(url)) return text;
return text ? `${text}\n\n${url}` : url;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* The name of the cache the service worker keeps.
*
* It is `VERSION` in `web/public/sw.js`, and the worker is not built from this
* source -- it is copied to `dist` verbatim, so nothing checks that the two
* agree. They have to: the worker uses that cache to leave things for a tab to
* collect when there was no tab to hand them to, and a name that has drifted
* does not fail, it silently finds nothing. A push verification never
* completes; a share arrives at an empty composer.
*
* One copy on this side of the line, so at least the app cannot disagree with
* itself.
*/
export const SW_CACHE_NAME = "ihasmail-v2";
+69
View File
@@ -0,0 +1,69 @@
/*
* What the service worker cannot work out for itself.
*
* The worker can act on mail see the note on `jmap()` in sw.js but it
* cannot read a catalogue or a store. It is plain JavaScript copied into the
* build, outside the bundle, with no i18n and no idea which mailbox is the
* archive. Both of those are things a tab knows and can simply write down.
*
* So the app leaves a short briefing in the same cache it uses for every other
* handoff, and the worker reads it when a notification arrives. Where there is
* none, the worker offers no actions at all rather than guessing: an untitled
* button that files mail somewhere is worse than a notification you have to
* open.
*
* That means the actions appear once ihasmail has been opened since the worker
* was installed, which is the same condition background notifications already
* carry a push subscription has to be renewed from a tab too.
*/
import { withBase } from "./basePath";
import { SW_CACHE_NAME } from "./swCache";
import { t } from "./i18n";
export const FACTS_KEY = "/ihasmail-worker-facts";
export interface WorkerFacts {
/** The account the notifications are about. */
accountId: string;
/** Where Archive files to; null where the account has no archive folder. */
archiveId: string | null;
/** The worker's own user-visible text, in the language this tab is in. */
strings: {
newMail: string;
newMessage: string;
noSubject: string;
archive: string;
markRead: string;
failed: string;
};
}
/**
* Write the briefing.
*
* Called again whenever what is in it could have changed the language, the
* account, the archive folder because it is what the worker will still be
* reading in a week's time. Rewriting it is one cache put; there is nothing to
* gain by working out whether it differs.
*/
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
if (typeof caches === "undefined" || !accountId) return;
const facts: WorkerFacts = {
accountId,
archiveId,
strings: {
newMail: t("New mail"),
newMessage: t("New message"),
noSubject: t("(no subject)"),
archive: t("Archive"),
markRead: t("Mark as read"),
failed: t("Could not do that — open ihasmail and try again"),
},
};
try {
const cache = await caches.open(SW_CACHE_NAME);
await cache.put(withBase(FACTS_KEY), new Response(JSON.stringify(facts), { headers: { "content-type": "application/json" } }));
} catch {
/* no cache storage: the worker falls back to a notification with no actions */
}
}
+2 -1
View File
@@ -7,6 +7,7 @@
*/
import { CAP } from "@/jmap/client";
import { withBase } from "./basePath";
import { SW_CACHE_NAME } from "./swCache";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
@@ -48,7 +49,7 @@ export function listenForVerification(): void {
/** 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 cache = await caches.open(SW_CACHE_NAME);
// The same absolute key the worker writes. Relative would be resolved
// against this document's URL, which is a different place on every route.
const key = withBase("/ihasmail-push-verification");
+8 -2
View File
@@ -756,6 +756,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.",
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
"Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
@@ -834,6 +835,7 @@ export const catalog: Catalog = {
"Nothing": "Nichts",
"No conversation selected": "Keine Konversation ausgewählt",
"No message selected": "Keine Nachricht ausgewählt",
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
// ── Remaining prose ────────────────────────────────────────────────
@@ -880,6 +882,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Wichtig",
"folder\u0004All mail": "Alle Nachrichten",
"share sheet\u0004Share": "Teilen",
"share sheet\u0004Share…": "Teilen…",
"folder": "Ordner",
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
@@ -890,6 +894,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Neue Nachricht",
"New mail": "Neue E-Mail",
"Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut",
"Sending…": "Wird gesendet…",
"Saving…": "Wird gespeichert…",
"Error": "Fehler",
@@ -930,6 +936,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Die Adresse konnte nicht kopiert werden",
"Could not empty folder: {error}": "Ordner konnte nicht geleert werden: {error}",
"Could not load source: {error}": "Quelltext konnte nicht geladen werden: {error}",
"Could not share: {error}": "Teilen nicht möglich: {error}",
"Could not mark as read: {error}": "Konnte nicht als gelesen markiert werden: {error}",
"Could not save draft: {error}": "Entwurf konnte nicht gespeichert werden: {error}",
"Could not save filter: {error}": "Filter konnte nicht gespeichert werden: {error}",
@@ -1407,8 +1414,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" },
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
"Already here: {n} events, nothing imported": { one: "Bereits vorhanden: {n} Termin, nichts importiert", other: "Bereits vorhanden: {n} Termine, nichts importiert" },
"{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
"Updated {n} events, nothing new": { one: "{n} Termin aktualisiert, nichts Neues", other: "{n} Termine aktualisiert, nichts Neues" },
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
+8 -2
View File
@@ -801,6 +801,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Importante",
"folder\u0004All mail": "Todos los mensajes",
"share sheet\u0004Share": "Compartir",
"share sheet\u0004Share…": "Compartir…",
"folder": "carpeta",
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
@@ -809,6 +811,7 @@ export const catalog: Catalog = {
"Rename folder": "Cambiar el nombre de la carpeta",
"Search: {query}": "Búsqueda: {query}",
"No conversation selected": "Ninguna conversación seleccionada",
"No message selected": "Ningún mensaje seleccionado",
"Drop here for the top level": "Suelte aquí para el nivel superior",
// ── Longer prose ───────────────────────────────────────────────────
@@ -817,6 +820,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Abra la vista de Correo para ver todos los atajos.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Los atajos al estilo de Gmail están siempre activos. Pulse {key} en cualquier momento para ver esta lista.",
"Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos",
"Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.",
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
@@ -863,6 +867,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Mensaje nuevo",
"New mail": "Correo nuevo",
"Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
"Sending…": "Enviando…",
"Saving…": "Guardando…",
"Error": "Error",
@@ -903,6 +909,7 @@ export const catalog: Catalog = {
"Could not copy the address": "No se pudo copiar la dirección",
"Could not empty folder: {error}": "No se pudo vaciar la carpeta: {error}",
"Could not load source: {error}": "No se pudo cargar el código fuente: {error}",
"Could not share: {error}": "No se pudo compartir: {error}",
"Could not mark as read: {error}": "No se pudo marcar como leído: {error}",
"Could not save draft: {error}": "No se pudo guardar el borrador: {error}",
"Could not save filter: {error}": "No se pudo guardar el filtro: {error}",
@@ -1380,8 +1387,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
"Already here: {n} events, nothing imported": { one: "Ya estaba aquí: {n} evento, no se importó nada", other: "Ya estaban aquí: {n} eventos, no se importó nada" },
"{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
"Updated {n} events, nothing new": { one: "{n} evento actualizado, nada nuevo", other: "{n} eventos actualizados, nada nuevo" },
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
+8 -2
View File
@@ -806,6 +806,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Important",
"folder\u0004All mail": "Tous les messages",
"share sheet\u0004Share": "Partager",
"share sheet\u0004Share…": "Partager…",
"folder": "dossier",
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
@@ -814,6 +816,7 @@ export const catalog: Catalog = {
"Rename folder": "Renommer le dossier",
"Search: {query}": "Recherche : {query}",
"No conversation selected": "Aucune conversation sélectionnée",
"No message selected": "Aucun message sélectionné",
"Drop here for the top level": "Déposer ici pour le niveau supérieur",
// ── Longer prose ───────────────────────────────────────────────────
@@ -822,6 +825,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Ouvrez la vue E-mail pour voir tous les raccourcis.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Les raccourcis façon Gmail sont toujours actifs. Appuyez sur {key} n'importe où pour afficher cette liste.",
"Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis",
"Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.",
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
@@ -868,6 +872,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Nouveau message",
"New mail": "Nouveau courrier",
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
"Sending…": "Envoi…",
"Saving…": "Enregistrement…",
"Error": "Erreur",
@@ -908,6 +914,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Impossible de copier ladresse",
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
"Could not load source: {error}": "Impossible de charger la source : {error}",
"Could not share: {error}": "Impossible de partager : {error}",
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
"Could not save draft: {error}": "Impossible denregistrer le brouillon : {error}",
"Could not save filter: {error}": "Impossible denregistrer le filtre : {error}",
@@ -1385,8 +1392,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" },
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
"Imported {n} events": { one: "{n} événement importé", other: "{n} événements importés" },
"Already here: {n} events, nothing imported": { one: "Déjà présent : {n} événement, rien dimporté", other: "Déjà présents : {n} événements, rien dimporté" },
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
"Updated {n} events, nothing new": { one: "{n} événement mis à jour, rien de nouveau", other: "{n} événements mis à jour, rien de nouveau" },
"{n} messages": { one: "{n} message", other: "{n} messages" },
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
+8 -2
View File
@@ -747,6 +747,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 形式のショートカットは常に有効です。どこでも {key} を押すとこの一覧を表示します。",
"Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧",
"Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
@@ -843,6 +844,7 @@ export const catalog: Catalog = {
"Not spam": "迷惑メールではない",
"Nothing": "何もしない",
"No conversation selected": "スレッドが選択されていません",
"No message selected": "メールが選択されていません",
"Drop here for the top level": "ここにドロップすると最上位へ移動します",
"Later today": "今日のうちに",
"Tomorrow morning": "明日の朝",
@@ -861,6 +863,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "迷惑メール",
"folder\u0004Important": "重要",
"folder\u0004All mail": "すべてのメール",
"share sheet\u0004Share": "共有",
"share sheet\u0004Share…": "共有…",
"folder": "フォルダー",
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
@@ -871,6 +875,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "新規メール",
"New mail": "新着メール",
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
"Sending…": "送信中…",
"Saving…": "保存中…",
"Error": "エラー",
@@ -911,6 +917,7 @@ export const catalog: Catalog = {
"Could not copy the address": "アドレスをコピーできませんでした",
"Could not empty folder: {error}": "フォルダーを空にできませんでした: {error}",
"Could not load source: {error}": "ソースを読み込めませんでした: {error}",
"Could not share: {error}": "共有できませんでした: {error}",
"Could not mark as read: {error}": "既読にできませんでした: {error}",
"Could not save draft: {error}": "下書きを保存できませんでした: {error}",
"Could not save filter: {error}": "フィルターを保存できませんでした: {error}",
@@ -1388,8 +1395,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
"{n} were already here": { other: "{n} 件はすでに存在していました" },
"Updated {n} events, nothing new": { other: "{n} 件の予定を更新しました。新規はありません" },
/*
* One form each, because Japanese has one. Intl.PluralRules returns
* `other` for every number, so `one`, `few` and `many` would never be
+8 -2
View File
@@ -797,6 +797,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Belangrijk",
"folder\u0004All mail": "Alle berichten",
"share sheet\u0004Share": "Delen",
"share sheet\u0004Share…": "Delen…",
"folder": "map",
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
@@ -805,6 +807,7 @@ export const catalog: Catalog = {
"Rename folder": "Map hernoemen",
"Search: {query}": "Zoeken: {query}",
"No conversation selected": "Geen gesprek geselecteerd",
"No message selected": "Geen bericht geselecteerd",
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
// ── Longer prose ───────────────────────────────────────────────────
@@ -813,6 +816,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.",
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
"Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.",
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
@@ -859,6 +863,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht",
"New mail": "Nieuwe e-mail",
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
"Sending…": "Bezig met verzenden…",
"Saving…": "Bezig met opslaan…",
"Error": "Fout",
@@ -899,6 +905,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Het adres kon niet worden gekopieerd",
"Could not empty folder: {error}": "Map legen mislukt: {error}",
"Could not load source: {error}": "De bron kon niet worden geladen: {error}",
"Could not share: {error}": "Delen is niet gelukt: {error}",
"Could not mark as read: {error}": "Markeren als gelezen mislukt: {error}",
"Could not save draft: {error}": "Concept opslaan mislukt: {error}",
"Could not save filter: {error}": "Filter opslaan mislukt: {error}",
@@ -1376,8 +1383,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
"Already here: {n} events, nothing imported": { one: "Al aanwezig: {n} afspraak, niets geïmporteerd", other: "Al aanwezig: {n} afspraken, niets geïmporteerd" },
"{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
"Updated {n} events, nothing new": { one: "{n} afspraak bijgewerkt, niets nieuws", other: "{n} afspraken bijgewerkt, niets nieuws" },
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
+8 -2
View File
@@ -804,6 +804,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Spam",
"folder\u0004Important": "Importante",
"folder\u0004All mail": "Todas as mensagens",
"share sheet\u0004Share": "Compartilhar",
"share sheet\u0004Share…": "Compartilhar…",
"folder": "pasta",
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
@@ -812,6 +814,7 @@ export const catalog: Catalog = {
"Rename folder": "Renomear a pasta",
"Search: {query}": "Pesquisa: {query}",
"No conversation selected": "Nenhuma conversa selecionada",
"No message selected": "Nenhuma mensagem selecionada",
"Drop here for the top level": "Solte aqui para o nível superior",
// ── Longer prose ───────────────────────────────────────────────────
@@ -820,6 +823,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Abra a visualização de E-mail para ver todos os atalhos.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Os atalhos no estilo do Gmail estão sempre ativos. Pressione {key} em qualquer lugar para ver esta lista.",
"Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos",
"Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.",
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
@@ -866,6 +870,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Nova mensagem",
"New mail": "Novo e-mail",
"Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
"Sending…": "Enviando…",
"Saving…": "Salvando…",
"Error": "Erro",
@@ -906,6 +912,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Não foi possível copiar o endereço",
"Could not empty folder: {error}": "Não foi possível esvaziar a pasta: {error}",
"Could not load source: {error}": "Não foi possível carregar o código-fonte: {error}",
"Could not share: {error}": "Não foi possível compartilhar: {error}",
"Could not mark as read: {error}": "Não foi possível marcar como lida: {error}",
"Could not save draft: {error}": "Não foi possível salvar o rascunho: {error}",
"Could not save filter: {error}": "Não foi possível salvar o filtro: {error}",
@@ -1383,8 +1390,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
"Already here: {n} events, nothing imported": { one: "Já estava aqui: {n} evento, nada importado", other: "Já estavam aqui: {n} eventos, nada importado" },
"{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
"Updated {n} events, nothing new": { one: "{n} evento atualizado, nada novo", other: "{n} eventos atualizados, nada novo" },
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
+8 -2
View File
@@ -803,6 +803,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Спам",
"folder\u0004Important": "Важное",
"folder\u0004All mail": "Вся почта",
"share sheet\u0004Share": "Поделиться",
"share sheet\u0004Share…": "Поделиться…",
"folder": "папка",
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
@@ -811,6 +813,7 @@ export const catalog: Catalog = {
"Rename folder": "Переименовать папку",
"Search: {query}": "Поиск: {query}",
"No conversation selected": "Цепочка не выбрана",
"No message selected": "Письмо не выбрано",
"Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень",
// ── Longer prose ───────────────────────────────────────────────────
@@ -819,6 +822,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сочетания клавиш в стиле Gmail всегда включены. Нажмите {key} в любом месте, чтобы увидеть этот список.",
"Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш",
"Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
@@ -865,6 +869,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Новое письмо",
"New mail": "Новое письмо",
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
"Sending…": "Отправка…",
"Saving…": "Сохранение…",
"Error": "Ошибка",
@@ -905,6 +911,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Не удалось скопировать адрес",
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
"Could not share: {error}": "Не удалось поделиться: {error}",
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
@@ -1382,8 +1389,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
"Imported {n} events": { one: "Импортировано {n} событие", few: "Импортировано {n} события", many: "Импортировано {n} событий", other: "Импортировано {n} события" },
"Already here: {n} events, nothing imported": { one: "Уже есть: {n} событие, ничего не импортировано", few: "Уже есть: {n} события, ничего не импортировано", many: "Уже есть: {n} событий, ничего не импортировано", other: "Уже есть: {n} события, ничего не импортировано" },
"{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
"Updated {n} events, nothing new": { one: "Обновлено {n} событие, новых нет", few: "Обновлено {n} события, новых нет", many: "Обновлено {n} событий, новых нет", other: "Обновлено {n} события, новых нет" },
/*
* Three forms, which is the whole reason plural() takes a map rather than
* (one, other). Intl.PluralRules picks: 1 is `one`, 2-4 are `few`, 5-20
+8 -2
View File
@@ -797,6 +797,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "Спам",
"folder\u0004Important": "Важливе",
"folder\u0004All mail": "Уся пошта",
"share sheet\u0004Share": "Поділитися",
"share sheet\u0004Share…": "Поділитися…",
"folder": "тека",
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
@@ -805,6 +807,7 @@ export const catalog: Catalog = {
"Rename folder": "Перейменувати теку",
"Search: {query}": "Пошук: {query}",
"No conversation selected": "Листування не вибрано",
"No message selected": "Лист не вибрано",
"Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень",
// ── Longer prose ───────────────────────────────────────────────────
@@ -813,6 +816,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сполучення клавіш у стилі Gmail завжди увімкнено. Натисніть {key} будь-де, щоб побачити цей список.",
"Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш",
"Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
@@ -859,6 +863,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "Новий лист",
"New mail": "Новий лист",
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
"Sending…": "Надсилання…",
"Saving…": "Збереження…",
"Error": "Помилка",
@@ -899,6 +905,7 @@ export const catalog: Catalog = {
"Could not copy the address": "Не вдалося скопіювати адресу",
"Could not empty folder: {error}": "Не вдалося очистити теку: {error}",
"Could not load source: {error}": "Не вдалося завантажити вихідний текст: {error}",
"Could not share: {error}": "Не вдалося поділитися: {error}",
"Could not mark as read: {error}": "Не вдалося позначити як прочитане: {error}",
"Could not save draft: {error}": "Не вдалося зберегти чернетку: {error}",
"Could not save filter: {error}": "Не вдалося зберегти фільтр: {error}",
@@ -1376,8 +1383,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
"Imported {n} events": { one: "Імпортовано {n} подію", few: "Імпортовано {n} події", many: "Імпортовано {n} подій", other: "Імпортовано {n} події" },
"Already here: {n} events, nothing imported": { one: "Уже є: {n} подія, нічого не імпортовано", few: "Уже є: {n} події, нічого не імпортовано", many: "Уже є: {n} подій, нічого не імпортовано", other: "Уже є: {n} події, нічого не імпортовано" },
"{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
"Updated {n} events, nothing new": { one: "Оновлено {n} подію, нових немає", few: "Оновлено {n} події, нових немає", many: "Оновлено {n} подій, нових немає", other: "Оновлено {n} події, нових немає" },
/*
* Ukrainian takes the same three forms as Russian and the same rule, but
* not the same words. Sharing a plural structure is not sharing a
+8 -2
View File
@@ -746,6 +746,7 @@ export const catalog: Catalog = {
"Open the Mail view to see all shortcuts.": "打开邮件视图以查看全部快捷键。",
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 风格的快捷键始终启用。在任意位置按 {key} 即可查看此列表。",
"Select a conversation to read it here · Press {key} for shortcuts": "选择一个会话即可在此阅读 · 按 {key} 查看快捷键",
"Select a message to read it here · Press {key} for shortcuts": "选择一封邮件即可在此阅读 · 按 {key} 查看快捷键",
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "提示:在会话上按 {key} 可添加标签。使用 {operator} 搜索。",
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "一款面向 {server} 的快速、友好的开源网页邮箱,基于 JMAP 构建。",
@@ -842,6 +843,7 @@ export const catalog: Catalog = {
"Not spam": "不是垃圾邮件",
"Nothing": "不执行任何操作",
"No conversation selected": "未选择会话",
"No message selected": "未选择邮件",
"Drop here for the top level": "拖放到此处可移至顶层",
"Later today": "今天晚些时候",
"Tomorrow morning": "明天上午",
@@ -860,6 +862,8 @@ export const catalog: Catalog = {
"folder\u0004Junk Mail": "垃圾邮件",
"folder\u0004Important": "重要",
"folder\u0004All mail": "全部邮件",
"share sheet\u0004Share": "分享",
"share sheet\u0004Share…": "分享…",
"folder": "文件夹",
"“{name}” moved into “{parent}”": "「{name}」已移入「{parent}」",
"“{name}” moved to the top level": "「{name}」已移至顶层",
@@ -870,6 +874,8 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ────────────────────────────────
"New message": "新邮件",
"New mail": "新邮件",
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
"Sending…": "正在发送…",
"Saving…": "正在保存…",
"Error": "错误",
@@ -910,6 +916,7 @@ export const catalog: Catalog = {
"Could not copy the address": "无法复制该地址",
"Could not empty folder: {error}": "无法清空文件夹:{error}",
"Could not load source: {error}": "无法加载原文:{error}",
"Could not share: {error}": "无法分享:{error}",
"Could not mark as read: {error}": "无法标为已读:{error}",
"Could not save draft: {error}": "无法保存草稿:{error}",
"Could not save filter: {error}": "无法保存过滤器:{error}",
@@ -1387,8 +1394,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
"Exported {n} events": { other: "已导出 {n} 个日程" },
"Imported {n} events": { other: "已导入 {n} 个日程" },
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
"{n} were already here": { other: "{n} 个已存在" },
"Updated {n} events, nothing new": { other: "已更新 {n} 个日程,无新增" },
/*
* One form each, because Chinese has one. Intl.PluralRules returns `other`
* for every number, so `one`, `few` and `many` would never be selected
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { client } from "@/jmap/client";
import type { SharedContent } from "@/lib/shareTarget";
/**
* What a share becomes once it reaches the composer.
*
* The signature is the part worth a test. `open()` only fits one when it is
* given no body at all, so the obvious implementation -- pass the shared text
* straight to `open()` -- silently drops the signature from every message that
* started as a share, and nothing about the draft looks wrong.
*/
const IDENTITY = {
id: "i1",
name: "John",
email: "[email protected]",
replyTo: null,
htmlSignature: "<p>-- <br>John</p>",
textSignature: "-- \nJohn",
};
function share(over: Partial<SharedContent> = {}): SharedContent {
return { title: "", text: "", url: "", files: [], ...over };
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ accountId: "a1", identities: [IDENTITY] as never });
// addFiles uploads as it goes; nothing here is testing the upload, and a
// real one would reach for the network.
vi.spyOn(client, "upload").mockResolvedValue({ blobId: "b1", type: "image/png", size: 6 } as never);
});
// Without this the spy installed above is the same one every test, so its call
// count is cumulative and "uploaded twice" quietly means "twice, plus whatever
// the test before it uploaded".
afterEach(() => {
vi.restoreAllMocks();
});
const draftFor = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!;
describe("opening a share as a draft", () => {
it("makes the shared title the subject and addresses nothing", () => {
// A share says what to send, never who to. Anything else would be putting
// a recipient in a field the sharer never filled in.
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Holiday plans" })));
expect(d.subject).toBe("Holiday plans");
expect(d.to).toEqual([]);
expect(d.cc).toEqual([]);
});
it("puts the shared text above the signature, not instead of it", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ text: "Look at this" })));
expect(d.text).toContain("Look at this");
expect(d.text).toContain("John");
expect(d.html).toContain("Look at this");
expect(d.html).toContain("-- ");
// Above, not below: the reply goes where the caret lands.
expect(d.html.indexOf("Look at this")).toBeLessThan(d.html.indexOf("-- "));
});
it("carries a shared link into the body", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ text: "worth reading", url: "https://example.com/a" })));
expect(d.text).toContain("https://example.com/a");
});
it("keeps the signature when a share carried nothing but files", () => {
const key = useCompose.getState().openFromShare(share({ files: [new File(["pixels"], "beach.png", { type: "image/png" })] }));
const d = draftFor(key);
expect(d.html).toContain("John");
expect(d.attachments.map((a) => [a.name, a.type])).toEqual([["beach.png", "image/png"]]);
});
it("attaches every shared file, and starts each one uploading", () => {
const files = [
new File(["a"], "one.png", { type: "image/png" }),
new File(["b"], "two.pdf", { type: "application/pdf" }),
];
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Two things", files })));
expect(d.attachments).toHaveLength(2);
expect(d.attachments.every((a) => a.error === null)).toBe(true);
expect(client.upload).toHaveBeenCalledTimes(2);
});
it("opens a plain draft for a share that carried only a subject", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Just this" })));
expect(d.subject).toBe("Just this");
expect(d.attachments).toEqual([]);
});
});
+99 -20
View File
@@ -28,17 +28,18 @@ const PARSED = [
},
];
interface SetArgs { create?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
interface SetArgs { create?: Record<string, Record<string, unknown>>; update?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
/**
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
* than an array is the single-event shape, which Stalwart also returns.
* @param notCreated refusals to hand back instead of creations.
* @param notUpdated refusals to hand back instead of updates.
* @param max the ceiling on objects in one call, refused the way Stalwart
* refuses it: the whole call, creating nothing.
* @param failOn which `/set` call (0-based) answers with an error instead.
*/
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; max?: number; failOn?: number; existing?: Array<{ id: string; uid: string; calendarIds: Record<string, boolean> }> } = {}) {
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; notUpdated?: Record<string, unknown>; max?: number; failOn?: number; existing?: Array<{ id: string; uid: string; calendarIds: Record<string, boolean> }> } = {}) {
const sets: SetArgs[] = [];
const existing = opts.existing ?? [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
@@ -50,18 +51,28 @@ function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; m
}
if (name === "CalendarEvent/set") {
const nth = sets.length;
sets.push({ create: args.create as Record<string, Record<string, unknown>>, sendSchedulingMessages: args.sendSchedulingMessages as boolean });
sets.push({
create: args.create as Record<string, Record<string, unknown>>,
update: args.update as Record<string, Record<string, unknown>>,
sendSchedulingMessages: args.sendSchedulingMessages as boolean,
});
const keys = Object.keys((args.create ?? {}) as object);
// Whole-call refusals, both of them: nothing in this call is created.
if (opts.max != null && keys.length > opts.max) {
const patched = Object.keys((args.update ?? {}) as object);
/* Whole-call refusals, both of them: nothing in this call gets written.
Creates and updates count against the ceiling together, which is why
the store batches them together. */
if (opts.max != null && keys.length + patched.length > opts.max) {
return ["error", { type: "requestTooLarge", description: "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call." }, id];
}
if (opts.failOn === nth) return ["error", { type: "serverFail", description: "the roof fell in" }, id];
const notCreated = opts.notCreated ?? {};
const notUpdated = opts.notUpdated ?? {};
return [name, {
accountId: "a1", oldState: "1", newState: "2",
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
notCreated,
updated: Object.fromEntries(patched.filter((k) => !(k in notUpdated)).map((k) => [k, null])),
notUpdated,
}, id];
}
// The scan for UIDs already in the calendar: a query for the account's
@@ -122,7 +133,7 @@ describe("importing an .ics file", () => {
it("creates every event in one call when the file fits in one, not one call each", async () => {
const sets = server(PARSED);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 2, skipped: 0 });
expect(n).toEqual({ created: 2, updated: 0 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toEqual(["e0", "e1"]);
});
@@ -163,7 +174,7 @@ describe("importing an .ics file", () => {
it("takes a single event, which is what a one-event file parses to", async () => {
const sets = server(PARSED[0]);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 1, skipped: 0 });
expect(n).toEqual({ created: 1, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toEqual(["e0"]);
});
@@ -179,7 +190,7 @@ describe("importing an .ics file", () => {
it("counts what got in when only some of it did", async () => {
server(PARSED, { notCreated: { e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 0 });
});
});
@@ -202,14 +213,14 @@ describe("importing a file bigger than the server will take at once", () => {
it("splits it into calls the server will accept, and files all of it", async () => {
const sets = server(many(1200), { max: MAX });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1200, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1200, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([500, 500, 200]);
});
it("splits by what the session advertises, not by a number of its own", async () => {
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
const sets = server(many(100), { max: 40 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 100, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 100, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([40, 40, 20]);
});
@@ -256,23 +267,59 @@ describe("importing a file bigger than the server will take at once", () => {
* whole of what is needed to recognise an event that is already here -- and
* nothing looked. Importing an export twice left second copies of everything,
* which the reporter's colleague hit during testing (#173, decided there:
* "duplicate checks on UIDs if UID present in event"). Issue #222.
* "duplicate checks on UIDs if UID present in event"). Issue #222 made that a
* skip; #279 made it an update, because the reason to import a file a second
* time is usually that the first one was not right.
*/
describe("re-importing events the calendar already has", () => {
const here = (uid: string, calendarId = "cal1") => ({ id: `srv-${uid}`, uid, calendarIds: { [calendarId]: true } });
it("skips an event whose uid is already in this calendar", async () => {
it("updates an event whose uid is already in this calendar", async () => {
const sets = server(PARSED, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, skipped: 1 });
// Only the second event, which has no uid of its own, was sent.
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 1 });
// Only the second event, which has no uid of its own, is new.
expect(Object.values(sets[0]!.create!).map((e) => e.title)).toEqual(["Retro (no uid)"]);
// The update is addressed to the event that is here, not to the file's id.
expect(Object.keys(sets[0]!.update!)).toEqual(["[email protected]"]);
expect(sets[0]!.update!["[email protected]"]!.title).toBe("Kickoff");
});
it("holds back the answers and the per-occurrence edits, which live on the event", async () => {
/*
* The one thing #279 turned on. `participants` carries who accepted and
* `recurrenceOverrides` carries every "just this Wednesday" change made
* here; a file describes both as they were at export, so writing either one
* over throws away work with no error anywhere. Everything else in the file
* wins, which is the point of importing it again.
*/
const withPeople = [{
...PARSED[0],
title: "Kickoff (moved)",
participants: { "[email protected]": { "@type": "Participant", participationStatus: "needs-action" } },
recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Skip" } },
}];
const sets = server(withPeople, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 1 });
const patch = sets[0]!.update!["[email protected]"]!;
expect(patch.title).toBe("Kickoff (moved)");
expect(patch).not.toHaveProperty("participants");
expect(patch).not.toHaveProperty("recurrenceOverrides");
// The identity the two were matched on is not re-asserted as a field.
expect(patch).not.toHaveProperty("uid");
});
it("does not mail anyone about an event it updated", async () => {
// Filing a file is not scheduling, on an update as much as on a create.
const sets = server(PARSED, { existing: [here("[email protected]")] });
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.sendSchedulingMessages).toBe(false);
});
it("imports an event whose uid is in a different calendar", async () => {
// A UID is what makes an event the same event *across* calendars, so the
// same event legitimately being in two of them is not a duplicate.
const sets = server(PARSED, { existing: [here("[email protected]", "cal2")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 2, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 2, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toHaveLength(2);
});
@@ -282,12 +329,44 @@ describe("re-importing events the calendar already has", () => {
expect(Object.values(sets[0]!.create!)[0]!.uid).toEqual(expect.any(String));
});
it("sends nothing at all when the whole file is already here", async () => {
// A file whose every event carries a uid the calendar holds: there is
// nothing to create, and nothing wrong either.
it("updates the lot when the whole file is already here, creating nothing", async () => {
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
const sets = server(both, { existing: [here("[email protected]"), here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, skipped: 2 });
expect(sets).toHaveLength(0);
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 2 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toHaveLength(0);
expect(Object.keys(sets[0]!.update!)).toHaveLength(2);
});
it("reports a refusal to update, rather than reporting nothing imported", async () => {
// Everything in the file is already here, so an update is the whole of the
// import -- and a refusal of it is the only thing there is to say.
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
server(both, {
existing: [here("[email protected]"), here("[email protected]")],
notUpdated: {
"[email protected]": { type: "forbidden", description: "the calendar is read-only" },
"[email protected]": { type: "forbidden" },
},
});
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/read-only/);
});
it("splits creates and updates against one ceiling, not one each", async () => {
/*
* Stalwart counts every object in a `/set` against `maxObjectsInSet`
* together and refuses the whole call over it. 300 new and 300 changed
* batched separately would be two calls of 300 -- neither over 500, both
* refused.
*/
const MAX = 500;
const file = Array.from({ length: 600 }, (_, i) => ({
"@type": "Event", uid: `uid-${i}@example.org`, title: `Event ${i}`,
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
}));
const existing = Array.from({ length: 300 }, (_, i) => here(`uid-${i}@example.org`));
const sets = server(file, { max: MAX, existing });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 300, updated: 300 });
expect(sets.map((s) => Object.keys(s.create ?? {}).length + Object.keys(s.update ?? {}).length)).toEqual([500, 100]);
});
});
+83 -26
View File
@@ -296,8 +296,8 @@ interface CalendarState {
findByUid(uid: string): Promise<CalendarEvent | null>;
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
/** Import a whole .ics file. Says how many it created, and how many were already here. */
importIcs(text: string, calendarId: Id): Promise<{ created: number; skipped: number }>;
/** Import a whole .ics file. Says how many it created and how many it updated. */
importIcs(text: string, calendarId: Id): Promise<{ created: number; updated: number }>;
/** The whole calendar as one .ics document, and how many events went into it. */
exportIcs(calendarId: Id): Promise<{ text: string; count: number }>;
applyChanges(types: Set<string>): void;
@@ -333,7 +333,7 @@ function forImport(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
}
/**
* The UIDs a calendar already holds.
* The events a calendar already holds, for recognising a re-import.
*
* A UID is what makes an event the same event across calendars, and the import
* already keeps the file's own wherever there is one -- so the thing needed to
@@ -367,10 +367,21 @@ async function eventsInCalendar(accountId: Id, calendarId: Id, properties: strin
return found;
}
/** Just the UIDs, for deciding what a re-import would duplicate. */
async function uidsInCalendar(accountId: Id, calendarId: Id): Promise<Set<string>> {
/**
* uid -> the id of the event carrying it, for deciding what a re-import updates.
*
* The id and not just the UID, because an event already here is now updated
* rather than skipped and updating needs something to address -- the same
* arrangement, and for the same reason, as contacts' `scanBook`. A UID the
* calendar somehow holds twice keeps the first: two events with one UID is
* already a state nothing here can make sense of, and addressing one of them
* is better than writing the file over both.
*/
async function eventIdsByUid(accountId: Id, calendarId: Id): Promise<Map<string, Id>> {
const events = await eventsInCalendar(accountId, calendarId, ["uid", "calendarIds"]);
return new Set(events.map((e) => e.uid).filter(Boolean));
const byUid = new Map<string, Id>();
for (const e of events) if (e.uid && !byUid.has(e.uid)) byUid.set(e.uid, e.id);
return byUid;
}
export const useCalendar = create<CalendarState>((set, get) => ({
@@ -862,17 +873,24 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* year of events one at a time would refetch the calendar a few hundred
* times. One invalidate here, after the last batch.
*
* No scheduling messages. Importing a file is filing something you already
* have, and mailing its participants would be a surprise to everyone.
* No scheduling messages, on a create or an update. Importing a file is
* filing something you already have, and mailing its participants would be a
* surprise to everyone. That is plainly right for a create and it is a real
* cost on an update -- moving an event without telling anyone leaves every
* attendee's own copy saying the old time, with nothing anywhere reporting
* the disagreement. Weighed on #279 and kept: an import is not the place to
* start sending mail on somebody's behalf, and the alternative is a file
* dropped into a calendar mailing a room full of people who never asked for
* it. Whoever is organising can send the update from the event itself.
*/
async importIcs(text, calendarId) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([text], { type: "text/calendar" }), { type: "text/calendar" });
const events = await get().parseIcs(up.blobId);
if (!events.length) throw new Error("it has no events in it");
const already = await uidsInCalendar(accountId, calendarId);
const already = await eventIdsByUid(accountId, calendarId);
const create: Record<string, unknown> = {};
let skipped = 0;
const update: Record<Id, unknown> = {};
events.forEach((e, i) => {
const rest = forImport(e);
/*
@@ -883,39 +901,78 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* about. Re-importing an export used to leave second copies of
* everything; asked for on #173, decided there.
*/
if (rest.uid && already.has(rest.uid)) {
skipped++;
const existing = rest.uid ? already.get(rest.uid) : undefined;
if (existing) {
/*
* An event this calendar already holds is updated from the file, the
* way a re-imported contact is (#242, #274): the reason to import a
* file a second time is usually that the first one was not right, and
* skipping meant a corrected export corrected nothing.
*
* Two properties are held back, decided on #279. `participants` carries
* every attendee's accepted/declined and `recurrenceOverrides` holds
* every "just this Wednesday" edit made here -- both are answers and
* decisions that happened after the file was written, and a file that
* mentions them at all describes them as they were at export. Writing
* either one over would destroy work nobody asked to lose, silently,
* with no error returned anywhere. So a corrected export fixes the
* time, the title and the location and leaves who said yes alone.
*
* The cost runs the other way: an attendee added at the source since
* the last import does not arrive, and nothing here can tell that apart
* from an RSVP given in ihasmail. Losing an answer somebody gave is
* worse than not gaining an attendee somebody can still be told about.
*
* `uid` is held back too -- it is what the two were matched on, so it
* is already equal, and it is the event's identity rather than a field
* of it worth re-asserting.
*/
const { uid: _u, participants: _p, recurrenceOverrides: _r, ...patch } = rest;
update[existing] = patch;
return;
}
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
});
// Everything in the file was already here. Nothing to send, and nothing
// wrong either -- say so rather than reporting an import of no events.
if (!Object.keys(create).length) return { created: 0, skipped };
const keys = Object.keys(create);
/*
* Creates and updates share one budget. Stalwart counts every object in a
* `/set` against `maxObjectsInSet` together, so batching the two separately
* would send a file of 300 new events and 300 changed ones as two calls of
* 300 and be refused for a ceiling of 500 that neither half crosses.
* Contacts' `writeCards` splits the same way for the same reason.
*/
const keys = [
...Object.keys(create).map((k) => ["create", k] as const),
...Object.keys(update).map((k) => ["update", k] as const),
];
let created = 0;
let updated = 0;
let refused: SetError | undefined;
try {
for (const part of chunk(keys, client.maxObjectsInSet)) {
const sub: Record<string, unknown> = {};
for (const k of part) sub[k] = create[k];
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: sub, sendSchedulingMessages: false });
const subCreate: Record<string, unknown> = {};
const subUpdate: Record<string, unknown> = {};
for (const [kind, k] of part) {
if (kind === "create") subCreate[k] = create[k];
else subUpdate[k] = update[k];
}
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: subCreate, update: subUpdate, sendSchedulingMessages: false });
created += Object.keys(res.created ?? {}).length;
refused ??= Object.values(res.notCreated ?? {})[0];
updated += Object.keys(res.updated ?? {}).length;
refused ??= Object.values(res.notCreated ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
} catch (err) {
// A batch that failed with earlier ones already filed: those events are
// A batch that failed with earlier ones already written: those events are
// in the calendar, and an error saying only that the import failed sends
// someone looking for events that are already there.
if (!created) throw err;
throw new Error(`${created} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
if (!created && !updated) throw err;
throw new Error(`${created + updated} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
} finally {
if (created) get().invalidate();
if (created || updated) get().invalidate();
}
// Nothing at all got in: say why rather than report importing zero events
// as though the file had been empty.
if (!created) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
return { created, skipped };
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
return { created, updated };
},
/*
+26
View File
@@ -14,6 +14,7 @@ import { BASE_PATH } from "@/lib/basePath";
import { settings } from "./settings";
import { emlFilename } from "@/lib/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
import { shareBody, type SharedContent } from "@/lib/shareTarget";
export interface ComposeAttachment {
id: string;
@@ -83,6 +84,8 @@ interface ComposeState {
activeKey: string | null;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
open(init?: Partial<Draft>): string;
/** Open a draft holding what the operating system's share sheet sent us. */
openFromShare(share: SharedContent): string;
openDraftEmail(email: Email): Promise<string>;
/** Open a message again as a mail that has not been sent yet. */
composeAsNew(email: Email): Promise<string>;
@@ -182,6 +185,29 @@ export const useCompose = create<ComposeState>((set, get) => ({
return d.key;
},
/*
* A share from the operating system, as a message being written.
*
* The subject and body are filled in but nothing is addressed and nothing is
* sent: a share says what to send, never who to. What arrives is somebody
* part-way through a thought, and the composer is where the rest of it goes.
*
* Opened empty first and the body pushed in above afterwards, rather than
* passed to `open()`. `open()` only fits a signature when it is given no
* body at all, so handing it the shared text would quietly drop the
* signature from every message that started as a share.
*/
openFromShare(share) {
const body = shareBody(share);
const key = get().open({ subject: share.title.trim() });
if (body) {
const d = get().drafts.find((x) => x.key === key);
if (d) get().update(key, { html: `<div>${textToHtml(body)}</div>${d.html}`, text: `${body}\n${d.text}` });
}
if (share.files.length) get().addFiles(key, share.files);
return key;
},
async openDraftEmail(email) {
const existing = get().drafts.find((d) => d.draftId === email.id);
if (existing) {
@@ -0,0 +1,102 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FilePreviewDialog, type PreviewFile } from "../filepreview";
import { resetShareSupport } from "@/lib/share";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* The share sheet is wiring rather than arithmetic, and wiring is what a store
* test cannot see: whether the button is drawn at all is a question about the
* browser, and what it does is a fetch, a File and a fallback that has to fire
* on any failure rather than leaving the reader with nothing.
*
* The dialog is also where both callers meet -- an attachment opened from a
* message and a file opened from Files land in this same component -- so it is
* the one place worth driving.
*/
const FILE: PreviewFile = {
name: "photo.png",
type: "image/png",
size: 12,
url: "/api/blob/photo.png",
inlineUrl: "/api/blob/photo.png?inline=1",
};
function stubNavigator(nav: Partial<Navigator>) {
vi.stubGlobal("navigator", nav as Navigator);
resetShareSupport();
}
describe("sharing from the file preview", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["bytes"], { type: "image/png" }), { status: 200 })));
});
afterEach(() => {
act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
resetShareSupport();
});
const show = () => act(() => root.render(<FilePreviewDialog file={FILE} onClose={() => undefined} />));
/* The dialog portals to the body, so the buttons are not under `host`. */
const shareButton = () => [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Share") ?? null;
it("draws no Share button where the browser cannot share files", () => {
// Desktop Linux and Firefox. A control that could only ever fall back to
// the Download beside it is one worth not drawing.
stubNavigator({});
show();
expect(shareButton()).toBeNull();
expect(document.body.textContent).toContain("Download");
});
it("hands the bytes to the sheet as a File, not the URL", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => true) as unknown as Navigator["canShare"] });
show();
const btn = shareButton();
expect(btn).not.toBeNull();
await act(async () => {
btn!.click();
await Promise.resolve();
});
await act(async () => { await Promise.resolve(); });
expect(fetch).toHaveBeenCalledWith(FILE.url, { credentials: "same-origin" });
const shared = (share.mock.calls[0] as unknown as [ShareData])[0];
const file = shared.files?.[0];
expect(file).toBeInstanceOf(File);
expect(file?.name).toBe("photo.png");
expect(file?.type).toBe("image/png");
});
it("downloads instead when the blob cannot be fetched", async () => {
// The failure that matters is silent: without the fallback the tap does
// nothing at all and the file is simply unreachable from a phone.
const clicked = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
stubNavigator({ share: vi.fn(), canShare: (() => true) as unknown as Navigator["canShare"] });
show();
await act(async () => {
shareButton()!.click();
await Promise.resolve();
});
await act(async () => { await Promise.resolve(); });
expect(clicked).toHaveBeenCalled();
});
});
+41 -2
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Code2, Download, Eye, Pencil, Printer, Save, X } from "lucide-react";
import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
import { confirmDialog, Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
import { t } from "@/lib/i18n";
import { canShareFiles, shareFile } from "@/lib/share";
import { t, tc } from "@/lib/i18n";
/**
* One blob, described the way both callers can describe it. The URLs are built
@@ -144,6 +145,43 @@ export function FilePreviewDialog({
void confirmDialog({ title: t("Close without saving?"), confirmLabel: t("Discard"), danger: true }).then((yes) => yes && onClose());
};
/*
* Hand the file to another app rather than to the filesystem.
*
* This is the surface where it matters most on a phone: opening an
* attachment lands here, and until now the only way onward was Download,
* which on Android and iOS means "put it somewhere and go and find it".
*
* The bytes have to be fetched rather than the URL passed along, because the
* share sheet takes a File. `same-origin` credentials because both URLs are
* ihasmail's own blob proxy and it is the session cookie that authorises the
* read -- which is also why this does not break the rule about `ui/` not
* reaching for the JMAP client: it is a plain fetch of a URL the caller
* already handed over.
*
* Anything that goes wrong, including a share the browser turned out not to
* support, falls through to the download. That is the button that was here
* before, so the worst case costs a tap rather than the file.
*/
const shareIt = useCallback(async () => {
if (!file) return;
const download = () => {
const l = document.createElement("a");
l.href = file.url;
l.download = file.name;
l.click();
};
try {
const res = await fetch(file.url, { credentials: "same-origin" });
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const out = await shareFile(new File([blob], file.name, { type: file.type || blob.type || "application/octet-stream" }));
if (out === "unsupported") download();
} catch {
download();
}
}, [file]);
/*
* Print what is on screen, not the mail or the file list behind it.
*
@@ -209,6 +247,7 @@ export function FilePreviewDialog({
</div>
)}
{editable && <button className="btn" onClick={startEditing}><Pencil size={16} /> {t("Edit")}</button>}
{canShareFiles() && <button className="btn" onClick={() => void shareIt()}><Share2 size={16} /> {tc("share sheet", "Share")}</button>}
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
</>
+24
View File
@@ -18,6 +18,7 @@ import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format";
import { collectShare } from "@/lib/shareTarget";
import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n";
@@ -35,6 +36,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const [drawer, setDrawer] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const openCompose = useCompose((s) => s.open);
const openShare = useCompose((s) => s.openFromShare);
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const logout = useSession((s) => s.logout);
@@ -73,6 +75,28 @@ export function AppShell({ children }: { children: ReactNode }) {
}
}, [openCompose, navigate]);
/*
* A share from the operating system, collected rather than read off the URL.
*
* The other deep links above arrive as a query the app can read on the spot.
* A share cannot: it is a POST, the service worker answered it, and what it
* left behind has to survive the redirect -- and, when nobody was signed in,
* a trip through the sign-in page as well. So this asks on every start
* instead of only when `?share=1` says so, and finds nothing almost every
* time. The `at` stamp is what stops an abandoned one turning up days later.
*
* It runs here rather than in `main.tsx` because attaching needs an account:
* `addFiles` uploads as it goes, and there is nothing to upload to until the
* session is in place. AppShell only exists once there is one.
*/
useEffect(() => {
void collectShare().then((share) => {
if (!share) return;
openShare(share);
if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true });
});
}, [openShare, navigate]);
/*
* There is no account switcher any more.
*
+11 -8
View File
@@ -65,16 +65,19 @@ export function CalendarSidebar() {
const calendarId = importInto.current;
if (!calendarId) return;
try {
const { created, skipped } = await cal.importIcs(await file.text(), calendarId);
const { created, updated } = await cal.importIcs(await file.text(), calendarId);
/*
* The two counts are kept apart on purpose. "Imported 40 events" over a
* file of 240 reads as a failure when 200 of them were simply already
* here, and a re-import where everything is already here would otherwise
* report importing nothing at all.
* The two counts are kept apart on purpose, the way the contacts import
* keeps them. "Imported 40 events" over a file of 240 reads as a failure
* when the other 200 were updated, and a re-import of a corrected export
* -- the reason for doing this at all -- creates nothing and would
* otherwise report importing nothing at all.
*/
if (!created) toast.success(plural(skipped, { one: "Already here: {n} event, nothing imported", other: "Already here: {n} events, nothing imported" }));
else if (skipped) toast.success(`${plural(created, { one: "Imported {n} event", other: "Imported {n} events" })} · ${plural(skipped, { one: "{n} was already here", other: "{n} were already here" })}`);
else toast.success(plural(created, { one: "Imported {n} event", other: "Imported {n} events" }));
const imported = plural(created, { one: "Imported {n} event", other: "Imported {n} events" });
const refreshed = plural(updated, { one: "{n} updated", other: "{n} updated" });
if (!created) toast.success(plural(updated, { one: "Updated {n} event, nothing new", other: "Updated {n} events, nothing new" }));
else if (updated) toast.success(`${imported} · ${refreshed}`);
else toast.success(imported);
} catch (err) {
toast.error(t("Could not import this file: {error}", { error: (err as Error).message }));
}
+54 -8
View File
@@ -107,15 +107,40 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile();
}, [mailboxId, scheduledId, mailboxesLoaded, reconcile]);
/*
* With conversation view off, a row is a message rather than a thread, and
* opening one must show that message and highlight that row -- not its whole
* thread and every sibling row in the list.
*
* The thread id stays in the path, so loading is unchanged; the message rides
* in `m`. Putting it in the URL rather than in memory is what makes a reload
* or a shared link land back on the same message, and dropping the parameter
* degrades to the conversation, which is the right thing for a link sent to
* somebody whose setting differs.
*/
const openThread = useCallback(
(tid: Id | null) => {
(tid: Id | null, messageId?: Id | null) => {
const base = search ? `/search` : `/mail/${mailboxId}`;
const qs = search ? `?q=${encodeURIComponent(q)}` : "";
const params = new URLSearchParams();
if (search) params.set("q", q);
if (tid && messageId) params.set("m", messageId);
const qs = params.size ? `?${params}` : "";
navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`);
},
[navigate, search, mailboxId, q],
);
/**
* The message the URL singles out, if any. Only meaningful with conversation
* view off; ThreadView decides what to do when the id names nothing in the
* thread, since it is the part that knows what the thread holds.
*/
const openMessageId = useMemo(() => {
if (settings.conversationMode) return null;
const m = new URLSearchParams(searchStr).get("m");
return m || null;
}, [settings.conversationMode, searchStr]);
// Row ids in list + helpers for keyboard nav
const ids = list?.ids ?? [];
const emails = useMail((s) => s.emails);
@@ -129,9 +154,17 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const i = ids.indexOf(focusId);
if (i >= 0) return i;
}
// A cold load has no focus yet. With conversation view off the URL names the
// row exactly; matching on the thread instead would land on whichever of its
// messages sorts first, so j/k and the scroll-into-view would start from the
// wrong row on any thread with more than one message in the folder.
if (openMessageId) {
const i = ids.indexOf(openMessageId);
if (i >= 0) return i;
}
if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId);
return -1;
}, [ids, focusId, threadId, rowThreadId]);
}, [ids, focusId, openMessageId, threadId, rowThreadId]);
/** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback(
@@ -339,9 +372,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
void openDraft(e);
return;
}
openThread(e.threadId);
openThread(e.threadId, settings.conversationMode ? null : rowId);
},
[emails, mailboxId, mailboxes, openThread, openDraft],
[emails, mailboxId, mailboxes, openThread, openDraft, settings.conversationMode],
);
const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail");
@@ -373,6 +406,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
title={title}
list={list}
openThreadId={threadId ?? null}
openMessageId={openMessageId}
focusId={focusId}
setFocusId={setFocusId}
onOpen={onOpenRow}
@@ -387,12 +421,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
{showReading && (
<div className="mail-reading-pane">
{threadId ? (
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
<ThreadView key={`${threadId}:${openMessageId ?? ""}`} threadId={threadId} messageId={openMessageId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t, settings.conversationMode ? null : next!); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
) : (
<div className="no-thread">
<img src={withBase("/img/logo.png")} alt="" />
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
<div>
{list?.total
? settings.conversationMode
? plural(list.total, { one: "{n} conversation", other: "{n} conversations" })
: plural(list.total, { one: "{n} message", other: "{n} messages" })
: settings.conversationMode
? translate("No conversation selected")
: translate("No message selected")}
</div>
<div className="hint">
{settings.conversationMode
? tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })
: tNode("Select a message to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}
</div>
</div>
)}
</div>
+9 -2
View File
@@ -9,6 +9,7 @@ import { formatListDate } from "@/lib/format";
import { mailboxDisplayName } from "@/lib/mailboxName";
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { rowIsOpen } from "@/lib/openMessage";
import { displayName, shortName } from "@/lib/address";
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
import { rowClick } from "@/lib/listSelection";
@@ -53,6 +54,12 @@ interface Props {
title: string;
list: ListState | null;
openThreadId: Id | null;
/**
* With conversation view off, the one message the reading pane is showing.
* The row highlight follows this instead of the thread, or every message in
* a thread lights up when one of them is opened.
*/
openMessageId: Id | null;
focusId: Id | null;
setFocusId: (id: Id | null) => void;
onOpen: (rowId: Id) => void;
@@ -61,7 +68,7 @@ interface Props {
isSearch: boolean;
}
export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
const [, navigate] = useLocation();
const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
@@ -485,7 +492,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
height={vi.size}
selected={Boolean(selected[id])}
focused={focusId === id}
open={openThreadId === e.threadId}
open={rowIsOpen(id, e.threadId, openMessageId, openThreadId)}
twoLine={twoLine}
showAvatar={settings.showAvatars}
showPreview={settings.showPreview}
+60 -6
View File
@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react";
import { useLocation } from "wouter";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
@@ -18,10 +18,11 @@ import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, textToHtml } from "@/lib/text";
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text";
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Dialog, choiceDialog} from "@/ui/dialog";
@@ -35,7 +36,7 @@ import { useScheduled } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule";
import { mdnDecision, refusalText } from "@/lib/mdn";
import { sendReadReceipt } from "@/store/mdn";
import { t as translate, tNode } from "@/lib/i18n";
import { t as translate, tc, tNode } from "@/lib/i18n";
interface Props {
email: Email;
@@ -136,7 +137,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const textPart = e.textBody?.[0];
const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw);
// Not `Boolean(htmlRaw)`: `htmlBody` carries the text part when there is no
// HTML alternative. See hasHtmlAlternative().
const showHtml = hasHtmlAlternative(htmlPart, htmlRaw);
const themeMessageBody = settings.themeMessageBody;
const themeStyledMessages = settings.themeStyledMessages;
@@ -211,6 +214,25 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
a.click();
};
/*
* Pass the message itself to another app -- the reply that has to go to
* somebody who is not on mail, the address read out over a chat.
*
* Text rather than the `.eml` above, and the difference is who the other end
* is. A message file is for another mail client; a share sheet is aimed at
* everything that is not one, and handing WhatsApp an `.eml` gives it an
* attachment nobody can open. So the plain-text body goes, falling back to
* the HTML flattened, which is the same body the sender wrote either way.
*/
const shareMessage = async () => {
const body = textRaw ?? (htmlRaw ? htmlToText(htmlRaw) : "");
try {
await shareText({ title: e.subject || translate("(no subject)"), text: body });
} catch (err) {
toast.error(translate("Could not share: {error}", { error: (err as Error).message }));
}
};
const onUnsubscribe = async () => {
if (!unsubscribe) return;
const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
@@ -322,6 +344,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
{canShare() && <MenuItem icon={<Share2 size={16} />} label={tc("share sheet", "Share…")} onClick={() => void shareMessage()} />}
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={printThis} />
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />}
@@ -743,7 +766,7 @@ export function attachmentIcon(type: string, name?: string | null) {
if (t === "text/calendar") return <Calendar size={18} />;
if (t.includes("vcard")) return <UserPlus size={18} />;
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
return <File size={18} />;
return <FileIcon size={18} />;
}
/**
@@ -822,6 +845,36 @@ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
/*
* The same share the preview dialog offers, on the row itself.
*
* Both are wanted: a photo is opened and then passed on, but a spreadsheet
* cannot be previewed at all and passing it on is the only thing anybody
* wants to do with it from a phone.
*
* Falls back to the download it sits beside where the browser turns out not
* to take the file -- see the note on `shareFile`, which is where the
* transient-activation case is explained.
*/
const shareAttachment = async (a: EmailBodyPart) => {
if (!a.blobId) return;
const name = a.name ?? "attachment";
const download = () => {
const l = document.createElement("a");
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
l.download = name;
l.click();
};
try {
const blob = await client.fetchBlob(accountId, a.blobId, a.type);
const out = await shareFile(new File([blob], name, { type: a.type || blob.type || "application/octet-stream" }));
if (out === "unsupported") download();
} catch {
download();
}
};
/* Whether we can show it, and whether the server will serve it inline, are
different questions -- see the note in lib/preview.ts. */
const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null;
@@ -842,6 +895,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
{canShareFiles() && a.blobId && <button className="icon-btn xs" title={tc("share sheet", "Share")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); void shareAttachment(a); }}><Share2 size={14} /></button>}
{openableInTab(a.type) && a.blobId && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
</span>
+11 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react";
import { useMail } from "@/store/mail";
import { visibleMessages } from "@/lib/openMessage";
import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose";
import type { Email, Id } from "@/jmap/types";
@@ -25,9 +26,15 @@ interface Props {
onNavigate: (delta: number) => void;
hasPrev: boolean;
hasNext: boolean;
/**
* With conversation view off, the single message to show. The thread is still
* what loads -- one request, and the reply/forward paths keep the context
* they need -- but only this message is rendered.
*/
messageId?: Id | null;
}
export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) {
export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext, messageId = null }: Props) {
const loadThread = useMail((s) => s.loadThread);
const thread = useMail((s) => s.threads[threadId]);
const emails = useMail((s) => s.emails);
@@ -82,8 +89,9 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
if (junk && e.mailboxIds[junk]) return false;
return true;
});
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
}, [thread, emails, fullIds, mailboxId]);
const shown = filtered.length ? filtered : all;
return visibleMessages(shown, messageId).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
}, [thread, emails, fullIds, mailboxId, messageId]);
/*
* Which messages were unread when this conversation was opened.
+1 -2
View File
@@ -16,8 +16,7 @@
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+16 -4
View File
@@ -44,11 +44,23 @@ export default defineConfig({
build: {
target: "es2022",
sourcemap: false,
rollupOptions: {
rolldownOptions: {
output: {
manualChunks: {
vendor: ["wouter", "zustand", "dompurify", "@tanstack/react-virtual"],
icons: ["lucide-react"],
/*
* Rolldown, which vite 8 bundles with, dropped the object form of
* `manualChunks` -- naming a chunk and listing the packages in it --
* and takes groups matched against module paths instead. Same two
* chunks out the other end; `icons` is listed first because groups are
* tried in order and the first match wins.
*/
codeSplitting: {
groups: [
{ name: "icons", test: /node_modules[\\/]lucide-react[\\/]/ },
{
name: "vendor",
test: /node_modules[\\/](wouter|zustand|dompurify|@tanstack[\\/]react-virtual)[\\/]/,
},
],
},
},
},