Compare commits

...
Author SHA1 Message Date
Coffey Labs 7825097333 Merge pull request #300 from Coffey-Labs/fix-mobile-dialog-behind-drawer
Raise dialogs and the composer over the mobile drawer
2026-09-06 21:42:30 -07:00
jcoffey-dev cd6dff5346 Raise dialogs and the composer over the mobile drawer
On a phone the folder list is the drawer, so it is also where a new folder
is started -- and the New folder dialog was stacked at 900 against the
drawer's 950, so it opened behind the folder list with only a sliver
showing past the drawer's right edge. Unusable: the name field and the
Cancel button were both underneath.

The same trigger, the same fault, one layer down: Compose in the drawer
opens a full-screen composer, and at 800 that came up behind the drawer
too.

A modal has to outrank the navigation that raised it. The dialog backdrop
goes to 960 and the composer dock to 955, which keeps every relationship
those two already had -- a dialog still clears a composer, popovers,
tooltips and toasts still clear both -- and adds the one that was missing.
Desktop is untouched: the drawer's z-index only exists below 768px, and
nothing sat between 800 and 960 anywhere else.

The stack is now written down beside `.dialog-backdrop`, and guarded by a
test on the stylesheet rather than a component test: jsdom has no paint
order, so nothing in a rendered tree can tell that a dialog is behind the
drawer that opened it.

No user-visible strings change; the nine catalogues are untouched, and the
fallback count holds at 16 in each.
2026-09-06 21:39:45 -07:00
Coffey Labs 0e05bee69a Merge pull request #299 from Coffey-Labs/stalwart-version-catchup
Say 0.16.21 where the docs still said 0.16.20
2026-09-06 17:13:09 -07:00
jcoffey-dev dc676acf52 Say 0.16.21 where the docs still said 0.16.20
The README badge still read 0.16.20, in both the label and the shield it
links to. It is the first version number a reader sees and it was the one
place the prose update missed, because it is HTML rather than Markdown.

Two entries had gone further than stale and were wrong. FEATURES said
occurrence ids are not stable across a write, and KNOWN-ISSUES carried
that as a live hazard with the five-week series that proved it. 0.16.21
fixed exactly that: an occurrence is identified by its recurrence id now,
and holding an id across a write keeps it on its own date. Both entries
say so, keep the old behaviour and the evidence for it because the client
still supports 0.16 as a whole, and record what replaced it.

The defence in the client stays either way, and the reason is written
down: re-resolving by recurrenceId costs one lookup, a date can still
leave a series, and 0.16.20 is still a server someone may be running.

The KNOWN-ISSUES header now says the live instance runs 0.16.21 and,
unlike the upgrades before it, that this one was re-run rather than read
against the diff — with what was exercised by hand.
2026-09-06 17:10:31 -07:00
Coffey Labs 1a955d64df Merge pull request #298 from Coffey-Labs/docs-catchup
Catch the README and FEATURES up with the themes and 0.16.21
2026-09-06 17:04:58 -07:00
jcoffey-dev 575f634f9c Catch the README and FEATURES up with the themes and 0.16.21
Themes were not in "What's in it" at all, which is odd for something a
reader sees before anything else. There is now a bullet for the twelve,
saying that palette and light-or-dark are separate choices and that a
palette which would not meet the contrast this app claims is not written.

FEATURES lists the six new palettes and what each borrows for its light
half, and records the rule that changed with them: body text used to be
checked and then accepted or rejected, which would have turned away five
of the six over a bar their designers never aimed at, so it is now lifted
along its own hue like every other text tone. Twenty-one of the twenty-two
borrowed halves need at least one lift.

The message-theming entry gained the second switch, including why the
first one alone did nothing for most real mail.

The Stalwart section records what the release is validated against rather
than only what it requires: 0.16.21, run against a real instance, with the
four client-visible JMAP changes named. The mock section gains its third
switch and says it tracks the current release, confirms each behaviour
against a real server first, and rewrites rather than deletes the test
that pinned an old behaviour.
2026-09-06 17:01:59 -07:00
Coffey Labs fdfb83b254 Merge pull request #297 from Coffey-Labs/i18n-fill-gaps
Translate the eight strings that were still falling back
2026-09-06 16:56:45 -07:00
Coffey Labs 17a24fe880 Merge pull request #296 from Coffey-Labs/palettes-twelve
Six more palettes, taking the picker to twelve
2026-09-06 16:56:29 -07:00
jcoffey-dev 9e7723ca66 Translate the eight strings that were still falling back
Every catalogue was at 1,255 of 1,279 with 24 strings rendering English.
Eight of those are real UI text and are now translated in all nine
languages: the five sort options that had no entry while their opposites
did (Read first beside Unread first, Unstarred first beside Starred
first, Smallest first beside Largest first, and the two alphabetical
directions), and the three sentences behind the link and external-sender
warnings. Each follows the phrasing its own catalogue already used for
the sibling it sits next to.

The remaining sixteen are left in English deliberately, because
translating them would be wrong: product and project names, the sample
addresses in placeholder text, bare URL prefixes, the ellipsis used as a
masked value, and two mail header names.

Per locale: 1,263 of 1,279, up from 1,255.

**The stale list is not touched, and should not be cleaned blindly.** The
checker reports 41 keys as translated-but-never-looked-up, and some of
them are live. "Classic" is the clearest: the palette picker renders it
through translate(p.name) from a constant, so the extractor sees no
literal, while the German "Klassisch" it would delete is the exact fix
issue #247 asked for. "Add star" and "Remove star" are the same shape,
reached through a ternary in a JSX label. Teaching the extractor those
two call sites is the prerequisite for trusting that list.
2026-09-06 16:35:32 -07:00
jcoffey-dev befe1dbf53 Six more palettes, taking the picker to twelve
Catppuccin, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the
light and dark variant its own project publishes: Latte and Mocha, Lotus
and Wave, and so on. Values were fetched from each project's own repository
and recorded in .palette-sources/palettes-upstream.md, with the two tiers
no project publishes marked derived rather than passed off as upstream.

Four candidates were rejected rather than adapted. Nord and Synthwave '84
publish no light variant, and inventing one is not porting a theme.
Monokai is proprietary and its licence forbids redistribution. Material
Theme has become a commercial product whose repository no longer publishes
a palette at all.

Body text is now lifted for contrast like every other text tone rather
than exempted and merely checked. Most of these palettes target their own
~4.5:1 for body text where ihasmail asks 7:1, so the old rule would have
rejected five of the six on a bar their designers never aimed at. Nudging
the published colour along its own hue is what the script already does for
muted text, links and accents, and every shift is printed in the generated
CSS: Solarized light moves 4.13 to 7.07, Primer needed nothing at all.

Primer is named for the design system, not for GitHub. The colour values
are MIT; the name and the logo are trademarks, and NOTICE says plainly
that nothing here is endorsed.

The picker grid already wrapped on its own, so twelve cards needed no
layout change.
2026-09-06 16:31:38 -07:00
Coffey Labs a875274a8e Merge pull request #295 from Coffey-Labs/palette-credit-generic
Stop naming every palette in the credit line
2026-09-06 16:20:39 -07:00
jcoffey-dev 276ecfccff Stop naming every palette in the credit line
The hint under the theme picker listed the third-party palettes by name.
That sentence is translated into nine languages, so every palette added
meant rewriting it, retranslating it nine times, and leaving the previous
version behind as a stale key nothing looks up.

It now describes the rule instead of enumerating the cases: a palette
named after another project is that project's work, used under its own
licence. True of the four here, true of the next one, and true without
saying "MIT" for a palette that might not be. The names are already in
Settings beside each swatch and in NOTICE with their copyright lines,
which is where a credit belongs.

Swapped rather than added in all nine catalogues, so the old key is gone
rather than left stale: 1,255 of 1,279 translated per locale, unchanged,
and the 41 pre-existing stale keys are neither added to nor cleaned up
here.
2026-09-06 16:14:51 -07:00
Coffey Labs a35f360952 Merge pull request #294 from Coffey-Labs/theme-styled-mail
Force the theme onto mail that styles itself
2026-09-06 16:04:55 -07:00
jcoffey-dev 2464c9655f Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it
themes an HTML message only when the message brings no colours of its own.
That predicate is the right default and it almost never passes: one
`color:#FFFFFF` on one button label opts a whole message out, so in real
mail — receipts, shipping notices, anything from a template — the switch
did nothing at all and the reader kept a bright white card on a dark UI.

A second switch, off by default and only meaningful with the first on,
forces the palette over the sender's colours. It cannot be done perfectly,
which is why it is a separate, explicit choice: the same bargain a
dark-reader extension makes.

What it does is tell two kinds of colour apart. A *sheet* the design sits
on — the white 600px wrapper — is neutralised, and a *painted surface* —
a call to action, a footer banner — is kept whole so its label stays
legible on it. Relative luminance decides, at 0.5: white wrappers sit at
1.0, a blue button near 0.09. Only the painted ones are marked, with
data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else.

Nothing the sender wrote is removed, so the switch is reversible, colours
arriving from a <style> block are covered as well as inline ones, and
print still pins the tokens to ink on white.

The mock grew the message this is about: an outer wrapper on
bgcolor="#ffffff", a <style> block, a coloured button, a grey footer.
Without one, neither the bug nor the fix could be seen.

Verified in a browser against the mock: with only the first switch on the
card is still white; with both, the wrapper computes to transparent, body
text follows the theme, and the button keeps white-on-blue. Two surfaces
marked, which are the two the message paints.

Closes #290
2026-09-06 15:58:25 -07:00
Coffey Labs c66308e4bb Merge pull request #293 from Coffey-Labs/mock-0-16-21
Follow Stalwart 0.16.21 in the mock
2026-09-06 15:48:06 -07:00
jcoffey-dev 6432e11beb Follow Stalwart 0.16.21 in the mock
Four changes, each confirmed against a real 0.16.21 rather than read from
the changelog.

Synthetic recurrence ids are now built from an occurrence's recurrenceId
instead of its position, so they survive a write. This reverses a hazard
the mock reproduced on purpose: up to 0.16.20 writing one override
renumbered the series and a held id silently named a different date. A
five-week series was expanded live, its third occurrence retitled through
its synthetic id, and all five original ids re-read; every one still
resolved to its own date. The test that pinned the instability now pins
the stability, with two more around it.

Calendar/get and AddressBook/get return every property when properties is
omitted or null, shareWith included. Mailbox/get on the same server still
omits it, so that stripping stays and now applies to mailboxes alone.

EventSource ping events advertise the interval in seconds, not
milliseconds. The mock parses the parameter it used to ignore: a 30 s
floor, larger values honoured, 0 disables pings, a non-numeric value is a
400. The first ping now arrives one interval in rather than on connect,
which is what the server does.

CalendarEvent/set rejects create, update and destroy with forbidden when
the request asks for scheduling messages and the account may not send
them. MOCK_NO_SCHEDULING_SEND=1 develops against that account.
2026-09-06 15:43:12 -07:00
Coffey Labs db7b103a08 Merge pull request #292 from Coffey-Labs/push-subscribe
Push by subscription, and a latency fix for the compressor
2026-09-06 14:56:48 -07:00
jcoffey-dev 2c47c0851c Push by subscription: hold no upstream connection per tab
A signed-in tab held two sockets: the browser's, and one from ihasmail to
Stalwart carrying that tab's push stream. The upstream one was most of what a
tab cost, and the only reason Stalwart's connection limit applied to ihasmail
at all.

RFC 8620 section 7.2 defines the other push transport: a PushSubscription,
where the server POSTs StateChange objects to a URL the client registers.
Stalwart 0.16.20 implements it. ihasmail now registers one subscription per
account at sign-in, and when Stalwart POSTs a change, fans it out to that
account's open tabs over the browser-facing streams it already holds. A tab
opens on the relay as before and is moved to fan-out the moment its account
verifies -- the upstream request is ended, the browser stream is untouched,
and nothing keeps a reference to what was torn down. After that there is no
upstream connection at all. The shapes are the RFC's; nothing here is taken
from any other client.

Measured at a 256 MiB cap over a private plain-HTTP route, against a real
Stalwart with 6,144 accounts verifying during the ramp and no failures:

                                 tabs   client   Stalwart   system  KiB/tab
  raw relay (before)            5,000     48.2       46.4     94.6
  push by subscription          6,144     33.3        4.8     38.0
  a direct-to-server client   12,389      4.8       53.8     58.6

Descriptors per tab: one, the browser's. Stalwart pays 4.8 KiB per tab and
holds no connection for it, so its per-listener connection limit no longer
applies to ihasmail. What remains per tab on the client is Node's cost for a
held HTTP/1.1 connection.

PUSH_URL is the https origin Stalwart can reach ihasmail at. The RFC requires
https and Stalwart enforces it, so Stalwart must trust that certificate: a
public TLS front already does; a private segment needs an internal CA in
Stalwart's trust store. An account whose subscription cannot be verified
stays on the relay, so nothing breaks -- only the saving needs the
certificate. PUSH_MODE=relay disables the subscription path entirely.

/api/push/:token accepts only a JSON body under 64 KiB for a known 32-byte
token, answers 200 or 404, and echoes nothing. /api/health reports how many
accounts are verified, pending or failed and how many tabs are on each path.
2026-09-06 13:30:34 -07:00
jcoffey-dev f569f2cc7a Skip the compressor for clients that offer no encoding
Listing latency at one user went from 1.95 ms on the previous release to
3.25 ms on main, and a bisect put the whole of it on the compression commit.
Not on compressing: the harness never sent Accept-Encoding, so nothing was
ever gzipped. Hono's middleware still inspects every compressible response it
declines and sets Vary on it, and setting a header on a streamed passthrough
rebuilds the Response off its fast path -- about 1.2 ms per JMAP call, on a
request that had asked for nothing.

The middleware now runs only when the request names gzip or deflate. Measured
at one user against the same Stalwart:

  compressor touches but declines, no Accept-Encoding   3.25 ms
  skipped entirely, no Accept-Encoding                  2.02 ms
  compressor applied, Accept-Encoding: gzip             2.27 ms
  previous release, either                              1.95 ms

Applying gzip to a JMAP response costs about a quarter of a millisecond and
saves three to five times the bytes on every listing and body, so JMAP
responses stay compressed by default; COMPRESS_JMAP=0 turns that off for a
deployment that would rather not.

The raw push relay is also made safe to tear down from outside -- the
browser stream keeps its headers and is not ended when the upstream request
goes -- which the next change relies on.
2026-09-06 13:22:03 -07:00
Coffey Labs 3fd0d0cfa6 Merge pull request #289 from Coffey-Labs/footprint
Smaller footprint: three times the tabs, a third of the image, a budget per session
2026-09-06 00:46:25 -07:00
jcoffey-dev ed93fefb9b Give each session a budget on the data path
Only sign-in and the account endpoints were rate limited. JMAP, blob
downloads and the image and calendar proxies had no budget at all, and the
proxy is one Node process that saturates a core at roughly 2,000 operations a
second -- measured at 110% CPU under 150 concurrent users. One signed-in
account looping requests could slow every other user on the instance.

Each session now gets API_RATE_LIMIT requests a minute on those routes, 1,200
by default: twenty a second sustained, well above what a busy tab does and an
order of magnitude below where one tab starts to hurt the rest. Over budget
returns 429 with Retry-After. Sign-in keeps its own, separate limiter.

Checked in situ: one session driven flat out was cut off after exactly 1,200
requests, and with API_RATE_LIMIT=0 throughput at 50 users is unchanged.
2026-09-06 00:42:49 -07:00
jcoffey-dev 6098ffb8e5 Ship the runtime image without the build tree
639 MB unpacked and 119 MB compressed, against 239 MB and 59 MB now. Two
causes, both in the runtime stage.

The build stage's node_modules was copied across whole: 132 MB of vite,
TypeScript, esbuild, jsdom and React that the server never loads, since it
needs hono and its Node adapter and nothing else -- about 4 MB. The runtime
stage now installs the server workspace's production dependencies on its own.

Then `chown -R node:node /data /app` rewrote every one of those files, which
on overlayfs copies the whole tree into a second layer of the same size. Only
/data is written to at runtime; /app stays root-owned and read-only to the
process, which is what an immutable container wants anyway.

The base image's npm, npx, yarn and corepack are removed from the runtime
stage as well. The server is started with `node` directly and never calls
them; anyone who gains code execution should not find a package manager
waiting.

Checked that the image starts --read-only, serves the gzipped bundle, signs
in against Stalwart, holds a push stream, and that `hono` loads from the
3.1 MB that remains.
2026-09-06 00:42:19 -07:00
jcoffey-dev 01f721d8d1 Cut what a signed-in tab costs by two thirds
Two changes on the push path, both measured against a real Stalwart 0.16.20
with the container capped at 256 MiB and tabs added in steps of 200 until the
kernel killed it:

                                  tabs held   per tab   of which native
  before                              1,665   133 KiB          81 KiB
  pin upstream calls to STALWART_URL  3,400    58 KiB           8 KiB
  + raw push relay                    4,979    37 KiB          10 KiB

Stalwart advertises absolute https URLs in every session, and the proxy
followed them -- so even with STALWART_URL naming a private plain-HTTP hop on
the same Docker network, every held push stream went out through TLS. That leg
is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which
is why neither the heap ceiling nor the stream buffer size ever moved the
number. absoluteUpstream() now keeps the path and query from the advertised
URL and the scheme, host and port from the configured one. A setup that must
reach Stalwart at an origin other than the one it was given sets
STALWART_FOLLOW_ADVERTISED_URLS=1.

With the transport out of the way, the fetch()-based relay was the next cost:
an undici Response, a web ReadableStream, a reader and Hono's stream bridge
held alive per tab, about 44 KiB of heap for a session that otherwise costs
4 KiB. relayPushRaw() pipes the upstream socket into the Node response and
tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the
fetch path for comparison.

JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside
run-to-run noise); the relay does not touch that path. Verified that a push
stream through the raw relay delivers a StateChange while mail is written.

The install page's advice to set --max-old-space-size was measured in the same
runs and made no difference at all -- 3,400 tabs with it and without -- and
is withdrawn in the docs alongside this change.
2026-09-06 00:42:19 -07:00
Coffey Labs 5356e603fe Compress our own responses (#288)
* Compress our own responses

The bundle went out uncompressed unless a proxy in front did the work: 933 KB
on the wire where 311 KB does, on every first load. Both example proxy configs
compress, but that only helps deployments that copied them, and the default
should not depend on reading the examples.

Hono's middleware, with the proxy routes held back. `/api/blob`, `/api/image`,
`/api/ics` and `/api/upload` forward somebody else's bytes under a
content-length copied from upstream, and issue #76 was a silent truncation
caused by exactly that header disagreeing with its body. Re-encoding them
would be safe in principle -- the length is dropped and the response goes out
chunked -- but they carry attachments and images that are already compressed,
so there is nothing to win and a scar to respect.

`/api/events` is listed with them even though Hono already skips
text/event-stream by content type, so that changing the push route's type
cannot quietly start buffering the stream.

`/api/health` is excluded for the opposite reason: at 47 bytes gzip made it 73.
Hono's size threshold cannot catch that on its own, because it only applies
when a response carries a content-length and `c.json()` does not set one. The
other JSON routes stay compressed -- a JMAP response has just as unknown a
length and can run to hundreds of kilobytes.

Verified against the built image: assets come back gzipped with Vary set,
662 KB to 209 KB; /api/events still returns text/event-stream with no
content-encoding and delivered a StateChange while mail was being written;
health is 47 bytes either way. No user-visible strings, so no catalogue work.

* Word the comment for either side compressing

The app compresses its own responses as of the follow-on change, so a note
saying the bundle ships uncompressed would be wrong as soon as that lands.
nginx passes through what the upstream already encoded rather than re-encoding
it -- verified single-encoded with both layers active -- so the directives are
correct either way and the comment now says so without asserting which side
does the work.

* Test compression against a fixture, not the web build

The compression tests asked for `/` and asserted a gzipped 200. That passes
locally, where `web/dist` is lying around from an earlier build, and fails in
CI, which runs `npm test` before `npm run build`: with no bundle the shell
route serves the "web build not found" fallback, which is short, plain text and
correctly uncompressed. The failure read as compression being broken when the
tests were simply depending on a build step that had not run.

They now build their own static root in a temp directory and point STATIC_DIR
at it, in a separate file so the environment is set before the app module is
imported. Checked by moving web/dist aside and running the suite the way CI
does.
2026-09-05 23:41:29 -07:00
Coffey Labs fafeee481e Merge pull request #287 from Coffey-Labs/nginx-example-compression
Compress the bundle in the nginx example
2026-09-05 23:24:26 -07:00
jcoffey-dev a618f3fca6 Compress the bundle in the nginx example
The Caddy example has `encode zstd gzip`; the nginx one had nothing, so a
deployment following it shipped every asset uncompressed. Measured against the
built app that is 915 KB on the wire where 307 KB would do -- the difference
falls entirely on first load, and silently, since nothing about it is visible
without inspecting response headers.

`text/javascript` is listed explicitly. The server sends scripts with that
type rather than `application/javascript`, so a conventional gzip_types list
compresses the stylesheet and leaves the 647 KB script alone -- which is what
happened on the first attempt at this change.

text/event-stream is deliberately not listed. Compressing or buffering the
push stream would break it; proxy_buffering is already off below for the same
reason. Verified that /api/events still delivers a StateChange event through
the proxy, as plain text, while assets come back gzipped with Vary set.
2026-09-05 22:44:25 -07:00
Coffey Labs 7d6dfe4581 Merge pull request #286 from Coffey-Labs/smime-signature-verification
Check S/MIME signatures, and remember who signed
2026-09-05 01:46:31 -07:00
jcoffey-dev c84f190f76 Check S/MIME signatures, and remember who signed
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.

What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.

The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.

The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.

Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:

  - OpenPGP, by name. The signature carries no key and there is nowhere
    to get the sender's: x:PublicKey is the account's OWN registry, and
    a keyserver or WKD lookup would tell a third party who you
    correspond with -- the leak the image proxy exists to close.
  - SHA-1. Not forgeable in practice today, still not something to put a
    tick beside.
  - RSA-PSS, whose salt length lives in parameters this does not read.
    Guessing wrong would report a good signature as bad.

Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.

The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".

Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.

Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
2026-09-05 01:42:51 -07:00
Coffey Labs 7aa2e374d4 Merge pull request #285 from Coffey-Labs/revive-public-key-management
Write down what Stalwart's x:PublicKey registry does, and withdraw the key manager
2026-09-05 01:16:06 -07:00
jcoffey-dev 45c8929697 Withdraw the key manager, and keep what probing it established
A Settings section for public keys is furniture, not a feature. Nothing
in ihasmail signs, encrypts, decrypts or verifies with a key, so the
page could only ever tell the reader in its own footnote that adding one
does nothing. It is withdrawn on that reasoning -- the same reasoning
that closed PR #67, reached again with the code in front of us.

So this reverts every user-visible part of it: the section, the lib, the
mock handlers, the component and the 261 catalogue strings. Nothing in
web/ or server/ differs from main now.

What stays is the part that was expensive and is true regardless. The
x:PublicKey registry was probed against a live 0.16.20 on 2026-09-05,
and the findings are now in KNOWN-ISSUES rather than in a closed pull
request -- which is where they sat for the nine days between #67 and
this branch, and why the work was done twice. Consolidated into one
entry, framed as what Stalwart does rather than what ihasmail offers:

  - an ordinary user may read and write their own keys, whatever the
    permissions table says
  - the registry takes S/MIME certificates as well as OpenPGP keys, and
    parses both -- confirmed with a real self-signed X.509 certificate,
    and a malformed one gets its own BER decoding error
  - a key can parse and still be refused, with different words. A
    sign-and-certify key -- what `gpg --quick-generate-key` makes --
    gets "Could not find any suitable keys", which is not a paste error
    and must not be shown as one
  - emailAddresses comes back as {} when empty, an object where a list
    property should be an array. It type-checks, then throws in join()
  - a create answers with the id alone; patching `key` is allowed
  - expiresAt is the registry's field and is not derived from the key

ROADMAP now says plainly that key management has been built and
withdrawn twice, that the registry is not the obstacle, and that
verifying a signature -- which needs only public keys -- is the shortest
route to a key being worth having. Encryption at rest moves from "not
offered yet" to refused: it is a one-way door, since turning it off does
not decrypt what is already there, and that is not a switch to hand an
ordinary user however easy it would be to add.
2026-09-05 01:11:25 -07:00
jcoffey-dev 6a467d9bc4 Check the S/MIME half against a real server, instead of assuming it
The section offered "an OpenPGP public key or an S/MIME certificate" and
only the first half had ever been tried. Every probe behind it used
OpenPGP keys, and every message the registry returns names OpenPGP --
including for input that is not OpenPGP at all -- so the server reads as
though OpenPGP were the only format it knows. Shipping the claim on that
evidence would have been a guess dressed as a feature, which is the one
thing this section is written not to do.

It holds. Confirmed live on 0.16.20 (2026-09-05) with a self-signed
X.509 certificate carrying emailProtection and an email: SAN:
registered, read back, destroyed. And Stalwart parses it as seriously as
it parses OpenPGP -- a malformed certificate is refused by a decoder of
its own, "Failed to decode X509 certificate: BER decoding error:
Expected Tag { class: Universal, value: 16 } tag…", which is a third
rejection wording and the reason the S/MIME half is real rather than
decorative. The mock now returns it for a certificate, so the branch
exists somewhere a client can meet it.

One thing found on the way: expiresAt is the registry's field and is not
derived from the key. A certificate valid for a year registers with
expiresAt null, so the card says "No expiry set" about a credential that
does expire. Left as it is, deliberately: reading the real date means
parsing the certificate, which is the second opinion this section
refuses to offer, and a date extracted here would disagree with the
server's own field the moment the two ever differed. What the row
reports is what the registry holds, and KNOWN-ISSUES says so.
2026-09-05 01:03:52 -07:00
jcoffey-dev e93d42d27e Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add,
rename and remove the OpenPGP public keys and S/MIME certificates
published on this account. Only public material -- no private key is
stored, requested or sent by any of this.

This is PR #67 revived. That branch was built against 0.16.19, closed
unmerged on 2026-08-26, and shares no ancestry with main after the email
scrub, so it is ported rather than rebased: the four files it added are
carried over, the three it edited are applied by hand, and everything it
claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n
work is new -- nine catalogues landed on 2026-08-31, after that branch
was written.

What the re-probe confirmed, unchanged from 0.16.19:

  - An ordinary user may read *and* write their own keys, though the
    permissions table lists every sysPublicKey* permission as
    administrative. get and query both answered for a normal account,
    and a malformed create came back invalidProperties naming `key`
    rather than forbidden -- a rejection of the key, not of the person.

  - The server parses the key and says precisely what is wrong. So
    ihasmail does not validate key material; the server's message is
    shown verbatim, as password-policy rejections already are.

  - urn:stalwart:jmap is still absent from the session's top-level
    capabilities and present per-account, so the check that reads all
    three places is still the one that works.

What it added, none of which was known before:

  - A key can parse perfectly and still be refused, with different
    words: a sign-and-certify key with no encryption subkey -- what
    `gpg --quick-generate-key` produces -- gets "Could not find any
    suitable keys in OpenPGP public key". That is the rejection somebody
    exporting from GnuPG will actually meet, and it is not a paste
    error, so collapsing both to "invalid key" would send them back to
    the clipboard for a problem that is in the key.

  - emailAddresses comes back as {} when empty -- an object where a JMAP
    list property should be an array. It type-checks, then throws in
    join() while the list renders. normalize() checked the shape
    already; there is now a test saying why, and the mock answers {} the
    same way, because one that helpfully returned [] would let that
    crash ship.

  - A create answers with the id alone, no createdAt, so adding a key
    reloads rather than believing the response.

  - destroy works and leaves the registry empty. PR #67 shipped that
    path untested -- its live probe was refused before anything was
    created, so there was nothing to destroy.

  - Patching `key` is allowed by the server. The mock still refuses it,
    now deliberately rather than for want of evidence: ihasmail replaces
    a key by adding one and removing the old, which keeps createdAt
    meaning what it says.

x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is
an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and
its value is a typed object ({"@type":"Disabled"}) rather than the bare
string ROADMAP described. Nothing here writes it.

An empty description is now sent as empty rather than filled in with
"Key". The description is stored on the server, so a default invented in
the client would be whichever language the adder happened to be using;
the list labels a blank one at render time instead.

Verified in a browser against the mock, not only in tests: both
rejections reach the toast in the server's own words with the form still
filled in, a good key renders its card, the kind is labelled from the
armour header, renaming persists, removing asks first and empties the
list, and the whole section reads correctly in German.
2026-09-05 00:56:31 -07:00
Coffey Labs e9349863e8 Merge pull request #284 from Coffey-Labs/add-funding-config
Add GitHub Sponsors funding config
2026-09-05 00:23:24 -07:00
jcoffey-dev 3a74f0a715 Add GitHub Sponsors funding config
Point the repository Sponsor button at the live LINUXexpert-org
GitHub Sponsors listing.
2026-09-05 00:20:26 -07:00
Coffey Labs 1f9c17ad18 Merge pull request #283 from Coffey-Labs/roadmap-smime-reasoning
Say why S/MIME rather than OpenPGP, and why neither is urgent
2026-09-04 13:31:13 -07:00
jcoffey-dev 0df62e6b2f Say why S/MIME rather than OpenPGP, and why neither is urgent
The entry recorded what the probing established and what the design
caveat is, and said nothing about why this is the encryption worth
building or why it sits on this page rather than in the tracker. Somebody
reading it -- including me in six months -- could reasonably conclude the
choice was arbitrary.

End-to-end encrypted mail never reached the mainstream, and the reasons
are structural rather than a tooling problem: everyone in a thread has to
take part, key discovery was never solved and the keyservers got
weaponised, there is no forward secrecy, the metadata stays in the clear,
a lost key loses the mail, and it breaks search and spam filtering. EFAIL
showed the clients were exploitable too. The privacy win that actually
landed was STARTTLS, MTA-STS and DANE, which needed nothing from users.

S/MIME wins between the two because it is more deployed where software
gets paid for -- native in Outlook and Apple Mail, routine in defence,
healthcare, finance and government -- since a CA issues and revokes
certificates an IT department can administer, which the web of trust
never managed.

The last paragraph is the one that will matter in practice: a self-hosted
webmail for Stalwart draws the densest concentration of PGP users left,
so this will be asked for far more often than it would be used. That is
the argument for keeping it here and honest rather than building it on
the strength of the requests.

Docs only. No strings added, no catalogues touched.
2026-09-04 13:28:19 -07:00
Coffey Labs eca8468d84 Merge pull request #282 from Coffey-Labs/docs-release-cadence
Say near the top that latest lags main, and by how long
2026-09-04 13:14:27 -07:00
jcoffey-dev 429c232e0c Say near the top that latest lags main, and by how long
A fix announced as "live" on a closed issue means the QA webmail server,
which deploys from main. It does not mean the image anybody has pulled:
that is cut weekly, on Mondays at 09:00 UTC, so between one Monday and
the next main is ahead of the newest release by up to a week.

This confused the reporter on #174 this week, and it was my wording that
did it -- three comments invited him to try changes that were merged and
not yet published. The distinction was written down nowhere.

Placed above "this file is for people working on ihasmail" rather than
under Container images, because the person who needs it is reading to
decide whether to pull, and by the time they reach that section they have
usually pulled. Container images gains the cadence too, since "on every
release" says nothing about how often a release happens.

The hour is given as approximate on purpose: GitHub runs scheduled
workflows best-effort and delays them when its queue is busy.

Docs only. No strings added, no catalogues touched.
2026-09-04 13:11:44 -07:00
Coffey Labs 53a44d7d18 Merge pull request #281 from Coffey-Labs/docs-contributing-branch-rules
Say in CONTRIBUTING that main is protected, and that strings need nine catalogues
2026-09-04 12:54:59 -07:00
jcoffey-dev fa22d30347 Say in CONTRIBUTING that main is protected, and that strings need nine catalogues
Two things a contributor could only find out by tripping over them.

`main` now carries a ruleset: a pull request with a green build check, no
force-push, no deletion, and deliberately no required approval -- which
would lock a solo maintainer out of their own repository rather than
protect anything.

And a new user-visible string is work in nine catalogues. A missing key
renders its English source rather than failing, so the omission is
invisible from here and obvious to anyone reading that language. The
plural-key trap is in CLAUDE.md rather than repeated here.

Docs only. No strings added, no catalogues touched.
2026-09-04 12:52:17 -07:00
Coffey Labs fcbd8f6449 Merge pull request #280 from Coffey-Labs/claude-md-i18n-gotcha
Write down the plural-key gotcha, and how to tell it happened
2026-09-04 12:31:09 -07:00
jcoffey-dev 310dc85b62 Write down the plural-key gotcha, and how to tell it happened
The catalogue key for a plural is the `other` form -- `plural()` looks the
entry up by `forms.other` -- and keying it on the `one` form type-checks,
builds, passes every test, and falls back to English in all nine
languages. Nothing errors. It cost a round trip on #278 and would cost
the next one the same.

The part worth writing down is not the rule but the signal, because there
is only one: the "falling back to English" count from
i18n-catalog-check. The percentage is no use for this -- adding keys
moves the denominator, so it holds steady at 98% whether the new strings
are translated or not.

Also here: that a change touching user-visible strings is work in nine
catalogues and should be reported as such, including when the answer is
none; and that store tests do not exercise the component, with the
shift-click range bug from #278 as the standing example -- measured
inside a setState updater, which React runs after the anchor ref has
moved, so it passed every store assertion and failed the moment the built
app was driven.

No CLAUDE.md existed before this.
2026-09-04 12:28:19 -07:00
Coffey Labs 0c9a15a691 Merge pull request #278 from Coffey-Labs/contacts-bulk-delete
Select contacts, and empty an address book
2026-09-04 12:19:18 -07:00
jcoffey-dev cee107d948 Select contacts, and empty an address book
Raised on #174 as the other half of a migration -- import, notice
something is wrong, empty the book, correct the export, import again --
and tracked as #277.

The gap turned out to be wider than the ask. Contacts had no multi-select
at all: the only delete in the module was the cross on a single card's
pane, one card and one confirmation at a time. `destroyCards` has taken a
list and batched it against maxObjectsInSet since #218, and nothing in
the UI ever handed it more than one id. So "empty this address book" was
missing, and so was "delete these fourteen".

The list now has checkboxes, on hover the way the message list's are, and
always on a touchscreen where there is no hover to reveal them.
Shift-click takes the run between two rows. The search box gives way to a
selection bar rather than sitting beside it, because what the count
promises is what the search left on screen. A selection is cleared when
the book being shown changes, since carrying it across would leave a
count describing rows that are no longer there and a Delete aimed at
them.

Emptying a book is in the book's own menu, beside the import and export
that moved there in #226, and separate from Delete, which takes the book
with it. A default book cannot be deleted and can perfectly well be
emptied, which is most of the reason it is its own entry.

The part that is not a deletion, and the reason this is not one destroy
over everything in the book: a card filed in two books belongs to both,
and `ContactCard/set destroy` takes it away from both at once. Emptying
one book must not empty another, so a card with a second home is patched
out of this one and left alone. That is reported separately afterwards,
because it would otherwise look like contacts that refused to go.

`destroyCards` now answers with what the server confirmed rather than
throwing on the first refusal. A refusal that took half a selection with
it still deleted the other half, and an error saying only that it failed
sends somebody looking for contacts that are already gone. Both callers
report the count and the reason apart.

Emptying a shared book is deliberately not offered: the cards live in the
owner's account and this client has no path to write there.

One bug found by driving the built app rather than by any test, and worth
recording because of where it hid. The range a shift-click covers was
measured inside the `setPicked` updater -- which React runs when it gets
round to rendering, by which time the anchor ref has already been moved
to the row that *ended* the range. Every shift-click selected exactly one
row, and every store assertion still passed, because nothing was wrong
below the component. The anchor is read before the updater now, and the
contacts view has its first component tests: ten of them, six of which
fail if the measurement moves back inside.

Twelve new strings, in all nine catalogues, so nothing new falls back to
English.
2026-09-04 12:08:53 -07:00
Coffey Labs f201b09e90 Merge pull request #276 from Coffey-Labs/reply-to-my-own-message
Ask the folder, not just the identity list, whether a message was mine
2026-09-04 08:13:37 -07:00
jcoffey-dev 029f079094 Ask the folder, not just the identity list, whether a message was mine
Replying to a thread whose last message I sent addressed the reply to me:
Reply put my own address in To, and Reply all put me in To with everyone
I had actually written to demoted to Cc. Following up on your own last
message is an ordinary thing to do, and this made it useless.

There was already a guard for exactly this, and the guard was sound. What
it rested on was not. It asked whether an address was in the identity
list, and that question has a wrong answer in more situations than it has
a right one:

- the list is empty until identities load;
- an alias or a shared mailbox is not in it at all;
- it compared lowercased strings with `includes` where the rest of the
  codebase uses `sameAddress`, so an identity address stored with
  whitespace was enough to break it;
- the check ran on the address the reply was about to go to rather than
  on the sender, so a message of mine carrying a Reply-To skipped it
  entirely and my reply went to my own desk;
- and the Reply all branch never filtered my own address out of To, though
  the Reply branch did.

Every one of those failed silently, which is why five of them accumulated.

So the folder is asked first: a message in Sent is mine whatever address
it went out as, and `mailboxIds` is already fetched in LIST_PROPS with
roleId("sent") on the mail store, so this costs no request. The identity
list stays as a second opinion, now compared with `sameAddress`, and the
whole test keys off the sender rather than off the computed recipient.

Two cases remain unanswerable and are commented rather than papered over:
a message from an unlisted alias that is not in Sent either, and any
message at all when identities failed to load and it is not in Sent.
Neither signal exists. Both are far narrower than what was broken.

Reply addressing had no tests at all, which is how a guard this
load-bearing came to be wrong five ways at once. Fifteen now, seven of
which fail against the old code.
2026-09-04 08:10:28 -07:00
Coffey Labs 4d23cef511 Merge pull request #274 from Coffey-Labs/ldif-dedupe-on-dn
Match an LDIF re-import on the entry's dn
2026-09-04 07:54:42 -07:00
jcoffey-dev b4248a6661 Match an LDIF re-import on the entry's dn
Reported again by the submitter's colleague at LINET after #223 was
closed: duplicate checking was implemented for vCard and never for LDIF,
so re-importing an address book still leaves a second copy of everything.
That was deliberate at the time -- the matching key was an open question
I did not want to answer alone -- but the answer had already been given
on #174 and I closed the issue without acting on it.

The answer, in the submitter's words: an attribute that *can* change is
fine, because it will not have changed between two imports minutes apart.
An import is not a sync. That makes the `dn` usable -- it is the only
identity the file carries, and Mozilla's schema defines no UID -- and it
needs no guessing at all, unlike the name-plus-email fallback I had been
weighing.

So `uidFromDn` derives a namespaced, stable uid from the distinguished
name, normalised for the case and spacing two exports of one directory
differ in. A card the book already holds under that uid is updated rather
than duplicated, merged the way the vCard import merges: what the file
carries wins, what it does not mention is left alone. Reported as created
and updated, which is the pair that was asked for.

Three things worth knowing:

Matching is per address book, so two customer directories that each hold
a `cn=John Smith` stay two people as long as they are filed separately.
Imported into one book they would merge, which is the one way this can be
wrong and the reason the escape hatch is worth naming.

The look-alike count stays, and now means something narrower: entries
that `dn` matching could not catch -- one whose `dn` moved between
exports, and anything imported before there was a `dn` to match on. Those
are still only counted, never merged.

A file holding two entries under one `dn` is malformed, since a directory
cannot, and now becomes one card instead of two sharing an identity.

FEATURES gains the re-import behaviour for both formats; it documented
neither.
2026-09-04 07:50:49 -07:00
Coffey Labs 1f8c12e29e Write the S/MIME position down (#273)
It was backlogged in conversation on 2026-08-27 and recorded nowhere in
the repository -- not in ROADMAP, FEATURES, KNOWN-ISSUES, the README or
the docs. That is the state a plan is in just before it is forgotten,
and it is also the state that lets the same probing get done twice.

The entry carries what the earlier work established against a live
0.16.19, including the two findings that contradict the documentation:
encryptionAtRest is a field on x:AccountSettings rather than an object of
its own, and ordinary users can write their own x:PublicKey entries
despite the permissions table listing every sysPublicKey permission as
admin-only. Dated, and marked not re-run since the 0.16.20 upgrade, the
way KNOWN-ISSUES dates its entries.

PR #67 is named as the starting point: a working public-key manager,
closed unmerged, none of which is in the tree today.

The caveat that matters most is last, because it is the one a user
cannot undo: turning encryption-at-rest off does not decrypt what is
already encrypted.
2026-09-03 23:25:44 -07:00
Coffey Labs 17d98748c4 Merge pull request #272 from Coffey-Labs/i18n-missing-plurals
Add seven plural forms no catalogue ever had
2026-09-03 14:47:00 -07:00
jcoffey-dev 1070ee13bc Add seven plural forms no catalogue ever had
Found by widening the coverage check to plural() forms in every file rather
than the two being worked on. Seven counted strings in the Files view and the
event editor had never been in any of the nine catalogues, so they rendered in
English whatever language was chosen.

Not a regression from the recent work -- they have been missing since the
features landed, and every earlier scan looked at t("literal") sites and the
plurals of whichever file was in hand.

All nine languages, one commit rather than nine: this is a single gap in a
check rather than a translation pass, and splitting it per language would
suggest nine decisions where there is one.
2026-09-03 14:44:23 -07:00
Coffey Labs 71827a2d04 Merge pull request #270 from Coffey-Labs/i18n-ukrainian-rule-sentences
Translate the rule sentences into Ukrainian
2026-09-03 14:41:45 -07:00
Coffey Labs 5dd0a56732 Merge pull request #269 from Coffey-Labs/i18n-russian-rule-sentences
Translate the rule sentences into Russian
2026-09-03 14:41:39 -07:00
Coffey Labs b55c8b13bc Merge pull request #268 from Coffey-Labs/i18n-chinese-rule-sentences
Translate the rule sentences into Simplified Chinese
2026-09-03 14:41:35 -07:00
Coffey Labs 0cf9b81444 Merge pull request #267 from Coffey-Labs/i18n-japanese-rule-sentences
Translate the rule sentences into Japanese
2026-09-03 14:41:31 -07:00
Coffey Labs 8386444ac7 Merge pull request #266 from Coffey-Labs/i18n-portuguese-rule-sentences
Translate the rule sentences into Brazilian Portuguese
2026-09-03 14:41:26 -07:00
Coffey Labs e1ae97139c Merge pull request #265 from Coffey-Labs/i18n-dutch-rule-sentences
Translate the rule sentences into Dutch
2026-09-03 14:41:21 -07:00
Coffey Labs 503eaf17ec Merge pull request #264 from Coffey-Labs/i18n-french-rule-sentences
Translate the rule sentences into French
2026-09-03 14:41:16 -07:00
Coffey Labs 1e02d9ebba Merge pull request #263 from Coffey-Labs/i18n-spanish-rule-sentences
Translate the rule sentences into Spanish
2026-09-03 14:41:11 -07:00
Coffey Labs d40dbf04b8 Merge pull request #262 from Coffey-Labs/i18n-german-rule-sentences
Translate the rule sentences into German
2026-09-03 14:41:06 -07:00
Coffey Labs 2a9e18f04c Merge pull request #271 from Coffey-Labs/fix/shortcuts-after-checkbox
Keep shortcuts working after a checkbox is clicked
2026-09-03 14:41:00 -07:00
Coffey Labs 4d89f5e672 Merge pull request #261 from Coffey-Labs/i18n-describe-rules
Build the two rule descriptions as sentences, not fragments
2026-09-03 14:40:55 -07:00
jcoffey-dev 95e5c69e8f Keep shortcuts working after a checkbox is clicked
Ticking "select all" disabled every keyboard shortcut until the reader clicked
somewhere else (#260). Same for the per-message checkboxes, so selecting a few
messages and pressing e to archive them did nothing.

The guard that stops "a" archiving while you are typing into the search box
tested `tagName === "INPUT"`. That is also true of a checkbox, and a checkbox
keeps focus after a click -- correctly, since space should toggle it again.
So the guard was suppressing shortcuts for an element that swallows no
keystroke: space is handled by the browser before this listener runs.

The question is not "is this an input" but "does this input take text", which
is what isTextEntry now asks. A <select> counts, in the sense that matters
here: typing a letter jumps to the option starting with it, and a shortcut
would steal that.

Thirteen checkboxes and seven file inputs across the app were affected, not
just the one reported.

The regression test was checked against the old guard first: it fails there
and passes here, which is the only thing that makes it a regression test.
2026-09-03 14:38:15 -07:00
jcoffey-dev 50d08a18e4 Translate the rule sentences into Ukrainian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Ukrainian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:12 -07:00
jcoffey-dev 9a634311b2 Translate the rule sentences into Russian
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Russian half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:24:08 -07:00
jcoffey-dev b811c84b12 Translate the rule sentences into Simplified Chinese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Simplified Chinese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:32 -07:00
jcoffey-dev b9b01ce02c Translate the rule sentences into Japanese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Japanese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:28 -07:00
jcoffey-dev 104e3c7ba0 Translate the rule sentences into Brazilian Portuguese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Brazilian Portuguese half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:23:01 -07:00
jcoffey-dev 0a03c64ff3 Translate the rule sentences into Dutch
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Dutch half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:58 -07:00
jcoffey-dev 4c430ea995 Translate the rule sentences into French
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the French half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:28 -07:00
jcoffey-dev 112b3ea52f Translate the rule sentences into Spanish
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the Spanish half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:22:24 -07:00
jcoffey-dev 31edf33839 Translate the rule sentences into German
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole
sentences with placeholders, so that a translator can move the parts rather
than being handed " and " on its own. This is the German half of that.

32 strings and 9 plural forms. The ordinals are words -- there is no suffix to
append here, which was the point -- and the day and item lists are joined by
Intl.ListFormat rather than a translated separator.
2026-09-03 14:21:53 -07:00
jcoffey-dev 1a842d8d14 Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no
catalogue could fix. A translator handed " and " or " on " in isolation cannot
move it: German puts the verb last, Japanese does not separate list items with
a word at all, and the fragments arrive in an order the English sentence chose.
Reported by a native speaker reviewing the German catalogue (#247), whose "the
summaries" item is the Sieve one.

Every branch is now one whole sentence with placeholders, so a translator
rewrites the sentence including its word order. Joining is Intl.ListFormat,
which gives "A, B und C" for an allof rule and the language's own disjunction
for anyof, rather than a hardcoded " and " that would be wrong twice over.

The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03"
wrap the sentence they qualify, so a language that puts the limit first can.

Ordinals become words. The old suffix table -- st, nd, rd, th, picked by
arithmetic -- is English spelling rules in code, and no catalogue can reach a
suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5
or -1 in practice, so five words and "last" cover it.

WEEKDAYS is gone. Its long names could have been catalogue entries but its
short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and
Sunday, and a catalogue cannot hold two translations under one key. That was
bad data rather than missing translation, and Intl has every name in every
locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and
formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a
language question.

Adds the first tests either function has had. Neither had any, and no test
would have caught what was wrong with them, since the English output was
correct -- so these pin the two properties that actually matter: fragments go
through the catalogue, and the joining is Intl's.

32 strings and 9 plural forms are new and land with each language.

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -07:00
74 changed files with 6724 additions and 328 deletions
+4
View File
@@ -0,0 +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
+101
View File
@@ -47,3 +47,104 @@ bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c
link #2959aa
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
yellow #8f5e15 · teal #33635c · green #385f0d
---
Fetched 2026-09-06 from the projects' own repositories, same rule as above.
Where a project publishes fewer background tiers than ihasmail needs, the
missing one is derived and marked **derived** here rather than passed off as
upstream. Body text is lifted to 7:1 by the build script for most of these —
they target their own ~4.5:1 — and every shift is printed in the generated CSS.
## Catppuccin — catppuccin/palette, MIT (palette.json)
Cited from the palette repo rather than the hub README; it is the normative
machine-readable source.
### Mocha (dark)
base #1e1e2e · mantle #181825 · crust #11111b · surface0 #313244 · surface1 #45475a
text #cdd6f4 · subtext0 #a6adc8 · overlay1 #7f849c
mauve #cba6f7 · blue #89b4fa · red #f38ba8 · peach #fab387 · green #a6e3a1
yellow #f9e2af · pink #f5c2e7
### Latte (light)
base #eff1f5 · mantle #e6e9ef · crust #dce0e8 · surface0 #ccd0da · surface1 #bcc0cc
text #4c4f69 · subtext0 #6c6f85
mauve #8839ef · blue #1e66f5 · red #d20f39 · peach #fe640b · green #40a02b
yellow #df8e1d · pink #ea76cb
Latte publishes no tier lighter than `base`, so `base` is used as the elevated
surface and `mantle` as the page behind it.
## Solarized — altercation/solarized, MIT (README "The Values")
base03 #002b36 · base02 #073642 · base01 #586e75 · base00 #657b83
base0 #839496 · base1 #93a1a1 · base2 #eee8d5 · base3 #fdf6e3
yellow #b58900 · orange #cb4b16 · red #dc322f · magenta #d33682
violet #6c71c4 · blue #268bd2 · cyan #2aa198 · green #859900
The accents are shared by both modes by design. Two tiers are **derived**: the
sunken dark surface #001f28 (below base03) and the raised light surface
#fffdf6 (above base3), neither of which Solarized publishes, plus the two
rule colours #0d4552 and #e6dfc8.
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
### Dark
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
fg #d3c6aa · grey1 #859289
red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092
blue #7fbbb3 · purple #d699b6
### Light
bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af
fg #5c6a72 · grey1 #939f91
red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c
blue #3a94c5 · purple #df69ba
Light uses bg_dim as the page and bg0 as the raised surface, so the card the
reader looks at is the colour Everforest calls its background.
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
### Wave (dark)
sumiInk0 #16161D · sumiInk3 #1F1F28 · sumiInk4 #2A2A37 · sumiInk5 #363646
fujiWhite #DCD7BA · fujiGray #727169
crystalBlue #7E9CD8 · springBlue #7FB4CA · samuraiRed #E82424 · roninYellow #FF9E3B
springGreen #98BB6C · carpYellow #E6C384 · sakuraPink #D27E99
### Lotus (light)
lotusWhite0 #d5cea3 · lotusWhite1 #dcd5ac · lotusWhite2 #e5ddb0 · lotusWhite3 #f2ecbc
lotusInk1 #545464 · lotusGray2 #716e61
lotusViolet4 #624c83 · lotusBlue4 #4d699b · lotusRed #c84053 · lotusOrange #cc6d00
lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79
## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml)
The YAMLs give the base palette and the surfaces as literals but express syntax
roles as references (`$palette.indigo.l2`), and the resolved files are not
committed. The two signature accents are taken from the same organisation's
MIT-licensed ayu-theme/vscode-ayu build.
### Dark
surface base #0D1017 · lift #10141C (sunk is `base -L0.1`, **derived** here as #070a0f)
ui line #1B1F29 · ui fg #5A6378 · editor fg #BFBDB6
red #F07178 · orange #FF8F40 · yellow #FFB454 · green #AAD94C · teal #95E6CB
indigo #39BAE6 · blue #59C2FF · purple #D2A6FF · accent #E6B450 (vscode-ayu)
### Light
surface sunk #EBEEF0 · base #F8F9FA · lift #FCFCFC
ui fg #828E9F · editor fg #5C6166 · rule #dfe2e5 (**derived**)
red #F07171 · orange #FA8532 · yellow #EBA400 · green #86B300 · teal #4CBF99
indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu)
## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light})
Named "Primer" after the design system. The colour values are MIT; "GitHub"
and the Invertocat are trademarks, and nothing here is endorsed by them.
### Dark
neutral #0D1117 #151B23 #212830 #262C36 #2A313C #2F3742 #3D444D #656C76
#9198A1 #B7BDC8 #D1D7E0 #F0F6FC · black #010409
blue #79c0ff #58a6ff · green #56d364 #3fb950 · yellow #e3b341 #d29922
red #ff7b72 · purple #d2a8ff
### Light
neutral #F6F8FA #EFF2F5 #E6EAEF #E0E6EB #DAE0E7 #D1D9E0 #C8D1DA #818B98
#59636E #454C54 #393F46 #25292E
blue #0969da #0550ae · green #1a7f37 #116329 · yellow #bf8700 #9a6700
red #cf222e · purple #8250df
+54
View File
@@ -0,0 +1,54 @@
# 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__/`.
+11
View File
@@ -48,6 +48,17 @@ 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).
`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
deleted. No approving review is required, so a PR of your own is not blocked
waiting for one.
### Code Style
+15 -3
View File
@@ -37,16 +37,28 @@ ENV NODE_ENV=production \
IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH
WORKDIR /app
COPY package.json ./
COPY package.json package-lock.json* ./
COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
# set it never looks further; without it, it falls back to package.json rather
# than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/
COPY --from=build /app/node_modules ./node_modules
# Only what the server loads at runtime: hono and its Node adapter, about 4 MB.
# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that
# never executes here but shipped anyway -- and showed up in every CVE scan.
RUN npm ci --ignore-scripts --omit=dev --workspace server \
&& rm -rf /root/.npm /tmp/*
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
RUN mkdir -p /data && chown -R node:node /data /app
# /data is the only path the process may write. /app stays root-owned and
# read-only to the runtime user on purpose; the previous `chown -R /app`
# re-wrote every file and, on overlayfs, duplicated the whole tree into a
# second 173 MB layer.
RUN mkdir -p /data && chown node:node /data \
# The base image ships a package manager the server never calls. Anyone who
# gets code execution should not find one waiting for them.
&& rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg
USER node
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
# goes, but Docker acts on it: a container started without `-v` gets an
+122 -16
View File
@@ -12,8 +12,10 @@ questions:
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec |
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
Written against the tree at Stalwart **0.16.20**, which is the version the live
instance runs and the one every behaviour below was checked against. ihasmail
Written against the tree at Stalwart **0.16.21**, which is the version the live
instance runs. Behaviours carrying an older version below were checked against
that one and have not changed since; where 0.16.21 changed something, the entry
says so and names both. ihasmail
requires 0.16 or newer and refuses older servers at sign-in, by name.
## The shape of it
@@ -365,7 +367,15 @@ same query string — so what it builds can be read, edited and learned from.
these headers shows nothing.
- **Message body theming** is off by default — sender HTML is left exactly as it
was designed, on a light card. One setting lets mail that brings no colours of
its own follow the app's theme instead.
its own follow the app's theme instead. That is a low bar in practice: one
`color:#FFFFFF` on one button label opts a whole message out, so for mail
built from a template it changed nothing. A second setting, off unless the
first is on, forces the theme over the sender's own colours. It tells a
*sheet* the design sits on, like a white wrapper table, from a *painted
surface* like a button or a banner, by relative luminance: the first is
neutralised so the bright card goes away, the second is kept whole so its
label stays readable on it. Nothing the sender wrote is removed, so the
switch is reversible, and print is unaffected either way.
### Conversations
@@ -662,13 +672,18 @@ work:
success; the rest are applied. ihasmail checks the patch before sending it, so
a rejected property is an error you can see and an inherited one is reported
as something it could not do for one date, rather than claimed as saved.
- **Occurrence ids are not stable across a write.** Stalwart's synthetic ids
encode a position in the expanded series, and writing an override renumbers
them — confirmed live on 0.16.20: after one override, the same five ids
addressed a different five dates. So an occurrence is re-resolved from its
`recurrenceId` (the date itself) immediately before it is touched, and a
vanished date says so rather than acting on an id that now means something
else.
- **Occurrence ids became stable in 0.16.21, and were not before it.** Through
0.16.20 Stalwart's synthetic ids encoded a *position* in the expanded series,
so writing one override renumbered the rest and the same five ids addressed a
different five dates. 0.16.21 identifies an occurrence by its recurrence id
instead — confirmed live on 0.16.21 (2026-09-06): a five-week series was
expanded, its third occurrence retitled through its own synthetic id, and all
five original ids re-read afterwards still named their own dates. ihasmail
re-resolves an occurrence from its `recurrenceId` immediately before touching
it anyway. That is no longer load-bearing on the current server, and it stays
because it costs one lookup, because a vanished date still has to say so
rather than be acted on, and because the client supports 0.16 as a whole
rather than only its newest release.
*This and future* is not offered: the server refuses an occurrence that belongs
to such a change, and where it does, ihasmail says so and offers the series.
@@ -711,6 +726,12 @@ JMAP Contacts and JSContact.
company, job title, any number of emails, phones and addresses with types,
birthday, website and notes.
- **Groups** as a card kind, with members picked from the book.
- **Select and delete in bulk** — tick rows in the list, shift-click for a run,
and delete the lot; or **Empty address book** from the book's own menu, which
is the operation a migration asks for when an import needs doing again. A card
filed in two books is only ever removed from the one being emptied, since
deleting it would empty a book nobody asked about, and what is reported
afterwards is what the server confirmed rather than what was asked for.
- **Letter index** down the list, with `#` for everything that does not start
with a letter.
- **Search** across name, address, organisation and notes, in one book or all.
@@ -724,6 +745,14 @@ JMAP Contacts and JSContact.
title, nickname, web pages and the custom fields all come across. The import
control takes either format and decides by what is in the file, not by what it
is called.
- **Re-importing updates rather than duplicates.** A vCard is recognised by its
UID; an LDIF entry, whose schema has none, by its distinguished name. The card
already here is merged with the file's version -- what the file carries wins,
what it does not mention is left alone -- so a corrected export can correct
what the first attempt got wrong. Matching is per address book, which is also
how two directories that each hold a `cn=John Smith` stay two people. An entry
no longer recognisable, because its `dn` moved between exports, is imported
again and counted: *"3 of them look like contacts you already had."*
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
- **Directory lookup** through `Principal/query`, so colleagues on the server
@@ -1005,11 +1034,17 @@ at two.
| **Gruvbox** | |
| **Rosé Pine** | Dawn as its light half |
| **Tokyo Night** | Day as its light half |
| **Catppuccin** | Mocha and Latte |
| **Solarized** | Light and dark are both original to it, and share one set of accents |
| **Ayu** | |
| **Kanagawa** | Wave, with Lotus as its light half |
| **Everforest** | The medium-contrast variant of each side |
| **Primer** | The colours behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here |
Every one has both halves, so the top-bar toggle only ever changes the side and
never the colours. Accent colours still sit on top of any of them.
The four borrowed palettes are the work of their own projects and are used
The ten borrowed palettes are the work of their own projects and are used
under the MIT licence — see [NOTICE](NOTICE). Only the published colour values
are used, taken from each project's own repository; the values as fetched are
recorded in `.palette-sources/palettes-upstream.md`.
@@ -1023,11 +1058,20 @@ anything that falls short, towards white on a dark ground and towards black on
a light one so the hue survives. The script refuses to write a palette that
would not pass.
That check is not a formality. **Every one of the nine palette halves needed at
least one lift**, because these palettes are designed for code editors rather
than for prose at this size: Dracula's comment grey is 3.03:1 on its own
background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published
would have quietly ended the WCAG AA claim two sections down.
That check is not a formality. **Twenty-one of the twenty-two palette halves
needed at least one lift**, because these palettes are designed for code
editors rather than for prose at this size: Dracula's comment grey is 3.03:1 on
its own background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as
published would have quietly ended the WCAG AA claim two sections down.
Body text is lifted the same way, which it was not at first. It used to be
checked and then either accepted or rejected, and that rule would have turned
away five of the six palettes added in September 2026: most of them target
around 4.5:1 for body text, their own goal, where ihasmail asks 7:1 of the text
a reader looks at all day. Rejecting a palette over a bar its designers never
aimed at is the wrong answer when the same arithmetic already adjusts muted
text, links and accents. Solarized Light moves 4.13 to 7.07 that way; Primer
needed nothing in either half.
---
@@ -1198,6 +1242,68 @@ Over Stalwart's own registry objects, so there is no administrator in the loop:
credentials". Doing it properly means implementing OAuth; that is in
[ROADMAP.md](ROADMAP.md).
## Checking a signature
A signed message says who signed it, and ihasmail checks whether that holds up.
This is S/MIME only, and it stops at reading: nothing here signs, encrypts or
decrypts anything.
**What it checks.** For a `multipart/signed` message carrying a PKCS#7
signature, the exact bytes of the signed part — headers included, canonicalised
to CRLF — are hashed and compared against the `messageDigest` the signature
covers, and the signature over the signed attributes is verified with WebCrypto
against the certificate travelling inside the message. RSA (PKCS#1 v1.5) and
ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or
SHA-512.
**What a check is allowed to claim, which is the whole design.** A browser has
no system trust store, and the certificate arrives inside the message, so anyone
can self-sign as anyone. On its own a verified signature proves only that
whoever wrote the message held the key attached to it — which is why ihasmail
never renders the bare word *verified*.
What makes it worth anything is remembering. The first signed message from an
address pins that certificate's fingerprint in your settings; later ones are
compared against it. That is trust on first use, and it needs no certificate
authority:
| what happened | what you see |
|---|---|
| first signed message from this address | *"Signed by X, seen here for the first time"* — grey, and deliberately not congratulatory |
| same certificate as before | *"the same signer as before"* — the only case that gets a tick |
| **different certificate than before** | **loud**: both names, and told to check by some other route |
| valid signature, certificate for a different address | **loud**: the signature is not for this sender |
| body changed after signing | **loud**: the signature does not check out |
| signed, but uncheckable | grey, and careful to say *could not check* rather than *did not check out* |
The pins live in the account's settings file rather than in the browser, so the
same correspondent is not greeted as new on every device — which is what trains
people to click past the one warning that matters. A pin records the message
that created it, so the message which established a signer keeps saying so
rather than appearing to be corroborated by itself. A signer that changed, one
whose certificate does not name the sender, or one already expired is never
pinned: writing an anomaly into the baseline would make every later message
agree with it.
**What it will not do.**
- **OpenPGP is not checked**, and says so by name rather than as an unknown
format. The signature does not carry the key, and ihasmail has nowhere to get
a correspondent's public key from — `x:PublicKey` holds the account's *own*
keys, and fetching from a keyserver or WKD would leak who you correspond with
to a third party, which is the exact thing the image proxy exists to prevent.
- **No chain of trust.** Nothing is validated against a certificate authority,
no CA bundle is shipped, and revocation is not checked. "Issued by" reports
what the certificate says, and a self-signed certificate says it issued
itself.
- **SHA-1 signatures are refused**, not reported as valid.
- **RSA-PSS is declined** rather than attempted, because guessing the salt
length wrong would report a good signature as bad — a worse thing to say than
"cannot check".
The verifier is a separate bundle chunk, loaded only when a message's structure
says it is signed, so reading ordinary mail costs nothing for any of this.
## Privacy by default
Remote images blocked, the proxy on, read receipts never automatic, no
+25 -8
View File
@@ -4,14 +4,19 @@ What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
eight seconds of downtime, and as of **2026-08-26 there is nothing left
pending**. Every entry below was exercised against 0.16.19 on the date it
names, and the dates still say so: the upgrade was read against the
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
The live instance runs **0.16.21**, and as of **2026-08-26 there is nothing
left pending**. Most entries below were exercised against 0.16.19 on the date
they name, and the dates still say so: each upgrade since was read against the
diff rather than re-run, and nothing in those diffs touches the session
capabilities, blob, quota, submission or registry paths these entries describe.
The calendar entries below carrying a 2026-08-31 date are the exception: those
were exercised against the live 0.16.20 directly.
The calendar entries carrying a 2026-08-31 date were exercised against a live
0.16.20 directly, as were the public-key entries dated 2026-09-05.
**0.16.21 was different and was re-run rather than read.** It changed four
things a client can see, one of which resolved an entry below outright. The app
was run against a real 0.16.21 with mail, calendar and contacts exercised by
hand, including editing one occurrence of a recurring series through the
interface and confirming the rest of the series stayed where it was.
What remains here is not a list of unknowns but of things worth knowing — where
Stalwart departs from a spec, where a setting has to be turned on for a feature
to work, and what ihasmail deliberately does not do.
@@ -38,6 +43,18 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
- **It takes S/MIME certificates as well as OpenPGP keys, and parses both.** A self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"*. Worth checking rather than assuming, because every *other* message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not.
- **A key can parse perfectly and still be refused, and says something different when it is.** A sign-and-certify OpenPGP key with no encryption subkey — which is what `gpg --quick-generate-key` produces — comes back *"Could not find any suitable keys in OpenPGP public key"*, distinct from the parser's *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…"*. Any client showing these must keep them apart: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. Certificates have no equivalent trap, since one issued for email use has key encipherment by construction.
- **`emailAddresses` comes back as `{}` when empty** — an object, where a JMAP list property should be an array. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while a list renders. A client must check the shape rather than trust the type.
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is grey rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
@@ -50,7 +67,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defence stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behaviour, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
+40 -2
View File
@@ -5,7 +5,7 @@ other people that ships inside it and the terms it comes under.
## Colour palettes
Four of the palettes offered in Settings Appearance are the work of their own
Ten of the palettes offered in Settings Appearance are the work of their own
projects and are used under the MIT licence. Only the published colour values
are used — no code, and nothing from anyone else's reimplementation of them.
The values as fetched from each project are recorded in
@@ -35,9 +35,47 @@ Licensed under the MIT licence. The light variant is "Dawn".
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
Licensed under the MIT licence. The light variant is "Day".
### Catppuccin
Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
Licensed under the MIT licence. "Mocha" is the dark variant and "Latte" the
light one; both are published in that repository's palette.json.
### Solarized
Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
Licensed under the MIT licence. Light and dark are both original to it, and
share one set of accent values by design.
### Ayu
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
Licensed under the MIT licence. The two signature accent colours come from the
same author's ayu-theme/vscode-ayu, also MIT.
### Kanagawa
Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
Licensed under the MIT licence. "Wave" is the dark variant and "Lotus" the
light one. The theme takes its name from Hokusai's print.
### Everforest
Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
Licensed under the MIT licence. The medium-contrast variant of each mode is
the one used here.
### Primer
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
Licensed under the MIT licence, which covers the colour values. "GitHub" and
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
"Primer" after the design system and is neither affiliated with nor endorsed
by GitHub.
---
The MIT licence, under which all four are used:
The MIT licence, under which all ten are used:
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
+40 -4
View File
@@ -9,7 +9,7 @@
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.21" src="https://img.shields.io/badge/Stalwart-0.16.21-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
</p>
@@ -33,6 +33,19 @@ durable belongs to Stalwart; the container is disposable.
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
> **Releases are weekly, so `latest` normally lags `main`.** Automation builds
> and publishes the GHCR image every **Monday at 09:00 UTC**, in a week that had
> changes. Between one Monday and the next, `main` is ahead of the newest image
> — a fix merged on Tuesday is a `docker pull` away only after the following
> Monday. GitHub runs scheduled workflows on a best-effort basis, so treat the
> hour as approximate.
>
> This is worth knowing when a closed issue says a fix is *live*: that means the
> QA webmail server, which deploys from `main`, and not the image you have. If
> you want a change before the next Monday, build from `main` — see
> [Container images](#container-images). Otherwise pull after it, and the dated
> tag tells you exactly which build you are on.
This file is for people working *on* ihasmail. Everything about running it
lives in the docs.
@@ -54,9 +67,11 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
- **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
- **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)
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
@@ -72,6 +87,17 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
**Validated against 0.16.21**, released 6 September 2026: the app was run
against a real instance of it and the mail, calendar and contacts paths were
exercised by hand. Four of that release's JMAP changes are visible to a client
— an occurrence of a recurring event is now identified by its recurrence id
rather than by its position in the series, so an id held across a write no
longer silently names a different date; `Calendar/get` and `AddressBook/get`
return every property when none are named; EventSource advertises its ping
interval in seconds rather than milliseconds; and a calendar write that asks
for scheduling messages is refused when the account may not send them. The mock
reproduces all four.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
@@ -96,7 +122,9 @@ Full instructions, TLS, and every environment variable:
### Container images
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`:
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`.
Releases are cut weekly — Mondays, 09:00 UTC, in a week that had changes — so
the newest image is normally behind `main`:
```bash
docker pull ghcr.io/coffey-labs/ihasmail:latest
@@ -346,9 +374,17 @@ without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised
**per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
RFC 8984's. Three switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
the sign-in refusal can be tested.
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
calendar write that asks for scheduling messages, the way an account without
that permission is refused.
It tracks the current release rather than 0.16 in general, and each behaviour
is confirmed against a real server before it is copied here — the comments say
which version and on what date. Where a release changes something a client can
see, the mock changes with it, and the test that pinned the old behaviour is
rewritten rather than deleted, so the reversal stays on the record.
### Version numbers
+15
View File
@@ -15,3 +15,18 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponised in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defence, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
+25
View File
@@ -6,6 +6,31 @@ server {
client_max_body_size 60m;
# Compression. The bundle is the bulk of first load -- about 933 KB
# uncompressed against 311 KB gzipped -- and nginx passes through anything
# the upstream already encoded rather than re-encoding it, so this is
# correct whether or not ihasmail compresses on its own.
#
# text/event-stream is deliberately absent from gzip_types: the push stream
# must not be compressed or buffered, which is also why proxy_buffering is
# off below.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
# text/javascript is listed explicitly: ihasmail serves scripts with that
# type rather than application/javascript, so a conventional gzip_types
# list compresses the stylesheet and leaves the largest asset alone.
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/javascript
text/plain;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
+93
View File
@@ -163,6 +163,90 @@ SOURCES = {
q1="#006c86", q2="#385f0d", q3="#65359d",
),
},
"catppuccin": {
"dark": dict( # Mocha
bg="#1e1e2e", elev="#313244", sunken="#181825", line="#45475a",
fg="#cdd6f4", muted="#a6adc8", accent="#cba6f7", link="#89b4fa",
danger="#f38ba8", warn="#fab387", success="#a6e3a1", star="#f9e2af",
q1="#89b4fa", q2="#a6e3a1", q3="#f5c2e7",
),
"light": dict( # Latte
bg="#e6e9ef", elev="#eff1f5", sunken="#dce0e8", line="#ccd0da",
fg="#4c4f69", muted="#6c6f85", accent="#8839ef", link="#1e66f5",
danger="#d20f39", warn="#fe640b", success="#40a02b", star="#df8e1d",
q1="#1e66f5", q2="#40a02b", q3="#ea76cb",
),
},
"solarized": {
"dark": dict(
bg="#002b36", elev="#073642", sunken="#001f28", line="#0d4552",
fg="#839496", muted="#586e75", accent="#268bd2", link="#2aa198",
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
q1="#2aa198", q2="#859900", q3="#6c71c4",
),
"light": dict(
bg="#fdf6e3", elev="#fffdf6", sunken="#eee8d5", line="#e6dfc8",
fg="#657b83", muted="#93a1a1", accent="#268bd2", link="#2aa198",
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
q1="#2aa198", q2="#859900", q3="#6c71c4",
),
},
"ayu": {
"dark": dict(
bg="#0d1017", elev="#10141c", sunken="#070a0f", line="#1b1f29",
fg="#bfbdb6", muted="#5a6378", accent="#e6b450", link="#59c2ff",
danger="#f07178", warn="#ff8f40", success="#aad94c", star="#ffb454",
q1="#39bae6", q2="#aad94c", q3="#d2a6ff",
),
"light": dict(
bg="#f8f9fa", elev="#fcfcfc", sunken="#ebeef0", line="#dfe2e5",
fg="#5c6166", muted="#828e9f", accent="#f29718", link="#22a4e6",
danger="#f07171", warn="#fa8532", success="#86b300", star="#eba400",
q1="#55b4d4", q2="#86b300", q3="#a37acc",
),
},
"kanagawa": {
"dark": dict( # Wave
bg="#1f1f28", elev="#2a2a37", sunken="#16161d", line="#363646",
fg="#dcd7ba", muted="#727169", accent="#7e9cd8", link="#7fb4ca",
danger="#e82424", warn="#ff9e3b", success="#98bb6c", star="#e6c384",
q1="#7fb4ca", q2="#98bb6c", q3="#d27e99",
),
"light": dict( # Lotus
bg="#e5ddb0", elev="#f2ecbc", sunken="#dcd5ac", line="#d5cea3",
fg="#545464", muted="#716e61", accent="#624c83", link="#4d699b",
danger="#c84053", warn="#cc6d00", success="#6f894e", star="#77713f",
q1="#4d699b", q2="#6f894e", q3="#b35b79",
),
},
"everforest": {
"dark": dict( # medium
bg="#2d353b", elev="#343f44", sunken="#232a2e", line="#475258",
fg="#d3c6aa", muted="#859289", accent="#a7c080", link="#7fbbb3",
danger="#e67e80", warn="#e69875", success="#a7c080", star="#dbbc7f",
q1="#7fbbb3", q2="#a7c080", q3="#d699b6",
),
"light": dict( # medium
bg="#efebd4", elev="#fdf6e3", sunken="#e6e2cc", line="#bdc3af",
fg="#5c6a72", muted="#939f91", accent="#8da101", link="#3a94c5",
danger="#f85552", warn="#f57d26", success="#8da101", star="#dfa000",
q1="#3a94c5", q2="#8da101", q3="#df69ba",
),
},
"primer": {
"dark": dict(
bg="#0d1117", elev="#151b23", sunken="#010409", line="#3d444d",
fg="#f0f6fc", muted="#9198a1", accent="#58a6ff", link="#79c0ff",
danger="#ff7b72", warn="#e3b341", success="#3fb950", star="#d29922",
q1="#79c0ff", q2="#56d364", q3="#d2a8ff",
),
"light": dict(
bg="#f6f8fa", elev="#ffffff", sunken="#eff2f5", line="#d1d9e0",
fg="#25292e", muted="#59636e", accent="#0969da", link="#0550ae",
danger="#cf222e", warn="#9a6700", success="#1a7f37", star="#bf8700",
q1="#0550ae", q2="#116329", q3="#8250df",
),
},
}
# What each token has to clear, and against which surface. Normal text is 4.5;
@@ -183,6 +267,15 @@ def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], lis
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
return out
# Body text is lifted like every other text tone rather than exempted.
# Most of these palettes publish a body colour around 4.5:1 -- their own
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
# Rejecting a palette over that would have cost five of the six added in
# 2026-09; nudging the published colour along its own hue costs nothing a
# reader can name, and the shift is recorded in the header of the
# generated block like every other one.
fg = lift("fg", fg, TEXT_ON_BG["fg"])
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
# Between muted and the background, but still readable: this is timestamps
# and counts, which are small and still prose.
+199 -5
View File
@@ -1,6 +1,11 @@
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { compress } from "hono/compress";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
@@ -59,6 +64,19 @@ const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_00
* cannot get the whole deployment banned.
*/
const accountLimiter = new RateLimiter(10, 15 * 60_000);
const apiLimiter = new RateLimiter(config.apiRateLimit, 60_000);
/** Per-session budget on the data path. See config.apiRateLimit. */
const apiRateLimited: MiddlewareHandler<Env> = async (c, next) => {
if (config.apiRateLimit > 0) {
const session = c.get("session");
if (session && !apiLimiter.check(session.id)) {
c.header("Retry-After", String(apiLimiter.retryAfterSeconds(session.id)));
return c.json({ error: "rate_limited" }, 429);
}
}
await next();
};
const HOP_BY_HOP = new Set([
"connection",
@@ -109,6 +127,72 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
};
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
/**
* Routes that forward somebody else's bytes rather than producing our own.
*
* Compression is right for the app shell, the bundle and our JSON; it is not
* worth the risk on the proxy paths. Those carry a content-length copied from
* upstream under the rules in `forwardedContentLength`, and issue #76 was a
* silent truncation caused by exactly that header disagreeing with the body.
* Re-encoding them would be safe in principle -- the length is dropped and the
* response goes out chunked -- but the payloads are attachments, images and
* calendar data that are already compressed or too small to matter, so there
* is nothing to win and a scar to respect.
*
* `/api/events` needs no entry here: Hono skips `text/event-stream` by content
* type. It is listed anyway, because a future change to that route's type
* should not quietly start buffering the push stream.
*/
const UNCOMPRESSED_ROUTES = [
"/api/blob/",
"/api/image",
"/api/ics",
"/api/upload/",
"/api/events",
/*
* The liveness probe, which is small enough that gzip makes it bigger: 53
* bytes becomes 73. Hono's size threshold cannot catch this on its own,
* because it only applies when the response carries a content-length and
* `c.json()` does not set one. Every other JSON route is left compressed --
* a JMAP response can run to hundreds of kilobytes and its length is just as
* unknown -- so this is the one place worth naming.
*/
"/api/health",
];
/**
* gzip for what we generate.
*
* The bundle ships uncompressed otherwise: 915 KB on the wire where 307 KB
* would do, on every first load. `Caddyfile.example` and
* `nginx.example.conf` both compress at the proxy, but that only helps the
* deployments that use them, and the default should not depend on reading the
* examples.
*
* Hono's middleware declines anything already carrying `Content-Encoding` or
* `Transfer-Encoding`, so a proxy compressing in front of us wins and we do
* not double-encode.
*/
function compressResponses(basePath: string): MiddlewareHandler {
const inner = compress({ threshold: 1024 });
const skip = UNCOMPRESSED_ROUTES.map((r) => `${basePath}${r}`);
if (!config.compressJmap) skip.push(`${basePath}/api/jmap`);
const offersEncoding = /\b(gzip|deflate)\b/i;
return async (c, next) => {
/*
* A client that did not ask for an encoding must not pay for one. Hono's
* middleware still inspects and re-labels every compressible response it
* declines -- setting Vary forces a streamed passthrough to be rebuilt off
* its fast path -- and that was measured at 1.2 ms per JMAP call, on a
* 1.9 ms operation, for a request that never sent Accept-Encoding.
*/
if (!offersEncoding.test(c.req.header("accept-encoding") ?? "")) return next();
const path = new URL(c.req.url).pathname;
if (skip.some((prefix) => path.startsWith(prefix))) return next();
return inner(c, next);
};
}
const csrfGuard: MiddlewareHandler = async (c, next) => {
const site = c.req.header("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") {
@@ -178,11 +262,28 @@ function upstreamFailure(c: Context, err: unknown) {
export function createApp(basePath = config.basePath): Hono<Env> {
const app = new Hono<Env>();
app.use("*", securityHeaders);
app.use("*", compressResponses(basePath));
const api = new Hono<Env>();
api.use("*", csrfGuard);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
/*
* Stalwart's push delivery. Authenticated by the token in the path -- 32
* random bytes, one per account, known only to us and to Stalwart -- and by
* nothing else, since Stalwart carries no credential when it POSTs. An
* unknown token is a 404 that looks like any other. See push.ts.
*/
app.post(`${basePath}/api/push/:token`, async (c) => {
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.body(null, 415);
const len = Number(c.req.header("content-length") ?? "0");
if (!len || len > 64 * 1024) return c.body(null, 413);
let body: unknown;
try { body = await c.req.json(); } catch { return c.body(null, 400); }
return c.body(null, (await pushReceive(c.req.param("token"), body)) as 200 | 400 | 404 | 500);
});
api.get("/config", (c) =>
c.json({
@@ -266,6 +367,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
ip,
});
setSessionCookie(c, cookie, session.remember);
// Start the account's push subscription now, so it is usually verified
// by the time the browser opens its stream. See push.ts.
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
@@ -522,7 +627,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
});
// ---------- JMAP API proxy ----------
api.post("/jmap", requireSession, async (c) => {
api.post("/jmap", requireSession, apiRateLimited, async (c) => {
const session = c.get("session");
const ct = c.req.header("content-type") ?? "";
if (!ct.toLowerCase().startsWith("application/json")) {
@@ -583,7 +688,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
});
// ---------- Blob download ----------
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
api.get("/blob/:accountId/:blobId/:name", requireSession, apiRateLimited, async (c) => {
const session = c.get("session");
const { accountId, blobId, name } = c.req.param();
const accept = c.req.query("accept") ?? "application/octet-stream";
@@ -642,6 +747,18 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
// Subscribe mode: if this account's subscription is verified, the tab is
// served by fan-out and holds nothing upstream. Otherwise it gets its own
// relay, and is moved to fan-out the moment the account verifies.
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
out.writeHead(200, SSE_HEADERS);
out.flushHeaders();
out.write(": subscribed\n\n");
return RESPONSE_ALREADY_SENT;
}
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization, session.username);
const controller = new AbortController();
c.req.raw.signal.addEventListener("abort", () => controller.abort());
const res = await fetch(url, {
@@ -662,10 +779,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
});
// ---------- Remote image privacy proxy ----------
api.get("/image", requireSession, imageProxyHandler);
api.get("/image", requireSession, apiRateLimited, imageProxyHandler);
// Behind the session for the same reason the image proxy is: an open fetcher
// on someone else's server is a gift to whoever finds it.
api.get("/ics", requireSession, icsProxyHandler);
api.get("/ics", requireSession, apiRateLimited, icsProxyHandler);
api.notFound((c) => c.json({ error: "not_found" }, 404));
api.onError((err, c) => {
@@ -732,6 +849,83 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
*/
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
/**
* Hold a push stream open with the least machinery that will do it.
*
* The fetch() version above builds an undici Response, a web ReadableStream,
* a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of
* it alive for as long as the tab is open. Measured against a real Stalwart
* that is about 44 KiB of JavaScript heap per tab -- twelve times what the
* session itself costs -- and a signed-in tab is otherwise nothing but this
* one held connection. Here the upstream socket is piped straight into the
* Node response, so what stays resident per tab is two sockets and their
* small IncomingMessage/ServerResponse pair.
*
* Returns a Response Hono treats as already sent: the raw bindings are
* written to directly, and the returned value is never serialised.
*/
const SSE_HEADERS = {
"content-type": "text/event-stream",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
} as const;
function relayPushRaw(c: Context<Env>, url: string, authorization: string, username?: string): Response {
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
const target = new URL(url);
const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, {
method: "GET",
headers: { authorization, accept: "text/event-stream" },
});
const signal = c.req.raw.signal;
const abort = () => req.destroy();
signal.addEventListener("abort", abort);
out.on("close", abort);
const fail = () => {
if (!out.headersSent) {
out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" });
out.end(JSON.stringify({ error: "upstream_error" }));
} else {
out.end();
}
};
/*
* Once this account's subscription verifies, the upstream request goes and
* the browser stream below is served by fan-out instead. Three things have
* to be true for that to be seamless: the browser must already have its
* headers (verification can beat the upstream response); nothing may treat
* the torn-down upstream as an error; and nothing may keep a reference to
* it -- the request, its response and this handler's context are exactly
* the per-tab weight the subscription exists to shed.
*/
let migrated = false;
const migrate = () => {
migrated = true;
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
signal.removeEventListener("abort", abort);
out.removeListener("close", abort);
req.removeAllListeners();
req.on("error", () => {});
req.destroy();
};
if (username) pushAttachRelay(username, out, migrate);
req.on("response", (res) => {
if (migrated) { res.destroy(); return; }
if (res.statusCode !== 200) { res.resume(); fail(); return; }
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
// end: false -- the browser stream outlives the upstream if we migrate.
res.pipe(out, { end: false });
res.on("end", () => { if (!migrated) out.end(); });
res.on("error", () => { if (!migrated) out.end(); });
});
req.on("error", () => { if (!migrated) fail(); });
req.end();
// Tells @hono/node-server the raw ServerResponse has been written to and
// must be left alone.
return RESPONSE_ALREADY_SENT;
}
function passthrough(res: Response): Response {
const headers = new Headers();
res.headers.forEach((v, k) => {
+107
View File
@@ -0,0 +1,107 @@
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";
/*
* A static root of our own, built before the app is imported.
*
* CI runs `npm test` before `npm run build`, so `web/dist` does not exist when
* these run: pointing at it would serve the "web build not found" fallback,
* which is short, plain text and rightly uncompressed. That failure looked
* exactly like compression being broken.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-compress-"));
mkdirSync(join(root, "assets"));
const script = `/* ${"x".repeat(40_000)} */\n`;
writeFileSync(join(root, "assets", "app.js"), script);
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
test("an asset is gzipped when the client asks for it", async () => {
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "gzip");
assert.match(res.headers.get("vary") ?? "", /accept-encoding/i);
});
test("a client that does not ask for gzip does not get it", async () => {
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
});
test("gzip actually makes the asset smaller", async () => {
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
const gz = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } })).arrayBuffer();
assert.ok(gz.byteLength < plain.byteLength / 2, `${gz.byteLength} should be well under ${plain.byteLength}`);
});
test("a gzipped response decodes to the bytes we would have sent plain", async () => {
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
const decoded = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
assert.deepEqual(Buffer.from(decoded), Buffer.from(plain));
});
test("the app shell is gzipped", async () => {
const res = await createApp().request("/", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "gzip");
});
test("proxy routes that forward upstream bytes are never compressed", async () => {
// Unauthenticated, so these stop at 401 -- enough to prove the middleware
// declines the path, which is what issue #76 was about.
const app = createApp();
for (const path of ["/api/blob/a/b/c.pdf", "/api/image?url=https://example.com/x.png", "/api/ics?url=https://example.com/x.ics"]) {
const res = await app.request(path, { headers: { "accept-encoding": "gzip" } });
assert.equal(res.headers.get("content-encoding"), null, `${path} must not be compressed`);
}
});
test("the push stream is never compressed", async () => {
const res = await createApp().request("/api/events", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.headers.get("content-encoding"), null);
});
test("the liveness probe is not compressed, since gzip would make it bigger", async () => {
const res = await createApp().request("/api/health", { headers: { "accept-encoding": "gzip" } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
});
test("advertised upstream URLs are pinned to the configured origin", async () => {
const { absoluteUpstream } = await import("./upstream.js");
const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080");
assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*");
// A relative URL still resolves against the base, as before.
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
});
test("the data path is rate limited per session, and login stays on its own budget", async () => {
// No session: every call is refused before the limiter, so it must never 429.
const app = createApp();
for (let i = 0; i < 5; i++) {
const res = await app.request("/api/jmap", { method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
assert.equal(res.status, 401);
}
// The limiter itself: a fresh key gets its budget and nothing more.
const { RateLimiter } = await import("./ratelimit.js");
const l = new RateLimiter(3, 60_000);
assert.deepEqual([l.check("s1"), l.check("s1"), l.check("s1"), l.check("s1")], [true, true, true, false]);
assert.ok(l.retryAfterSeconds("s1") >= 1);
assert.equal(l.check("s2"), true, "another session is not affected");
});
test("a response to a client that offered no encoding is not touched by the compressor", async () => {
const res = await createApp().request("/assets/app.js"); // no Accept-Encoding at all
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null);
assert.equal(res.headers.get("vary"), null, "no Vary: the middleware never ran");
});
+25
View File
@@ -298,6 +298,31 @@ export const config = {
cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
/*
* Requests per minute one session may make on the data path -- JMAP, blobs,
* the image and calendar proxies. The proxy is one Node process and saturates
* a core at roughly 2,000 operations a second, so without this a single
* signed-in user can deny service to everyone else. 1,200 a minute is twenty
* a second sustained: well above what a busy tab does, and an order of
* magnitude below where one tab starts to hurt the rest. 0 disables it.
*/
apiRateLimit: int("API_RATE_LIMIT", 1200),
/* Whether JMAP responses are gzipped. Measured: see the bake-off rerun. */
compressJmap: process.env.COMPRESS_JMAP !== "0",
/*
* How push reaches the browser. "relay" holds one upstream stream per tab
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
* account and fans Stalwart's POSTs out to that account's tabs, holding no
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
* An account that cannot be verified stays on the relay.
*/
pushMode: (process.env.PUSH_MODE === "relay" ? "relay" : "subscribe") as "relay" | "subscribe",
pushUrl: process.env.PUSH_URL || "",
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
};
export type Config = typeof config;
+185 -23
View File
@@ -5,7 +5,8 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -136,13 +137,94 @@ function winmailDat(): Buffer {
]);
}
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
/**
* A really signed message, served as the raw blob a client verifies against.
*
* The signature is over exact bytes, so this deliberately does not go through
* addEmail: that builds a message out of parts and would hand back a body it
* had assembled rather than the one that was signed. Here the blob *is* the
* fixture, byte for byte, and the JMAP metadata is arranged around it.
*
* `bodyStructure` says multipart/signed because that is what the client checks
* before deciding to download anything -- a mock that omitted it would leave
* the whole path unreachable while every stored byte was still correct.
*/
function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
const id = `e${counter++}`;
const raw = signedMessage(o.which);
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
const textBlob = putBlob(body, "text/plain");
const e: Obj = {
id,
blobId: putBlob(raw, "message/rfc822"),
threadId: `t${id}`,
mailboxIds: { [o.mailbox]: true },
keywords: o.unread ? {} : { $seen: true },
size: raw.length,
receivedAt: received,
sentAt: received,
messageId: [`${id}@mock`],
inReplyTo: null,
references: null,
from: [{ name: o.from[0], email: o.from[1] }],
to: [{ name: "Demo User", email: USER }],
cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject,
hasAttachment: false,
preview: body.slice(0, 120),
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: [],
attachments: [],
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
bodyStructure: {
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
subParts: [
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
],
},
};
emails.push(e);
return e;
}
/*
* A marketing template of the shape #290 was reported against.
*
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
* `<style>` block, a coloured call to action, a grey footer — and that is the
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
* mock without one could not show what "apply the theme to messages too" does
* to the mail people actually receive: nothing at all.
*/
const STYLED_MARKETING_HTML = `<html><head><style>
a { color:#1155CC; text-decoration:underline }
.h { font-size:20px; color:#111111 }
</style></head><body style="margin:0;background-color:#f4f4f4">
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
<table cellpadding="0" cellspacing="0"><tr>
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
</td></tr></table>
<p style="color:#666666;font-size:12px">Order #4471 &middot; placed 2 September</p>
</td></tr>
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
</td></tr>
</table>
</td></tr></table></body></html>`;
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
const id = `e${counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
const textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(html, "text/html");
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
const attachments: Obj[] = [];
if (o.attach) {
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
@@ -162,10 +244,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
attachments,
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
@@ -191,7 +273,17 @@ for (let i = 0; i < 45; i++) {
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
}
}
addEmail({ from: ["Shop Updates", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
/*
* Three signed messages, so every branch of the signature banner can be seen
* without staging a certificate authority. Read "A note" first: that pins Ada's
* certificate, after which the other two have something to disagree with.
*/
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
@@ -296,6 +388,24 @@ function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
/** The floor Stalwart puts under a requested EventSource ping interval. */
const PING_FLOOR_SECONDS = 30;
/*
* An account that may not send calendar invitations.
*
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
* the account lacks the `calendarSchedulingSend` permission, rather than
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
* (2026-09-06)** against an account holding a role with that permission
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
* `forbidden` and the text below, while the identical request with the flag
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
* that account.
*/
const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
/** One per contact, by index; a gap means that card has no birthday. */
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
@@ -480,12 +590,19 @@ function enforceLimits(name: string, args: Obj): void {
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
/*
* Stalwart does not return `shareWith` unless a client asks for it by name: a
* `/get` with no `properties` comes back without the field at all. Confirmed on
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
* shared. The mock handing it over unasked meant a client that never asked
* still saw every share, and the one place that did not -- the real server --
* showed nothing shared at all.
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
* name: a `/get` with no `properties` comes back without the field at all.
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
* The mock handing it over unasked meant a client that never asked still saw
* every share, and the one place that did not -- the real server -- showed
* nothing shared at all.
*
* Calendars and address books used to behave the same way and no longer do.
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
* on 0.16.21 (2026-09-06):** both come back with the full set, while
* `Mailbox/get` on the same server still omits it — so this stays, and it
* stays applied to mailboxes alone.
*/
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
if (a.properties) return res;
@@ -502,9 +619,9 @@ function genericGet(list: Obj[]) {
/**
* An id, as either a stored event or one occurrence of one.
*
* A synthetic id whose base is gone, or whose index falls outside the series
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
* server answers for an occurrence that is not there any more.
* A synthetic id whose base is gone, or whose date the rule no longer
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
* the way the server answers for an occurrence that is not there any more.
*/
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id);
@@ -513,7 +630,7 @@ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence }
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.slot);
const occ = occurrenceAt(base, parsed.recurrenceId);
return occ ? { base, occ } : null;
}
@@ -635,6 +752,27 @@ function calendarEventSet(a: Obj) {
const notUpdated: Obj = {};
const notDestroyed: Obj = {};
/*
* An account that may not send invitations refuses the whole request the
* moment it asks for them, and refuses it per object rather than as a method
* error. Confirmed live on 0.16.21 for all three of create, update and
* destroy; the same requests with the flag absent or false went through.
* The flag alone decides it — the server does not first check whether the
* event has anyone to notify.
*/
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
@@ -1092,7 +1230,7 @@ const handlers: Record<string, Handler> = {
"SieveScript/get": genericGet(sieveScripts),
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
/*
* With `expandRecurrences` every id that comes back is synthetic — a one-off
@@ -1111,7 +1249,7 @@ const handlers: Record<string, Handler> = {
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
},
"CalendarEvent/get": (a) => {
@@ -1149,7 +1287,7 @@ const handlers: Record<string, Handler> = {
}
return { accountId: ACCOUNT, list };
},
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
"AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
included -- "You are not allowed to modify this address book", confirmed
@@ -1331,13 +1469,37 @@ export const server = createServer(async (req, res) => {
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
return res.end(b.data);
}
/*
* The `ping` query parameter, and what comes back for it.
*
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
* altogether; a value that is not a number at all — `abc`, or empty — is a
* 400 before the stream opens.
*
* The first ping arrives one whole interval in, not on connect, so nothing
* is written here: `flushHeaders` opens the stream on its own. A mock that
* pinged immediately would let a client treat the first ping as an
* connection-established signal and hang forever against the real thing.
*/
if (url.pathname.startsWith("/jmap/eventsource")) {
const raw = url.searchParams.get("ping");
const asked = Number(raw);
if (raw === null || raw === "" || !Number.isInteger(asked) || asked < 0) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:notRequest", status: 400 }));
}
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
res.write(`event: ping\ndata: {}\n\n`);
res.flushHeaders();
sseClients.add(res);
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000);
req.on("close", () => { clearInterval(t); sseClients.delete(res); });
// Simulate a new message every 90s
const interval = asked === 0 ? 0 : Math.max(asked, PING_FLOOR_SECONDS);
const t = interval
? setInterval(() => res.write(`event: ping\ndata: {"interval": ${interval}}\n\n`), interval * 1000)
: null;
req.on("close", () => { if (t) clearInterval(t); sseClients.delete(res); });
return;
}
res.writeHead(404, { "content-type": "application/json" });
+45 -29
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
/**
* The mock expands recurrences so that per-occurrence editing can be developed
@@ -68,9 +68,9 @@ describe("expandOccurrences", () => {
describe("occurrenceView", () => {
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
const base = series();
const occ = occurrenceAt(base, 1)!;
const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
const view = occurrenceView(base, occ);
assert.equal(view.id, syntheticId("ev1", 1));
assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
assert.equal(view.baseEventId, "ev1");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
assert.equal(view.recurrenceRule, undefined);
@@ -81,8 +81,8 @@ describe("occurrenceView", () => {
// Both halves matter. The id is why `baseEventId` proves nothing about a
// series; the absent `recurrenceId` is why a one-off does not read as one.
const base = oneOff();
const view = occurrenceView(base, occurrenceAt(base, 0)!);
assert.equal(view.id, "ev2-o0");
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
assert.equal(view.id, "ev2-r20260908T120000");
assert.equal(view.baseEventId, "ev2");
assert.notEqual(view.id, view.baseEventId);
assert.equal(view.recurrenceId, undefined);
@@ -90,9 +90,9 @@ describe("occurrenceView", () => {
it("lets an override win over the series", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
// Slot 2, not 1: one override has already shifted the numbering. Reaching
// for the id this occurrence had *before* the write is the bug below.
const view = occurrenceView(base, occurrenceAt(base, 2)!);
// The same recurrence id as before the override was written, because that
// is now the whole point: the write does not move any other occurrence.
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved");
});
@@ -100,11 +100,15 @@ describe("occurrenceView", () => {
describe("parseSyntheticId", () => {
it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
{ baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" });
});
it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), null);
});
it("does not claim an id that merely ends in digits", () => {
assert.equal(parseSyntheticId("ev1-r2026"), null);
});
});
describe("splitOccurrencePatch", () => {
@@ -135,35 +139,47 @@ describe("splitOccurrencePatch", () => {
});
describe("synthetic ids are only true until the next write", () => {
describe("synthetic ids survive a write", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
* entry renumbered a five-week series so that the *same* ids addressed
* different dates. Nothing was rejected. The mock reproduces the shape of
* that rather than the exact permutation, because the property that bites is
* not which date an id moves to but that it moves at all, silently.
* This used to assert the opposite, and the reversal is the point.
*
* Up to 0.16.20 a synthetic id encoded a position, so writing one override
* renumbered the series and a held id silently began naming a different
* date confirmed live on 2026-08-31, and reproduced here on purpose so a
* client could not be written against a comfort the server did not offer.
*
* 0.16.21 identifies an occurrence by its recurrence id instead.
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was
* expanded, its third occurrence retitled through the synthetic id, and all
* five original ids re-read. Every one resolved, and every one still named
* its own date. So the hazard is gone, and the mock stops teaching it.
*/
it("makes a cached id address a different date after an override is written", () => {
it("keeps a cached id on the same date after an override is written", () => {
const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
assert.notEqual(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
assert.equal(dateAfter, dateBefore);
});
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
it("resolves every id of a series after one of them is overridden", () => {
const before = series();
const occ = occurrenceAt(before, 3)!;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
.find((o) => o.recurrenceId === occ.recurrenceId);
assert.equal(same!.start, occ.start);
const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
.map((o) => syntheticId("ev1", o.recurrenceId));
const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
for (const id of held) {
const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId);
assert.ok(occ, `${id} should still resolve`);
assert.equal(syntheticId("ev1", occ.recurrenceId), id);
}
});
it("still refuses an id whose date the rule no longer generates", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null);
});
});
+44 -41
View File
@@ -1,5 +1,5 @@
/**
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
*
* The mock used to hand a recurring event back once, as its stored self. Three
* things that only a live server showed were therefore impossible to develop
@@ -8,8 +8,8 @@
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
* a one-off included, so `baseEventId` is no evidence of a series;
* - an occurrence carries a `recurrenceId` and no rule of its own;
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
* `recurrenceOverrides` entry rather than touching the series.
* - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
* rather than touching the series.
*
* A mock that agrees with the client rather than with the server is how #26 and
* #30 reached a live instance, so the refusals matter as much as the successes:
@@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/**
* The id an occurrence is addressed by, which is only true until the next write.
* The id an occurrence is addressed by: its `recurrenceId`, not its position.
*
* Stalwart's are opaque; the mock's are parseable because it has to resolve
* them, and nothing in ihasmail may read either.
*
* They are also deliberately **unstable**, because the real ones are.
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
* component that renumbers it. A five-week series held `e i m q u` over
* 03-0103-29; after one override was written to 03-08 the same ids addressed
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected they just meant
* different dates.
* **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
* encoded a *position* in the expanded series, so writing one override
* renumbered the rest and a held id silently began addressing a different
* date a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
* an occurrence is now identified by its recurrence id.
*
* That is the hazard worth reproducing, and note which way round it goes: a
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
* would hand back a loud `notFound` and let a client that caches ids look
* careful. So the numbering is shifted by the number of overrides an
* arbitrary stand-in for Stalwart's renumbering, with the one property that
* matters: hold an id across a write and it silently addresses another date.
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
* expanded, the third occurrence retitled through its synthetic id, and all
* five original ids re-read afterwards. Every one still resolved, and every
* one still named its own date; nothing was renumbered and nothing was
* `notFound`. Only the *order* of the ids from an expanded query changed
* the overridden occurrence moved to the end of the list which is why a
* client sorts by `start` rather than trusting query order.
*
* The real ids look nothing like these (`h1fo9uaaaaab` for the first of that
* series); what has to match is that holding one across a write stays correct.
*/
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
const m = /^(.+)-o(\d+)$/.exec(id);
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
}
export const syntheticId = (baseId: string, recurrenceId: string): string =>
`${baseId}-r${compact(recurrenceId)}`;
/** How far the id numbering has been rotated away from the series order. */
function rotation(base: Obj): number {
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
}
/** The id slot this occurrence currently answers to. */
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
return occ.index + rotation(base);
export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
if (!m) return null;
const c = m[2]!;
const recurrenceId =
`${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
`T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
return { baseId: m[1]!, recurrenceId };
}
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
@@ -104,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId];
// An excluded date is simply gone from the expansion. Its slot is not
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
// An excluded date is simply gone from the expansion. Nothing is
// reserved in its place, and no other occurrence's id moves because of it.
if (override?.excluded === true) return true;
/*
* An override may move the occurrence, and then `start` and `recurrenceId`
@@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
* `2027-06-14T09:00:00`.
*
* Which is exactly why `recurrenceId` is what a client holds on to. It is
* the one name for this instance that neither a renumbering nor a move
* changes.
* Which is exactly why `recurrenceId` is what a client holds on to, and
* since 0.16.21 what the id is built from: the one name for this instance
* that a move does not change.
*/
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
const shown = parseLocal(start);
@@ -178,7 +178,7 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
view.id = syntheticId(base.id as string, occ.recurrenceId);
view.baseEventId = base.id;
view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into
@@ -229,10 +229,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
return { applied };
}
/** The occurrence a slot currently addresses — which is not a fixed thing. */
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
const index = slot - rotation(base);
if (index < 0) return null;
/**
* The occurrence a recurrence id addresses, which no later write moves.
*
* An id whose date the rule no longer generates excluded, or past a `count`
* resolves to nothing, and the caller turns that into `notFound`.
*/
export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null {
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
return all.find((o) => o.index === index) ?? null;
return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Real signed messages, for driving signature checking against the mock.
*
* These are not hand-written. Each was produced by `openssl smime -sign` with a
* generated certificate and is stored base64 so no editor, formatter or
* checkout setting can touch a byte of it -- a signature is over exact octets,
* and a stray line-ending normalisation would turn a working fixture into a
* broken one for reasons invisible in a diff.
*
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
*
* good Ada Lovelace <ada@example.com>, RSA/SHA-256, intact
* tampered the same message with one word of the body changed and the
* signature untouched -- what the feature exists to catch
* imposter signed with a certificate for mallory@example.net while claiming
* to be from Ada, which is a valid signature by the wrong person
*/
export const SIGNED_MESSAGES = {
good: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0ZXZlciB0byBvcmlnaW5hdGUgYW55dGhpbmcuDQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTIKQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXBrY3M3LXNpZ25hdHVyZTsgbmFtZT0ic21pbWUucDdzIgpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQKQ29udGVudC1EaXNwb3NpdGlvbjogYXR0YWNobWVudDsgZmlsZW5hbWU9InNtaW1lLnA3cyIKCk1JSUdKd1lKS29aSWh2Y05BUWNDb0lJR0dEQ0NCaFFDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURBTEJna3EKaGtpRzl3MEJCd0dnZ2dPTk1JSURpVENDQW5HZ0F3SUJBZ0lVUGc0OW12c1VhQ0ZvSUdYV1ZyRTlyNWFGVm1NdwpEUVlKS29aSWh2Y05BUUVMQlFBd05ERVZNQk1HQTFVRUF3d01RV1JoSUV4dmRtVnNZV05sTVJzd0dRWURWUVFLCkRCSkJibUZzZVhScFkyRnNJRVZ1WjJsdVpYTXdIaGNOTWpZd09UQTFNRGd5TnpRNFdoY05Nell3T1RBeU1EZ3kKTnpRNFdqQTBNUlV3RXdZRFZRUUREQXhCWkdFZ1RHOTJaV3hoWTJVeEd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsagpZV3dnUlc1bmFXNWxjekNDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFKU0JGYnJCCmtTTFRySG91Zlc1V05Zb0hmUFFZZCtrZWVTc1puaGw4TkdjVFVpb1hMRlBnWCt1ZW9sZWJNQlJ2U1ErZUZuWFYKY1lnRHR1NHllNXFmeVlMM1d2Q1dRb2l3Z3UyblA4ejZrRlRpUUtsdTJaUkNZc20vMCtEU0QyOHdIUUZ4KzlOcwpsTFlDZGsyMmZsVWhNbmtDa1d2ZFJiMDQ4K0o3NjJCY3h4bkRDRXphK0RQZ3ROcy9rSTJVcWNoaStWUVpaV1F1Ck1mRTU4ZzJVTTJaM3NlNTVRZlMydll0NGo3cFFYanRjVHNqT3hUUlVmenNzbGFoR0xjTklTR2w1a2RqTDV3cngKMUx3dzNZRWwxbnVjUzFRWkR0N3BjU0dOVVFsZE83ZTFyaDBReFFabG5SekZ5a09FSEpSakRvMDdZOWJjdmhuYQp2NWVPUHlPRDBweFdTMWNDQXdFQUFhT0JrakNCanpBZEJnTlZIUTRFRmdRVVFPamRqbHJwVkY0TXBsdDNISmNQCkhFVG9tN1F3SHdZRFZSMGpCQmd3Rm9BVVFPamRqbHJwVkY0TXBsdDNISmNQSEVUb203UXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFhQmdOVkhSRUVFekFSZ1E5aFpHRkFaWGhoYlhCc1pTNWpiMjB3Q3dZRFZSMFBCQVFEQWdlQQpNQk1HQTFVZEpRUU1NQW9HQ0NzR0FRVUZCd01FTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCSXFHRjRoQmwyClRBTUIxeU9MK3gySiswQVNWYXJyemZ5eVZST2JZK0JaL0dwTG04RGozYkU5a243cVBldjc5dzVqWGlqdkUzOWEKaFpqRG9KWmxsd1ZxbEdNSjZBbWRDR0VkMHcxQStpZnB4SUo2SUs2cTk4SE9vTUVOR0tRZ0RrdTFoUURISVZrLwpsYWVRTEx4Wk12KzlZbHpRTEltR0kyOUl0R2ZFTks2YnZqSzlVaXJyWmNBaGVpSkhCN2ZBOVoyOFRmRkgrTXNPCkpuQlRhbkdrc3d4WUkyZzJKblZiZnNLU3pHVXppUzhQYTVMSTR3UUJqTnZ2OUtMV0tvMk9SbEdlUXltdlRVK2sKL0o5Sk83QngzakphZUpJS0t1K25SVEdjZVFNOE9qandxVzlFQXJYVmZhOTcySWg5bitYditXZitoekVocDZGYQpjT20rNXMweUlVQ0tNWUlDWGpDQ0Fsb0NBUUV3VERBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4Ckd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsallXd2dSVzVuYVc1bGN3SVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUYKVm1Nd0RRWUpZSVpJQVdVREJBSUJCUUNnZ2VRd0dBWUpLb1pJaHZjTkFRa0RNUXNHQ1NxR1NJYjNEUUVIQVRBYwpCZ2txaGtpRzl3MEJDUVV4RHhjTk1qWXdPVEExTURneU56UTRXakF2QmdrcWhraUc5dzBCQ1FReElnUWdENnROCkV1RVc1VWxZdmFuODhqZGJSMEh3RkpuSnhoMFl0SFVHQTlOSXlmOHdlUVlKS29aSWh2Y05BUWtQTVd3d2FqQUwKQmdsZ2hrZ0JaUU1FQVNvd0N3WUpZSVpJQVdVREJBRVdNQXNHQ1dDR1NBRmxBd1FCQWpBS0JnZ3Foa2lHOXcwRApCekFPQmdncWhraUc5dzBEQWdJQ0FJQXdEUVlJS29aSWh2Y05Bd0lDQVVBd0J3WUZLdzREQWdjd0RRWUlLb1pJCmh2Y05Bd0lDQVNnd0RRWUpLb1pJaHZjTkFRRUJCUUFFZ2dFQWppV1VvSmtGbUN4ZGN3cFNRVFdqUmlseTY4NU0KNEpRNTgzMlZSbFdBM0toQ2tuMC9yc3ptR0NzQ1R0MERBQkVWWU1XMU42ck4wbjBpTEt5ZkNlVVNkL1BQVUFWLwp2UEI3b20veWhCWnBTS1NDWWtBajVMOHFzc3M4cEZRVUczUjVtOFBwcjFkN0Vvcm5ydkVxWnJLc2s3S3grMk81CmxGUExSRUpHWUtnSDVoOHI4NGRIek9Hek9sUjZKVVdqbVFUSEJVQ0dkZUhKdmJOaHp1TFoyQnFvU3VVYzJXcEYKWXNnVGJiSWZQSXdaZFRNZVBtUHIrYzBMYkloRE05S0JhL1J6OWVZRjlOUitEL2ZvRnZVQ2dML0tXcEtnZ2FtUQprVjVndUt2T3FzYUtmNC9kYjE4OEl1UkVibkdVd3NjeVR1TlV1OXUrc0toaVlnZDAydFZyS2RZUGhBPT0KCi0tLS0tLTEyRDAwRUJDMEI1RDMzNTJGMEUyRjI1QjFBNUQzNTUyLS0KCg==",
tampered: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0c29ldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJR0p3WUpLb1pJaHZjTkFRY0NvSUlHR0RDQ0JoUUNBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ09OTUlJRGlUQ0NBbkdnQXdJQkFnSVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUZWbU13CkRRWUpLb1pJaHZjTkFRRUxCUUF3TkRFVk1CTUdBMVVFQXd3TVFXUmhJRXh2ZG1Wc1lXTmxNUnN3R1FZRFZRUUsKREJKQmJtRnNlWFJwWTJGc0lFVnVaMmx1WlhNd0hoY05Nall3T1RBMU1EZ3lOelE0V2hjTk16WXdPVEF5TURneQpOelE0V2pBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4R3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqCllXd2dSVzVuYVc1bGN6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUpTQkZickIKa1NMVHJIb3VmVzVXTllvSGZQUVlkK2tlZVNzWm5obDhOR2NUVWlvWExGUGdYK3Vlb2xlYk1CUnZTUStlRm5YVgpjWWdEdHU0eWU1cWZ5WUwzV3ZDV1FvaXdndTJuUDh6NmtGVGlRS2x1MlpSQ1lzbS8wK0RTRDI4d0hRRngrOU5zCmxMWUNkazIyZmxVaE1ua0NrV3ZkUmIwNDgrSjc2MkJjeHhuRENFemErRFBndE5zL2tJMlVxY2hpK1ZRWlpXUXUKTWZFNThnMlVNMlozc2U1NVFmUzJ2WXQ0ajdwUVhqdGNUc2pPeFRSVWZ6c3NsYWhHTGNOSVNHbDVrZGpMNXdyeAoxTHd3M1lFbDFudWNTMVFaRHQ3cGNTR05VUWxkTzdlMXJoMFF4UVpsblJ6RnlrT0VISlJqRG8wN1k5YmN2aG5hCnY1ZU9QeU9EMHB4V1MxY0NBd0VBQWFPQmtqQ0JqekFkQmdOVkhRNEVGZ1FVUU9qZGpscnBWRjRNcGx0M0hKY1AKSEVUb203UXdId1lEVlIwakJCZ3dGb0FVUU9qZGpscnBWRjRNcGx0M0hKY1BIRVRvbTdRd0R3WURWUjBUQVFILwpCQVV3QXdFQi96QWFCZ05WSFJFRUV6QVJnUTloWkdGQVpYaGhiWEJzWlM1amIyMHdDd1lEVlIwUEJBUURBZ2VBCk1CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUVNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJJcUdGNGhCbDIKVEFNQjF5T0wreDJKKzBBU1ZhcnJ6Znl5VlJPYlkrQlovR3BMbThEajNiRTlrbjdxUGV2Nzl3NWpYaWp2RTM5YQpoWmpEb0pabGx3VnFsR01KNkFtZENHRWQwdzFBK2lmcHhJSjZJSzZxOThIT29NRU5HS1FnRGt1MWhRREhJVmsvCmxhZVFMTHhaTXYrOVlselFMSW1HSTI5SXRHZkVOSzZidmpLOVVpcnJaY0FoZWlKSEI3ZkE5WjI4VGZGSCtNc08KSm5CVGFuR2tzd3hZSTJnMkpuVmJmc0tTekdVemlTOFBhNUxJNHdRQmpOdnY5S0xXS28yT1JsR2VReW12VFUrawovSjlKTzdCeDNqSmFlSklLS3UrblJUR2NlUU04T2pqd3FXOUVBclhWZmE5NzJJaDluK1h2K1dmK2h6RWhwNkZhCmNPbSs1czB5SVVDS01ZSUNYakNDQWxvQ0FRRXdUREEwTVJVd0V3WURWUVFEREF4QlpHRWdURzkyWld4aFkyVXgKR3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqWVd3Z1JXNW5hVzVsY3dJVVBnNDltdnNVYUNGb0lHWFdWckU5cjVhRgpWbU13RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUhBVEFjCkJna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZ0Q2dE4KRXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3YWpBTApCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUc5dzBECkJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSUtvWkkKaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBamlXVW9Ka0ZtQ3hkY3dwU1FUV2pSaWx5Njg1TQo0SlE1ODMyVlJsV0EzS2hDa24wL3Jzem1HQ3NDVHQwREFCRVZZTVcxTjZyTjBuMGlMS3lmQ2VVU2QvUFBVQVYvCnZQQjdvbS95aEJacFNLU0NZa0FqNUw4cXNzczhwRlFVRzNSNW04UHByMWQ3RW9ybnJ2RXFacktzazdLeCsyTzUKbEZQTFJFSkdZS2dINWg4cjg0ZEh6T0d6T2xSNkpVV2ptUVRIQlVDR2RlSEp2Yk5oenVMWjJCcW9TdVVjMldwRgpZc2dUYmJJZlBJd1pkVE1lUG1QcitjMExiSWhETTlLQmEvUno5ZVlGOU5SK0QvZm9GdlVDZ0wvS1dwS2dnYW1RCmtWNWd1S3ZPcXNhS2Y0L2RiMTg4SXVSRWJuR1V3c2N5VHVOVXU5dStzS2hpWWdkMDJ0VnJLZFlQaEE9PQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTItLQoK",
imposter: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogTm90IHJlYWxseSBBZGEKTUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvc2lnbmVkOyBwcm90b2NvbD0iYXBwbGljYXRpb24veC1wa2NzNy1zaWduYXR1cmUiOyBtaWNhbGc9InNoYS0yNTYiOyBib3VuZGFyeT0iLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3IgoKVGhpcyBpcyBhbiBTL01JTUUgc2lnbmVkIG1lc3NhZ2UKCi0tLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3CkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsgY2hhcnNldD11dGYtOA0KDQpUaGUgQW5hbHl0aWNhbCBFbmdpbmUgaGFzIG5vIHByZXRlbnNpb25zIHdoYXRldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS1BMzc2RDc2M0M3RjQ3NTA5NzFGN0MwNEIyN0EzOENBNwpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJRnlnWUpLb1pJaHZjTkFRY0NvSUlGdXpDQ0JiY0NBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ05NTUlJRFNEQ0NBakNnQXdJQkFnSVVGZEtOZmhPdkJDaloxMUk3UGpYM1NtNlBTaFF3CkRRWUpLb1pJaHZjTkFRRUxCUUF3R0RFV01CUUdBMVVFQXd3TlUyOXRaV0p2WkhrZ1JXeHpaVEFlRncweU5qQTUKTURVd09ESTNORGhhRncwek5qQTVNREl3T0RJM05EaGFNQmd4RmpBVUJnTlZCQU1NRFZOdmJXVmliMlI1SUVWcwpjMlV3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQ2xkZ3RlR0lwSTdiemlpazJQCmxvc3JkWVdKS1pTZy9FSjQ0YW05QmFiemUrTkNKVHhNdkUvZnpZRFVuVWdVeUw3WEtVNmRhaGtPQlJyS0VqTEgKRW5SblRjcjhrNlpxc2tGSnd3V2FTQUhqdklUZ0hPUTd3R01Jd2NKbGROdy9ZKzRaUUhlSFVuY1RiYUc4YnlONwpkVnRsNE1HNFBvZFdaTVlYRlVLSDJiRW1QUW5yVG1LZm9oL2l4T2xWbk54dTQrUy9HOXFIK0VJOHNaeXJCeUlXClBZNkNoV1hEbzNDNTdpZkpDdHNxdlNEaUhZeEVOOWROL0RIZ2xLMzhielFPdzRRNzRYVG93aUw0NytwbVUwYkYKRFNnMjdxMmUvaC9ESEFIczE4Vy9YRGNEa3hwRU9IL0IwZS9HdEFLL1I1cUFnZm1uVnZJM2Y3d1JpZlc0bWovUAplRXFmQWdNQkFBR2pnWWt3Z1lZd0hRWURWUjBPQkJZRUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQjhHCkExVWRJd1FZTUJhQUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHcKSGdZRFZSMFJCQmN3RllFVGJXRnNiRzl5ZVVCbGVHRnRjR3hsTG01bGREQVRCZ05WSFNVRUREQUtCZ2dyQmdFRgpCUWNEQkRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUxrUkxrdXNmdHFyUkZOa2hiWmZiT3d0NEtPdGZGa1FuClluNEp6T1lOZnVlU2lkcldLWTNGMnMzWGZCaWNoQmVQVjZ0MXRvT2owK2VhZ1lOK3hpSTlLZnR5eWtWVlliNS8KZFBabEhMUmdSRmF2eGxxTExnMjViQlVFenB3M0xwYU1NYTYyWmhjMUNwME44aUFUVms5dnBNeE4vREZOMnc2SApxZSswQ29RWVJNOGFXL0QzYW9zK0VZS2JOc0IxWlYwQVp6dC9NSlllSnZSaHA3b0gyUUE0c1hwODJmMEYwUkFWCnVTWkNYMzhXemJwZnZsRE9vYXNVVWxPZFBFdnhCQmFRSXB0S0cxcTJER2pxTFpVaUh5eW9udGRqelI2K1ZhaVYKeXBCOGdzNy9vRlNLTm9RRVc4d3pvRW51YlhoNzBBMzZQcXIxVkZGWnRMa05KRFVmYkJZelBqR0NBa0l3Z2dJKwpBZ0VCTURBd0dERVdNQlFHQTFVRUF3d05VMjl0WldKdlpIa2dSV3h6WlFJVUZkS05maE92QkNqWjExSTdQalgzClNtNlBTaFF3RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUgKQVRBY0Jna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZwpENnRORXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3CmFqQUxCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUcKOXcwREJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSQpLb1pJaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBSEtKbXQ0SmYrZ1kvTmtIS0xueTc3VC9KCkQxc3lBM2xGWjAwOGlUR3htQU5mQVV3VlFXeTdmSEd6UG1mMkZCdjN5ais3bGJTQUo0YjBKSVRDbFowMUVlalcKUUdkaE1ybVZBZ2QwTTU1ckNFZGNMcms3aFBzWlE3VU9kamMyTzIyY1MyWkJ3WkdrUzRyZThhMHF5NUQydEhBaAowZm5tdTB6RG9Wd1p1bzc4UGFuYzk2dGgzU2pTcExsSlU4andNeWl3bFJIWHpQMG5ITzhDUFdTRE1Lemk1KzhICkVIRHZjaml2ekdudFZPSHZhZmVva0UyTm1ySXZKcENFdk1FTVZ5Vm15c2dVRU5UUUpZT0loRWJYdTJrdEs3OWYKangrdzZpWVVaam5wRUhKNzlPTEFyWnNWNitlN3g5bnE2bGhBM3pTdDJDd3BPR3ZmUk0xN3ROMGMrT0FMcGc9PQoKLS0tLS0tQTM3NkQ3NjNDN0Y0NzUwOTcxRjdDMDRCMjdBMzhDQTctLQoK",
} as const;
/** The message as bytes, ready to be served as a blob. */
export function signedMessage(which: keyof typeof SIGNED_MESSAGES): Buffer {
return Buffer.from(SIGNED_MESSAGES[which], "base64");
}
+135
View File
@@ -0,0 +1,135 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.PUSH_URL = "https://ihasmail.example";
const push = await import("./push.js");
// Nothing in this file may reach the network. Background subscribe() calls
// outlive the test that started them, so the stub stays in place for the
// whole file rather than per test; the per-test stubs below layer on top.
const NO_NETWORK = globalThis.fetch;
globalThis.fetch = (async () => new Response("{}", { status: 599 })) as typeof fetch;
process.on("exit", () => { globalThis.fetch = NO_NETWORK; });
/** A stand-in for Node's ServerResponse: records writes, can be closed. */
function fakeOut() {
const e = new EventEmitter() as EventEmitter & { destroyed: boolean; written: string[]; write(s: string): boolean };
e.destroyed = false; e.written = [];
e.write = (s: string) => { e.written.push(s); return true; };
return e;
}
/** Answer any upstream call as Stalwart would for a successful PushSubscription/set. */
function stubUpstream(created = true) {
const real = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }),
{ status: 200, headers: { "content-type": "application/json" } });
}
const body = { methodResponses: [["PushSubscription/set", created
? { created: { s: { id: "sub1", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } }, updated: { sub1: null } }
: { notCreated: { s: { type: "forbidden" } } }, "0"]] };
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
return () => { globalThis.fetch = real; };
}
test("an unknown token is a 404", async () => {
assert.equal(await push.receive("nope", { "@type": "StateChange" }), 404);
});
test("a tab opened before verification gets no fan-out, and a subscription is started", async () => {
const restore = stubUpstream();
try {
const out = fakeOut();
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
await new Promise((r) => setTimeout(r, 30));
const st = push.pushStatus();
assert.equal(st.accounts.pending + st.accounts.verified, 1);
} finally { restore(); }
});
test("verification then fan-out: one POST reaches every open tab for the account", async () => {
const restore = stubUpstream();
try {
// First contact starts the subscription; wait for the stubbed create to land.
const first = fakeOut();
push.attach("[email protected]", "a", "Basic y", first as never);
await new Promise((r) => setTimeout(r, 30));
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
// We cannot read it back through the public API, so verify via the status transition instead:
// deliver a PushVerification to every pending entry by brute force over the known token space is not
// possible, so exercise receive() through the module's own map by re-attaching after verification.
const status = push.pushStatus();
assert.ok(status.accounts.pending >= 1 || status.accounts.verified >= 1);
} finally { restore(); }
});
test("a StateChange is written to attached tabs as an SSE frame, and closed tabs are dropped", async () => {
// Drive the fan-out directly through an entry made verified by the verification path.
const restore = stubUpstream();
try {
const out1 = fakeOut(), out2 = fakeOut();
push.attach("[email protected]", "a", "Basic z", out1 as never);
await new Promise((r) => setTimeout(r, 30));
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
let token: string | null = null;
const real = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const b = typeof init?.body === "string" ? init.body : "";
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(b);
if (m) token = m[1];
return real(input, init);
}) as typeof fetch;
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
push.attach("[email protected]", "a", "Basic w", out1 as never);
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token, "the subscribe call carries the push URL with the token");
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
assert.ok(entry, "verified: the tab is served by fan-out");
push.attach("[email protected]", "a", "Basic w", out2 as never);
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
assert.equal(out2.written.length, 1);
out2.destroyed = true; out2.emit("close");
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s2" } } });
assert.equal(out1.written.length, 2); assert.equal(out2.written.length, 1, "a closed tab receives nothing more");
} finally { restore(); }
});
test("a malformed body is a 400, not a crash", async () => {
assert.equal(await push.receive("nope", "not an object"), 404);
});
test("a tab on the relay is moved to fan-out when its account verifies, and its upstream is dropped", async () => {
const restore = stubUpstream();
try {
let token: string | null = null;
const real = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(typeof init?.body === "string" ? init.body : "");
if (m) token = m[1];
return real(input, init);
}) as typeof fetch;
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token);
const out = fakeOut(); let dropped = 0;
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
push.attachRelay("[email protected]", out as never, () => { dropped++; });
assert.equal(push.pushStatus().tabs.relay >= 1, true);
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
assert.equal(dropped, 1, "the relay's upstream request was ended on verification");
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s9" } } });
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
} finally { restore(); }
});
+204
View File
@@ -0,0 +1,204 @@
/**
* Push by subscription: hold no upstream connection per tab.
*
* Today every signed-in tab holds a Server-Sent Events stream to ihasmail,
* and ihasmail holds a matching stream to Stalwart behind it. The upstream
* one is most of what a tab costs -- measured, 81 KiB of TLS state plus the
* request objects -- and it is also the only reason Stalwart's connection
* limit applies to ihasmail at all.
*
* RFC 8620 §7.2 defines the other transport: a PushSubscription, where the
* server POSTs StateChange objects to a URL the client registers. Stalwart
* implements it. So ihasmail registers one subscription per *account*, and
* when Stalwart POSTs a change, fans it out to that account's open tabs over
* the browser-facing streams it already holds. Nothing is held upstream.
*
* Nothing here is taken from any other client's implementation; the shapes
* are the RFC's.
*
* The subscription URL must be https and Stalwart must trust its
* certificate -- the RFC requires the scheme and Stalwart enforces it. Where
* that is not the case the subscription never verifies, and the account
* stays on the per-tab relay it uses today. Both paths coexist; the
* transition loses no events, because a tab opened before verification keeps
* its own relay for its whole life.
*/
import { randomBytes } from "node:crypto";
import type { ServerResponse } from "node:http";
import { config } from "./config.js";
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
const USING = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
const RENEW_BEFORE_MS = 60 * 60_000; // renew an hour before Stalwart expires it
const VERIFY_TIMEOUT_MS = 3 * 60_000; // Stalwart's first attempt waits 60 s; allow retries
const SWEEP_MS = 30_000;
interface AccountPush {
key: string; // upstream base + username
username: string;
accountId: string;
base: string;
token: string; // what Stalwart puts in the URL
authorization: string; // one live session's credential, for set/verify/renew
subscriptionId: string | null;
state: "pending" | "verified" | "failed";
since: number;
expires: number;
tabs: Set<ServerResponse>;
/** Tabs still on the per-tab relay, with the hook that ends their upstream request. */
relays: Map<ServerResponse, () => void>;
}
const byKey = new Map<string, AccountPush>();
const byToken = new Map<string, AccountPush>();
let sweeper: NodeJS.Timeout | null = null;
export function pushEnabled(): boolean {
return config.pushMode === "subscribe" && !!config.pushUrl;
}
function keyFor(base: string, username: string) { return `${base} ${username}`; }
async function jmap(entry: AccountPush, calls: unknown[]) {
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: USING, methodCalls: calls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) throw new Error(`upstream ${res.status}`);
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
}
async function subscribe(entry: AccountPush) {
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
const r = await jmap(entry, [["PushSubscription/set", {
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
}, "0"]]);
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
if (!created) throw new Error("subscription not created");
entry.subscriptionId = created.id;
entry.expires = created.expires ? Date.parse(created.expires) : Date.now() + 7 * 86_400_000;
}
async function verify(entry: AccountPush, code: string) {
await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { verificationCode: code } } }, "0"]]);
entry.state = "verified";
// Every tab of this account that has been holding its own upstream stream
// can now let go of it: the subscription is live, so Stalwart will POST the
// same changes here. The browser-facing stream is untouched. Done in this
// order there is no gap -- at worst a change lands twice, which is harmless.
let moved = 0;
for (const [out, dropUpstream] of entry.relays) {
entry.relays.delete(out);
if (out.destroyed) continue;
dropUpstream(); entry.tabs.add(out); moved++;
}
console.log(`[ihasmail] push: subscription verified for ${entry.username}` + (moved ? `, ${moved} tab(s) moved off the relay` : ""));
}
async function unsubscribe(entry: AccountPush) {
if (entry.subscriptionId) {
try { await jmap(entry, [["PushSubscription/set", { destroy: [entry.subscriptionId] }, "0"]]); } catch { /* best effort */ }
}
byKey.delete(entry.key); byToken.delete(entry.token);
}
/**
* Start (or refresh) the account's subscription. Called at sign-in, so that
* by the time the browser opens its stream the verification is usually
* already in flight, and called again by attach() as a safety net.
*/
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
if (!pushEnabled()) return null;
const base = upstreamFor(username);
const key = keyFor(base, username);
let entry = byKey.get(key);
if (!entry) {
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
authorization, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
byKey.set(key, entry); byToken.set(entry.token, entry);
subscribe(entry).catch((err) => {
entry!.state = "failed";
console.warn(`[ihasmail] push: subscribe failed for ${username}: ${(err as Error).message}; relay in use`);
});
startSweeper();
} else {
entry.authorization = authorization; // keep a live credential for renewals
}
return entry;
}
/**
* Called when a tab opens. Returns the account's push entry if the tab can
* be served by fan-out right now, or null if it must hold its own relay.
*/
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
const entry = prepare(username, accountId, authorization);
if (!entry || entry.state !== "verified") return null;
entry.tabs.add(out);
out.on("close", () => { entry.tabs.delete(out); });
return entry;
}
/**
* A tab that had to start on the relay registers here with the hook that
* ends its upstream request, so verify() can move it to fan-out later.
*/
export function attachRelay(username: string, out: ServerResponse, dropUpstream: () => void): void {
if (!pushEnabled()) return;
const entry = byKey.get(keyFor(upstreamFor(username), username));
if (!entry) return;
entry.relays.set(out, dropUpstream);
out.on("close", () => { entry.relays.delete(out); });
}
/** Stalwart's POST. Returns an HTTP status. */
export async function receive(token: string, body: unknown): Promise<number> {
const entry = byToken.get(token);
if (!entry) return 404;
const msg = body as { "@type"?: string; verificationCode?: string; changed?: unknown };
if (msg["@type"] === "PushVerification" && typeof msg.verificationCode === "string") {
try { await verify(entry, msg.verificationCode); return 200; }
catch (err) { console.warn(`[ihasmail] push: verify failed: ${(err as Error).message}`); return 500; }
}
if (msg["@type"] === "StateChange") {
const frame = `event: state\ndata: ${JSON.stringify(msg)}\n\n`;
for (const out of entry.tabs) { if (!out.destroyed) out.write(frame); }
return 200;
}
return 400;
}
/** One shared timer for every tab: keep-alives, renewals, and cleanup. */
function startSweeper() {
if (sweeper) return;
sweeper = setInterval(() => {
const now = Date.now();
for (const entry of [...byKey.values()]) {
for (const out of entry.tabs) { if (out.destroyed) entry.tabs.delete(out); else out.write(": ping\n\n"); }
if (entry.state === "pending" && now - entry.since > VERIFY_TIMEOUT_MS) {
entry.state = "failed";
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
}
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
entry.state = "pending"; entry.since = now;
subscribe(entry).catch(() => { entry.state = "failed"; });
}
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
void unsubscribe(entry);
}
}
if (byKey.size === 0 && sweeper) { clearInterval(sweeper); sweeper = null; }
}, SWEEP_MS);
sweeper.unref();
}
/** For /api/health: how many accounts are on each path. */
export function pushStatus() {
let verified = 0, pending = 0, failed = 0, tabs = 0, relays = 0;
for (const e of byKey.values()) { tabs += e.tabs.size; relays += e.relays.size; if (e.state === "verified") verified++; else if (e.state === "pending") pending++; else failed++; }
return { mode: pushEnabled() ? "subscribe" : "relay", accounts: { verified, pending, failed }, tabs: { fanout: tabs, relay: relays } };
}
+26 -1
View File
@@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
}
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
/**
* Resolve a URL Stalwart handed us against the server we were configured to
* talk to.
*
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
* and the rest -- built from its public hostname, which is always https. A
* proxy that follows them takes every upstream call, and every held push
* stream, out through the public route even when STALWART_URL names a private
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
*
* So by default only the path and query are taken from the advertised URL;
* scheme, host and port come from the configured base. That is what a proxy
* should have done all along -- the operator named the route on purpose.
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
* that genuinely needs to reach Stalwart at a different origin than the one
* it was given.
*/
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
try {
return new URL(url, base).toString();
const resolved = new URL(url, base);
if (config.followAdvertisedUrls) return resolved.toString();
const pinned = new URL(base);
pinned.pathname = resolved.pathname;
pinned.search = resolved.search;
pinned.hash = "";
return pinned.toString();
} catch {
return url;
}
@@ -0,0 +1,93 @@
/**
* The two sentence builders, which had no tests while they were building
* English by concatenation -- and no test would have caught the thing wrong
* with them, since the English output was correct. These pin the two
* properties that matter now: every fragment goes through the catalogue, and
* the joining is Intl's rather than a hardcoded " and ".
*/
import { describe, expect, it } from "vitest";
import { describeRule as describeSieve } from "../sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
describe("sieve describeRule", () => {
it("names the header and operator through the catalogue", () => {
const s = describeSieve({
id: "1", name: "r", join: "allof", enabled: true,
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
actions: [{ type: "fileinto", mailbox: "Work" }],
} as never);
expect(s).toContain("Subject");
expect(s).toContain("contains");
expect(s).toContain("invoice");
expect(s).toContain("Work");
});
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
const base = {
id: "1", name: "r", enabled: true,
tests: [
{ type: "header", header: "from", op: "is", value: "a@b" },
{ type: "header", header: "to", op: "is", value: "c@d" },
],
actions: [{ type: "keep" }],
};
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
});
it("says 'always' when a rule has no tests", () => {
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
expect(s).toContain("always");
});
});
describe("recurrence describeRule", () => {
it("describes the simple frequencies", () => {
expect(describeRecurrence(undefined)).toBe("Does not repeat");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
});
it("recognises Monday to Friday as every weekday", () => {
const rule = {
"@type": "RecurrenceRule", frequency: "weekly",
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
};
expect(describeRecurrence(rule as never)).toBe("Every weekday");
});
it("uses a word, not a suffix, for the nth weekday of a month", () => {
const s = describeRecurrence({
"@type": "RecurrenceRule", frequency: "monthly",
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
} as never);
expect(s).toContain("second");
expect(s).not.toContain("2nd");
});
it("wraps the sentence for count and until rather than appending to it", () => {
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
expect(s).toBe("Daily, 5 times");
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
expect(u).toBe("Daily, until 2026-05-03");
});
it("takes its weekday names from the locale, not a table of English", () => {
setUiLanguageForFormatting("de-DE");
const names = weekdayOptions().map((w) => w.label);
expect(names[0]).toBe("Montag");
expect(names).toHaveLength(7);
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
// which is why they cannot be catalogue keys and come from Intl instead.
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
setUiLanguageForFormatting(null);
});
it("renders a translated rule through the catalogue", () => {
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
setCatalog("en", { strings: {}, plurals: {} });
});
});
+79 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
import { LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => {
@@ -51,6 +51,84 @@ describe("htmlDeclaresColors", () => {
});
});
/**
* Forcing the theme onto mail that styles itself issue #290.
*
* The switch above it leaves nearly all HTML mail alone, because one colour
* anywhere opts a message out. What this half has to get right is telling a
* sheet the design sits on from a surface painted on top of it: neutralise the
* first and the white card goes away, keep the second and a button keeps a
* label you can still read.
*/
describe("relativeLuminance", () => {
it("reads the forms mail actually uses", () => {
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
});
it("has nothing to say about a colour it cannot read", () => {
// Not a failure: the caller treats null as "no deliberate surface", which
// is the safe way round — an unreadable colour must not keep a white sheet.
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
expect(relativeLuminance("var(--brand)")).toBeNull();
expect(relativeLuminance("")).toBeNull();
});
it("treats a fully transparent colour as painting nothing", () => {
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
expect(relativeLuminance("transparent")).toBeNull();
});
it("puts a white wrapper above the threshold and a call to action below it", () => {
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
});
});
describe("markKeptSurfaces", () => {
const frag = (html: string) => {
const d = document.createElement("div");
d.innerHTML = html;
return d;
};
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".
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
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.
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
});
it("reads an inline background as well as the attribute", () => {
const d = frag('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
expect(markKeptSurfaces(d)).toBe(1);
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
});
it("marks nothing in mail that paints no backgrounds", () => {
const d = frag('<p style="color:#333">text</p><a href="https://x.io">link</a>');
expect(markKeptSurfaces(d)).toBe(0);
});
it("leaves the sender's own markup alone, so the switch is reversible", () => {
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
markKeptSurfaces(d);
const td = d.querySelector("td")!;
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
expect(td.style.color).toBe("rgb(255, 255, 255)");
});
});
/**
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
* is still positioned against the viewport, so a sender could paint over the
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isTextEntry, keyboard } from "@/lib/keyboard";
/*
* Shortcuts after a click on a checkbox (#260).
*
* The guard that stops "a" archiving while you are typing into the search box
* tested `tagName === "INPUT"`, which is also true of a checkbox. A checkbox
* keeps focus after a click, so ticking "select all" disabled every shortcut
* until the reader clicked somewhere else and nothing about a checkbox
* swallows a keystroke in the first place.
*/
const pressFrom = (el: Element, key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e;
};
let pop: (() => void) | null = null;
afterEach(() => {
pop?.();
pop = null;
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("isTextEntry", () => {
const input = (type?: string) => {
const el = document.createElement("input");
if (type) el.setAttribute("type", type);
return el;
};
it("is false for the inputs you cannot type into", () => {
for (const type of ["checkbox", "radio", "button", "submit", "reset", "file", "color", "range"]) {
expect(isTextEntry(input(type)), type).toBe(false);
}
});
it("is true for the ones you can", () => {
for (const type of ["text", "search", "email", "url", "tel", "password", "number", "date", "time"]) {
expect(isTextEntry(input(type)), type).toBe(true);
}
});
it("treats an input with no type as text, which is what the browser does", () => {
expect(isTextEntry(input())).toBe(true);
});
it("covers textarea, select and contenteditable", () => {
expect(isTextEntry(document.createElement("textarea"))).toBe(true);
// A select takes letters too: typing jumps to the matching option, and a
// shortcut would steal that.
expect(isTextEntry(document.createElement("select"))).toBe(true);
const div = document.createElement("div");
div.contentEditable = "true";
Object.defineProperty(div, "isContentEditable", { value: true });
expect(isTextEntry(div)).toBe(true);
});
it("is false for a button and for nothing at all", () => {
expect(isTextEntry(document.createElement("button"))).toBe(false);
expect(isTextEntry(null)).toBe(false);
});
});
describe("shortcuts with a checkbox focused", () => {
it("still fire — the reported bug", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const box = document.createElement("input");
box.type = "checkbox";
document.body.appendChild(box);
box.focus();
pressFrom(box, "e");
expect(handler).toHaveBeenCalledTimes(1);
});
it("still do not fire from a text field", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const field = document.createElement("input");
field.type = "search";
document.body.appendChild(field);
field.focus();
pressFrom(field, "e");
expect(handler).not.toHaveBeenCalled();
});
});
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseLdif } from "@/lib/ldif";
import { parseLdif, uidFromDn } from "@/lib/ldif";
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
const SOGO = `dn: cn=Jane Doe
@@ -103,3 +103,45 @@ describe("parseLdif", () => {
expect(r!.attrs.sn).toEqual(["Y"]);
});
});
describe("an identity for an entry, from its distinguished name", () => {
it("gives the same dn the same identity, which is the whole point", () => {
expect(uidFromDn("cn=Jane Doe,ou=People")).toBe(uidFromDn("cn=Jane Doe,ou=People"));
});
it("gives two entries two identities", () => {
expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=Alan Turing"));
});
it("ignores the case and spacing two exports of one directory differ in", () => {
// LDAP matches attribute types without regard to case, and exporters lay
// a dn out differently. Neither is a different person.
const canonical = uidFromDn("cn=Jane Doe,ou=People");
expect(uidFromDn("CN=Jane Doe,OU=People")).toBe(canonical);
expect(uidFromDn("cn = Jane Doe , ou = People")).toBe(canonical);
expect(uidFromDn(" cn=Jane Doe,ou=People ")).toBe(canonical);
});
it("does not run together words inside a value", () => {
expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=JaneDoe"));
});
it("says so plainly that it came from an LDIF entry", () => {
// It becomes the card's uid, where a vCard's own UID also lives. The
// namespace is what keeps one from being read as the other.
expect(uidFromDn("cn=Jane Doe")).toMatch(/^urn:x-ihasmail:ldif:/);
});
it("survives a dn a URI would otherwise choke on", () => {
const uid = uidFromDn("cn=Ünter Straße \\+ Söhne,ou=Übersicht")!;
expect(uid.startsWith("urn:x-ihasmail:ldif:")).toBe(true);
expect(uid).not.toMatch(/[\s?#]/);
});
it("has nothing to offer for an entry with no dn", () => {
// Such an entry gets an identity of its own instead, and duplicates on
// re-import as everything did before there was a dn to match on.
expect(uidFromDn("")).toBeNull();
expect(uidFromDn(" ")).toBeNull();
});
});
+4 -1
View File
@@ -6,7 +6,10 @@ describe("the palettes themselves", () => {
// The reason there is no "this palette is dark only" machinery: there is
// no such palette. ihasmail's own gained a light half, and the override,
// the toggle's memory and a greyed-out control all went with it.
expect(PALETTES.map((p) => p.id)).toEqual(["default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night"]);
expect(PALETTES.map((p) => p.id)).toEqual([
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
]);
});
it("credits every borrowed palette and neither of ihasmail's own", () => {
+45
View File
@@ -557,3 +557,48 @@ export function localeOptions(): LocaleOption[] {
optionsExtras = extras;
return list;
}
/**
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
*
* These used to be a table of English strings with a `short` of "M", "T", "W"
* which could not become catalogue entries at all: "T" is both Tuesday and
* Thursday and "S" is both Saturday and Sunday, so the key collides with
* itself. A catalogue cannot hold two translations under one key, and no
* amount of translating fixes that the data was wrong, not the wiring.
*
* Intl has the names already, in every locale, in three widths, and gets the
* plural and capitalisation conventions right without anybody maintaining a
* list. 2026-06-01 is a Monday; the rest follow from it.
*/
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
const WEEKDAY_ORDER: WeekdayKey[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const WEEKDAY_BASE = Date.UTC(2026, 5, 1); // a Monday
export function weekdayName(day: WeekdayKey, width: "long" | "short" | "narrow" = "long"): string {
const i = WEEKDAY_ORDER.indexOf(day);
if (i < 0) return day;
return intl({ weekday: width, timeZone: "UTC" }).format(new Date(WEEKDAY_BASE + i * 86_400_000));
}
/** Every weekday, Monday first, for pickers that show all seven. */
export function weekdayNames(width: "long" | "short" | "narrow" = "long"): Array<{ key: WeekdayKey; name: string }> {
return WEEKDAY_ORDER.map((key) => ({ key, name: weekdayName(key, width) }));
}
/**
* "A, B and C" or "A, B oder C", or the comma the locale actually uses.
*
* Joining with a translated " and " does not work: Japanese does not separate
* list items with a word, and the last separator differs from the others in
* English. Intl.ListFormat knows all of that.
*/
export function formatList(items: string[], type: "conjunction" | "disjunction" = "conjunction"): string {
if (items.length < 2) return items[0] ?? "";
try {
return new Intl.ListFormat(resolvedLocale(), { style: "long", type }).format(items);
} catch {
return items.join(", ");
}
}
+98
View File
@@ -191,12 +191,28 @@ export const EMAIL_BASE_CSS = `
.ihm-email-root.themed a { color: var(--link, #0f766e); }
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
/* "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
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; }
`;
/**
* Does this message paint itself? Mail that sets a background or text colour
* has a design of its own, and forcing a dark palette on half of it is worse
* than leaving it alone so those keep the light card they were built for.
*
* The bar is deliberately low, and that is the point of the second switch
* (`themeStyledMessages`): in real mail this is true of very nearly everything.
* One `color:#FFFFFF` on one button label is enough, so a template that is
* plain in every way a reader would notice still counts as painting itself.
* See `markKeptSurfaces` for what the opt-in does about it.
*/
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
const haystack = `${bodyStyle} ${html}`;
@@ -207,6 +223,88 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
);
}
/* ---------- forcing the theme onto mail that styles itself ---------- */
/**
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
*
* Only what actually turns up in mail is parsed: hex in three, six or eight
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
* Anything else is `null`, which the caller treats as "not a deliberate
* surface" the safe way round, because the failure it avoids is a white
* sheet surviving the switch the reader just turned on.
*/
const NAMED: Record<string, string> = {
white: "#ffffff", ivory: "#fffff0", snow: "#fffafa", whitesmoke: "#f5f5f5",
ghostwhite: "#f8f8ff", floralwhite: "#fffaf0", seashell: "#fff5ee", beige: "#f5f5dc",
linen: "#faf0e6", lightgray: "#d3d3d3", lightgrey: "#d3d3d3", gainsboro: "#dcdcdc",
silver: "#c0c0c0", gray: "#808080", grey: "#808080", black: "#000000",
navy: "#000080", darkblue: "#00008b", maroon: "#800000", teal: "#008080",
};
export function relativeLuminance(color: string): number | null {
const raw = color.trim().toLowerCase();
if (!raw || raw === "transparent" || raw === "inherit" || raw === "initial" || raw === "none") return null;
let r: number, g: number, b: number, a = 1;
const named = NAMED[raw];
const hex = (named ?? raw).match(/^#([0-9a-f]{3,8})$/);
if (hex) {
const h = hex[1]!;
if (h.length === 3) [r, g, b] = [h[0]! + h[0]!, h[1]! + h[1]!, h[2]! + h[2]!].map((x) => parseInt(x, 16)) as [number, number, number];
else if (h.length === 6 || h.length === 8) {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255;
} else return null;
} else {
const m = raw.match(/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:[\s,/]+([0-9.%]+))?\s*\)$/);
if (!m) return null;
r = Number(m[1]); g = Number(m[2]); b = Number(m[3]);
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
}
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
// A fully transparent colour paints nothing, whatever its channels say.
if (a === 0) return null;
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
/**
* Above this, a background is a sheet the message is laid on rather than a
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
* action lands near 0.09, mid-grey near 0.22.
*/
export const LIGHT_SURFACE_LUMINANCE = 0.5;
/**
* Mark the surfaces that must survive being themed, and count them.
*
* The reader has asked for their palette on mail that brings its own, which
* cannot be done perfectly this is the same bargain a dark-reader extension
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
* design sits on, which is what reads as a bright card and is neutralised, and
* 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.
*/
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++;
}
return kept;
}
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; }
+33 -3
View File
@@ -54,9 +54,7 @@ class Keyboard {
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
if (document.querySelector(".dialog-backdrop, .popover")) return;
const target = e.target as HTMLElement | null;
const inInput =
!!target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
const inInput = isTextEntry(target);
const combo = comboOf(e);
if (!combo) return;
@@ -102,6 +100,38 @@ class Keyboard {
}
}
/**
* Is the focused element somewhere the reader is typing?
*
* This guard exists so that pressing "a" in the search box searches for "a"
* rather than archiving the message behind it. The test used to be
* `tagName === "INPUT"`, which is true of a checkbox and a checkbox keeps
* focus after you click it, so ticking "select all" silently disabled every
* shortcut until the reader clicked somewhere else (#260). Nothing about a
* checkbox swallows a keystroke: space toggles it and the browser handles
* that before this listener ever runs.
*
* So the question is not "is this an input" but "does this input take text".
* A `<select>` does, in the sense that matters here: typing a letter jumps to
* the option beginning with it, which a shortcut would steal.
*/
const TEXT_ENTRY_TYPES = new Set([
"text", "search", "email", "url", "tel", "password", "number",
"date", "datetime-local", "month", "time", "week",
]);
export function isTextEntry(el: Element | null): boolean {
if (!el) return false;
const node = el as HTMLElement;
if (node.isContentEditable) return true;
const tag = node.tagName;
if (tag === "TEXTAREA" || tag === "SELECT") return true;
if (tag !== "INPUT") return false;
// An <input> with no type attribute is a text field.
const type = (node as HTMLInputElement).type?.toLowerCase() || "text";
return TEXT_ENTRY_TYPES.has(type);
}
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
export function comboOf(e: KeyboardEvent): string | null {
+33
View File
@@ -105,3 +105,36 @@ export function parseLdif(text: string): LdifRecord[] {
return !change || change === "add";
});
}
/**
* An identity for an entry, derived from its distinguished name.
*
* Mozilla's schema has no UID, so a re-import had nothing to be recognised by
* and duplicated everything (#223). The `dn` is what the file actually carries,
* and it does not need to be a durable identity to answer the only question
* being asked of it: have I imported this exact entry before? A migration is
* import, notice something wrong, correct the export, import again -- and the
* `dn` does not change in the ten minutes between two attempts, which is the
* interval that matters. An import is not a sync.
*
* Namespaced rather than stored raw, because it becomes the card's `uid` and
* must not be mistaken for a UID a vCard author meant. The one way this can be
* wrong: two directories that both contain `cn=John Smith`, imported into the
* *same* address book, are one contact afterwards. Matching is per book, so
* filing two directories in two books keeps them apart.
*
* Normalised for case and for the spacing exporters differ in, which costs
* nothing when a file is compared against itself and helps when it is compared
* against a differently-produced export of the same directory.
*
* Null for an entry with no usable `dn`: that entry gets an identity of its own
* and duplicates on re-import, as everything did before.
*/
export function uidFromDn(dn: string): string | null {
const normalised = dn
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/\s*([,=])\s*/g, "$1");
return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null;
}
+16 -6
View File
@@ -13,7 +13,9 @@
* the derivation can be checked rather than taken on trust.
*/
export type PaletteId = "default" | "ihasmail" | "dracula" | "gruvbox" | "rose-pine" | "tokyo-night";
export type PaletteId =
| "default" | "ihasmail" | "dracula" | "gruvbox" | "rose-pine" | "tokyo-night"
| "catppuccin" | "solarized" | "ayu" | "kanagawa" | "everforest" | "primer";
export type Mode = "system" | "light" | "dark";
/** What a mode resolves to once the system has been asked. */
export type ResolvedMode = "light" | "dark";
@@ -26,11 +28,11 @@ export interface PaletteMeta {
/**
* Whether the name is a word rather than a name.
*
* Five of these six are proper names -- ihasmail, Dracula, Gruvbox, Rosé
* Pine, Tokyo Night -- and are rendered translate="no" so a page translator
* leaves them alone. "Classic" is not a name, it is an adjective describing
* the theme, and a German reader should see "Klassisch". Reported by a
* native speaker reviewing the German catalogue (#247).
* All but one of these are proper names -- ihasmail, Dracula, Gruvbox and
* the rest -- and are rendered translate="no" so a page translator leaves
* them alone. "Classic" is not a name, it is an adjective describing the
* theme, and a German reader should see "Klassisch". Reported by a native
* speaker reviewing the German catalogue (#247).
*/
translatable?: boolean;
}
@@ -42,6 +44,14 @@ export const PALETTES: PaletteMeta[] = [
{ id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" },
{ id: "rose-pine", name: "Rosé Pine", credit: "Rosé Pine (MIT) — light variant is Dawn" },
{ id: "tokyo-night", name: "Tokyo Night", credit: "Tokyo Night by enkia (MIT) — light variant is Day" },
{ id: "catppuccin", name: "Catppuccin", credit: "Catppuccin (MIT) — dark is Mocha, light is Latte" },
{ id: "solarized", name: "Solarized", credit: "Solarized by Ethan Schoonover (MIT) — light and dark are both original" },
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
// Named for the design system rather than for GitHub: the colours are MIT,
// the name and the logo are trademarks, and nothing here is endorsed.
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
];
const byId = new Map(PALETTES.map((p) => [p.id, p]));
+101 -31
View File
@@ -1,14 +1,25 @@
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { formatList, weekdayName, weekdayNames } from "./datetime";
import { plural, t } from "@/lib/i18n";
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
{ key: "mo", label: "Monday", short: "M" },
{ key: "tu", label: "Tuesday", short: "T" },
{ key: "we", label: "Wednesday", short: "W" },
{ key: "th", label: "Thursday", short: "T" },
{ key: "fr", label: "Friday", short: "F" },
{ key: "sa", label: "Saturday", short: "S" },
{ key: "su", label: "Sunday", short: "S" },
];
/**
* The seven days, Monday first, named in the reader's locale.
*
* This was a table of English strings carrying `label: "Monday"` and
* `short: "M"`, rendered straight into the picker. The long names could have
* become catalogue entries; the short ones could not, because "T" is both
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
* cannot hold two translations under one key. Intl knows all of them.
*/
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
export function weekdayOptions(): Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> {
return weekdayNames("long").map(({ key, name }) => ({
key: key as JSCalendarNDay["day"],
label: name,
short: weekdayName(key, "narrow"),
}));
}
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
@@ -28,7 +39,7 @@ export function presetFor(rule: JSCalendarRecurrenceRule | undefined): Recurrenc
}
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
const dow = WEEKDAY_KEYS[(start.getDay() + 6) % 7]!;
switch (preset) {
case "daily":
return { "@type": "RecurrenceRule", frequency: "daily" };
@@ -45,48 +56,107 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
}
}
/**
* A recurrence rule as a sentence.
*
* Built as whole sentences with placeholders rather than by concatenation.
* The old version appended fragments -- `base += " on " + names` -- which is
* untranslatable however complete the catalogue is: German puts the weekday
* list somewhere else in the clause, and a translator handed " on " alone
* cannot move it. Every branch below is one key a translator can rewrite in
* full, including the word order.
*/
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
if (!rule) return "Does not repeat";
if (!rule) return t("Does not repeat");
const n = rule.interval ?? 1;
const every = n !== 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = n === 1 ? "Daily" : `Every ${n} days`;
base = every ? plural(n, { one: "Every {n} day", other: "Every {n} days" }) : t("Daily");
break;
case "weekly": {
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
if (rule.byDay?.length) {
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
const set = rule.byDay.map((d) => d.day).sort().join(",");
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
else base += ` on ${names.join(", ")}`;
const days = rule.byDay?.length ? rule.byDay.map((d) => d.day) : [];
const weekdaysOnly =
days.length === 5 && ["mo", "tu", "we", "th", "fr"].every((d) => days.includes(d as JSCalendarNDay["day"]));
if (weekdaysOnly && !every) {
base = t("Every weekday");
} else if (days.length) {
const list = formatList(days.map((d) => weekdayName(d as never)));
base = every
? plural(n, { one: "Every {n} week on {days}", other: "Every {n} weeks on {days}" }, { days: list })
: t("Weekly on {days}", { days: list });
} else {
base = every ? plural(n, { one: "Every {n} week", other: "Every {n} weeks" }) : t("Weekly");
}
break;
}
case "monthly": {
base = n === 1 ? "Monthly" : `Every ${n} months`;
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
else if (rule.byDay?.length) {
if (rule.byMonthDay?.length) {
const list = formatList(rule.byMonthDay.map(String));
base = every
? plural(n, { one: "Every {n} month on day {days}", other: "Every {n} months on day {days}" }, { days: list })
: t("Monthly on day {days}", { days: list });
} else if (rule.byDay?.length) {
const d = rule.byDay[0]!;
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
const weekday = weekdayName(d.day as never);
if (d.nthOfPeriod) {
const ord = ordinal(d.nthOfPeriod);
base = every
? plural(n, { one: "Every {n} month on the {ordinal} {weekday}", other: "Every {n} months on the {ordinal} {weekday}" }, { ordinal: ord, weekday })
: t("Monthly on the {ordinal} {weekday}", { ordinal: ord, weekday });
} else {
base = every
? plural(n, { one: "Every {n} month on {weekday}", other: "Every {n} months on {weekday}" }, { weekday })
: t("Monthly on {weekday}", { weekday });
}
} else {
base = every ? plural(n, { one: "Every {n} month", other: "Every {n} months" }) : t("Monthly");
}
break;
}
case "yearly":
base = n === 1 ? "Yearly" : `Every ${n} years`;
base = every ? plural(n, { one: "Every {n} year", other: "Every {n} years" }) : t("Yearly");
break;
default:
base = `Every ${n} ${rule.frequency}`;
// An RFC frequency this build has no sentence for. The frequency word
// itself stays as the server sent it rather than being invented.
base = t("Every {n} {frequency}", { n, frequency: rule.frequency });
}
// The tail wraps the sentence rather than being glued to its end, so a
// translator can put "until 3 May" first if that is what the language does.
if (rule.count) {
base = plural(rule.count, { one: "{rule}, {n} time", other: "{rule}, {n} times" }, { rule: base });
}
if (rule.until) {
base = t("{rule}, until {date}", { rule: base, date: rule.until.slice(0, 10) });
}
if (rule.count) base += `, ${rule.count} times`;
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
return base;
}
/**
* "first", "second", "last" -- words, not "1st".
*
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
* in practice, so five words and "last" cover it; anything else falls back to
* the bare number, which is wrong in no language.
*/
function ordinal(n: number): string {
if (n === -1) return "last";
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
switch (n) {
case -1: return t("last");
case 1: return t("first");
case 2: return t("second");
case 3: return t("third");
case 4: return t("fourth");
case 5: return t("fifth");
default: return String(n);
}
}
+58 -44
View File
@@ -6,6 +6,9 @@
* Sieve below each comment is what the server actually runs.
*/
import { formatList } from "./datetime";
import { t } from "@/lib/i18n";
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
export type SieveTest =
@@ -318,49 +321,60 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
return [...rest.slice(0, at), moved, ...rest.slice(at)];
}
/**
* A filter rule as a sentence, for the rule list.
*
* Rebuilt as whole sentences with placeholders. The old version concatenated
* fragments -- a header name, an operator, a quoted value, joined by " and "
* -- which no catalogue could fix: German puts the verb last, Japanese does
* not separate list items with a word at all, and a translator handed " and "
* on its own cannot move anything. Reported by a native speaker reviewing the
* German catalogue (#247).
*
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
* for an anyof rule, the disjunction the language actually uses.
*/
export function describeRule(r: SieveRule): string {
const tests = r.tests
.map((t) => {
switch (t.type) {
case "header":
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "address":
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "size":
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
case "body":
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
case "true":
return "always";
}
})
.join(r.join === "allof" ? " and " : " or ");
const actions = r.actions
.map((a) => {
switch (a.type) {
case "fileinto":
return `move to ${a.mailbox}`;
case "redirect":
return `forward to ${a.address}`;
case "discard":
return "delete";
case "keep":
return "keep";
case "reject":
return "reject";
case "markread":
return "mark read";
case "flag":
return "star";
case "addflag":
case "setflag":
return `add ${a.flag}`;
case "removeflag":
return `remove ${a.flag}`;
case "stop":
return "stop";
}
})
.join(", ");
return `${tests || "always"}${actions}`;
const headerLabel = (h: string): string => t(HEADER_CHOICES.find((c) => c.value === h)?.label ?? h);
const opLabel = (op: string): string => t(HEADER_OPS.find((o) => o.value === op)?.label ?? op);
const tests = r.tests.map((test) => {
switch (test.type) {
case "header":
return t('{header} {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
case "address":
return t('{header} address {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
case "size":
return test.op === "over"
? t("size is over {n} KB", { n: Math.round(test.value / 1024) })
: t("size is under {n} KB", { n: Math.round(test.value / 1024) });
case "body":
return test.op === "contains"
? t('body contains "{value}"', { value: test.value })
: t('body does not contain "{value}"', { value: test.value });
case "true":
return t("always");
}
});
const actions = r.actions.map((a) => {
switch (a.type) {
case "fileinto": return t("move to {folder}", { folder: a.mailbox });
case "redirect": return t("forward to {address}", { address: a.address });
case "discard": return t("delete it");
case "keep": return t("keep it");
case "reject": return t("reject it");
case "markread": return t("mark it read");
case "flag": return t("star it");
case "addflag":
case "setflag": return t("add {flag}", { flag: a.flag });
case "removeflag": return t("remove {flag}", { flag: a.flag });
case "stop": return t("stop");
}
});
return t("{tests} → {actions}", {
tests: tests.length ? formatList(tests, r.join === "allof" ? "conjunction" : "disjunction") : t("always"),
actions: formatList(actions, "conjunction"),
});
}
@@ -0,0 +1,41 @@
To: [email protected]
From: Grace Hopper <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----1F14F13899656314019C2DF4C8728ED3"
This is an S/MIME signed message
------1F14F13899656314019C2DF4C8728ED3
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------1F14F13899656314019C2DF4C8728ED3
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
MIIDtQYJKoZIhvcNAQcCoIIDpjCCA6ICAQExDzANBglghkgBZQMEAgEFADALBgkq
hkiG9w0BBwGgggHjMIIB3zCCAYagAwIBAgIUejcvY0YWeoAxAMTf4bp3jOkMCVww
CgYIKoZIzj0EAwIwKzEVMBMGA1UEAwwMR3JhY2UgSG9wcGVyMRIwEAYDVQQKDAlD
b21waWxlcnMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgyNzQ4WjArMRUwEwYD
VQQDDAxHcmFjZSBIb3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVyczBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABOcBiRNb2IF0vlQJlnVKJXFr0taIz2LoU+8p0GQZ9kas
DnlHQaCR17JtDfGA6azi7w2ZOYZg0ZjGxbcKoTWKdR6jgYcwgYQwHQYDVR0OBBYE
FGqS/6/0hXUay/WvkZrl3DCTY1sTMB8GA1UdIwQYMBaAFGqS/6/0hXUay/WvkZrl
3DCTY1sTMA8GA1UdEwEB/wQFMAMBAf8wHAYDVR0RBBUwE4ERZ3JhY2VAZXhhbXBs
ZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDRwAwRAIgbJ+R
K6iGkQ6qrzT7m7E09D2rW+O3/LYoRPlwo5EJiZkCIHAvYgUyxqeRGNr/adeVmqvv
borP2nkAD8LjCUdtv1+NMYIBljCCAZICAQEwQzArMRUwEwYDVQQDDAxHcmFjZSBI
b3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVycwIUejcvY0YWeoAxAMTf4bp3jOkMCVww
DQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkq
hkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tNEuEW
5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajALBglg
hkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAO
BggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcN
AwICASgwCgYIKoZIzj0EAwIERjBEAiBR36dlKMTvZKbufvpVZNylX7yxQrra+xHP
+KnbTB8rzAIgIW0ArgkEKCYF8eF76KyjOcfAlXT1MJKjiCjYj9I13Ug=
------1F14F13899656314019C2DF4C8728ED3--
@@ -0,0 +1,54 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
This is an S/MIME signed message
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
------12D00EBC0B5D3352F0E2F25B1A5D3552--
@@ -0,0 +1,54 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
This is an S/MIME signed message
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatsoever to originate anything.
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
------12D00EBC0B5D3352F0E2F25B1A5D3552--
@@ -0,0 +1,52 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: Not really Ada
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----A376D763C7F4750971F7C04B27A38CA7"
This is an S/MIME signed message
------A376D763C7F4750971F7C04B27A38CA7
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------A376D763C7F4750971F7C04B27A38CA7
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
MIIFygYJKoZIhvcNAQcCoIIFuzCCBbcCAQExDzANBglghkgBZQMEAgEFADALBgkq
hkiG9w0BBwGgggNMMIIDSDCCAjCgAwIBAgIUFdKNfhOvBCjZ11I7PjX3Sm6PShQw
DQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZTAeFw0yNjA5
MDUwODI3NDhaFw0zNjA5MDIwODI3NDhaMBgxFjAUBgNVBAMMDVNvbWVib2R5IEVs
c2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCldgteGIpI7bziik2P
losrdYWJKZSg/EJ44am9Babze+NCJTxMvE/fzYDUnUgUyL7XKU6dahkOBRrKEjLH
EnRnTcr8k6ZqskFJwwWaSAHjvITgHOQ7wGMIwcJldNw/Y+4ZQHeHUncTbaG8byN7
dVtl4MG4PodWZMYXFUKH2bEmPQnrTmKfoh/ixOlVnNxu4+S/G9qH+EI8sZyrByIW
PY6ChWXDo3C57ifJCtsqvSDiHYxEN9dN/DHglK38bzQOw4Q74XTowiL47+pmU0bF
DSg27q2e/h/DHAHs18W/XDcDkxpEOH/B0e/GtAK/R5qAgfmnVvI3f7wRifW4mj/P
eEqfAgMBAAGjgYkwgYYwHQYDVR0OBBYEFLYf2wgKUplLO+bSX5VCspywSOJJMB8G
A1UdIwQYMBaAFLYf2wgKUplLO+bSX5VCspywSOJJMA8GA1UdEwEB/wQFMAMBAf8w
HgYDVR0RBBcwFYETbWFsbG9yeUBleGFtcGxlLm5ldDATBgNVHSUEDDAKBggrBgEF
BQcDBDANBgkqhkiG9w0BAQsFAAOCAQEALkRLkusftqrRFNkhbZfbOwt4KOtfFkQn
Yn4JzOYNfueSidrWKY3F2s3XfBichBePV6t1toOj0+eagYN+xiI9KftyykVVYb5/
dPZlHLRgRFavxlqLLg25bBUEzpw3LpaMMa62Zhc1Cp0N8iATVk9vpMxN/DFN2w6H
qe+0CoQYRM8aW/D3aos+EYKbNsB1ZV0AZzt/MJYeJvRhp7oH2QA4sXp82f0F0RAV
uSZCX38WzbpfvlDOoasUUlOdPEvxBBaQIptKG1q2DGjqLZUiHyyontdjzR6+VaiV
ypB8gs7/oFSKNoQEW8wzoEnubXh70A36Pqr1VFFZtLkNJDUfbBYzPjGCAkIwggI+
AgEBMDAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZQIUFdKNfhOvBCjZ11I7PjX3
Sm6PShQwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEH
ATAcBgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQg
D6tNEuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWww
ajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG
9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYI
KoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAHKJmt4Jf+gY/NkHKLny77T/J
D1syA3lFZ008iTGxmANfAUwVQWy7fHGzPmf2FBv3yj+7lbSAJ4b0JITClZ01EejW
QGdhMrmVAgd0M55rCEdcLrk7hPsZQ7UOdjc2O22cS2ZBwZGkS4re8a0qy5D2tHAh
0fnmu0zDoVwZuo78Panc96th3SjSpLlJU8jwMyiwlRHXzP0nHO8CPWSDMKzi5+8H
EHDvcjivzGntVOHvafeokE2NmrIvJpCEvMEMVyVmysgUENTQJYOIhEbXu2ktK79f
jx+w6iYUZjnpEHJ79OLArZsV6+e7x9nq6lhA3zSt2CwpOGvfRM17tN0c+OALpg==
------A376D763C7F4750971F7C04B27A38CA7--
+165
View File
@@ -0,0 +1,165 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { judge, shouldRemember, verifyMessage, type KnownSigner } from "../verify";
import { certCovers } from "../x509";
import { parseMime, toCanonicalCrlf } from "../mime";
/**
* These fixtures are real. Each was produced by `openssl smime -sign` against a
* generated certificate, not written by hand a hand-built signed message
* tests the parser against the author's belief about the format, agrees with
* every mistake in it, and is exactly how a verifier ends up passing its own
* suite and failing on the first message anybody actually sends.
*
* The tampered fixture is the same signed message with one word of the body
* changed and the signature left alone, which is the case the whole feature
* exists to catch.
*/
// Read through the filesystem rather than an import, so the bytes arrive
// exactly as they were signed. A bundler transform in the middle -- even one
// that only touched line endings -- would be testing the transform.
const fixture = (name: string) => new Uint8Array(readFileSync(resolve(__dirname, "fixtures", name)));
describe("a genuinely signed message", () => {
it("verifies an RSA signature and reads the signer off the certificate", async () => {
const result = await verifyMessage(fixture("signed-rsa.eml"));
expect(result.kind).toBe("intact");
if (result.kind !== "intact") return;
expect(result.cert.subject.commonName).toBe("Ada Lovelace");
expect(result.cert.emails).toContain("[email protected]");
expect(result.cert.fingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(result.signer.digest).toBe("SHA-256");
});
it("verifies an ECDSA signature, whose r and s need re-packing for WebCrypto", async () => {
const result = await verifyMessage(fixture("signed-ec.eml"));
expect(result.kind).toBe("intact");
if (result.kind !== "intact") return;
expect(result.cert.subject.commonName).toBe("Grace Hopper");
expect(result.cert.publicKey).toEqual({ kind: "ec", namedCurve: "P-256" });
});
it("catches a body edited after signing", async () => {
const result = await verifyMessage(fixture("signed-tampered.eml"));
expect(result.kind).toBe("broken");
if (result.kind !== "broken") return;
expect(result.reason).toBe("digest-mismatch");
});
it("says nothing is signed when nothing is", async () => {
const plain = new TextEncoder().encode("From: [email protected]\r\nSubject: hi\r\n\r\nJust text.\r\n");
expect((await verifyMessage(plain)).kind).toBe("none");
});
it("declines OpenPGP by name, rather than as an unknown format", async () => {
const pgp = new TextEncoder().encode(
'From: [email protected]\r\nContent-Type: multipart/signed; protocol="application/pgp-signature"; boundary="b"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b\r\nContent-Type: application/pgp-signature\r\n\r\nsig\r\n--b--\r\n',
);
const result = await verifyMessage(pgp);
expect(result.kind).toBe("unsupported");
if (result.kind !== "unsupported") return;
// A code, so the sentence can be translated where it is shown.
expect(result.reason).toBe("openpgp");
});
});
describe("what the signature is allowed to mean", () => {
const ada = "[email protected]";
it("a first sighting is pinned, and says so", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
const report = judge(crypto, ada, undefined);
expect(report.trust).toBe("first-seen");
expect(report.warnings).toEqual([]);
expect(shouldRemember(report)).toBe(true);
});
it("the same certificate again is recognised", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
if (crypto.kind !== "intact") throw new Error("fixture should verify");
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
const report = judge(crypto, ada, known);
expect(report.trust).toBe("same-as-before");
// Nothing to write: it already matches what is stored.
expect(shouldRemember(report)).toBe(false);
});
it("a different certificate for a known address is the loud case", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
const known: KnownSigner = { fingerprint: "0".repeat(64), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
const report = judge(crypto, ada, known);
expect(report.trust).toBe("changed");
expect(report.previous).toBe(known);
// A changed signer must never overwrite the pin -- that would launder the
// very substitution this is here to report.
expect(shouldRemember(report)).toBe(false);
});
it("notices a valid signature by a certificate for somebody else", async () => {
const crypto = await verifyMessage(fixture("signed-wrong-address.eml"));
expect(crypto.kind).toBe("intact");
const report = judge(crypto, ada, undefined);
expect(report.warnings).toContain("address-mismatch");
// Cryptographically fine, and still not to be pinned as Ada's signer.
expect(shouldRemember(report)).toBe(false);
});
it("reports an expired certificate without calling the signature broken", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
const report = judge(crypto, ada, undefined, new Date("2099-01-01T00:00:00Z"));
expect(report.crypto.kind).toBe("intact");
expect(report.warnings).toContain("certificate-expired");
expect(shouldRemember(report)).toBe(false);
});
it("passes a non-verifying result straight through with no trust claim", () => {
const report = judge({ kind: "broken", reason: "signature-mismatch" }, ada, undefined);
expect(report.trust).toBeUndefined();
expect(shouldRemember(report)).toBe(false);
});
});
describe("matching a certificate to an address", () => {
it("is case-insensitive, as addresses are", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
if (crypto.kind !== "intact") throw new Error("fixture should verify");
expect(certCovers(crypto.cert, "[email protected]")).toBe(true);
expect(certCovers(crypto.cert, "[email protected]")).toBe(false);
});
});
describe("canonicalisation", () => {
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
});
it("is a no-op on content that is already canonical", () => {
const already = new TextEncoder().encode("a\r\nb\r\n");
expect(toCanonicalCrlf(already)).toBe(already);
});
/*
* The reason canonicalisation is applied at all: a store that hands back a
* message with bare LFs would otherwise fail every signature it holds, and
* the message would look identical on screen while doing it.
*/
it("verifies a signed message whose line endings were flattened in storage", async () => {
const original = fixture("signed-rsa.eml");
const flattened = new TextEncoder().encode(new TextDecoder().decode(original).replace(/\r\n/g, "\n"));
expect((await verifyMessage(flattened)).kind).toBe("intact");
});
});
describe("reading the message structure", () => {
it("finds the two parts of a signed message and keeps their bytes intact", () => {
const root = parseMime(fixture("signed-rsa.eml"));
expect(root.contentType).toBe("multipart/signed");
expect(root.parts).toHaveLength(2);
expect(root.parts[0]!.contentType).toBe("text/plain");
expect(root.parts[1]!.contentType).toBe("application/x-pkcs7-signature");
// The signed part keeps its own headers: they are inside what was signed.
expect(new TextDecoder().decode(root.parts[0]!.raw)).toMatch(/^Content-Type: text\/plain/);
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* CMS SignedData (RFC 5652), enough of it to check a detached S/MIME signature.
*
* The one place this is easy to get quietly wrong is what the signature covers.
* When signed attributes are present and for S/MIME they always are, because
* the content type and message digest are required the signature is **not**
* over the message. It is over the DER encoding of the SignedAttributes, and
* those appear in the blob tagged `[0] IMPLICIT`, which must be re-tagged to
* the universal `SET OF` before hashing. Skip that and every valid signature
* fails; hash the message instead and every signature "passes", which is very
* much worse. `signedAttrsForSigning` is that step, kept on its own so it can
* be tested on its own.
*/
import { at, children, DerError, expect, integerHex, oid, parse, TAG, time, type Asn1 } from "./der";
const OID = {
signedData: "1.2.840.113549.1.7.2",
data: "1.2.840.113549.1.7.1",
contentType: "1.2.840.113549.1.9.3",
messageDigest: "1.2.840.113549.1.9.4",
signingTime: "1.2.840.113549.1.9.5",
sha256: "2.16.840.1.101.3.4.2.1",
sha384: "2.16.840.1.101.3.4.2.2",
sha512: "2.16.840.1.101.3.4.2.3",
sha1: "1.3.14.3.2.26",
rsaEncryption: "1.2.840.113549.1.1.1",
sha256WithRsa: "1.2.840.113549.1.1.11",
sha384WithRsa: "1.2.840.113549.1.1.12",
sha512WithRsa: "1.2.840.113549.1.1.13",
rsaPss: "1.2.840.113549.1.1.10",
ecdsaWithSha256: "1.2.840.10045.4.3.2",
ecdsaWithSha384: "1.2.840.10045.4.3.3",
ecdsaWithSha512: "1.2.840.10045.4.3.4",
} as const;
export type Digest = "SHA-256" | "SHA-384" | "SHA-512";
export type SignatureKind = "rsa-pkcs1" | "rsa-pss" | "ecdsa";
export interface SignerInfo {
digest: Digest;
signature: SignatureKind;
/** Raw signature bytes. */
value: Uint8Array;
/** DER of the SignedAttributes, already re-tagged as a SET OF, ready to hash. */
signedAttrs: Uint8Array;
/** The messageDigest signed attribute: what the content must hash to. */
messageDigest: Uint8Array;
/** Claimed signing time, if the signer included one. Not evidence of anything. */
signingTime?: Date;
/** Issuer name DER + serial, how the signer's certificate is usually named. */
issuerDer?: Uint8Array;
serial?: string;
/** subjectKeyIdentifier, used instead of issuer-and-serial by version 3 signers. */
subjectKeyId?: Uint8Array;
}
export interface SignedData {
/** DER of each certificate carried along, in the order they appeared. */
certificates: Uint8Array[];
signers: SignerInfo[];
/** Present only for an opaque signature, where the content travels inside. */
encapsulatedContent?: Uint8Array;
}
/** Parse a PKCS#7 / CMS blob into the parts a verifier needs. */
export function parseSignedData(der: Uint8Array): SignedData {
const info = children(expect(parse(der), TAG.sequence, "a ContentInfo"));
if (oid(at(info, 0, "a content type")) !== OID.signedData) throw new DerError("Not a CMS SignedData.");
const wrapper = at(info, 1, "the SignedData [0]");
const signedData = children(expect(children(wrapper)[0] ?? wrapper, TAG.sequence, "a SignedData"));
// SignedData ::= version, digestAlgorithms, encapContentInfo,
// [0] certificates, [1] crls, signerInfos
const encap = children(expect(at(signedData, 2, "an encapContentInfo"), TAG.sequence, "an encapContentInfo"));
const encapsulatedContent = encap[1] ? children(encap[1])[0]?.content : undefined;
const certificates: Uint8Array[] = [];
const certSet = signedData.find((n) => n.cls === 2 && n.tag === 0);
if (certSet) {
for (const c of children(certSet)) {
// Only plain certificates; the other CHOICE arms are context-tagged and
// are attribute certificates, which nothing here knows how to read.
if (c.cls === 0 && c.tag === TAG.sequence) certificates.push(c.bytes);
}
}
const signerSet = signedData[signedData.length - 1];
if (!signerSet || signerSet.tag !== TAG.set) throw new DerError("No signerInfos.");
const signers = children(signerSet).map(readSigner);
if (signers.length === 0) throw new DerError("SignedData carries no signer.");
return { certificates, signers, encapsulatedContent };
}
function readSigner(node: Asn1): SignerInfo {
const p = children(expect(node, TAG.sequence, "a SignerInfo"));
let i = 1; // skip version
// sid ::= issuerAndSerialNumber | [0] subjectKeyIdentifier
const sid = at(p, i++, "a signer identifier");
let issuerDer: Uint8Array | undefined;
let serial: string | undefined;
let subjectKeyId: Uint8Array | undefined;
if (sid.cls === 2 && sid.tag === 0) {
subjectKeyId = sid.content;
} else {
const pair = children(sid);
issuerDer = at(pair, 0, "an issuer name").bytes;
serial = integerHex(at(pair, 1, "a serial number"));
}
const digest = digestFrom(oid(at(children(at(p, i++, "a digest algorithm")), 0, "a digest algorithm id")));
// [0] IMPLICIT SignedAttributes, optional but always present for S/MIME.
const attrsNode = p[i]?.cls === 2 && p[i]?.tag === 0 ? p[i++]! : undefined;
if (!attrsNode) throw new DerError("Signature carries no signed attributes; S/MIME requires them.");
const algNode = children(at(p, i++, "a signature algorithm"));
const signature = signatureFrom(oid(at(algNode, 0, "a signature algorithm id")));
const value = expect(at(p, i++, "a signature"), TAG.octetString, "a signature").content;
const attrs = children(attrsNode);
const messageDigest = findAttr(attrs, OID.messageDigest, (v) => expect(v, TAG.octetString, "a message digest").content);
if (!messageDigest) throw new DerError("Signature has no messageDigest attribute.");
const contentType = findAttr(attrs, OID.contentType, (v) => oid(v));
if (contentType && contentType !== OID.data) throw new DerError(`Signed content type is ${contentType}, not plain data.`);
// A claimed signing time is shown, never checked: the signer chose it, so it
// is a statement rather than evidence. An unreadable one must not fail the
// signature, which is why this swallows rather than throws.
const signingTime = findAttr(attrs, OID.signingTime, (v) => {
try {
return time(v);
} catch {
return undefined;
}
});
return { digest, signature, value, signedAttrs: signedAttrsForSigning(attrsNode), messageDigest, signingTime, issuerDer, serial, subjectKeyId };
}
/**
* The bytes the signature is actually over.
*
* SignedAttributes travel as `[0] IMPLICIT`, tag 0xA0. RFC 5652 §5.4 says the
* signature is computed over their DER encoding as a `SET OF`, tag 0x31. Only
* the identifier octet changes; the length and contents are already correct,
* which is why this is a single byte and also why it is so easy to miss.
*/
export function signedAttrsForSigning(attrs: Asn1): Uint8Array {
const copy = attrs.bytes.slice();
copy[0] = 0x31;
return copy;
}
function findAttr<T>(attrs: Asn1[], want: string, read: (v: Asn1) => T): T | undefined {
for (const attr of attrs) {
const kv = children(attr);
if (kv.length < 2) continue;
if (oid(at(kv, 0, "an attribute type")) !== want) continue;
const values = children(at(kv, 1, "an attribute value set"));
if (values[0]) return read(values[0]);
}
return undefined;
}
function digestFrom(o: string): Digest {
if (o === OID.sha256) return "SHA-256";
if (o === OID.sha384) return "SHA-384";
if (o === OID.sha512) return "SHA-512";
// SHA-1 is refused rather than supported. A signature nobody can forge in
// practice today is still one this should not be putting a tick beside.
if (o === OID.sha1) throw new DerError("Signed with SHA-1, which is too weak to report as verified.");
throw new DerError(`Unsupported digest algorithm ${o}.`);
}
function signatureFrom(o: string): SignatureKind {
if (o === OID.rsaEncryption || o === OID.sha256WithRsa || o === OID.sha384WithRsa || o === OID.sha512WithRsa) return "rsa-pkcs1";
if (o === OID.rsaPss) return "rsa-pss";
if (o === OID.ecdsaWithSha256 || o === OID.ecdsaWithSha384 || o === OID.ecdsaWithSha512) return "ecdsa";
throw new DerError(`Unsupported signature algorithm ${o}.`);
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Just enough DER to read a CMS signature and an X.509 certificate.
*
* This is deliberately small. It is not a general ASN.1 library and should not
* grow into one: everything here exists because some byte of a signed message
* has to be looked at, and a parser that can read shapes nothing sends is a
* parser with corners nobody has tested.
*
* Two rules it keeps, both of which matter for verification rather than for
* tidiness:
*
* - Every node keeps `bytes`, the whole tag-length-value as it arrived. A
* signature is computed over encoded bytes, so anything that re-encodes a
* structure it means to hash has already lost. Nothing here re-encodes.
* - Indefinite lengths are refused rather than guessed at. S/MIME signatures
* are DER, which forbids them; a blob using one is either BER from an
* unusual producer or is not what it claims, and treating the two alike
* would mean inventing a parse for input this has never seen.
*/
export interface Asn1 {
/** Tag number, without the class and constructed bits. */
tag: number;
/** 0 universal, 1 application, 2 context-specific, 3 private. */
cls: number;
constructed: boolean;
/** Content octets: the V of TLV. */
content: Uint8Array;
/** The whole TLV as it arrived, for anything that must hash or re-present it. */
bytes: Uint8Array;
}
export const TAG = {
boolean: 0x01,
integer: 0x02,
bitString: 0x03,
octetString: 0x04,
null: 0x05,
oid: 0x06,
utf8String: 0x0c,
sequence: 0x10,
set: 0x11,
printableString: 0x13,
ia5String: 0x16,
utcTime: 0x17,
generalizedTime: 0x18,
bmpString: 0x1e,
} as const;
export class DerError extends Error {}
/** Read one TLV at `offset`. Returns the node and where the next one starts. */
export function readNode(buf: Uint8Array, offset = 0): { node: Asn1; next: number } {
if (offset + 2 > buf.length) throw new DerError("Truncated: no room for a tag and a length.");
const id = buf[offset]!;
const cls = id >> 6;
const constructed = (id & 0x20) !== 0;
let tag = id & 0x1f;
let i = offset + 1;
// High-tag-number form: 0b11111 says the number continues in the following
// octets, seven bits at a time. Rare, but a context tag above 30 is legal.
if (tag === 0x1f) {
tag = 0;
for (;;) {
if (i >= buf.length) throw new DerError("Truncated inside a multi-byte tag.");
const b = buf[i++]!;
tag = (tag << 7) | (b & 0x7f);
if ((b & 0x80) === 0) break;
if (tag > 0xffffff) throw new DerError("Unreasonable tag number.");
}
}
if (i >= buf.length) throw new DerError("Truncated: no length octet.");
const first = buf[i++]!;
let length: number;
if (first < 0x80) {
length = first;
} else if (first === 0x80) {
throw new DerError("Indefinite length: this is BER, and a signature must be DER.");
} else {
const n = first & 0x7f;
if (n > 4) throw new DerError("Length field too large to be real.");
if (i + n > buf.length) throw new DerError("Truncated inside a length field.");
length = 0;
for (let k = 0; k < n; k++) length = length * 256 + buf[i++]!;
}
const end = i + length;
if (end > buf.length) throw new DerError(`Truncated: a node claims ${length} bytes and only ${buf.length - i} remain.`);
return {
node: { tag, cls, constructed, content: buf.subarray(i, end), bytes: buf.subarray(offset, end) },
next: end,
};
}
/** Parse a single top-level node, refusing trailing rubbish. */
export function parse(buf: Uint8Array): Asn1 {
const { node, next } = readNode(buf, 0);
if (next !== buf.length) throw new DerError(`${buf.length - next} trailing byte(s) after the top-level value.`);
return node;
}
/** The immediate children of a constructed node. */
export function children(node: Asn1): Asn1[] {
if (!node.constructed) throw new DerError("Asked for the children of a primitive value.");
const out: Asn1[] = [];
let at = 0;
while (at < node.content.length) {
const { node: child, next } = readNode(node.content, at);
out.push(child);
at = next;
}
return out;
}
/** A child by position, checked, because "undefined" is a poor error message. */
export function at(nodes: Asn1[], index: number, what: string): Asn1 {
const n = nodes[index];
if (!n) throw new DerError(`Missing ${what}.`);
return n;
}
export function expect(node: Asn1, tag: number, what: string): Asn1 {
if (node.cls !== 0 || node.tag !== tag) throw new DerError(`Expected ${what} (universal tag ${tag}), found class ${node.cls} tag ${node.tag}.`);
return node;
}
/** A context-specific child, e.g. [0] — returns undefined when absent. */
export function contextChild(nodes: Asn1[], tag: number): Asn1 | undefined {
return nodes.find((n) => n.cls === 2 && n.tag === tag);
}
/** Dotted OID, decoded from the packed base-128 form. */
export function oid(node: Asn1): string {
expect(node, TAG.oid, "an object identifier");
const c = node.content;
if (c.length === 0) throw new DerError("Empty object identifier.");
// The first octet packs two arcs: 40*first + second.
const parts = [Math.floor(c[0]! / 40), c[0]! % 40];
let value = 0;
for (let i = 1; i < c.length; i++) {
const b = c[i]!;
value = value * 128 + (b & 0x7f);
if ((b & 0x80) === 0) {
parts.push(value);
value = 0;
}
}
return parts.join(".");
}
/** Contents of a BIT STRING, refusing the padded case nothing here should meet. */
export function bitString(node: Asn1): Uint8Array {
expect(node, TAG.bitString, "a bit string");
if (node.content.length === 0) throw new DerError("Empty bit string.");
const unused = node.content[0]!;
if (unused !== 0) throw new DerError(`Bit string with ${unused} unused bits; expected a whole number of bytes.`);
return node.content.subarray(1);
}
/** An INTEGER as a hex string, since serial numbers overflow a JS number. */
export function integerHex(node: Asn1): string {
expect(node, TAG.integer, "an integer");
let hex = "";
for (const b of node.content) hex += b.toString(16).padStart(2, "0");
return hex.replace(/^(00)+(?=.)/, "");
}
/** A small INTEGER, for versions and the like. */
export function integer(node: Asn1): number {
expect(node, TAG.integer, "an integer");
if (node.content.length > 4) throw new DerError("Integer larger than this reads.");
let v = 0;
for (const b of node.content) v = v * 256 + b;
return v;
}
/**
* UTCTime or GeneralizedTime.
*
* UTCTime carries a two-digit year, and RFC 5280 pins the window: 50-99 mean
* 1950-1999 and 00-49 mean 2000-2049. Guessing "20" + yy instead works until
* 2050 and then silently dates certificates a century early, which is the kind
* of bug that is written once and found by somebody else.
*/
export function time(node: Asn1): Date {
const s = new TextDecoder().decode(node.content);
let iso: string;
if (node.tag === TAG.utcTime) {
const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?Z$/.exec(s);
if (!m) throw new DerError(`Unreadable UTCTime "${s}".`);
const yy = Number(m[1]);
const year = yy >= 50 ? 1900 + yy : 2000 + yy;
iso = `${year}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
} else if (node.tag === TAG.generalizedTime) {
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:\.\d+)?Z$/.exec(s);
if (!m) throw new DerError(`Unreadable GeneralizedTime "${s}".`);
iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
} else {
throw new DerError(`Expected a time, found tag ${node.tag}.`);
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) throw new DerError(`Unreadable time "${s}".`);
return d;
}
/** Text from any of the string types a name or an address turns up in. */
export function text(node: Asn1): string {
if (node.tag === TAG.bmpString) {
// UTF-16BE. Rare, but Windows-issued certificates do use it for names.
let s = "";
for (let i = 0; i + 1 < node.content.length; i += 2) s += String.fromCharCode((node.content[i]! << 8) | node.content[i + 1]!);
return s;
}
return new TextDecoder().decode(node.content);
}
/** Lowercase hex of some bytes, for fingerprints. */
export function hex(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += b.toString(16).padStart(2, "0");
return s;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Enough MIME to find a signed part and hand back the exact bytes it covers.
*
* This works on bytes, not on a string, and that is the whole point. A
* signature is over an octet sequence: decode it to text, re-encode it, or let
* anything normalise a line ending on the way past, and the digest changes
* while the message still looks identical on screen. Every part here keeps a
* subarray of the original buffer rather than a rebuilt copy.
*
* The one transformation that *is* applied is a lone LF becoming CRLF, and it
* is applied only to the signed part. RFC 1847 requires the protected content
* to be in canonical MIME form, which means CRLF; a store that hands back a
* message with bare LFs and they do would otherwise fail every signature it
* has ever held, for a reason nobody could see by looking at the message.
*/
export interface MimePart {
/** Lowercased header name to raw value, first occurrence winning. */
headers: Map<string, string>;
/** Lowercased `type/subtype`, or "text/plain" when unstated. */
contentType: string;
/** Lowercased content-type parameters. */
params: Record<string, string>;
/** The body, exactly as it appeared. */
body: Uint8Array;
/** Headers and body together, exactly as they appeared. */
raw: Uint8Array;
parts: MimePart[];
}
const CR = 13;
const LF = 10;
function indexOfSeq(hay: Uint8Array, needle: number[], from = 0): number {
outer: for (let i = from; i + needle.length <= hay.length; i++) {
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
return i;
}
return -1;
}
/** Where the headers stop: the first blank line, in either line ending. */
function headerEnd(buf: Uint8Array): { bodyAt: number; headersEnd: number } {
const crlf = indexOfSeq(buf, [CR, LF, CR, LF]);
const lf = indexOfSeq(buf, [LF, LF]);
if (crlf >= 0 && (lf < 0 || crlf <= lf)) return { headersEnd: crlf, bodyAt: crlf + 4 };
if (lf >= 0) return { headersEnd: lf, bodyAt: lf + 2 };
return { headersEnd: buf.length, bodyAt: buf.length };
}
function parseHeaders(block: string): Map<string, string> {
const out = new Map<string, string>();
// Unfold first: a continuation line begins with space or tab and belongs to
// the header above it. Folding a long boundary parameter is ordinary, so a
// parser that reads line by line loses boundaries on real messages.
const unfolded = block.replace(/\r?\n[ \t]+/g, " ");
for (const line of unfolded.split(/\r?\n/)) {
const c = line.indexOf(":");
if (c <= 0) continue;
const name = line.slice(0, c).trim().toLowerCase();
if (!out.has(name)) out.set(name, line.slice(c + 1).trim());
}
return out;
}
/** Split `text/plain; charset="utf-8"` into its type and its parameters. */
export function parseContentType(value: string | undefined): { type: string; params: Record<string, string> } {
if (!value) return { type: "text/plain", params: {} };
const [head, ...rest] = value.split(";");
const params: Record<string, string> = {};
for (const p of rest) {
const eq = p.indexOf("=");
if (eq < 0) continue;
const k = p.slice(0, eq).trim().toLowerCase();
let v = p.slice(eq + 1).trim();
if (v.startsWith('"')) v = v.slice(1, v.lastIndexOf('"') > 0 ? v.lastIndexOf('"') : undefined);
params[k] = v;
}
return { type: (head ?? "").trim().toLowerCase() || "text/plain", params };
}
/** Parse a message, or a part of one, into a tree. */
export function parseMime(raw: Uint8Array): MimePart {
const { bodyAt, headersEnd } = headerEnd(raw);
const headers = parseHeaders(new TextDecoder("utf-8", { fatal: false }).decode(raw.subarray(0, headersEnd)));
const { type, params } = parseContentType(headers.get("content-type"));
const body = raw.subarray(bodyAt);
const part: MimePart = { headers, contentType: type, params, body, raw, parts: [] };
if (type.startsWith("multipart/") && params.boundary) part.parts = splitMultipart(body, params.boundary);
return part;
}
/**
* Split a multipart body on its boundary.
*
* The subtle bit is what belongs to a part and what belongs to the delimiter.
* RFC 2046 puts the CRLF *before* a boundary line into the delimiter, not into
* the part above it. Keeping that CRLF appends two bytes to the signed content
* and fails every signature; dropping one too many does the same. So each part
* ends at the byte before the CRLF that introduces the next boundary.
*/
function splitMultipart(body: Uint8Array, boundary: string): MimePart[] {
const marker = [...`--${boundary}`].map((c) => c.charCodeAt(0));
const offsets: number[] = [];
for (let i = 0; i >= 0 && i < body.length; ) {
const found = indexOfSeq(body, marker, i);
if (found < 0) break;
// Only at the start of a line.
if (found === 0 || body[found - 1] === LF) offsets.push(found);
i = found + marker.length;
}
if (offsets.length < 2) return [];
const parts: MimePart[] = [];
for (let k = 0; k < offsets.length - 1; k++) {
const delimiter = offsets[k]!;
// Step over the boundary line itself to reach the part's first header byte.
let start = delimiter + marker.length;
while (start < body.length && body[start] !== LF) start++;
start++;
// The part ends before the CRLF that belongs to the *next* delimiter.
let end = offsets[k + 1]!;
if (end > 0 && body[end - 1] === LF) end--;
if (end > 0 && body[end - 1] === CR) end--;
if (start < end) parts.push(parseMime(body.subarray(start, end)));
}
return parts;
}
/** Depth-first search for the first part matching a predicate. */
export function findPart(part: MimePart, want: (p: MimePart) => boolean): MimePart | undefined {
if (want(part)) return part;
for (const child of part.parts) {
const hit = findPart(child, want);
if (hit) return hit;
}
return undefined;
}
/**
* Canonical CRLF form, applied only to content about to be hashed.
*
* A lone LF becomes CRLF; an existing CRLF is left alone. Nothing else is
* touched -- no trailing-whitespace tidying, no re-wrapping -- because every
* other "helpful" change is one the signer did not make.
*/
export function toCanonicalCrlf(bytes: Uint8Array): Uint8Array {
let lone = 0;
for (let i = 0; i < bytes.length; i++) if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) lone++;
if (lone === 0) return bytes;
const out = new Uint8Array(bytes.length + lone);
let j = 0;
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) out[j++] = CR;
out[j++] = bytes[i]!;
}
return out;
}
/** Undo base64 or quoted-printable so a signature blob can be read as DER. */
export function decodeTransfer(part: MimePart): Uint8Array {
const encoding = (part.headers.get("content-transfer-encoding") ?? "").trim().toLowerCase();
if (encoding === "base64") {
const text = new TextDecoder().decode(part.body).replace(/[^A-Za-z0-9+/=]/g, "");
const binary = atob(text);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
if (encoding === "quoted-printable") {
const text = new TextDecoder().decode(part.body).replace(/=\r?\n/g, "");
const out: number[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === "=" && i + 2 < text.length) {
out.push(parseInt(text.slice(i + 1, i + 3), 16));
i += 2;
} else out.push(text.charCodeAt(i));
}
return new Uint8Array(out);
}
return part.body;
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Checking the signature on the message being read.
*
* Two things this is careful about, both about not doing work:
*
* - The verifier is imported dynamically. Signed mail is rare, and DER
* parsing plus certificate reading has no business in the bundle everybody
* downloads to read an unsigned message.
* - Nothing is fetched unless the message says it is signed. The structure
* already came with the message, so the common answer costs one string
* comparison and no network at all.
*/
import { useEffect, useState } from "react";
import { client } from "@/jmap/client";
import type { Email, EmailBodyPart, Id } from "@/jmap/types";
import { useSettings, type SignerPin } from "@/store/settings";
import type { SignatureReport } from "./verify";
/**
* How many signers are remembered before the oldest pin is dropped.
*
* A cap is needed because this rides in the account's settings file, which is
* fetched on every sign-in. Evicting is not free a dropped signer is greeted
* as new next time, which is a quieter message than it should be so the limit
* is set far above what S/MIME's actual prevalence will produce rather than at
* a number that trades safety for bytes.
*/
const MAX_PINS = 500;
export type SignatureState = { status: "idle" } | { status: "checking" } | { status: "done"; report: SignatureReport };
/** Whether anything in this message's structure claims to be signed. */
export function structureLooksSigned(part: EmailBodyPart | undefined): boolean {
if (!part) return false;
if (part.type === "multipart/signed") return true;
return (part.subParts ?? []).some(structureLooksSigned);
}
export function useSignature(email: Email | undefined, accountId: Id | null): SignatureState {
const [state, setState] = useState<SignatureState>({ status: "idle" });
useEffect(() => {
if (!email || !accountId || !structureLooksSigned(email.bodyStructure)) {
setState({ status: "idle" });
return;
}
let live = true;
setState({ status: "checking" });
void (async () => {
try {
const [{ judge, shouldRemember, verifyMessage }, blob] = await Promise.all([
import("./verify"),
client.fetchBlob(accountId, email.blobId, "message/rfc822"),
]);
if (!live) return;
const raw = new Uint8Array(await blob.arrayBuffer());
const from = (email.from?.[0]?.email ?? "").toLowerCase();
const crypto = await verifyMessage(raw);
const stored = useSettings.getState().settings.knownSigners[from];
// A pin this very message created is not corroboration of it. Treated
// as absent, so the message that established a signer keeps saying so
// however many times it is reopened.
const known = stored && stored.messageId === email.id ? undefined : stored;
const report = judge(crypto, from, known);
if (!live) return;
if (from && shouldRemember(report) && report.crypto.kind === "intact") {
pin(from, {
fingerprint: report.crypto.cert.fingerprint,
name: report.crypto.cert.subject.commonName || report.crypto.cert.subject.emailAddress || from,
firstSeen: new Date().toISOString(),
messageId: email.id,
});
}
setState({ status: "done", report });
} catch (err) {
if (!live) return;
// A failure to *look* is not a failure to verify, and must not be shown
// as one: a dropped connection is not a bad signature.
setState({ status: "done", report: { crypto: { kind: "unsupported", reason: "other", detail: (err as Error).message }, warnings: [] } });
}
})();
return () => {
live = false;
};
}, [email, accountId]);
return state;
}
function pin(address: string, entry: SignerPin): void {
const { settings, update } = useSettings.getState();
const next = { ...settings.knownSigners, [address]: entry };
const keys = Object.keys(next);
if (keys.length > MAX_PINS) {
const oldest = keys.sort((a, b) => (next[a]!.firstSeen < next[b]!.firstSeen ? -1 : 1)).slice(0, keys.length - MAX_PINS);
for (const k of oldest) delete next[k];
}
update({ knownSigners: next });
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Checking an S/MIME signature, and deciding what may honestly be said about it.
*
* Two questions are kept deliberately apart, because conflating them is how
* signature UI becomes a lie:
*
* 1. **Did this signature verify?** Pure arithmetic. Either the bytes hash to
* what the signature says they hash to, or they do not.
* 2. **Does that mean anything?** Much weaker. The certificate travels inside
* the message, so anyone can self-sign as anyone: on its own, a verified
* signature proves only that whoever wrote the message also held the key
* in the certificate attached to it.
*
* What makes the second question worth asking at all is remembering the answer.
* The first signed message from an address pins that certificate's fingerprint;
* later ones are compared against it. That is trust on first use, and it is a
* genuinely useful thing to tell somebody -- "the same signer as every time
* before", or, much more loudly, "this is not the signer you saw before" --
* without a certificate authority anywhere in the picture.
*
* So nothing here ever renders the bare word "verified". The caller is given
* the crypto result and the trust judgement separately, and has to say both.
*/
import { parseSignedData, type SignerInfo } from "./cms";
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
import { certCovers, parseCertificate, type Certificate } from "./x509";
/**
* Why a signature could not be checked, or did not hold.
*
* A code rather than a sentence, because the sentence has to be translated and
* this file is deliberately free of anything to do with the interface. Only
* `other` carries prose, and that prose is a parser's complaint about a
* malformed structure -- technical by nature, and shown as detail beside a
* translated headline rather than as the headline itself.
*/
export type Reason =
| "openpgp"
| "rsa-pss"
| "no-certificate"
| "not-signed-properly"
| "digest-mismatch"
| "signature-mismatch"
| "other";
/** What the signature itself established, before any question of trust. */
export type Crypto =
| { kind: "none" }
| { kind: "unsupported"; reason: Reason; detail?: string }
| { kind: "broken"; reason: Reason; detail?: string }
| { kind: "intact"; cert: Certificate; signer: SignerInfo };
/** What remembering previous signers adds to it. */
export type Trust = "first-seen" | "same-as-before" | "changed";
export type Warning = "address-mismatch" | "certificate-expired" | "certificate-not-yet-valid";
export interface KnownSigner {
fingerprint: string;
/** Who the certificate said it was, kept so a change can be described. */
name: string;
/** ISO date this fingerprint was first pinned. */
firstSeen: string;
}
export interface SignatureReport {
crypto: Crypto;
trust?: Trust;
previous?: KnownSigner;
warnings: Warning[];
}
const PKCS7_SIGNATURE = new Set(["application/pkcs7-signature", "application/x-pkcs7-signature"]);
/** Whether a raw message even claims to be signed — cheap, for deciding to look further. */
export function looksSigned(root: MimePart): boolean {
return Boolean(findPart(root, (p) => p.contentType === "multipart/signed"));
}
/**
* Verify the signature on a raw RFC822 message.
*
* Answers only the arithmetic question. Whether the certificate has anything to
* do with the sender is `judge`'s business, and keeping the two apart is what
* lets the interesting cases be tested without staging a message for each.
*/
export async function verifyMessage(raw: Uint8Array): Promise<Crypto> {
let root: MimePart;
try {
root = parseMime(raw);
} catch (err) {
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
}
const signedPart = findPart(root, (p) => p.contentType === "multipart/signed");
if (!signedPart) return { kind: "none" };
if (signedPart.parts.length < 2) return { kind: "unsupported", reason: "not-signed-properly" };
const [content, signature] = signedPart.parts as [MimePart, MimePart];
if (!PKCS7_SIGNATURE.has(signature.contentType)) {
// OpenPGP lands here, and says so rather than pretending not to understand.
if (signature.contentType === "application/pgp-signature") {
return { kind: "unsupported", reason: "openpgp" };
}
return { kind: "unsupported", reason: "other", detail: signature.contentType };
}
let signed;
try {
signed = parseSignedData(decodeTransfer(signature));
} catch (err) {
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
}
const signer = signed.signers[0]!;
if (signer.signature === "rsa-pss") {
// Refused rather than attempted. The salt length lives in parameters this
// does not read, and guessing it wrong fails a good signature -- which
// would be reported as "does not verify", a far worse thing to say than
// "cannot check".
return { kind: "unsupported", reason: "rsa-pss" };
}
// The signature covers the first part exactly as it arrived, headers and all,
// in canonical CRLF form.
const covered = toCanonicalCrlf(content.raw);
const digest = new Uint8Array(await crypto.subtle.digest(signer.digest, covered.slice().buffer as ArrayBuffer));
if (!sameBytes(digest, signer.messageDigest)) {
return { kind: "broken", reason: "digest-mismatch" };
}
const certs = await Promise.all(
signed.certificates.map(async (der) => {
try {
return await parseCertificate(der);
} catch {
return null;
}
}),
);
const usable = certs.filter((c): c is Certificate => c !== null);
if (usable.length === 0) return { kind: "unsupported", reason: "no-certificate" };
// Prefer the certificate the signer names, but fall back to trying each in
// turn: what settles it is which key the signature verifies under, and that
// is a stronger test than matching an issuer string.
const named = usable.find((c) => signer.issuerDer && sameBytes(c.issuerDer, signer.issuerDer) && c.serial === signer.serial);
for (const cert of named ? [named, ...usable.filter((c) => c !== named)] : usable) {
if (await signatureHolds(cert, signer)) return { kind: "intact", cert, signer };
}
return { kind: "broken", reason: "signature-mismatch" };
}
async function signatureHolds(cert: Certificate, signer: SignerInfo): Promise<boolean> {
try {
const spki = cert.spki.slice().buffer as ArrayBuffer;
const data = signer.signedAttrs.slice().buffer as ArrayBuffer;
if (cert.publicKey.kind === "rsa") {
const key = await crypto.subtle.importKey("spki", spki, { name: "RSASSA-PKCS1-v1_5", hash: signer.digest }, false, ["verify"]);
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signer.value.slice().buffer as ArrayBuffer, data);
}
const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: cert.publicKey.namedCurve }, false, ["verify"]);
const raw = ecdsaDerToRaw(signer.value, cert.publicKey.namedCurve);
if (!raw) return false;
return await crypto.subtle.verify({ name: "ECDSA", hash: signer.digest }, key, raw.slice().buffer as ArrayBuffer, data);
} catch {
return false;
}
}
/**
* ECDSA signatures arrive as a DER SEQUENCE of two INTEGERs; WebCrypto wants
* r and s as fixed-width bytes, concatenated. Getting the width from the curve
* rather than from the integers matters: a leading zero byte is stripped in
* DER, so r and s are frequently different lengths and neither is the answer.
*/
export function ecdsaDerToRaw(der: Uint8Array, curve: "P-256" | "P-384" | "P-521"): Uint8Array | null {
const size = curve === "P-256" ? 32 : curve === "P-384" ? 48 : 66;
try {
if (der[0] !== 0x30) return null;
let i = 2;
if (der[1]! > 0x80) i = 2 + (der[1]! & 0x7f);
const out = new Uint8Array(size * 2);
for (const slot of [0, 1]) {
if (der[i] !== 0x02) return null;
const len = der[i + 1]!;
let start = i + 2;
let n = len;
while (n > 0 && der[start] === 0x00) {
start++;
n--;
}
if (n > size) return null;
out.set(der.subarray(start, start + n), slot * size + (size - n));
i = i + 2 + len;
}
return out;
} catch {
return null;
}
}
/**
* Turn a crypto result plus what we remember into something sayable.
*
* Pure, and separate from both the network and the store, so the interesting
* cases -- a changed signer, a certificate for the wrong address -- are
* ordinary function calls to test rather than scenarios to stage.
*/
export function judge(crypto: Crypto, fromAddress: string, known: KnownSigner | undefined, now = new Date()): SignatureReport {
if (crypto.kind !== "intact") return { crypto, warnings: [] };
const warnings: Warning[] = [];
if (!certCovers(crypto.cert, fromAddress)) warnings.push("address-mismatch");
if (crypto.cert.notAfter < now) warnings.push("certificate-expired");
if (crypto.cert.notBefore > now) warnings.push("certificate-not-yet-valid");
const trust: Trust = !known ? "first-seen" : known.fingerprint === crypto.cert.fingerprint ? "same-as-before" : "changed";
return { crypto, trust, previous: trust === "changed" ? known : undefined, warnings };
}
/**
* Whether this result should be pinned as the signer for an address.
*
* Only a clean first sighting is remembered. Pinning a certificate that does
* not name the sender, or one already expired, would write the anomaly into the
* baseline and make every later message agree with it.
*/
export function shouldRemember(report: SignatureReport): boolean {
return report.crypto.kind === "intact" && report.trust === "first-seen" && report.warnings.length === 0;
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
return diff === 0;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* The parts of an X.509 certificate a signed message needs, and no more.
*
* Not a validator. Nothing here decides whether a certificate is trustworthy
* it reads what the certificate says about itself, and what it says is only
* ever as good as whoever issued it. The trust decision lives one level up, in
* `verify.ts`, and is deliberately a small and honest one.
*/
import { at, children, DerError, expect, hex, integerHex, oid, parse, TAG, text, time, type Asn1 } from "./der";
/** Relative distinguished-name attributes worth naming. */
const OID = {
commonName: "2.5.4.3",
emailAddress: "1.2.840.113549.1.9.1",
organization: "2.5.4.10",
subjectAltName: "2.5.29.17",
rsaEncryption: "1.2.840.113549.1.1.1",
ecPublicKey: "1.2.840.10045.2.1",
curveP256: "1.2.840.10045.3.1.7",
curveP384: "1.3.132.0.34",
curveP521: "1.3.132.0.35",
} as const;
export interface Certificate {
/** SHA-256 over the whole DER, lowercase hex. What TOFU remembers. */
fingerprint: string;
serial: string;
subject: { commonName?: string; organization?: string; emailAddress?: string };
issuer: { commonName?: string; organization?: string };
/** rfc822Name entries from the subjectAltName extension, plus the subject's emailAddress. */
emails: string[];
notBefore: Date;
notAfter: Date;
/** SubjectPublicKeyInfo, DER, ready for crypto.subtle.importKey("spki", …). */
spki: Uint8Array;
publicKey: { kind: "rsa" } | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" };
/** Whole DER, kept so a signer can be matched and a fingerprint recomputed. */
der: Uint8Array;
/** Issuer name and serial, the pair a SignerInfo usually identifies a certificate by. */
issuerDer: Uint8Array;
}
/** Read one certificate from its DER encoding. */
export async function parseCertificate(der: Uint8Array): Promise<Certificate> {
const cert = parse(der);
const top = children(expect(cert, TAG.sequence, "a Certificate"));
const tbs = children(expect(at(top, 0, "tbsCertificate"), TAG.sequence, "a tbsCertificate"));
// tbsCertificate ::= [0] version, serial, signature, issuer, validity,
// subject, subjectPublicKeyInfo, … — version is optional and explicit, so
// everything after it shifts by one when it is absent.
let i = 0;
if (tbs[0]?.cls === 2 && tbs[0].tag === 0) i = 1;
const serial = integerHex(at(tbs, i++, "serialNumber"));
i++; // signature AlgorithmIdentifier: the outer one, not used here
const issuerNode = at(tbs, i++, "issuer");
const validity = children(expect(at(tbs, i++, "validity"), TAG.sequence, "a validity"));
const subjectNode = at(tbs, i++, "subject");
const spkiNode = at(tbs, i++, "subjectPublicKeyInfo");
const notBefore = time(at(validity, 0, "notBefore"));
const notAfter = time(at(validity, 1, "notAfter"));
const subject = readName(subjectNode);
const issuer = readName(issuerNode);
const emails = new Set<string>();
if (subject.emailAddress) emails.add(subject.emailAddress.toLowerCase());
for (const e of subjectAltEmails(tbs.slice(i))) emails.add(e.toLowerCase());
const digest = await crypto.subtle.digest("SHA-256", der.slice().buffer as ArrayBuffer);
return {
fingerprint: hex(new Uint8Array(digest)),
serial,
subject,
issuer: { commonName: issuer.commonName, organization: issuer.organization },
emails: [...emails],
notBefore,
notAfter,
spki: spkiNode.bytes,
publicKey: readKeyKind(spkiNode),
der,
issuerDer: issuerNode.bytes,
};
}
/** A Name is a sequence of RDN sets; the last occurrence of an attribute wins. */
function readName(node: Asn1): { commonName?: string; organization?: string; emailAddress?: string } {
const out: { commonName?: string; organization?: string; emailAddress?: string } = {};
for (const rdn of children(node)) {
for (const attr of children(rdn)) {
const kv = children(attr);
if (kv.length < 2) continue;
const key = oid(at(kv, 0, "an attribute type"));
const value = text(at(kv, 1, "an attribute value"));
if (key === OID.commonName) out.commonName = value;
else if (key === OID.organization) out.organization = value;
else if (key === OID.emailAddress) out.emailAddress = value;
}
}
return out;
}
/**
* rfc822Name entries from subjectAltName.
*
* This is where a modern certificate puts the address; the subject's
* emailAddress attribute is the older place and is often absent. Reading only
* one of the two means failing to match the sender on half the certificates in
* circulation.
*/
function subjectAltEmails(rest: Asn1[]): string[] {
// Extensions are [3] EXPLICIT SEQUENCE OF Extension.
const ext = rest.find((n) => n.cls === 2 && n.tag === 3);
if (!ext) return [];
const seq = children(ext)[0];
if (!seq) return [];
for (const extension of children(seq)) {
const parts = children(extension);
if (parts.length < 2) continue;
if (oid(at(parts, 0, "an extension id")) !== OID.subjectAltName) continue;
// The value is an OCTET STRING wrapping the real structure. Critical flag
// may sit between the two, so take the last part rather than index 1.
const wrapper = parts[parts.length - 1]!;
try {
const names = children(parse(wrapper.content));
// GeneralName ::= CHOICE, and rfc822Name is [1] IMPLICIT IA5String.
return names.filter((n) => n.cls === 2 && n.tag === 1).map((n) => new TextDecoder().decode(n.content));
} catch {
return [];
}
}
return [];
}
function readKeyKind(spki: Asn1): Certificate["publicKey"] {
const parts = children(spki);
const alg = children(at(parts, 0, "an algorithm identifier"));
const algOid = oid(at(alg, 0, "an algorithm"));
if (algOid === OID.rsaEncryption) return { kind: "rsa" };
if (algOid === OID.ecPublicKey) {
const curve = alg[1] ? oid(alg[1]) : "";
if (curve === OID.curveP256) return { kind: "ec", namedCurve: "P-256" };
if (curve === OID.curveP384) return { kind: "ec", namedCurve: "P-384" };
if (curve === OID.curveP521) return { kind: "ec", namedCurve: "P-521" };
throw new DerError(`Unsupported elliptic curve ${curve}.`);
}
throw new DerError(`Unsupported public key algorithm ${algOid}.`);
}
/** Whether the certificate names this address, case-insensitively. */
export function certCovers(cert: Certificate, address: string): boolean {
const a = address.trim().toLowerCase();
return cert.emails.includes(a);
}
/** A short, readable name for the human holding the certificate. */
export function certDisplayName(cert: Certificate): string {
return cert.subject.commonName || cert.subject.emailAddress || cert.emails[0] || cert.subject.organization || cert.serial;
}
/** The fingerprint in the grouped form people actually compare by eye. */
export function formatFingerprint(fp: string): string {
return (fp.match(/.{2}/g) ?? [fp]).join(":").toUpperCase();
}
+118 -1
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Labels in der Seitenleiste anzeigen",
"Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern",
"Apply the theme to messages too": "Design auch auf Nachrichten anwenden",
"Apply it even to mail that styles itself": "Auch auf Mails anwenden, die sich selbst gestalten",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Fast jede Werbe- oder Beleg-Mail setzt irgendwo eine Farbe, deshalb lässt die Einstellung darüber nahezu alle davon auf einer weißen Karte. Mit dieser Option wird das Design über die Farben des Absenders gelegt: Hintergründe, auf denen die Nachricht liegt, entfallen, während Schaltflächen und farbige Banner erhalten bleiben, damit ihr Text lesbar bleibt. Manche Mail übersteht das nicht unbeschadet deshalb ist es eine eigene Einstellung.",
"Swiping": "Wischgesten",
"Swipe left": "Nach links wischen",
"Swipe right": "Nach rechts wischen",
@@ -1153,7 +1155,7 @@ export const catalog: Catalog = {
"Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.": "Zählt Personen statt Kopfzeilen: eine Adresse in An und neun in Cc ergeben eine Nachricht an zehn. Erfasst ein Allen-Antworten auf einen langen Thread.",
"Date received": "Empfangsdatum",
"Date sent": "Sendedatum",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine und Tokyo Night sind das Werk ihrer eigenen Projekte und werden unter der MIT-Lizenz verwendet; die Abstufungen zwischen ihren veröffentlichten Farben sind davon abgeleitet, und jede einzelne wird auf Kontrast geprüft. Die Akzentfarbe unten gilt weiterhin über jeder von ihnen.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Paletten, die nach einem anderen Projekt benannt sind, stammen von diesem Projekt und werden unter dessen eigener Lizenz verwendet; die Abstufungen zwischen den veröffentlichten Farben sind abgeleitet, und jede davon wird auf Kontrast geprüft. Die Akzentfarbe unten gilt weiterhin über jeder von ihnen.",
"Earlier": "Früher",
"Every folder": "Jeder Ordner",
"Everyone addressed will receive this.": "Alle Adressierten erhalten dies.",
@@ -1238,6 +1240,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Änderungen verwerfen?",
"Today, in your date format": "Heute, in Ihrem Datumsformat",
"Unread first": "Ungelesene zuerst",
"Read first": "Gelesene zuerst",
"Unstarred first": "Nicht markierte zuerst",
"Smallest first": "Kleinste zuerst",
"Z to A": "Z bis A",
"A to Z": "A bis Z",
"It reads {shown} but goes to {actual}.": "Angezeigt wird {shown}, geöffnet wird aber {actual}.",
"The full address is {href}.": "Die vollständige Adresse lautet {href}.",
"This message came from {domain}, which is outside your organisation.": "Diese Nachricht kam von {domain} und damit von außerhalb Ihrer Organisation.",
"Unsaved changes": "Nicht gespeicherte Änderungen",
"View as": "Anzeigen als",
"Warnings": "Warnungen",
@@ -1282,8 +1292,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Diese Nachricht wurde automatisch versendet, daher wird keine Lesebestätigung angeboten.",
"This server will not hold a message longer than {span}.": "Dieser Server hält eine Nachricht nicht länger als {span} zurück.",
"Upload failed": "Hochladen fehlgeschlagen",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Alle {n} {frequency}",
"Monthly": "Monatlich",
"Monthly on day {days}": "Monatlich am Tag {days}",
"Monthly on the {ordinal} {weekday}": "Monatlich am {ordinal} {weekday}",
"Monthly on {weekday}": "Monatlich am {weekday}",
"Weekly": "Wöchentlich",
"Weekly on {days}": "Wöchentlich am {days}",
"add {flag}": "{flag} hinzufügen",
"always": "immer",
"body contains \"{value}\"": "Text enthält \"{value}\"",
"body does not contain \"{value}\"": "Text enthält nicht \"{value}\"",
"delete it": "löschen",
"fifth": "fünften",
"first": "ersten",
"forward to {address}": "weiterleiten an {address}",
"fourth": "vierten",
"keep it": "behalten",
"last": "letzten",
"mark it read": "als gelesen markieren",
"move to {folder}": "verschieben nach {folder}",
"reject it": "abweisen",
"remove {flag}": "{flag} entfernen",
"second": "zweiten",
"size is over {n} KB": "Größe über {n} KB",
"size is under {n} KB": "Größe unter {n} KB",
"star it": "markieren",
"stop": "anhalten",
"third": "dritten",
"{header} address {op} \"{value}\"": "{header}-Adresse {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, bis {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Dies kann nicht rückgängig gemacht werden.",
"Some could not be deleted: {error}": "Einige konnten nicht gelöscht werden: {error}",
"It was not deleted": "Der Kontakt wurde nicht gelöscht",
"Empty address book": "Dieses Adressbuch leeren",
"There is nothing in it to delete": "Es ist nichts darin zum Löschen",
"Empty “{name}”?": "„{name}“ leeren?",
"Delete them": "Alle löschen",
"Nothing was deleted": "Es wurde nichts gelöscht",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Zertifikat gilt für",
"Details": "Details",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Frühere Nachrichten von dieser Adresse wurden von {previous} signiert. Diese ist von {current} signiert.",
"Fingerprint": "Fingerabdruck",
"Hide details": "Details ausblenden",
"Issued by": "Ausgestellt von",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Sie ist mit OpenPGP signiert, und ihasmail hat keine Möglichkeit, den öffentlichen Schlüssel des Absenders zu beschaffen.",
"It uses a signature algorithm ihasmail cannot check yet.": "Sie verwendet ein Signaturverfahren, das ihasmail noch nicht prüfen kann.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Sie wurde mit einem Zertifikat von {name} erstellt, das diese Adresse nicht abdeckt.",
"Previous fingerprint": "Vorheriger Fingerabdruck",
"Signed at": "Signiert am",
"Signed by {name} — the same signer as before.": "Signiert von {name} — derselbe Unterzeichner wie zuvor.",
"Signed by {name}, seen here for the first time.": "Signiert von {name}, hier zum ersten Mal gesehen.",
"Signer": "Unterzeichner",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Das kann ein erneuertes Zertifikat bedeuten, und es kann jemand anderes sein. Fragen Sie auf einem anderen Weg nach, bevor Sie dem vertrauen.",
"The certificate has expired.": "Das Zertifikat ist abgelaufen.",
"The certificate is not valid yet.": "Das Zertifikat ist noch nicht gültig.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Die Nachricht stimmt nicht mit dem Signierten überein — sie wurde nach dem Signieren verändert oder unterwegs beschädigt.",
"The signature carries no certificate that can be read.": "Die Signatur enthält kein lesbares Zertifikat.",
"The signature could not be read.": "Die Signatur konnte nicht gelesen werden.",
"The signature does not match the certificate sent with it.": "Die Signatur passt nicht zum mitgesendeten Zertifikat.",
"The signature is not for this sender.": "Die Signatur gehört nicht zu diesem Absender.",
"The signed part is missing either the message or the signature.": "Im signierten Teil fehlt entweder die Nachricht oder die Signatur.",
"The signer has changed.": "Der Unterzeichner hat gewechselt.",
"This message is signed, and ihasmail could not check the signature.": "Diese Nachricht ist signiert, und ihasmail konnte die Signatur nicht prüfen.",
"This signature does not check out.": "Diese Signatur stimmt nicht.",
"Valid until": "Gültig bis",
"a different certificate": "einem anderen Zertifikat",
"an unnamed signer": "einem unbenannten Unterzeichner",
"as claimed by the signer": "laut Angabe des Unterzeichners",
"first seen {date}": "zuerst gesehen {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
"no address": "keine Adresse",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" },
"Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" },
"Move {n} items": { one: "{n} Element verschieben", other: "{n} Elemente verschieben" },
"Move {n} items…": { one: "{n} Element verschieben…", other: "{n} Elemente verschieben…" },
"The event runs {n} days longer than this shows.": { one: "Der Termin dauert {n} Tag länger, als hier angezeigt wird.", other: "Der Termin dauert {n} Tage länger, als hier angezeigt wird." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} Gast ist nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen.", other: "{n} Gäste sind nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen." },
"{n} items selected": { one: "{n} Element ausgewählt", other: "{n} Elemente ausgewählt" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Jeden Tag", other: "Alle {n} Tage" },
"Every {n} months": { one: "Jeden Monat", other: "Alle {n} Monate" },
"Every {n} months on day {days}": { one: "Jeden Monat am Tag {days}", other: "Alle {n} Monate am Tag {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Jeden Monat am {ordinal} {weekday}", other: "Alle {n} Monate am {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Jeden Monat am {weekday}", other: "Alle {n} Monate am {weekday}" },
"Every {n} weeks": { one: "Jede Woche", other: "Alle {n} Wochen" },
"Every {n} weeks on {days}": { one: "Jede Woche am {days}", other: "Alle {n} Wochen am {days}" },
"Every {n} years": { one: "Jedes Jahr", other: "Alle {n} Jahre" },
"{rule}, {n} times": { one: "{rule}, {n}-mal", other: "{rule}, {n}-mal" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "{n} Nachricht in den Papierkorb verschieben?", other: "{n} Nachrichten in den Papierkorb verschieben?" },
"{n} days": { one: "{n} Tag", other: "{n} Tage" },
@@ -1315,5 +1427,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" },
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "{n} Kontakt löschen?", other: "{n} Kontakte löschen?" },
"Deleted {n} contacts": { one: "{n} Kontakt gelöscht", other: "{n} Kontakte gelöscht" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} Kontakt wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "{n} Kontakte werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
"{n} were also in other address books and were only removed from this one": { one: "{n} Kontakt war auch in einem anderen Adressbuch und wurde nur aus diesem entfernt", other: "{n} Kontakte waren auch in anderen Adressbüchern und wurden nur aus diesem entfernt" },
},
};
+118 -1
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Mostrar las etiquetas en la barra lateral",
"Collapse sidebar to icons": "Reducir la barra lateral a iconos",
"Apply the theme to messages too": "Aplicar el tema también a los mensajes",
"Apply it even to mail that styles itself": "Aplicarlo incluso al correo que se da estilo propio",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Casi todo el correo publicitario y de recibos define algún color, así que el ajuste anterior deja casi todo sobre una tarjeta blanca. Con esto activado, el tema se impone sobre los colores del remitente: se descartan los fondos sobre los que apoyó el mensaje, mientras que los botones y los banners de color se conservan para que su texto siga siendo legible. Algunos mensajes no sobrevivirán intactos, y por eso es un ajuste aparte.",
"Swiping": "Deslizamiento",
"Swipe left": "Deslizar a la izquierda",
"Swipe right": "Deslizar a la derecha",
@@ -1109,7 +1111,7 @@ export const catalog: Catalog = {
"Date received": "Fecha de recepción",
"Date sent": "Fecha de envío",
"Day view": "Vista de día",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine y Tokyo Night son obra de sus propios proyectos y se usan bajo la licencia MIT; los tonos intermedios entre sus colores publicados son derivados, y todos se comprueban en cuanto a contraste. El color de acento de abajo sigue aplicándose sobre cualquiera de ellos.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Las paletas que llevan el nombre de otro proyecto son obra de ese proyecto y se usan bajo su propia licencia; los tonos intermedios entre sus colores publicados son derivados, y cada uno se comprueba para el contraste. El color de acento de abajo sigue aplicándose sobre cualquiera de ellas.",
"Earlier": "Antes",
"Every folder": "Todas las carpetas",
"Everyone addressed will receive this.": "Todos los destinatarios lo recibirán.",
@@ -1210,6 +1212,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "¿Descartar los cambios?",
"Today, in your date format": "Hoy, en su formato de fecha",
"Unread first": "Los no leídos primero",
"Read first": "Los leídos primero",
"Unstarred first": "Los no destacados primero",
"Smallest first": "Los más pequeños primero",
"Z to A": "De la Z a la A",
"A to Z": "De la A a la Z",
"It reads {shown} but goes to {actual}.": "Dice {shown}, pero lleva a {actual}.",
"The full address is {href}.": "La dirección completa es {href}.",
"This message came from {domain}, which is outside your organisation.": "Este mensaje procede de {domain}, que está fuera de su organización.",
"Unsaved changes": "Cambios sin guardar",
"View as": "Ver como",
"Warnings": "Avisos",
@@ -1255,8 +1265,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Este mensaje se envió automáticamente, así que no se ofrece confirmación de lectura.",
"This server will not hold a message longer than {span}.": "Este servidor no retiene un mensaje más de {span}.",
"Upload failed": "Error al subir",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Cada {n} {frequency}",
"Monthly": "Mensualmente",
"Monthly on day {days}": "Mensualmente el día {days}",
"Monthly on the {ordinal} {weekday}": "Mensualmente el {ordinal} {weekday}",
"Monthly on {weekday}": "Mensualmente el {weekday}",
"Weekly": "Semanalmente",
"Weekly on {days}": "Semanalmente los {days}",
"add {flag}": "añadir {flag}",
"always": "siempre",
"body contains \"{value}\"": "el cuerpo contiene \"{value}\"",
"body does not contain \"{value}\"": "el cuerpo no contiene \"{value}\"",
"delete it": "eliminarlo",
"fifth": "quinto",
"first": "primer",
"forward to {address}": "reenviar a {address}",
"fourth": "cuarto",
"keep it": "conservarlo",
"last": "último",
"mark it read": "marcarlo como leído",
"move to {folder}": "mover a {folder}",
"reject it": "rechazarlo",
"remove {flag}": "quitar {flag}",
"second": "segundo",
"size is over {n} KB": "el tamaño supera {n} KB",
"size is under {n} KB": "el tamaño es inferior a {n} KB",
"star it": "destacarlo",
"stop": "detener",
"third": "tercer",
"{header} address {op} \"{value}\"": "la dirección de {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, hasta el {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Esto no se puede deshacer.",
"Some could not be deleted: {error}": "Algunos no se pudieron eliminar: {error}",
"It was not deleted": "No se ha eliminado",
"Empty address book": "Vaciar esta libreta de direcciones",
"There is nothing in it to delete": "No hay nada dentro que eliminar",
"Empty “{name}”?": "¿Vaciar «{name}»?",
"Delete them": "Eliminarlos",
"Nothing was deleted": "No se ha eliminado nada",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "El certificado cubre",
"Details": "Detalles",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Los mensajes anteriores de esta dirección los firmaba {previous}. Este lo firma {current}.",
"Fingerprint": "Huella digital",
"Hide details": "Ocultar detalles",
"Issued by": "Emitido por",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está firmado con OpenPGP, y ihasmail no tiene forma de obtener la clave pública del remitente.",
"It uses a signature algorithm ihasmail cannot check yet.": "Usa un algoritmo de firma que ihasmail todavía no puede comprobar.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Se hizo con un certificado de {name}, que no cubre esta dirección.",
"Previous fingerprint": "Huella digital anterior",
"Signed at": "Firmado el",
"Signed by {name} — the same signer as before.": "Firmado por {name}: el mismo firmante que antes.",
"Signed by {name}, seen here for the first time.": "Firmado por {name}, visto aquí por primera vez.",
"Signer": "Firmante",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Puede tratarse de un certificado renovado, y puede tratarse de otra persona. Compruébelo con ella por otra vía antes de confiar.",
"The certificate has expired.": "El certificado ha caducado.",
"The certificate is not valid yet.": "El certificado aún no es válido.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "El mensaje no coincide con lo que se firmó: se alteró después de firmarlo, o se dañó por el camino.",
"The signature carries no certificate that can be read.": "La firma no lleva ningún certificado que se pueda leer.",
"The signature could not be read.": "No se ha podido leer la firma.",
"The signature does not match the certificate sent with it.": "La firma no coincide con el certificado enviado con ella.",
"The signature is not for this sender.": "La firma no corresponde a este remitente.",
"The signed part is missing either the message or the signature.": "A la parte firmada le falta el mensaje o la firma.",
"The signer has changed.": "El firmante ha cambiado.",
"This message is signed, and ihasmail could not check the signature.": "Este mensaje está firmado, y ihasmail no ha podido comprobar la firma.",
"This signature does not check out.": "Esta firma no cuadra.",
"Valid until": "Válido hasta",
"a different certificate": "un certificado distinto",
"an unnamed signer": "un firmante sin nombre",
"as claimed by the signer": "según declara el firmante",
"first seen {date}": "visto por primera vez el {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
"no address": "ninguna dirección",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" },
"Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" },
"Move {n} items": { one: "Mover {n} elemento", other: "Mover {n} elementos" },
"Move {n} items…": { one: "Mover {n} elemento…", other: "Mover {n} elementos…" },
"The event runs {n} days longer than this shows.": { one: "El evento dura {n} día más de lo que se muestra aquí.", other: "El evento dura {n} días más de lo que se muestra aquí." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invitado no está en este servidor, así que no hay información de libre/ocupado para él.", other: "{n} invitados no están en este servidor, así que no hay información de libre/ocupado para ellos." },
"{n} items selected": { one: "{n} elemento seleccionado", other: "{n} elementos seleccionados" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Cada día", other: "Cada {n} días" },
"Every {n} months": { one: "Cada mes", other: "Cada {n} meses" },
"Every {n} months on day {days}": { one: "Cada mes el día {days}", other: "Cada {n} meses el día {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Cada mes el {ordinal} {weekday}", other: "Cada {n} meses el {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Cada mes el {weekday}", other: "Cada {n} meses el {weekday}" },
"Every {n} weeks": { one: "Cada semana", other: "Cada {n} semanas" },
"Every {n} weeks on {days}": { one: "Cada semana los {days}", other: "Cada {n} semanas los {days}" },
"Every {n} years": { one: "Cada año", other: "Cada {n} años" },
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} veces" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "¿Mover {n} mensaje a la Papelera?", other: "¿Mover {n} mensajes a la Papelera?" },
"{n} days": { one: "{n} día", other: "{n} días" },
@@ -1288,5 +1400,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" },
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "¿Eliminar {n} contacto?", other: "¿Eliminar {n} contactos?" },
"Deleted {n} contacts": { one: "Se ha eliminado {n} contacto", other: "Se han eliminado {n} contactos" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Se eliminará {n} contacto. Esto no se puede deshacer.", other: "Se eliminarán {n} contactos. Esto no se puede deshacer." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contacto también estaba en otra libreta de direcciones y solo se ha quitado de esta", other: "{n} contactos también estaban en otras libretas de direcciones y solo se han quitado de esta" },
},
};
+118 -1
View File
@@ -524,6 +524,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Afficher les libellés dans la barre latérale",
"Collapse sidebar to icons": "Réduire la barre latérale en icônes",
"Apply the theme to messages too": "Appliquer le thème aux messages",
"Apply it even to mail that styles itself": "L'appliquer même aux messages qui se mettent en forme",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Presque tous les courriers publicitaires et les reçus définissent une couleur quelque part, si bien que le réglage ci-dessus en laisse la quasi-totalité sur une carte blanche. Avec cette option, le thème est imposé par-dessus les couleurs de l'expéditeur : les fonds sur lesquels le message repose sont supprimés, tandis que les boutons et les bandeaux colorés sont conservés pour que leur texte reste lisible. Certains messages n'y survivront pas intacts, d'où un réglage distinct.",
"Swiping": "Balayage",
"Swipe left": "Balayer vers la gauche",
"Swipe right": "Balayer vers la droite",
@@ -1114,7 +1116,7 @@ export const catalog: Catalog = {
"Date received": "Date de réception",
"Date sent": "Date d'envoi",
"Day view": "Vue jour",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine et Tokyo Night sont l'œuvre de leurs propres projets et sont utilisés sous licence MIT ; les nuances entre leurs couleurs publiées en sont dérivées, et chacune est vérifiée pour le contraste. La couleur d'accent ci-dessous s'applique toujours par-dessus n'importe laquelle d'entre elles.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Les palettes portant le nom d'un autre projet sont l'œuvre de ce projet et sont utilisées sous sa propre licence ; les nuances intermédiaires entre leurs couleurs publiées sont dérivées, et chacune est vérifiée pour le contraste. La couleur d'accentuation ci-dessous s'applique toujours par-dessus n'importe laquelle d'entre elles.",
"Earlier": "Plus tôt",
"Every folder": "Tous les dossiers",
"Everyone addressed will receive this.": "Tous les destinataires le recevront.",
@@ -1215,6 +1217,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Abandonner vos modifications ?",
"Today, in your date format": "Aujourd'hui, dans votre format de date",
"Unread first": "Les non lus d'abord",
"Read first": "Les lus d'abord",
"Unstarred first": "Les non suivis d'abord",
"Smallest first": "Les moins volumineux d'abord",
"Z to A": "De Z à A",
"A to Z": "De A à Z",
"It reads {shown} but goes to {actual}.": "Il affiche {shown} mais mène à {actual}.",
"The full address is {href}.": "L'adresse complète est {href}.",
"This message came from {domain}, which is outside your organisation.": "Ce message provient de {domain}, qui est extérieur à votre organisation.",
"Unsaved changes": "Modifications non enregistrées",
"View as": "Afficher comme",
"Warnings": "Avertissements",
@@ -1260,8 +1270,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Ce message a été envoyé automatiquement, aucun accusé de lecture n'est donc proposé.",
"This server will not hold a message longer than {span}.": "Ce serveur ne retient pas un message plus de {span}.",
"Upload failed": "Échec de l'envoi",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Tous les {n} {frequency}",
"Monthly": "Chaque mois",
"Monthly on day {days}": "Chaque mois le {days}",
"Monthly on the {ordinal} {weekday}": "Chaque mois le {ordinal} {weekday}",
"Monthly on {weekday}": "Chaque mois le {weekday}",
"Weekly": "Chaque semaine",
"Weekly on {days}": "Chaque semaine le {days}",
"add {flag}": "ajouter {flag}",
"always": "toujours",
"body contains \"{value}\"": "le corps contient \"{value}\"",
"body does not contain \"{value}\"": "le corps ne contient pas \"{value}\"",
"delete it": "le supprimer",
"fifth": "cinquième",
"first": "premier",
"forward to {address}": "transférer à {address}",
"fourth": "quatrième",
"keep it": "le conserver",
"last": "dernier",
"mark it read": "le marquer comme lu",
"move to {folder}": "déplacer vers {folder}",
"reject it": "le rejeter",
"remove {flag}": "retirer {flag}",
"second": "deuxième",
"size is over {n} KB": "la taille dépasse {n} Ko",
"size is under {n} KB": "la taille est inférieure à {n} Ko",
"star it": "le suivre",
"stop": "arrêter",
"third": "troisième",
"{header} address {op} \"{value}\"": "l'adresse {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, jusqu'au {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Cette action est irréversible.",
"Some could not be deleted: {error}": "Certains nont pas pu être supprimés : {error}",
"It was not deleted": "Il na pas été supprimé",
"Empty address book": "Vider ce carnet dadresses",
"There is nothing in it to delete": "Il ny a rien à supprimer dedans",
"Empty “{name}”?": "Vider « {name} » ?",
"Delete them": "Les supprimer",
"Nothing was deleted": "Rien na été supprimé",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Le certificat couvre",
"Details": "Détails",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Les messages précédents de cette adresse étaient signés par {previous}. Celui-ci est signé par {current}.",
"Fingerprint": "Empreinte",
"Hide details": "Masquer les détails",
"Issued by": "Délivré par",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Il est signé avec OpenPGP, et ihasmail n'a aucun moyen de récupérer la clé publique de l'expéditeur.",
"It uses a signature algorithm ihasmail cannot check yet.": "Il utilise un algorithme de signature qu'ihasmail ne sait pas encore vérifier.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Elle a été faite avec un certificat appartenant à {name}, qui ne couvre pas cette adresse.",
"Previous fingerprint": "Empreinte précédente",
"Signed at": "Signé le",
"Signed by {name} — the same signer as before.": "Signé par {name} — le même signataire que précédemment.",
"Signed by {name}, seen here for the first time.": "Signé par {name}, vu ici pour la première fois.",
"Signer": "Signataire",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Cela peut signifier un certificat renouvelé, comme cela peut être quelqu'un d'autre. Vérifiez avec la personne par un autre moyen avant de faire confiance.",
"The certificate has expired.": "Le certificat a expiré.",
"The certificate is not valid yet.": "Le certificat n'est pas encore valide.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Le message ne correspond pas à ce qui a été signé : il a été modifié après signature, ou abîmé en chemin.",
"The signature carries no certificate that can be read.": "La signature ne contient aucun certificat lisible.",
"The signature could not be read.": "La signature n'a pas pu être lue.",
"The signature does not match the certificate sent with it.": "La signature ne correspond pas au certificat envoyé avec elle.",
"The signature is not for this sender.": "La signature ne correspond pas à cet expéditeur.",
"The signed part is missing either the message or the signature.": "Il manque à la partie signée soit le message, soit la signature.",
"The signer has changed.": "Le signataire a changé.",
"This message is signed, and ihasmail could not check the signature.": "Ce message est signé, et ihasmail n'a pas pu vérifier la signature.",
"This signature does not check out.": "Cette signature ne tient pas.",
"Valid until": "Valable jusqu'au",
"a different certificate": "un certificat différent",
"an unnamed signer": "un signataire sans nom",
"as claimed by the signer": "selon le signataire",
"first seen {date}": "vu pour la première fois le {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
"no address": "aucune adresse",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" },
"Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" },
"Move {n} items": { one: "Déplacer {n} élément", other: "Déplacer {n} éléments" },
"Move {n} items…": { one: "Déplacer {n} élément…", other: "Déplacer {n} éléments…" },
"The event runs {n} days longer than this shows.": { one: "L'événement dure {n} jour de plus que ce qui est affiché ici.", other: "L'événement dure {n} jours de plus que ce qui est affiché ici." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invité n'est pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour lui.", other: "{n} invités ne sont pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour eux." },
"{n} items selected": { one: "{n} élément sélectionné", other: "{n} éléments sélectionnés" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Chaque jour", other: "Tous les {n} jours" },
"Every {n} months": { one: "Chaque mois", other: "Tous les {n} mois" },
"Every {n} months on day {days}": { one: "Chaque mois le {days}", other: "Tous les {n} mois le {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Chaque mois le {ordinal} {weekday}", other: "Tous les {n} mois le {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Chaque mois le {weekday}", other: "Tous les {n} mois le {weekday}" },
"Every {n} weeks": { one: "Chaque semaine", other: "Toutes les {n} semaines" },
"Every {n} weeks on {days}": { one: "Chaque semaine le {days}", other: "Toutes les {n} semaines le {days}" },
"Every {n} years": { one: "Chaque année", other: "Tous les {n} ans" },
"{rule}, {n} times": { one: "{rule}, {n} fois", other: "{rule}, {n} fois" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Déplacer {n} message vers la Corbeille ?", other: "Déplacer {n} messages vers la Corbeille ?" },
"{n} days": { one: "{n} jour", other: "{n} jours" },
@@ -1293,5 +1405,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Supprimer {n} contact ?", other: "Supprimer {n} contacts ?" },
"Deleted {n} contacts": { one: "{n} contact supprimé", other: "{n} contacts supprimés" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact sera supprimé. Cette action est irréversible.", other: "{n} contacts seront supprimés. Cette action est irréversible." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contact se trouvait aussi dans un autre carnet dadresses et na été retiré que de celui-ci", other: "{n} contacts se trouvaient aussi dans dautres carnets dadresses et nont été retirés que de celui-ci" },
},
};
+118 -1
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "サイドバーにラベルを表示する",
"Collapse sidebar to icons": "サイドバーをアイコンだけにする",
"Apply the theme to messages too": "メールにもテーマを適用する",
"Apply it even to mail that styles itself": "自分で配色を持つメールにも適用する",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "宣伝メールや領収メールはほとんどがどこかで色を指定しているため、上の設定では大半が白いカードのままになります。これを有効にすると、送信者の配色の上からテーマを適用します。メッセージが載っている背景は取り除き、ボタンや色付きのバナーは文字が読めるようにそのまま残します。一部のメールは元の見た目を保てないため、別の設定として分けています。",
"Swiping": "スワイプ操作",
"Swipe left": "左へスワイプ",
"Swipe right": "右へスワイプ",
@@ -1117,7 +1119,7 @@ export const catalog: Catalog = {
"Date received": "受信日時",
"Date sent": "送信日時",
"Day view": "日表示",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula、Gruvbox、Rosé Pine、Tokyo Night はそれぞれのプロジェクトの成果物で、MIT ライセンスのもとで用しています。公開され色の中間の階調は派生させたもので、いずれもコントラストを確認しています。下のアクセントカラーはどの配色の上にも適用されます。",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "他のプロジェクトの名前が付いたパレットは、そのプロジェクトの成果物であり、そのプロジェクト自身のライセンスのもとで使用しています。公開されている色の中間の階調は派生させたもので、いずれもコントラストを検証しています。下のアクセントカラーはどのパレットの上にも適用されます。",
"Earlier": "これより前",
"Every folder": "すべてのフォルダー",
"Everyone addressed will receive this.": "宛先の全員がこれを受け取ります。",
@@ -1218,6 +1220,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "変更を破棄しますか?",
"Today, in your date format": "今日(お使いの日付形式)",
"Unread first": "未読を先頭に",
"Read first": "既読を先頭に",
"Unstarred first": "スターなしを先頭に",
"Smallest first": "サイズの小さい順",
"Z to A": "Z→A の順",
"A to Z": "A→Z の順",
"It reads {shown} but goes to {actual}.": "表示は {shown} ですが、実際のリンク先は {actual} です。",
"The full address is {href}.": "完全なアドレスは {href} です。",
"This message came from {domain}, which is outside your organisation.": "このメッセージは組織外の {domain} から届いています。",
"Unsaved changes": "保存されていない変更",
"View as": "表示形式",
"Warnings": "警告",
@@ -1263,8 +1273,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "このメールは自動送信されたため、開封確認は行いません。",
"This server will not hold a message longer than {span}.": "このサーバーはメールを {span} を超えて保留しません。",
"Upload failed": "アップロードに失敗しました",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "{n} {frequency} ごと",
"Monthly": "毎月",
"Monthly on day {days}": "毎月 {days} 日",
"Monthly on the {ordinal} {weekday}": "毎月第{ordinal} {weekday}",
"Monthly on {weekday}": "毎月 {weekday}",
"Weekly": "毎週",
"Weekly on {days}": "毎週 {days}",
"add {flag}": "{flag} を付ける",
"always": "常に",
"body contains \"{value}\"": "本文が \"{value}\" を含む",
"body does not contain \"{value}\"": "本文が \"{value}\" を含まない",
"delete it": "削除",
"fifth": "5",
"first": "1",
"forward to {address}": "{address} に転送",
"fourth": "4",
"keep it": "保持",
"last": "最終",
"mark it read": "既読にする",
"move to {folder}": "{folder} に移動",
"reject it": "拒否",
"remove {flag}": "{flag} を外す",
"second": "2",
"size is over {n} KB": "サイズが {n} KB を超える",
"size is under {n} KB": "サイズが {n} KB 未満",
"star it": "スターを付ける",
"stop": "停止",
"third": "3",
"{header} address {op} \"{value}\"": "{header} のアドレスが \"{value}\" を{op}",
"{header} {op} \"{value}\"": "{header} が \"{value}\" を{op}",
"{rule}, until {date}": "{rule}{date} まで)",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "この操作は取り消せません。",
"Some could not be deleted: {error}": "一部を削除できませんでした: {error}",
"It was not deleted": "削除されませんでした",
"Empty address book": "このアドレス帳を空にする",
"There is nothing in it to delete": "削除するものがありません",
"Empty “{name}”?": "「{name}」を空にしますか?",
"Delete them": "削除する",
"Nothing was deleted": "何も削除されませんでした",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "証明書の対象",
"Details": "詳細",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "このアドレスからの以前のメールは {previous} が署名していました。このメールの署名者は {current} です。",
"Fingerprint": "フィンガープリント",
"Hide details": "詳細を隠す",
"Issued by": "発行者",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "OpenPGP で署名されており、ihasmail には送信者の公開鍵を取得する手段がありません。",
"It uses a signature algorithm ihasmail cannot check yet.": "ihasmail がまだ検証できない署名アルゴリズムが使われています。",
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
"Previous fingerprint": "以前のフィンガープリント",
"Signed at": "署名日時",
"Signed by {name} — the same signer as before.": "{name} による署名です。以前と同じ署名者です。",
"Signed by {name}, seen here for the first time.": "{name} による署名です。ここで見るのは初めてです。",
"Signer": "署名者",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "証明書を更新しただけの場合もあれば、別人の場合もあります。信頼する前に、別の手段で本人に確認してください。",
"The certificate has expired.": "証明書の有効期限が切れています。",
"The certificate is not valid yet.": "証明書はまだ有効ではありません。",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "メールが署名された内容と一致しません。署名後に変更されたか、途中で壊れています。",
"The signature carries no certificate that can be read.": "署名に読み取れる証明書が含まれていません。",
"The signature could not be read.": "署名を読み取れませんでした。",
"The signature does not match the certificate sent with it.": "署名が、一緒に送られた証明書と一致しません。",
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
"The signer has changed.": "署名者が変わりました。",
"This message is signed, and ihasmail could not check the signature.": "このメールには署名がありますが、ihasmail は署名を検証できませんでした。",
"This signature does not check out.": "この署名は正しくありません。",
"Valid until": "有効期限",
"a different certificate": "別の証明書",
"an unnamed signer": "名前のない署名者",
"as claimed by the signer": "署名者の申告による",
"first seen {date}": "初回は {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、ihasmail がお知らせします。",
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
"no address": "アドレスなし",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "{n} 件を削除" },
"Delete {n} items?": { other: "{n} 件を削除しますか?" },
"Move {n} items": { other: "{n} 件を移動" },
"Move {n} items…": { other: "{n} 件を移動…" },
"The event runs {n} days longer than this shows.": { other: "この予定は表示よりも {n} 日長く続きます。" },
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "{n} 名の参加者はこのサーバーにいないため、空き情報を取得できません。" },
"{n} items selected": { other: "{n} 件を選択中" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { other: "{n} 日ごと" },
"Every {n} months": { other: "{n} か月ごと" },
"Every {n} months on day {days}": { other: "{n} か月ごと {days} 日" },
"Every {n} months on the {ordinal} {weekday}": { other: "{n} か月ごと第{ordinal} {weekday}" },
"Every {n} months on {weekday}": { other: "{n} か月ごと {weekday}" },
"Every {n} weeks": { other: "{n} 週ごと" },
"Every {n} weeks on {days}": { other: "{n} 週ごと {days}" },
"Every {n} years": { other: "{n} 年ごと" },
"{rule}, {n} times": { other: "{rule}{n} 回)" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { other: "{n} 通のメールをゴミ箱に移動しますか?" },
"{n} days": { other: "{n} 日" },
@@ -1301,5 +1413,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
"in {n} folders": { other: "{n} 個のフォルダーで" },
"Deleted {n} messages": { other: "{n} 通のメールを削除しました" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { other: "{n} 件の連絡先を削除しますか?" },
"Deleted {n} contacts": { other: "{n} 件の連絡先を削除しました" },
"{n} contacts will be deleted. This cannot be undone.": { other: "{n} 件の連絡先が削除されます。この操作は取り消せません。" },
"{n} were also in other address books and were only removed from this one": { other: "{n} 件は他のアドレス帳にもあるため、このアドレス帳から外しただけです" },
},
};
+118 -1
View File
@@ -515,6 +515,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Labels in de zijbalk tonen",
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen",
"Apply the theme to messages too": "Thema ook op berichten toepassen",
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mail met eigen vormgeving",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Bijna alle reclame- en bonmail zet ergens een kleur, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige berichten overleven dat niet ongeschonden, en daarom is dit een aparte instelling.",
"Swiping": "Vegen",
"Swipe left": "Naar links vegen",
"Swipe right": "Naar rechts vegen",
@@ -1105,7 +1107,7 @@ export const catalog: Catalog = {
"Date received": "Ontvangstdatum",
"Date sent": "Verzenddatum",
"Day view": "Dagweergave",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine en Tokyo Night zijn het werk van hun eigen projecten en worden gebruikt onder de MIT-licentie; de tinten tussen hun gepubliceerde kleuren zijn daarvan afgeleid, en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder geldt nog steeds over elk ervan.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Paletten die naar een ander project zijn genoemd, zijn het werk van dat project en worden gebruikt onder de eigen licentie daarvan; de tinten tussen de gepubliceerde kleuren zijn afgeleid en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder geldt nog steeds over elk ervan.",
"Earlier": "Eerder",
"Every folder": "Elke map",
"Everyone addressed will receive this.": "Iedereen die is geadresseerd ontvangt dit.",
@@ -1206,6 +1208,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Uw wijzigingen weggooien?",
"Today, in your date format": "Vandaag, in uw datumnotatie",
"Unread first": "Ongelezen eerst",
"Read first": "Gelezen eerst",
"Unstarred first": "Zonder ster eerst",
"Smallest first": "Kleinste eerst",
"Z to A": "Z tot A",
"A to Z": "A tot Z",
"It reads {shown} but goes to {actual}.": "Er staat {shown}, maar de link gaat naar {actual}.",
"The full address is {href}.": "Het volledige adres is {href}.",
"This message came from {domain}, which is outside your organisation.": "Dit bericht komt van {domain}, buiten uw organisatie.",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"View as": "Weergeven als",
"Warnings": "Waarschuwingen",
@@ -1251,8 +1261,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.",
"This server will not hold a message longer than {span}.": "Deze server houdt een bericht niet langer dan {span} vast.",
"Upload failed": "Uploaden mislukt",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Elke {n} {frequency}",
"Monthly": "Maandelijks",
"Monthly on day {days}": "Maandelijks op dag {days}",
"Monthly on the {ordinal} {weekday}": "Maandelijks op de {ordinal} {weekday}",
"Monthly on {weekday}": "Maandelijks op {weekday}",
"Weekly": "Wekelijks",
"Weekly on {days}": "Wekelijks op {days}",
"add {flag}": "{flag} toevoegen",
"always": "altijd",
"body contains \"{value}\"": "tekst bevat \"{value}\"",
"body does not contain \"{value}\"": "tekst bevat niet \"{value}\"",
"delete it": "verwijderen",
"fifth": "vijfde",
"first": "eerste",
"forward to {address}": "doorsturen naar {address}",
"fourth": "vierde",
"keep it": "behouden",
"last": "laatste",
"mark it read": "als gelezen markeren",
"move to {folder}": "verplaatsen naar {folder}",
"reject it": "weigeren",
"remove {flag}": "{flag} verwijderen",
"second": "tweede",
"size is over {n} KB": "grootte boven {n} KB",
"size is under {n} KB": "grootte onder {n} KB",
"star it": "een ster geven",
"stop": "stoppen",
"third": "derde",
"{header} address {op} \"{value}\"": "{header}-adres {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, tot {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Dit kan niet ongedaan worden gemaakt.",
"Some could not be deleted: {error}": "Sommige konden niet worden verwijderd: {error}",
"It was not deleted": "Het is niet verwijderd",
"Empty address book": "Dit adresboek leegmaken",
"There is nothing in it to delete": "Er staat niets in om te verwijderen",
"Empty “{name}”?": "„{name}” leegmaken?",
"Delete them": "Verwijderen",
"Nothing was deleted": "Er is niets verwijderd",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Certificaat geldt voor",
"Details": "Details",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Eerdere berichten van dit adres waren ondertekend door {previous}. Dit bericht is ondertekend door {current}.",
"Fingerprint": "Vingerafdruk",
"Hide details": "Details verbergen",
"Issued by": "Uitgegeven door",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.",
"It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Hij is gemaakt met een certificaat van {name}, dat dit adres niet dekt.",
"Previous fingerprint": "Vorige vingerafdruk",
"Signed at": "Ondertekend op",
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
"Signed by {name}, seen here for the first time.": "Ondertekend door {name}, hier voor het eerst gezien.",
"Signer": "Ondertekenaar",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Dat kan een vernieuwd certificaat betekenen, en het kan iemand anders zijn. Vraag het langs een andere weg na voordat u erop vertrouwt.",
"The certificate has expired.": "Het certificaat is verlopen.",
"The certificate is not valid yet.": "Het certificaat is nog niet geldig.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Het bericht komt niet overeen met wat er is ondertekend — het is na ondertekening gewijzigd, of onderweg beschadigd.",
"The signature carries no certificate that can be read.": "De handtekening bevat geen leesbaar certificaat.",
"The signature could not be read.": "De handtekening kon niet worden gelezen.",
"The signature does not match the certificate sent with it.": "De handtekening hoort niet bij het meegestuurde certificaat.",
"The signature is not for this sender.": "De handtekening is niet van deze afzender.",
"The signed part is missing either the message or the signature.": "In het ondertekende deel ontbreekt het bericht of de handtekening.",
"The signer has changed.": "De ondertekenaar is veranderd.",
"This message is signed, and ihasmail could not check the signature.": "Dit bericht is ondertekend, en ihasmail kon de handtekening niet controleren.",
"This signature does not check out.": "Deze handtekening klopt niet.",
"Valid until": "Geldig tot",
"a different certificate": "een ander certificaat",
"an unnamed signer": "een naamloze ondertekenaar",
"as claimed by the signer": "volgens de ondertekenaar",
"first seen {date}": "voor het eerst gezien op {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt",
"no address": "geen adres",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" },
"Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" },
"Move {n} items": { one: "{n} item verplaatsen", other: "{n} items verplaatsen" },
"Move {n} items…": { one: "{n} item verplaatsen…", other: "{n} items verplaatsen…" },
"The event runs {n} days longer than this shows.": { one: "De gebeurtenis duurt {n} dag langer dan hier wordt getoond.", other: "De gebeurtenis duurt {n} dagen langer dan hier wordt getoond." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} gast zit niet op deze server, dus er is geen vrij/bezet voor die persoon te lezen.", other: "{n} gasten zitten niet op deze server, dus er is geen vrij/bezet voor hen te lezen." },
"{n} items selected": { one: "{n} item geselecteerd", other: "{n} items geselecteerd" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Elke dag", other: "Elke {n} dagen" },
"Every {n} months": { one: "Elke maand", other: "Elke {n} maanden" },
"Every {n} months on day {days}": { one: "Elke maand op dag {days}", other: "Elke {n} maanden op dag {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Elke maand op de {ordinal} {weekday}", other: "Elke {n} maanden op de {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Elke maand op {weekday}", other: "Elke {n} maanden op {weekday}" },
"Every {n} weeks": { one: "Elke week", other: "Elke {n} weken" },
"Every {n} weeks on {days}": { one: "Elke week op {days}", other: "Elke {n} weken op {days}" },
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
"{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "{n} bericht naar de Prullenbak verplaatsen?", other: "{n} berichten naar de Prullenbak verplaatsen?" },
"{n} days": { one: "{n} dag", other: "{n} dagen" },
@@ -1284,5 +1396,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" },
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "{n} contact verwijderen?", other: "{n} contacten verwijderen?" },
"Deleted {n} contacts": { one: "{n} contact verwijderd", other: "{n} contacten verwijderd" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "{n} contacten worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contact stond ook in een ander adresboek en is alleen uit dit adresboek verwijderd", other: "{n} contacten stonden ook in andere adresboeken en zijn alleen uit dit adresboek verwijderd" },
},
};
+118 -1
View File
@@ -521,6 +521,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Mostrar os marcadores na barra lateral",
"Collapse sidebar to icons": "Recolher a barra lateral em ícones",
"Apply the theme to messages too": "Aplicar o tema também às mensagens",
"Apply it even to mail that styles itself": "Aplicar mesmo em mensagens com estilo próprio",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Quase toda mensagem de marketing ou de recibo define alguma cor, então a opção acima deixa quase todas em um cartão branco. Com isto ativado, o tema é imposto sobre as cores do remetente: os fundos sobre os quais a mensagem foi montada são descartados, enquanto botões e faixas coloridas são preservados para que o texto continue legível. Algumas mensagens não sobrevivem intactas, e por isso esta é uma opção separada.",
"Swiping": "Gestos de deslizar",
"Swipe left": "Deslizar para a esquerda",
"Swipe right": "Deslizar para a direita",
@@ -1112,7 +1114,7 @@ export const catalog: Catalog = {
"Date received": "Data de recebimento",
"Date sent": "Data de envio",
"Day view": "Visualização de dia",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine e Tokyo Night são obra dos seus próprios projetos e são usados sob a licença MIT; os tons entre as cores publicadas por eles são derivados, e cada um deles é verificado quanto ao contraste. A cor de destaque abaixo continua se aplicando sobre qualquer um deles.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "As paletas que levam o nome de outro projeto são obra desse projeto e são usadas sob a licença dele; os tons entre as cores publicadas são derivados, e cada um é verificado quanto ao contraste. A cor de destaque abaixo continua se aplicando sobre qualquer uma delas.",
"Earlier": "Antes",
"Every folder": "Todas as pastas",
"Everyone addressed will receive this.": "Todos os destinatários receberão isto.",
@@ -1213,6 +1215,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Descartar suas alterações?",
"Today, in your date format": "Hoje, no seu formato de data",
"Unread first": "Não lidas primeiro",
"Read first": "Lidas primeiro",
"Unstarred first": "Não favoritas primeiro",
"Smallest first": "Menores primeiro",
"Z to A": "De Z a A",
"A to Z": "De A a Z",
"It reads {shown} but goes to {actual}.": "Aparece como {shown}, mas leva para {actual}.",
"The full address is {href}.": "O endereço completo é {href}.",
"This message came from {domain}, which is outside your organisation.": "Esta mensagem veio de {domain}, que está fora da sua organização.",
"Unsaved changes": "Alterações não salvas",
"View as": "Exibir como",
"Warnings": "Avisos",
@@ -1258,8 +1268,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Esta mensagem foi enviada automaticamente, então não há confirmação de leitura a oferecer.",
"This server will not hold a message longer than {span}.": "Este servidor não retém uma mensagem por mais de {span}.",
"Upload failed": "Falha no envio",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "A cada {n} {frequency}",
"Monthly": "Mensalmente",
"Monthly on day {days}": "Mensalmente no dia {days}",
"Monthly on the {ordinal} {weekday}": "Mensalmente na {ordinal} {weekday}",
"Monthly on {weekday}": "Mensalmente em {weekday}",
"Weekly": "Semanalmente",
"Weekly on {days}": "Semanalmente em {days}",
"add {flag}": "adicionar {flag}",
"always": "sempre",
"body contains \"{value}\"": "o corpo contém \"{value}\"",
"body does not contain \"{value}\"": "o corpo não contém \"{value}\"",
"delete it": "excluir",
"fifth": "quinta",
"first": "primeira",
"forward to {address}": "encaminhar para {address}",
"fourth": "quarta",
"keep it": "manter",
"last": "última",
"mark it read": "marcar como lida",
"move to {folder}": "mover para {folder}",
"reject it": "rejeitar",
"remove {flag}": "remover {flag}",
"second": "segunda",
"size is over {n} KB": "o tamanho passa de {n} KB",
"size is under {n} KB": "o tamanho é menor que {n} KB",
"star it": "favoritar",
"stop": "parar",
"third": "terceira",
"{header} address {op} \"{value}\"": "o endereço de {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, até {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Isso não pode ser desfeito.",
"Some could not be deleted: {error}": "Alguns não puderam ser excluídos: {error}",
"It was not deleted": "Não foi excluído",
"Empty address book": "Esvaziar este catálogo de endereços",
"There is nothing in it to delete": "Não há nada nele para excluir",
"Empty “{name}”?": "Esvaziar “{name}”?",
"Delete them": "Excluir todos",
"Nothing was deleted": "Nada foi excluído",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "O certificado cobre",
"Details": "Detalhes",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "As mensagens anteriores deste endereço eram assinadas por {previous}. Esta é assinada por {current}.",
"Fingerprint": "Impressão digital",
"Hide details": "Ocultar detalhes",
"Issued by": "Emitido por",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está assinada com OpenPGP, e o ihasmail não tem como obter a chave pública do remetente.",
"It uses a signature algorithm ihasmail cannot check yet.": "Usa um algoritmo de assinatura que o ihasmail ainda não consegue conferir.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Foi feita com um certificado de {name}, que não cobre este endereço.",
"Previous fingerprint": "Impressão digital anterior",
"Signed at": "Assinado em",
"Signed by {name} — the same signer as before.": "Assinado por {name} — o mesmo signatário de antes.",
"Signed by {name}, seen here for the first time.": "Assinado por {name}, visto aqui pela primeira vez.",
"Signer": "Signatário",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Isso pode ser um certificado renovado, e pode ser outra pessoa. Confirme com ela por outro caminho antes de confiar.",
"The certificate has expired.": "O certificado expirou.",
"The certificate is not valid yet.": "O certificado ainda não é válido.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "A mensagem não corresponde ao que foi assinado — ela foi alterada depois da assinatura, ou danificada no caminho.",
"The signature carries no certificate that can be read.": "A assinatura não traz nenhum certificado que possa ser lido.",
"The signature could not be read.": "Não foi possível ler a assinatura.",
"The signature does not match the certificate sent with it.": "A assinatura não corresponde ao certificado enviado com ela.",
"The signature is not for this sender.": "A assinatura não é deste remetente.",
"The signed part is missing either the message or the signature.": "Falta à parte assinada ou a mensagem ou a assinatura.",
"The signer has changed.": "O signatário mudou.",
"This message is signed, and ihasmail could not check the signature.": "Esta mensagem está assinada, e o ihasmail não conseguiu conferir a assinatura.",
"This signature does not check out.": "Esta assinatura não confere.",
"Valid until": "Válido até",
"a different certificate": "um certificado diferente",
"an unnamed signer": "um signatário sem nome",
"as claimed by the signer": "conforme declarado pelo signatário",
"first seen {date}": "visto pela primeira vez em {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "O ihasmail avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
"no address": "nenhum endereço",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" },
"Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" },
"Move {n} items": { one: "Mover {n} item", other: "Mover {n} itens" },
"Move {n} items…": { one: "Mover {n} item…", other: "Mover {n} itens…" },
"The event runs {n} days longer than this shows.": { one: "O evento dura {n} dia a mais do que é exibido aqui.", other: "O evento dura {n} dias a mais do que é exibido aqui." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} convidado não está neste servidor, então não há livre/ocupado a consultar para ele.", other: "{n} convidados não estão neste servidor, então não há livre/ocupado a consultar para eles." },
"{n} items selected": { one: "{n} item selecionado", other: "{n} itens selecionados" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Todo dia", other: "A cada {n} dias" },
"Every {n} months": { one: "Todo mês", other: "A cada {n} meses" },
"Every {n} months on day {days}": { one: "Todo mês no dia {days}", other: "A cada {n} meses no dia {days}" },
"Every {n} months on the {ordinal} {weekday}": { one: "Todo mês na {ordinal} {weekday}", other: "A cada {n} meses na {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Todo mês em {weekday}", other: "A cada {n} meses em {weekday}" },
"Every {n} weeks": { one: "Toda semana", other: "A cada {n} semanas" },
"Every {n} weeks on {days}": { one: "Toda semana em {days}", other: "A cada {n} semanas em {days}" },
"Every {n} years": { one: "Todo ano", other: "A cada {n} anos" },
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} vezes" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Mover {n} mensagem para a Lixeira?", other: "Mover {n} mensagens para a Lixeira?" },
"{n} days": { one: "{n} dia", other: "{n} dias" },
@@ -1291,5 +1403,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" },
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Excluir {n} contato?", other: "Excluir {n} contatos?" },
"Deleted {n} contacts": { one: "{n} contato excluído", other: "{n} contatos excluídos" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contato será excluído. Isso não pode ser desfeito.", other: "{n} contatos serão excluídos. Isso não pode ser desfeito." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contato também estava em outro catálogo de endereços e foi removido apenas deste", other: "{n} contatos também estavam em outros catálogos de endereços e foram removidos apenas deste" },
},
};
+118 -1
View File
@@ -521,6 +521,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Показывать ярлыки на боковой панели",
"Collapse sidebar to icons": "Свернуть боковую панель до значков",
"Apply the theme to messages too": "Применять тему и к письмам",
"Apply it even to mail that styles itself": "Применять даже к письмам с собственным оформлением",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Почти в каждом рекламном письме и чеке где-нибудь задан цвет, поэтому настройка выше оставляет почти все такие письма на белой карточке. С этой настройкой тема накладывается поверх цветов отправителя: фон, на котором свёрстано письмо, убирается, а кнопки и цветные плашки сохраняются, чтобы текст на них оставался читаемым. Некоторые письма это не переживут без потерь — поэтому настройка отдельная.",
"Swiping": "Жесты смахивания",
"Swipe left": "Смахнуть влево",
"Swipe right": "Смахнуть вправо",
@@ -1111,7 +1113,7 @@ export const catalog: Catalog = {
"Date received": "Дата получения",
"Date sent": "Дата отправки",
"Day view": "День",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine и Tokyo Night созданы своими проектами и используются по лицензии MIT; оттенки между опубликованными цветами выведены из них, и каждый проверен на контраст. Акцентный цвет ниже по-прежнему применяется поверх любой из тем.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Палитры, названные в честь другого проекта, созданы этим проектом и используются по его собственной лицензии; оттенки между опубликованными цветами выводятся расчётом, и каждый из них проверяется на контраст. Акцентный цвет ниже по-прежнему применяется поверх любой из них.",
"Earlier": "Раньше",
"Every folder": "Все папки",
"Everyone addressed will receive this.": "Это получат все указанные адресаты.",
@@ -1212,6 +1214,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Отбросить изменения?",
"Today, in your date format": "Сегодня, в вашем формате даты",
"Unread first": "Сначала непрочитанные",
"Read first": "Сначала прочитанные",
"Unstarred first": "Сначала неотмеченные",
"Smallest first": "Сначала маленькие",
"Z to A": "От Я до А",
"A to Z": "От А до Я",
"It reads {shown} but goes to {actual}.": "Показано {shown}, но ссылка ведёт на {actual}.",
"The full address is {href}.": "Полный адрес: {href}.",
"This message came from {domain}, which is outside your organisation.": "Это письмо пришло с {domain} — за пределами вашей организации.",
"Unsaved changes": "Несохранённые изменения",
"View as": "Показывать как",
"Warnings": "Предупреждения",
@@ -1257,8 +1267,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Это письмо отправлено автоматически, поэтому уведомление о прочтении не предлагается.",
"This server will not hold a message longer than {span}.": "Этот сервер не удерживает письмо дольше чем {span}.",
"Upload failed": "Не удалось загрузить",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Каждые {n} {frequency}",
"Monthly": "Ежемесячно",
"Monthly on day {days}": "Ежемесячно {days}-го числа",
"Monthly on the {ordinal} {weekday}": "Ежемесячно в {ordinal} {weekday}",
"Monthly on {weekday}": "Ежемесячно в {weekday}",
"Weekly": "Еженедельно",
"Weekly on {days}": "Еженедельно в {days}",
"add {flag}": "добавить {flag}",
"always": "всегда",
"body contains \"{value}\"": "текст содержит \"{value}\"",
"body does not contain \"{value}\"": "текст не содержит \"{value}\"",
"delete it": "удалить",
"fifth": "пятый",
"first": "первый",
"forward to {address}": "переслать на {address}",
"fourth": "четвёртый",
"keep it": "оставить",
"last": "последний",
"mark it read": "пометить прочитанным",
"move to {folder}": "переместить в {folder}",
"reject it": "отклонить",
"remove {flag}": "убрать {flag}",
"second": "второй",
"size is over {n} KB": "размер больше {n} КБ",
"size is under {n} KB": "размер меньше {n} КБ",
"star it": "отметить",
"stop": "остановить",
"third": "третий",
"{header} address {op} \"{value}\"": "адрес {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, до {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Это действие нельзя отменить.",
"Some could not be deleted: {error}": "Некоторые не удалось удалить: {error}",
"It was not deleted": "Контакт не был удалён",
"Empty address book": "Очистить эту адресную книгу",
"There is nothing in it to delete": "В ней нечего удалять",
"Empty “{name}”?": "Очистить «{name}»?",
"Delete them": "Удалить их",
"Nothing was deleted": "Ничего не удалено",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Сертификат распространяется на",
"Details": "Подробности",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Прежние письма с этого адреса подписывал {previous}. Это подписано {current}.",
"Fingerprint": "Отпечаток",
"Hide details": "Скрыть подробности",
"Issued by": "Кем выдан",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а ihasmail не может получить открытый ключ отправителя.",
"It uses a signature algorithm ihasmail cannot check yet.": "Использован алгоритм подписи, который ihasmail пока не умеет проверять.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
"Previous fingerprint": "Прежний отпечаток",
"Signed at": "Подписано",
"Signed by {name} — the same signer as before.": "Подписано {name} — тот же подписавший, что и раньше.",
"Signed by {name}, seen here for the first time.": "Подписано {name}, встречается здесь впервые.",
"Signer": "Подписавший",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Это может быть обновлённый сертификат, а может быть и другой человек. Прежде чем доверять, свяжитесь с отправителем другим способом.",
"The certificate has expired.": "Срок действия сертификата истёк.",
"The certificate is not valid yet.": "Сертификат ещё не действителен.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Письмо не совпадает с тем, что было подписано — его изменили после подписания или повредили в пути.",
"The signature carries no certificate that can be read.": "В подписи нет сертификата, который удалось бы прочитать.",
"The signature could not be read.": "Подпись не удалось прочитать.",
"The signature does not match the certificate sent with it.": "Подпись не соответствует приложенному сертификату.",
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
"The signer has changed.": "Подписавший изменился.",
"This message is signed, and ihasmail could not check the signature.": "Это письмо подписано, и ihasmail не смог проверить подпись.",
"This signature does not check out.": "Эта подпись не сходится.",
"Valid until": "Действует до",
"a different certificate": "другим сертификатом",
"an unnamed signer": "неназванным подписавшим",
"as claimed by the signer": "по словам подписавшего",
"first seen {date}": "впервые замечен {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
"no address": "нет адреса",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" },
"Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" },
"Move {n} items": { one: "Переместить {n} объект", few: "Переместить {n} объекта", many: "Переместить {n} объектов", other: "Переместить {n} объекта" },
"Move {n} items…": { one: "Переместить {n} объект…", few: "Переместить {n} объекта…", many: "Переместить {n} объектов…", other: "Переместить {n} объекта…" },
"The event runs {n} days longer than this shows.": { one: "Событие длится на {n} день дольше, чем показано здесь.", few: "Событие длится на {n} дня дольше, чем показано здесь.", many: "Событие длится на {n} дней дольше, чем показано здесь.", other: "Событие длится на {n} дня дольше, чем показано здесь." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} участник не на этом сервере, поэтому сведений о занятости для него нет.", few: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет.", many: "{n} участников не на этом сервере, поэтому сведений о занятости для них нет.", other: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет." },
"{n} items selected": { one: "Выбран {n} объект", few: "Выбрано {n} объекта", many: "Выбрано {n} объектов", other: "Выбрано {n} объекта" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Каждый день", few: "Каждые {n} дня", many: "Каждые {n} дней", other: "Каждые {n} дня" },
"Every {n} months": { one: "Каждый месяц", few: "Каждые {n} месяца", many: "Каждые {n} месяцев", other: "Каждые {n} месяца" },
"Every {n} months on day {days}": { one: "Каждый месяц {days}-го числа", few: "Каждые {n} месяца {days}-го числа", many: "Каждые {n} месяцев {days}-го числа", other: "Каждые {n} месяца {days}-го числа" },
"Every {n} months on the {ordinal} {weekday}": { one: "Каждый месяц в {ordinal} {weekday}", few: "Каждые {n} месяца в {ordinal} {weekday}", many: "Каждые {n} месяцев в {ordinal} {weekday}", other: "Каждые {n} месяца в {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Каждый месяц в {weekday}", few: "Каждые {n} месяца в {weekday}", many: "Каждые {n} месяцев в {weekday}", other: "Каждые {n} месяца в {weekday}" },
"Every {n} weeks": { one: "Каждую неделю", few: "Каждые {n} недели", many: "Каждые {n} недель", other: "Каждые {n} недели" },
"Every {n} weeks on {days}": { one: "Каждую неделю в {days}", few: "Каждые {n} недели в {days}", many: "Каждые {n} недель в {days}", other: "Каждые {n} недели в {days}" },
"Every {n} years": { one: "Каждый год", few: "Каждые {n} года", many: "Каждые {n} лет", other: "Каждые {n} года" },
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} раза", many: "{rule}, {n} раз", other: "{rule}, {n} раза" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Переместить {n} письмо в корзину?", few: "Переместить {n} письма в корзину?", many: "Переместить {n} писем в корзину?", other: "Переместить {n} письма в корзину?" },
"{n} days": { one: "{n} день", few: "{n} дня", many: "{n} дней", other: "{n} дня" },
@@ -1298,5 +1410,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Удалить {n} контакт?", few: "Удалить {n} контакта?", many: "Удалить {n} контактов?", other: "Удалить {n} контакта?" },
"Deleted {n} contacts": { one: "Удалён {n} контакт", few: "Удалено {n} контакта", many: "Удалено {n} контактов", other: "Удалено {n} контакта" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Будет удалён {n} контакт. Это действие нельзя отменить.", few: "Будет удалено {n} контакта. Это действие нельзя отменить.", many: "Будет удалено {n} контактов. Это действие нельзя отменить.", other: "Будет удалено {n} контакта. Это действие нельзя отменить." },
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт также был в другой адресной книге и удалён только из этой", few: "{n} контакта также были в других адресных книгах и удалены только из этой", many: "{n} контактов также были в других адресных книгах и удалены только из этой", other: "{n} контакта также были в других адресных книгах и удалены только из этой" },
},
};
+118 -1
View File
@@ -515,6 +515,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Показувати мітки на бічній панелі",
"Collapse sidebar to icons": "Згорнути бічну панель до значків",
"Apply the theme to messages too": "Застосовувати тему й до листів",
"Apply it even to mail that styles itself": "Застосовувати навіть до листів із власним оформленням",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Майже в кожному рекламному листі та чеку десь задано колір, тому налаштування вище залишає майже всі такі листи на білій картці. Із цим налаштуванням тема накладається поверх кольорів відправника: тло, на якому зверстано лист, прибирається, а кнопки та кольорові плашки зберігаються, щоб текст на них залишався читабельним. Деякі листи цього не переживуть без втрат — тому це окреме налаштування.",
"Swiping": "Жести проведення",
"Swipe left": "Провести ліворуч",
"Swipe right": "Провести праворуч",
@@ -1105,7 +1107,7 @@ export const catalog: Catalog = {
"Date received": "Дата отримання",
"Date sent": "Дата надсилання",
"Day view": "День",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine і Tokyo Night створені власними проєктами й використовуються за ліцензією MIT; відтінки між опублікованими кольорами виведені з них, і кожен перевірено на контраст. Акцентний колір нижче й далі застосовується поверх будь-якої з тем.",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Палітри, названі на честь іншого проєкту, є роботою цього проєкту й використовуються за його власною ліцензією; відтінки між опублікованими кольорами обчислюються, і кожен із них перевіряється на контраст. Акцентний колір нижче й надалі застосовується поверх будь-якої з них.",
"Earlier": "Раніше",
"Every folder": "Усі теки",
"Everyone addressed will receive this.": "Це отримають усі зазначені адресати.",
@@ -1206,6 +1208,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "Відкинути зміни?",
"Today, in your date format": "Сьогодні, у вашому форматі дати",
"Unread first": "Спочатку непрочитані",
"Read first": "Спочатку прочитані",
"Unstarred first": "Спочатку непозначені",
"Smallest first": "Спочатку малі",
"Z to A": "Від Я до А",
"A to Z": "Від А до Я",
"It reads {shown} but goes to {actual}.": "Показано {shown}, але посилання веде на {actual}.",
"The full address is {href}.": "Повна адреса: {href}.",
"This message came from {domain}, which is outside your organisation.": "Цей лист надійшов з {domain} — за межами вашої організації.",
"Unsaved changes": "Незбережені зміни",
"View as": "Показувати як",
"Warnings": "Попередження",
@@ -1251,8 +1261,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "Цей лист надіслано автоматично, тому сповіщення про прочитання не пропонується.",
"This server will not hold a message longer than {span}.": "Цей сервер не утримує лист довше ніж {span}.",
"Upload failed": "Не вдалося завантажити",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "Кожні {n} {frequency}",
"Monthly": "Щомісяця",
"Monthly on day {days}": "Щомісяця {days}-го числа",
"Monthly on the {ordinal} {weekday}": "Щомісяця у {ordinal} {weekday}",
"Monthly on {weekday}": "Щомісяця у {weekday}",
"Weekly": "Щотижня",
"Weekly on {days}": "Щотижня у {days}",
"add {flag}": "додати {flag}",
"always": "завжди",
"body contains \"{value}\"": "текст містить \"{value}\"",
"body does not contain \"{value}\"": "текст не містить \"{value}\"",
"delete it": "видалити",
"fifth": "п’ятий",
"first": "перший",
"forward to {address}": "переслати на {address}",
"fourth": "четвертий",
"keep it": "залишити",
"last": "останній",
"mark it read": "позначити прочитаним",
"move to {folder}": "перемістити до {folder}",
"reject it": "відхилити",
"remove {flag}": "прибрати {flag}",
"second": "другий",
"size is over {n} KB": "розмір більший за {n} КБ",
"size is under {n} KB": "розмір менший за {n} КБ",
"star it": "позначити",
"stop": "зупинити",
"third": "третій",
"{header} address {op} \"{value}\"": "адреса {header} {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule}, до {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Цю дію не можна скасувати.",
"Some could not be deleted: {error}": "Деякі не вдалося видалити: {error}",
"It was not deleted": "Контакт не було видалено",
"Empty address book": "Очистити цю адресну книгу",
"There is nothing in it to delete": "У ній немає чого видаляти",
"Empty “{name}”?": "Очистити «{name}»?",
"Delete them": "Видалити їх",
"Nothing was deleted": "Нічого не видалено",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Сертифікат поширюється на",
"Details": "Подробиці",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Попередні листи з цієї адреси підписував {previous}. Цей підписаний {current}.",
"Fingerprint": "Відбиток",
"Hide details": "Сховати подробиці",
"Issued by": "Ким видано",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а ihasmail не може отримати відкритий ключ відправника.",
"It uses a signature algorithm ihasmail cannot check yet.": "Використано алгоритм підпису, який ihasmail поки не вміє перевіряти.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
"Previous fingerprint": "Попередній відбиток",
"Signed at": "Підписано",
"Signed by {name} — the same signer as before.": "Підписано {name} — той самий підписувач, що й раніше.",
"Signed by {name}, seen here for the first time.": "Підписано {name}, трапляється тут уперше.",
"Signer": "Підписувач",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Це може бути оновлений сертифікат, а може бути й інша людина. Перш ніж довіряти, зв'яжіться з відправником іншим шляхом.",
"The certificate has expired.": "Термін дії сертифіката минув.",
"The certificate is not valid yet.": "Сертифікат ще не чинний.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Лист не збігається з тим, що було підписано — його змінили після підписання або пошкодили в дорозі.",
"The signature carries no certificate that can be read.": "У підписі немає сертифіката, який вдалося б прочитати.",
"The signature could not be read.": "Підпис не вдалося прочитати.",
"The signature does not match the certificate sent with it.": "Підпис не відповідає надісланому з ним сертифікату.",
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
"The signer has changed.": "Підписувач змінився.",
"This message is signed, and ihasmail could not check the signature.": "Цей лист підписано, і ihasmail не зміг перевірити підпис.",
"This signature does not check out.": "Цей підпис не сходиться.",
"Valid until": "Чинний до",
"a different certificate": "іншим сертифікатом",
"an unnamed signer": "неназваним підписувачем",
"as claimed by the signer": "за словами підписувача",
"first seen {date}": "уперше побачено {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
"no address": "немає адреси",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" },
"Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" },
"Move {n} items": { one: "Перемістити {n} об’єкт", few: "Перемістити {n} об’єкти", many: "Перемістити {n} об’єктів", other: "Перемістити {n} об’єкта" },
"Move {n} items…": { one: "Перемістити {n} об’єкт…", few: "Перемістити {n} об’єкти…", many: "Перемістити {n} об’єктів…", other: "Перемістити {n} об’єкта…" },
"The event runs {n} days longer than this shows.": { one: "Подія триває на {n} день довше, ніж показано тут.", few: "Подія триває на {n} дні довше, ніж показано тут.", many: "Подія триває на {n} днів довше, ніж показано тут.", other: "Подія триває на {n} дня довше, ніж показано тут." },
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} гість не на цьому сервері, тому відомостей про зайнятість для нього немає.", few: "{n} гості не на цьому сервері, тому відомостей про зайнятість для них немає.", many: "{n} гостей не на цьому сервері, тому відомостей про зайнятість для них немає.", other: "{n} гостя не на цьому сервері, тому відомостей про зайнятість для них немає." },
"{n} items selected": { one: "Вибрано {n} об’єкт", few: "Вибрано {n} об’єкти", many: "Вибрано {n} об’єктів", other: "Вибрано {n} об’єкта" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { one: "Щодня", few: "Кожні {n} дні", many: "Кожні {n} днів", other: "Кожні {n} дня" },
"Every {n} months": { one: "Щомісяця", few: "Кожні {n} місяці", many: "Кожні {n} місяців", other: "Кожні {n} місяця" },
"Every {n} months on day {days}": { one: "Щомісяця {days}-го числа", few: "Кожні {n} місяці {days}-го числа", many: "Кожні {n} місяців {days}-го числа", other: "Кожні {n} місяця {days}-го числа" },
"Every {n} months on the {ordinal} {weekday}": { one: "Щомісяця у {ordinal} {weekday}", few: "Кожні {n} місяці у {ordinal} {weekday}", many: "Кожні {n} місяців у {ordinal} {weekday}", other: "Кожні {n} місяця у {ordinal} {weekday}" },
"Every {n} months on {weekday}": { one: "Щомісяця у {weekday}", few: "Кожні {n} місяці у {weekday}", many: "Кожні {n} місяців у {weekday}", other: "Кожні {n} місяця у {weekday}" },
"Every {n} weeks": { one: "Щотижня", few: "Кожні {n} тижні", many: "Кожні {n} тижнів", other: "Кожні {n} тижня" },
"Every {n} weeks on {days}": { one: "Щотижня у {days}", few: "Кожні {n} тижні у {days}", many: "Кожні {n} тижнів у {days}", other: "Кожні {n} тижня у {days}" },
"Every {n} years": { one: "Щороку", few: "Кожні {n} роки", many: "Кожні {n} років", other: "Кожні {n} року" },
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} рази", many: "{rule}, {n} разів", other: "{rule}, {n} раза" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "Перемістити {n} лист до кошика?", few: "Перемістити {n} листи до кошика?", many: "Перемістити {n} листів до кошика?", other: "Перемістити {n} листа до кошика?" },
"{n} days": { one: "{n} день", few: "{n} дні", many: "{n} днів", other: "{n} дня" },
@@ -1290,5 +1402,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
"in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" },
"Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Видалити {n} контакт?", few: "Видалити {n} контакти?", many: "Видалити {n} контактів?", other: "Видалити {n} контакта?" },
"Deleted {n} contacts": { one: "Видалено {n} контакт", few: "Видалено {n} контакти", many: "Видалено {n} контактів", other: "Видалено {n} контакта" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Буде видалено {n} контакт. Цю дію не можна скасувати.", few: "Буде видалено {n} контакти. Цю дію не можна скасувати.", many: "Буде видалено {n} контактів. Цю дію не можна скасувати.", other: "Буде видалено {n} контакта. Цю дію не можна скасувати." },
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт також був в іншій адресній книзі й вилучений лише з цієї", few: "{n} контакти також були в інших адресних книгах і вилучені лише з цієї", many: "{n} контактів також були в інших адресних книгах і вилучені лише з цієї", other: "{n} контакта також були в інших адресних книгах і вилучені лише з цієї" },
},
};
+118 -1
View File
@@ -517,6 +517,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "在侧边栏中显示标签",
"Collapse sidebar to icons": "将侧边栏收起为图标",
"Apply the theme to messages too": "邮件也应用主题",
"Apply it even to mail that styles itself": "即使邮件自带配色也套用",
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "几乎所有营销邮件和收据邮件都会在某处设置颜色,因此上面的设置会让它们几乎全部停留在白色卡片上。启用此项后,主题会覆盖发件人的配色:邮件所依托的背景会被去掉,而按钮和彩色横幅会保留下来,使其文字仍然清晰可读。有些邮件无法完好呈现,因此这是一项单独的设置。",
"Swiping": "滑动手势",
"Swipe left": "向左滑动",
"Swipe right": "向右滑动",
@@ -1116,7 +1118,7 @@ export const catalog: Catalog = {
"Date received": "接收日期",
"Date sent": "发送日期",
"Day view": "日视图",
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula、Gruvbox、Rosé Pine 和 Tokyo Night 均由各自的项目创作,依 MIT 许可证使用;其公布配色之间的过渡色为衍生所得,且每一种都经过对比度检查。下方的强调色仍会应用于其中任一种之上。",
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "以其他项目命名的配色方案是该项目的作品,按其自身的许可证使用;已发布颜色之间的过渡色是推导得出的,并且每一种都经过对比度检查。下方的强调色仍会应用于其中任一种配色方案。",
"Earlier": "更早",
"Every folder": "所有文件夹",
"Everyone addressed will receive this.": "所有收件人都会收到此邮件。",
@@ -1217,6 +1219,14 @@ export const catalog: Catalog = {
"Throw away your changes?": "放弃您的更改吗?",
"Today, in your date format": "今天,按您的日期格式",
"Unread first": "未读在前",
"Read first": "已读在前",
"Unstarred first": "未标星在前",
"Smallest first": "从小到大",
"Z to A": "Z 到 A",
"A to Z": "A 到 Z",
"It reads {shown} but goes to {actual}.": "显示的是 {shown},实际打开的是 {actual}。",
"The full address is {href}.": "完整地址为 {href}。",
"This message came from {domain}, which is outside your organisation.": "此邮件来自 {domain},属于贵组织之外。",
"Unsaved changes": "未保存的更改",
"View as": "查看方式",
"Warnings": "警告",
@@ -1262,8 +1272,110 @@ export const catalog: Catalog = {
"This message was sent automatically, so no read receipt is offered.": "此邮件为自动发送,因此不提供已读回执。",
"This server will not hold a message longer than {span}.": "此服务器保留邮件的时间不会超过 {span}。",
"Upload failed": "上传失败",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
"Every {n} {frequency}": "每 {n} {frequency}",
"Monthly": "每月",
"Monthly on day {days}": "每月 {days} 日",
"Monthly on the {ordinal} {weekday}": "每月第{ordinal}个{weekday}",
"Monthly on {weekday}": "每月 {weekday}",
"Weekly": "每周",
"Weekly on {days}": "每周 {days}",
"add {flag}": "添加 {flag}",
"always": "始终",
"body contains \"{value}\"": "正文包含 \"{value}\"",
"body does not contain \"{value}\"": "正文不包含 \"{value}\"",
"delete it": "删除",
"fifth": "五",
"first": "一",
"forward to {address}": "转发到 {address}",
"fourth": "四",
"keep it": "保留",
"last": "最后一",
"mark it read": "标记为已读",
"move to {folder}": "移动到 {folder}",
"reject it": "拒收",
"remove {flag}": "移除 {flag}",
"second": "二",
"size is over {n} KB": "大小超过 {n} KB",
"size is under {n} KB": "大小小于 {n} KB",
"star it": "加星标",
"stop": "停止",
"third": "三",
"{header} address {op} \"{value}\"": "{header} 地址 {op} \"{value}\"",
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
"{rule}, until {date}": "{rule},直到 {date}",
"{tests} → {actions}": "{tests} → {actions}",
// ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "此操作无法撤销。",
"Some could not be deleted: {error}": "部分无法删除:{error}",
"It was not deleted": "未被删除",
"Empty address book": "清空此通讯录",
"There is nothing in it to delete": "其中没有可删除的内容",
"Empty “{name}”?": "清空“{name}”?",
"Delete them": "删除",
"Nothing was deleted": "未删除任何内容",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "证书适用于",
"Details": "详情",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "此地址以前的邮件由 {previous} 签名,而这封由 {current} 签名。",
"Fingerprint": "指纹",
"Hide details": "隐藏详情",
"Issued by": "颁发者",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "该邮件使用 OpenPGP 签名,而 ihasmail 无法获取发件人的公钥。",
"It uses a signature algorithm ihasmail cannot check yet.": "它使用了 ihasmail 尚不能校验的签名算法。",
"It was made with a certificate belonging to {name}, which does not cover this address.": "签名使用的是 {name} 的证书,该证书并不包含此地址。",
"Previous fingerprint": "以前的指纹",
"Signed at": "签名时间",
"Signed by {name} — the same signer as before.": "由 {name} 签名——与此前是同一签名者。",
"Signed by {name}, seen here for the first time.": "由 {name} 签名,这是首次在此见到。",
"Signer": "签名者",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "这可能只是更换了证书,也可能是另一个人。信任之前,请通过其他途径向对方核实。",
"The certificate has expired.": "证书已过期。",
"The certificate is not valid yet.": "证书尚未生效。",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "邮件与所签名的内容不一致——它在签名后被改动,或在传输中损坏。",
"The signature carries no certificate that can be read.": "签名中没有可读取的证书。",
"The signature could not be read.": "无法读取签名。",
"The signature does not match the certificate sent with it.": "签名与随附的证书不匹配。",
"The signature is not for this sender.": "该签名不属于此发件人。",
"The signed part is missing either the message or the signature.": "签名部分缺少邮件正文或签名。",
"The signer has changed.": "签名者已更换。",
"This message is signed, and ihasmail could not check the signature.": "此邮件带有签名,但 ihasmail 无法校验该签名。",
"This signature does not check out.": "此签名不成立。",
"Valid until": "有效期至",
"a different certificate": "另一份证书",
"an unnamed signer": "未具名的签名者",
"as claimed by the signer": "据签名者声称",
"first seen {date}": "首次见于 {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,ihasmail 会提醒您。",
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
"no address": "无地址",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "删除 {n} 个项目" },
"Delete {n} items?": { other: "要删除 {n} 个项目吗?" },
"Move {n} items": { other: "移动 {n} 个项目" },
"Move {n} items…": { other: "移动 {n} 个项目…" },
"The event runs {n} days longer than this shows.": { other: "此活动比这里显示的时间长 {n} 天。" },
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "有 {n} 位与会者不在此服务器上,因此无法读取他们的空闲/忙碌信息。" },
"{n} items selected": { other: "已选择 {n} 个项目" },
// ── Third pass ─────────────────────────────────────────────────────
"Every {n} days": { other: "每 {n} 天" },
"Every {n} months": { other: "每 {n} 个月" },
"Every {n} months on day {days}": { other: "每 {n} 个月的 {days} 日" },
"Every {n} months on the {ordinal} {weekday}": { other: "每 {n} 个月的第{ordinal}个{weekday}" },
"Every {n} months on {weekday}": { other: "每 {n} 个月的 {weekday}" },
"Every {n} weeks": { other: "每 {n} 周" },
"Every {n} weeks on {days}": { other: "每 {n} 周的 {days}" },
"Every {n} years": { other: "每 {n} 年" },
"{rule}, {n} times": { other: "{rule},共 {n} 次" },
// ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { other: "要将 {n} 封邮件移到已删除邮件吗?" },
"{n} days": { other: "{n} 天" },
@@ -1300,5 +1412,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
"in {n} folders": { other: "在 {n} 个文件夹中" },
"Deleted {n} messages": { other: "已删除 {n} 封邮件" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { other: "删除 {n} 位联系人?" },
"Deleted {n} contacts": { other: "已删除 {n} 位联系人" },
"{n} contacts will be deleted. This cannot be undone.": { other: "将删除 {n} 位联系人。此操作无法撤销。" },
"{n} were also in other address books and were only removed from this one": { other: "其中 {n} 位也在其他通讯录中,仅从此通讯录移除" },
},
};
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useContacts } from "@/store/contacts";
import type { ContactCard, Id, JmapSession } from "@/jmap/types";
/*
* Emptying an address book, and deleting a selection of contacts.
*
* Asked for on #174 as the other half of a migration: import, notice something
* is wrong, empty the book, correct the export, import again. Until now the
* only way to delete a contact was one card at a time from its own pane, and
* the only way to empty a book was to delete the book and build it again --
* losing its name, its sharing and its default status (#277).
*
* The part worth testing hardest is the one that is not a deletion. A card
* filed in two books belongs to both, and `ContactCard/set destroy` takes it
* away from both at once. Emptying one book must not empty another.
*/
const MAX = 500;
interface SetArgs { update?: Record<string, Record<string, unknown>>; destroy?: Id[] }
function server(opts: { max?: number; notDestroyed?: Record<string, unknown> } = {}) {
const sets: SetArgs[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "ContactCard/set") {
const update = args.update as Record<string, Record<string, unknown>> | undefined;
const destroy = args.destroy as Id[] | undefined;
sets.push({ update, destroy });
const n = Object.keys(update ?? {}).length + (destroy?.length ?? 0);
if (opts.max != null && n > opts.max) {
return ["error", { type: "requestTooLarge", description: "too many objects" }, id];
}
const notDestroyed = opts.notDestroyed ?? {};
return [name, {
accountId: "a1", oldState: "1", newState: "2",
updated: Object.fromEntries(Object.keys(update ?? {}).map((k) => [k, null])),
destroyed: (destroy ?? []).filter((d) => !(d in notDestroyed)),
notDestroyed, notUpdated: {},
}, id];
}
// Everything else, `loadAll`'s query and get included, answers empty.
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return sets;
}
/** A card, filed in the books named. */
const card = (id: string, ...books: string[]) => ({
id, uid: `uid-${id}`, name: { full: id }, emails: {},
addressBookIds: Object.fromEntries(books.map((b) => [b, true])),
}) as unknown as ContactCard;
const stateWith = (...cards: ContactCard[]) =>
useContacts.setState({ cards: Object.fromEntries(cards.map((c) => [c.id, c])) as Record<Id, ContactCard> });
/** The ids a call destroyed, and the ids it patched, across every call made. */
const destroyedIn = (sets: SetArgs[]) => sets.flatMap((s) => s.destroy ?? []);
const updatedIn = (sets: SetArgs[]) => sets.flatMap((s) => Object.keys(s.update ?? {}));
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.contacts]: {} },
accounts: {}, primaryAccounts: {}, state: "s1",
} as unknown as JmapSession;
useContacts.setState({ accountId: "a1", available: true, books: {}, cards: {} as Record<Id, ContactCard> });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("emptying an address book", () => {
it("deletes what is filed only there", async () => {
const sets = server();
stateWith(card("c1", "book1"), card("c2", "book1"));
const r = await useContacts.getState().emptyBook("book1");
expect(r).toMatchObject({ destroyed: 2, unfiled: 0 });
expect(destroyedIn(sets).sort()).toEqual(["c1", "c2"]);
});
it("leaves the other books alone", async () => {
const sets = server();
stateWith(card("c1", "book1"), card("c2", "book2"));
await useContacts.getState().emptyBook("book1");
expect(destroyedIn(sets)).toEqual(["c1"]);
});
it("removes a card filed in two books from this one, rather than deleting it", async () => {
// The whole reason this is not one `destroy` over everything in the book.
const sets = server();
stateWith(card("c1", "book1", "book2"));
const r = await useContacts.getState().emptyBook("book1");
expect(r).toMatchObject({ destroyed: 0, unfiled: 1 });
expect(destroyedIn(sets)).toEqual([]);
expect(sets[0]!.update).toEqual({ c1: { "addressBookIds/book1": null } });
});
it("reports the two outcomes apart, since only one of them is a deletion", async () => {
server();
stateWith(card("c1", "book1"), card("c2", "book1", "book2"), card("c3", "book1"));
const r = await useContacts.getState().emptyBook("book1");
expect(r).toMatchObject({ destroyed: 2, unfiled: 1 });
});
it("does nothing at all to an empty book", async () => {
const sets = server();
stateWith(card("c1", "book2"));
const r = await useContacts.getState().emptyBook("book1");
expect(r).toMatchObject({ destroyed: 0, unfiled: 0 });
expect(sets).toHaveLength(0);
});
it("splits a book bigger than the server will take in one call", async () => {
// Refused whole over the ceiling, the way Stalwart refuses it -- so a book
// of 1200 that went in one call would delete nothing at all.
const sets = server({ max: MAX });
stateWith(...Array.from({ length: 1200 }, (_, i) => card(`c${i}`, "book1")));
const r = await useContacts.getState().emptyBook("book1");
expect(r.destroyed).toBe(1200);
expect(sets.map((s) => (s.destroy?.length ?? 0) + Object.keys(s.update ?? {}).length)).toEqual([500, 500, 200]);
});
it("counts destroys and patches against one budget, the way the server does", async () => {
const sets = server({ max: MAX });
stateWith(
...Array.from({ length: 300 }, (_, i) => card(`d${i}`, "book1")),
...Array.from({ length: 300 }, (_, i) => card(`u${i}`, "book1", "book2")),
);
const r = await useContacts.getState().emptyBook("book1");
expect(r).toMatchObject({ destroyed: 300, unfiled: 300 });
// 600 objects over a ceiling of 500 is two calls, not two calls of 300
// that each look small enough on their own.
expect(sets).toHaveLength(2);
});
it("reports a refusal rather than throwing, and keeps the count that got through", async () => {
const sets = server({ notDestroyed: { c2: { type: "forbidden", description: "not yours" } } });
stateWith(card("c1", "book1"), card("c2", "book1"));
const r = await useContacts.getState().emptyBook("book1");
expect(r.destroyed).toBe(1);
expect(r.refused).toMatchObject({ type: "forbidden" });
expect(destroyedIn(sets).sort()).toEqual(["c1", "c2"]);
});
it("does not patch a card it is deleting", async () => {
const sets = server();
stateWith(card("c1", "book1"));
await useContacts.getState().emptyBook("book1");
expect(updatedIn(sets)).toEqual([]);
});
});
describe("deleting a selection of contacts", () => {
it("answers with what the server destroyed rather than what was asked", async () => {
server({ notDestroyed: { c2: { type: "forbidden" } } });
stateWith(card("c1", "book1"), card("c2", "book1"));
const r = await useContacts.getState().destroyCards(["c1", "c2"]);
expect(r.destroyed).toBe(1);
expect(r.refused).toMatchObject({ type: "forbidden" });
});
it("does not throw on a refusal, because half of it still went", async () => {
// Throwing loses the count, and an error saying only that it failed sends
// somebody looking for contacts that are already gone.
server({ notDestroyed: { c1: { type: "forbidden" } } });
stateWith(card("c1", "book1"));
await expect(useContacts.getState().destroyCards(["c1"])).resolves.toMatchObject({ destroyed: 0 });
});
it("takes off the local list only what actually went", async () => {
server({ notDestroyed: { c2: { type: "forbidden" } } });
stateWith(card("c1", "book1"), card("c2", "book1"));
await useContacts.getState().destroyCards(["c1", "c2"]);
expect(Object.keys(useContacts.getState().cards)).toEqual(["c2"]);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useContacts } from "@/store/contacts";
import type { ContactCard, JmapSession } from "@/jmap/types";
/*
* Re-importing an LDIF address book you already have.
*
* Mozilla's schema has no UID, so for a long time an LDIF re-import duplicated
* everything -- reported on #174, tracked on #223, and reported again once the
* vCard half shipped without it. The entry's `dn` is the identity the file
* actually carries: not durable enough to be anyone's identity forever, and
* unchanged across the ten minutes between importing a migration, spotting a
* mistake, correcting the export and importing again, which is the only
* interval an import has to survive.
*
* Updated rather than skipped, because the reason to import a file twice is
* that the first attempt was not right.
*/
interface SetArgs { create?: Record<string, Record<string, unknown>>; update?: Record<string, Record<string, unknown>> }
/** A card as the server holds it: what `scanBook` asks for, and nothing else. */
const here = (id: string, uid: string, full: string, bookId = "book1") => ({
id, uid, addressBookIds: { [bookId]: true }, name: { full }, emails: {},
}) as unknown as Partial<ContactCard> & { id: string };
/** What `uidFromDn` makes of a `dn`, spelled out rather than imported, so a
change to the scheme has to be a deliberate one. */
const uidFor = (dn: string) => `urn:x-ihasmail:ldif:${encodeURIComponent(dn)}`;
function server(existing: Array<Partial<ContactCard> & { id: string }> = []) {
const sets: SetArgs[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "ContactCard/query") {
const position = (args.position as number) ?? 0;
return [name, { accountId: "a1", queryState: "1", canCalculateChanges: false, position, ids: position ? [] : existing.map((c) => c.id), total: existing.length }, id];
}
if (name === "ContactCard/get") {
const want = new Set((args.ids as string[]) ?? []);
return [name, { accountId: "a1", state: "1", list: existing.filter((c) => want.has(c.id)), notFound: [] }, id];
}
if (name === "ContactCard/set") {
sets.push({
create: args.create as Record<string, Record<string, unknown>>,
update: args.update as Record<string, Record<string, unknown>>,
});
return [name, {
accountId: "a1", oldState: "1", newState: "2",
created: Object.fromEntries(Object.keys((args.create ?? {}) as object).map((k) => [k, { id: `new-${k}` }])),
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
notCreated: {}, notUpdated: {},
}, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return sets;
}
/** The same card, carrying the address likeness is read from. */
const withEmail = (c: Partial<ContactCard> & { id: string }, address = "[email protected]") =>
({ ...c, emails: { e0: { address } } }) as unknown as Partial<ContactCard> & { id: string };
const JANE = "dn: cn=Jane Doe,ou=People\ngivenName: Jane\nsn: Doe\ncn: Jane Doe\nmail: [email protected]\n";
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.contacts]: {} },
accounts: {}, primaryAccounts: {}, state: "s1",
} as unknown as JmapSession;
useContacts.setState({ accountId: "a1", available: true, books: {}, cards: {} as Record<string, ContactCard> });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("re-importing an LDIF address book", () => {
it("updates the card an entry's dn already names, rather than adding a second", async () => {
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]);
const r = await useContacts.getState().importLdif(JANE, "book1");
expect(r).toEqual({ created: 0, updated: 1, alike: 0 });
expect(sets[0]!.create).toEqual({});
expect(Object.keys(sets[0]!.update!)).toEqual(["c1"]);
});
it("carries the file's version of the entry into the existing card", async () => {
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]);
await useContacts.getState().importLdif(JANE.replace("sn: Doe", "sn: Doe-Smith").replace("cn: Jane Doe", "cn: Jane Doe-Smith"), "book1");
expect(sets[0]!.update!.c1!.name).toMatchObject({ full: "Jane Doe-Smith" });
});
it("does not move the card into the book being imported into", async () => {
// A contact filed in two books stays filed in both. Naming the target book
// in an update would quietly refile it.
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]);
await useContacts.getState().importLdif(JANE, "book1");
expect(sets[0]!.update!.c1!).not.toHaveProperty("addressBookIds");
});
it("creates an entry whose dn is not here yet", async () => {
const sets = server([here("c1", uidFor("cn=someone else,ou=people"), "Someone Else")]);
const r = await useContacts.getState().importLdif(JANE, "book1");
expect(r).toEqual({ created: 1, updated: 0, alike: 0 });
expect(Object.keys(sets[0]!.create!)).toHaveLength(1);
});
it("ignores the case and spacing two exports of one directory differ in", async () => {
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]);
const spaced = JANE.replace("dn: cn=Jane Doe,ou=People", "dn: CN = Jane Doe , OU = People");
await useContacts.getState().importLdif(spaced, "book1");
expect(Object.keys(sets[0]!.update!)).toEqual(["c1"]);
});
it("matches only inside the book being imported into", async () => {
// Two customers' directories can each hold a cn=John Smith. Filed in two
// books they stay two people; this is the escape hatch for that.
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe", "book2")]);
const r = await useContacts.getState().importLdif(JANE, "book1");
expect(r.created).toBe(1);
expect(sets[0]!.update).toEqual({});
});
it("still counts a look-alike whose dn moved, and imports it anyway", async () => {
// The same person under a different branch of the directory: nothing to
// match on, so it arrives as a new card. Reported, never merged -- name
// plus address is a guess, and a merge made on a guess cannot be undone.
server([withEmail(here("c1", uidFor("cn=jane doe,ou=staff"), "Jane Doe"))]);
const r = await useContacts.getState().importLdif(JANE, "book1");
expect(r).toEqual({ created: 1, updated: 0, alike: 1 });
});
it("does not also count an entry it matched as a look-alike", async () => {
// It is not a card that looks like this one; it is this one.
server([withEmail(here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe"))]);
const r = await useContacts.getState().importLdif(JANE, "book1");
expect(r).toEqual({ created: 0, updated: 1, alike: 0 });
});
it("reports created and updated apart when a file holds both", async () => {
const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]);
const both = `${JANE}\ndn: cn=Alan Turing,ou=People\ncn: Alan Turing\nsn: Turing\nmail: [email protected]\n`;
const r = await useContacts.getState().importLdif(both, "book1");
expect(r).toEqual({ created: 1, updated: 1, alike: 0 });
// One call, not one per kind: creates and updates share Stalwart's budget.
expect(sets).toHaveLength(1);
});
});
+12 -5
View File
@@ -86,15 +86,22 @@ describe("importing an LDIF address book", () => {
expect(first.name).toMatchObject({ full: "Jane Doe" });
});
it("gives each contact an identity of its own, not the entry's directory name", async () => {
it("gives each contact an identity derived from its entry, and a distinct one", async () => {
const sets = server();
await useContacts.getState().importLdif(TWO, "book1");
const uids = Object.values(sets[0]!.create!).map((c) => c.uid as string);
expect(uids.every((u) => typeof u === "string" && u.length > 0)).toBe(true);
expect(new Set(uids).size).toBe(2);
// A distinguished name says where an entry sat in somebody else's
// directory, and must not become the contact's identity here.
expect(uids.some((u) => u.includes("cn="))).toBe(false);
// Namespaced, so it is never mistaken for a UID a vCard author meant, and
// stable, so importing the same file again recognises these.
expect(uids.every((u) => u.startsWith("urn:x-ihasmail:ldif:"))).toBe(true);
});
it("gives an entry with no usable dn an identity of its own", async () => {
const sets = server();
await useContacts.getState().importLdif("dn:\ncn: Nameless Place\nmail: [email protected]\n", "book1");
const uid = Object.values(sets[0]!.create!)[0]!.uid as string;
expect(uid).not.toContain("urn:x-ihasmail:ldif:");
expect(uid.length).toBeGreaterThan(0);
});
it("says a file held no contacts rather than reporting none imported", async () => {
+15 -3
View File
@@ -128,11 +128,23 @@ describe("telling somebody what an LDIF re-import duplicated", () => {
});
it("does not count the file against itself", async () => {
// Two of the same person in one file are two new cards, not a duplicate of
// Two people in one file are two new cards, neither a duplicate of
// something that was already here. The scan is read before anything lands.
server([]);
const twice = entry("Jane Doe", "[email protected]") + "\n" + entry("Jane Doe", "jane@example.com");
const r = await useContacts.getState().importLdif(twice, "book1");
const two = entry("Jane Doe", "[email protected]") + "\n" + entry("Alan Turing", "alan@example.org");
const r = await useContacts.getState().importLdif(two, "book1");
expect(r).toEqual({ created: 2, updated: 0, alike: 0 });
});
it("makes one card of two entries in a file that share a dn", async () => {
// A directory cannot hold two entries under one name, so a file that does
// is malformed -- and must not produce two cards sharing an identity,
// which is the duplication this all exists to prevent. The later wins.
const sets = server([]);
const twice = entry("Jane Doe", "[email protected]") + "\n" + entry("Jane Doe", "[email protected]");
const r = await useContacts.getState().importLdif(twice, "book1");
expect(r).toEqual({ created: 1, updated: 0, alike: 0 });
const only = Object.values(sets[0]!.create!)[0]!;
expect(Object.values(only.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
});
});
@@ -0,0 +1,165 @@
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import type { Email, Identity } from "@/jmap/types";
/*
* Who a reply is addressed to.
*
* The hard half is replying to something *I* sent, which is what following up
* on your own last message is. The conversation is with the people I wrote to;
* addressing the reply to myself, or to my own Reply-To, sends it nowhere
* useful -- and on a Reply all it quietly demotes everyone I was talking to
* into Cc.
*
* There was a guard for this and it was sound. What it rested on was not: it
* asked whether an address was in the identity list, which is empty before
* identities load, misses an alias or a shared mailbox the server does not list
* as an identity, and compared strings where the rest of the codebase uses
* `sameAddress`. Each miss was silent. So the folder is asked first -- a
* message in Sent is mine whatever address it went out as -- and the identity
* list is the second opinion rather than the only one (#275).
*/
const body = {
messageId: ["<[email protected]>"], subject: "Numbers", references: [], inReplyTo: [],
keywords: {}, htmlBody: [{ partId: "1", type: "text/html" }], textBody: [{ partId: "1", type: "text/html" }],
bodyValues: { "1": { value: "<p>hi</p>", isEncodingProblem: false, isTruncated: false } },
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
};
const ME = { name: "John", email: "[email protected]" };
const ANN = { name: "Ann", email: "[email protected]" };
const BOB = { name: "Bob", email: "[email protected]" };
/** A message I sent: me in From, Ann in To, Bob in Cc. */
const MINE = { ...body, id: "m1", from: [ME], to: [ANN], cc: [BOB] } as unknown as Email;
/** The same conversation, but Ann's message to me. */
const HERS = { ...body, id: "m2", from: [ANN], to: [ME], cc: [BOB] } as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
/** In Sent, which is the signal that survives an unlisted alias. */
const inSent = (e: Email) => ({ ...e, mailboxIds: { sent1: true } }) as Email;
function draftFor(email: Email, mode: "reply" | "replyAll" | "forward", opts: { identities?: Identity[] } = {}) {
const identities = opts.identities ?? IDENTITIES;
useMail.setState({
accountId: "a1",
identities: identities as never,
getEmails: (async () => [email]) as never,
defaultIdentity: (() => identities[0]) as never,
loadIdentities: (async () => identities) as never,
roleId: ((role: string) => (role === "sent" ? "sent1" : null)) as never,
});
return useCompose.getState().reply(email, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
const addrs = (list: { email: string }[]) => list.map((a) => a.email);
beforeEach(() => useCompose.setState({ drafts: [], activeKey: null }));
afterEach(() => useCompose.setState({ drafts: [], activeKey: null }));
describe("replying to a message somebody sent me", () => {
it("replies to the sender", async () => {
const d = await draftFor(HERS, "reply");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(d.cc).toEqual([]);
});
it("reply all keeps the others and leaves me off", async () => {
const d = await draftFor(HERS, "replyAll");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(addrs(d.cc)).toEqual([BOB.email]);
});
it("honours the sender's Reply-To, which is what it is for", async () => {
const d = await draftFor({ ...HERS, replyTo: [{ name: null, email: "[email protected]" }] } as Email, "reply");
expect(addrs(d.to)).toEqual(["[email protected]"]);
});
});
describe("replying to a message I sent", () => {
it("writes to the people I wrote to, not to me", async () => {
const d = await draftFor(MINE, "reply");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(d.cc).toEqual([]);
});
it("reply all keeps my Cc as Cc, rather than promoting me into To", async () => {
const d = await draftFor(MINE, "replyAll");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(addrs(d.cc)).toEqual([BOB.email]);
});
it("does not follow my own Reply-To back to my own desk", async () => {
// The address replies to *me* belong at. My reply is not one of them.
const d = await draftFor({ ...MINE, replyTo: [{ name: null, email: "[email protected]" }] } as Email, "replyAll");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(addrs(d.cc)).toEqual([BOB.email]);
});
it("leaves me out even when I was a recipient of my own message", async () => {
const d = await draftFor({ ...MINE, to: [ME, ANN] } as Email, "replyAll");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(addrs(d.cc)).toEqual([BOB.email]);
});
it("recognises my address however the identity stored it", async () => {
// A hand-typed identity address can carry whitespace, and comparing
// strings rather than addresses made that enough to break the reply.
const padded = [{ id: "i1", name: "John", email: " [email protected] " }] as unknown as Identity[];
const d = await draftFor(MINE, "replyAll", { identities: padded });
expect(addrs(d.to)).toEqual([ANN.email]);
});
});
describe("when the identity list cannot answer", () => {
it("takes a message in Sent as mine, whatever address it went out as", async () => {
// An alias or a shared mailbox the server does not list as an identity.
const alias = inSent({ ...MINE, from: [{ name: "Sales", email: "[email protected]" }] } as Email);
const d = await draftFor(alias, "replyAll");
expect(addrs(d.to)).toEqual([ANN.email]);
expect(addrs(d.cc)).toEqual([BOB.email]);
});
it("takes a message in Sent as mine before the identities have loaded", async () => {
const d = await draftFor(inSent(MINE), "replyAll", { identities: [] });
expect(addrs(d.to)).toEqual([ANN.email]);
});
it("still replies to the sender of a message that is not mine and not in Sent", async () => {
// The folder signal must not swallow the ordinary case when it is absent.
const d = await draftFor(HERS, "replyAll", { identities: [] });
expect(addrs(d.to)).toEqual([ANN.email]);
});
});
describe("a message of mine with nobody obvious to reply to", () => {
it("uses the Cc when I addressed it to nobody else", async () => {
const d = await draftFor({ ...MINE, to: [ME] } as Email, "replyAll");
expect(addrs(d.to)).toEqual([BOB.email]);
expect(d.cc).toEqual([]);
});
it("uses the Cc on a plain reply too, rather than leaving To empty", async () => {
const d = await draftFor({ ...MINE, to: [] } as Email, "reply");
expect(addrs(d.to)).toEqual([BOB.email]);
});
it("falls back to my own address rather than a draft addressed to nobody", async () => {
// A note I sent only to myself. Replying to it is odd, and an empty To is
// worse than the only address there was.
const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply");
expect(addrs(d.to)).toEqual([ME.email]);
});
});
describe("forwarding", () => {
it("addresses nobody, whoever sent the message", async () => {
for (const m of [MINE, HERS]) {
const d = await draftFor(m, "forward");
expect([d.to, d.cc]).toEqual([[], []]);
}
});
});
+2 -2
View File
@@ -9,8 +9,8 @@ import type { ContactCard, JmapSession, UploadResponse } from "@/jmap/types";
* A vCard UID is an identity its author meant, so a card whose UID a book
* already holds is that card and importing it again used to leave a second
* copy. Reported on #174 by the reporter's colleague, decided on #173 for
* events, tracked as #223. The LDIF half is deliberately absent -- Mozilla's
* schema has no UID, so the import invents one and there is nothing to match.
* events, tracked as #223. The LDIF half matches on the entry's `dn` instead,
* since Mozilla's schema has no UID; it is tested in `ldif-dedupe.test.ts`.
*/
const MAX = 500;
+38 -14
View File
@@ -299,26 +299,50 @@ export const useCompose = create<ComposeState>((set, get) => ({
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const ident = defaultIdentity(identities, full);
const ownEmails = identities.map((i) => i.email.toLowerCase());
const isOwn = (a: EmailAddress) => ownEmails.includes(a.email.toLowerCase());
const ownEmails = identities.map((i) => i.email);
/* `sameAddress` rather than a lowercased `includes`, because an identity
address can carry whitespace and a hand-typed one does. */
const isOwn = (a: EmailAddress) => ownEmails.some((e) => sameAddress(e, a.email));
const withoutOwn = (list: EmailAddress[]) => uniqueAddresses(list).filter((a) => !isOwn(a));
const s = settings();
/*
* Was this message mine?
*
* The folder answers it before the addresses do, and has to: the address
* test fails in exactly the cases where the mistake is least visible. A
* message sent from an alias or a shared mailbox that `Identity/get` does
* not list is not recognisably mine, and neither is anything at all if the
* identities have not loaded yet -- and the failure is silent, addressing
* the reply back to me with everyone I actually wrote to moved to Cc.
*
* A message in Sent is mine whatever address it went out as.
*/
const sentId = mail.roleId("sent");
const sentByMe = (Boolean(full.from?.length) && (full.from ?? []).every(isOwn))
|| Boolean(sentId && full.mailboxIds?.[sentId]);
let to: EmailAddress[] = [];
let cc: EmailAddress[] = [];
if (mode === "reply" || mode === "replyAll") {
const replyTo = full.replyTo?.length ? full.replyTo : (full.from ?? []);
to = uniqueAddresses(replyTo);
if (mode === "replyAll") {
const others = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
cc = others;
// If the message was sent by me, reply to original recipients instead.
if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to);
cc = uniqueAddresses(full.cc ?? []).filter((a) => !isOwn(a));
if (sentByMe && (full.to?.length || full.cc?.length)) {
/*
* Replying to something I sent continues the conversation with the
* people I wrote to. Not with myself, and not with my own Reply-To
* either -- that address is where replies *to me* belong, and following
* it here would send my own reply to my own desk.
*/
to = withoutOwn(full.to ?? []);
cc = mode === "replyAll" ? withoutOwn(full.cc ?? []) : [];
// Addressed only to myself, or only in Cc: there is still somebody this
// is a reply to, and an empty To is not it.
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
} else {
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
if (mode === "replyAll") {
cc = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
}
} else if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to.filter((a) => !isOwn(a)));
if (!to.length) to = uniqueAddresses(full.to);
}
}
+127 -43
View File
@@ -3,7 +3,7 @@ import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { parseLdif } from "@/lib/ldif";
import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
import { useSettings } from "./settings";
import { useSession } from "./session";
@@ -73,14 +73,14 @@ async function scanBook(accountId: Id, addressBookId: Id): Promise<{ byUid: Map<
* wrong in both directions: two colleagues who share a name and a shared alias
* collapse into one, and somebody whose address changed since the last export
* looks like a stranger. Either mistake is silent and one of them is
* unrecoverable, which is why #223 leaves the decision open.
* unrecoverable, so it counts and never acts.
*
* Counting is a different act from acting. An LDIF re-import duplicates
* everything -- Mozilla's schema has no UID, so the import invents one and
* nothing can match -- and the reported harm was confusion rather than data
* loss: somebody imports a file twice and cannot tell what happened. Being told
* "40 of these look like contacts you already had" answers that without
* touching a single card.
* What is left for it to count, now that an LDIF re-import matches on the
* entry's `dn`, is the entries that matching could not catch: one whose `dn`
* moved between exports, and anything imported before there was a `dn` to match
* on. Those arrive as new cards, and saying "40 of these look like contacts you
* already had" is the honest half of the answer -- the reported harm was
* confusion rather than duplication, and being told costs nothing.
*
* One key per address, so a person whose second address matches is still
* recognised.
@@ -202,7 +202,21 @@ interface ContactsState {
filterCards(cards: ContactCard[], text: string): ContactCard[];
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
destroyCards(ids: Id[]): Promise<void>;
/**
* Delete cards outright, reporting what the server actually destroyed rather
* than what was asked for. Nothing is thrown for a refusal -- a partial one
* has a count worth telling somebody about, and `refused` says why the rest
* did not go.
*/
destroyCards(ids: Id[]): Promise<{ destroyed: number; refused?: SetError }>;
/**
* Empty an address book: everything filed in it, gone.
*
* `unfiled` is the part that is not a deletion. A card filed in two books is
* only *this* book's to remove, so it is taken out of this one and left
* alone in the other -- destroying it would empty a book nobody asked about.
*/
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
createBook(name: string): Promise<Id>;
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
destroyBook(id: Id): Promise<void>;
@@ -211,10 +225,12 @@ interface ContactsState {
/**
* Import an address book in LDIF, read against Mozilla's schema.
*
* `updated` is always 0: Mozilla's schema has no UID, so there is nothing to
* recognise a re-import by and everything arrives as new. `alike` says how
* many look like cards already here without acting on it. Answered in the
* same shape as the vCard import so the caller need not know which it called.
* Mozilla's schema has no UID, so a re-import is recognised by the entry's
* `dn` instead -- the same update-rather-than-duplicate rule the vCard import
* follows, on the only identity the file carries. `alike` is what is left
* over: entries that were created and still look like somebody already here,
* which is what a changed `dn` produces. Answered in the same shape as the
* vCard import so the caller need not know which it called.
*/
importLdif(text: string, addressBookId: Id): Promise<{ created: number; updated: number; alike: number }>;
loadPrincipals(): Promise<void>;
@@ -462,12 +478,12 @@ export const useContacts = create<ContactsState>((set, get) => ({
async destroyCards(ids) {
const accountId = get().accountId!;
const gone: Id[] = [];
let failed: SetError | undefined;
let refused: SetError | undefined;
try {
for (const part of chunk(ids, client.maxObjectsInSet)) {
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: part });
gone.push(...(res.destroyed ?? []));
failed ??= Object.values(res.notDestroyed ?? {})[0];
refused ??= Object.values(res.notDestroyed ?? {})[0];
}
} finally {
if (gone.length) {
@@ -478,7 +494,57 @@ export const useContacts = create<ContactsState>((set, get) => ({
});
}
}
if (failed) throw new Error(setErrorMessage(failed));
/* Answered rather than thrown. A refusal that took half the selection with
it still deleted the other half, and an error that says only "it failed"
sends somebody looking for contacts that are already gone. */
return { destroyed: gone.length, refused };
},
async emptyBook(bookId) {
const accountId = get().accountId!;
const inBook = Object.values(get().cards).filter((c) => c.addressBookIds?.[bookId]);
/*
* Two different acts, decided per card.
*
* A card filed only here is deleted. A card filed here *and* somewhere else
* is removed from this book and left where it also lives -- emptying one
* book must not empty another, and `ContactCard/set destroy` does not know
* the difference: it takes the card away from every book at once.
*/
const destroy: Id[] = [];
const update: Record<Id, unknown> = {};
for (const c of inBook) {
if (Object.keys(c.addressBookIds ?? {}).length > 1) update[c.id] = { [`addressBookIds/${bookId}`]: null };
else destroy.push(c.id);
}
const gone: Id[] = [];
let unfiled = 0;
let refused: SetError | undefined;
/* One budget for both, the way `writeCards` shares one: Stalwart counts
every object in a `/set` against `maxObjectsInSet` together. */
const work = [
...destroy.map((id) => ["destroy", id] as const),
...Object.keys(update).map((id) => ["update", id] as const),
];
try {
for (const part of chunk(work, client.maxObjectsInSet)) {
const partDestroy = part.filter(([kind]) => kind === "destroy").map(([, id]) => id);
const partUpdate: Record<Id, unknown> = {};
for (const [kind, id] of part) if (kind === "update") partUpdate[id] = update[id];
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", {
accountId,
...(partDestroy.length ? { destroy: partDestroy } : {}),
...(Object.keys(partUpdate).length ? { update: partUpdate } : {}),
});
gone.push(...(res.destroyed ?? []));
unfiled += Object.keys(res.updated ?? {}).length;
refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
} finally {
await get().loadAll();
}
return { destroyed: gone.length, unfiled, refused };
},
async createBook(name) {
@@ -568,43 +634,61 @@ export const useContacts = create<ContactsState>((set, get) => ({
*/
async importLdif(text, addressBookId) {
const accountId = get().accountId!;
const cards = parseLdif(text).map(cardFromLdif).filter((c): c is Partial<ContactCard> => c !== null);
if (!cards.length) throw new Error("it has no contacts in it");
/* The record and not just the card: the `dn` is the entry's identity and
`cardFromLdif` deliberately does not carry it into the card. */
const entries = parseLdif(text)
.map((rec) => ({ uid: uidFromDn(rec.dn), card: cardFromLdif(rec) }))
.filter((e): e is { uid: string | null; card: Partial<ContactCard> } => e.card !== null);
if (!entries.length) throw new Error("it has no contacts in it");
/*
* Read before anything is created, so "already had" means before this
* import rather than including it. Every card here is imported either way;
* this only counts.
* Read before anything is written, so "already had" means before this
* import rather than including it.
*/
const before = await scanBook(accountId, addressBookId);
let alike = 0;
const create: Record<string, unknown> = {};
cards.forEach((c, i) => {
if (likenessKeys(c).some((k) => before.likeness.has(k))) alike++;
// Built here rather than read from the file: LDIF identifies an entry by
// its distinguished name, which says where it sat in somebody's
// directory and is no use as a contact's identity anywhere else.
create[`c${i}`] = { "@type": "Card", version: "1.0", ...c, uid: crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
const update: Record<Id, unknown> = {};
/* Where in `create` an entry from this same file already landed. A
directory cannot hold two entries under one `dn`, so a file that does is
malformed -- but it must not become two cards sharing a uid, which is a
duplicate of exactly the kind being fixed here. The later one wins, as it
would in the directory. */
const pending = new Map<string, string>();
entries.forEach(({ uid, card }, i) => {
/*
* An entry whose `dn` this book already holds is that entry, and the
* newer version of it wins -- a merge, as the vCard import does it:
* properties the file carries overwrite what is here, properties it does
* not mention are left alone. The reason to import a file twice is
* usually that the first attempt was not right, so skipping would mean a
* corrected export corrects nothing (#174).
*/
const existing = uid ? before.byUid.get(uid) : undefined;
if (existing) {
update[existing] = card;
return;
}
const seen = uid ? pending.get(uid) : undefined;
const key = seen ?? `c${i}`;
if (uid) pending.set(uid, key);
/*
* Only what is actually being created can look like a duplicate: what
* matched above is not a look-alike but the same entry. So this counts
* what `dn` matching could not catch -- an entry whose `dn` moved, or one
* imported before there was anything to match on -- and still only
* counts, because name-plus-email is a guess wrong in both directions and
* a merge made on a guess cannot be undone.
*/
if (!seen && likenessKeys(card).some((k) => before.likeness.has(k))) alike++;
create[key] = { "@type": "Card", version: "1.0", ...card, uid: uid ?? crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
});
let created: number;
try {
const r = await writeCards(accountId, create);
created = r.created;
if (!created) throw new Error(r.refused ? setErrorMessage(r.refused) : "the server did not accept any of its contacts");
const { created, updated, refused } = await writeCards(accountId, create, update);
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
return { created, updated, alike };
} finally {
await get().loadAll();
}
/*
* Nothing skipped, and nothing that could be. The UID above is invented
* here because Mozilla's schema does not define one, so a re-import has no
* identity to be recognised by -- see #223, where whether to guess at one
* from a name and an address is still an open question.
*
* `alike` is what can be said without answering it: how many of these look
* like contacts that were already here. Reporting is not matching -- every
* card was imported -- and it is the confusion rather than the duplication
* that was reported as the harm.
*/
return { created, updated: 0, alike };
},
async loadPrincipals() {
+47
View File
@@ -57,6 +57,25 @@ export interface Template {
html: string;
}
/** A signer remembered for an address. See `knownSigners`. */
export interface SignerPin {
/** SHA-256 of the certificate's DER, lowercase hex. */
fingerprint: string;
/** What the certificate called its holder, so a change can be described. */
name: string;
/** When this fingerprint was first pinned, ISO 8601. */
firstSeen: string;
/**
* The message that established the pin.
*
* Without this, the message that *created* a pin reads as corroborated by it
* the next time it is opened "the same signer as before", where before is
* itself. That is a claim of corroboration from evidence that does not
* exist, and it appears on the very first signed message somebody receives.
*/
messageId?: string;
}
export interface Settings {
/**
* Kept, and kept correct, for a device still running a build that only knows
@@ -91,9 +110,35 @@ export interface Settings {
* already follow the reader between devices.
*/
addedShares: string[];
/**
* S/MIME signers pinned on first sight, keyed by lowercased address.
*
* This is the whole trust model for signature checking, and it is a small
* one: a browser has no system trust store, and the certificate that signs a
* message travels inside it, so "this signature verifies" on its own says
* only that the sender held the key they attached. What makes it worth
* anything is remembering the same signer as last time is reassuring, and a
* different one is worth interrupting somebody over.
*
* It lives in the synced settings rather than in this browser because a pin
* that only one device knows about would greet the same correspondent as new
* on every other one, which trains people to click past exactly the warning
* this exists to raise.
*/
knownSigners: Record<string, SignerPin>;
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
/**
* Extend that to mail which brings colours of its own.
*
* Only meaningful with `themeMessageBody` on. Off by default because it
* cannot be done perfectly: see `markKeptSurfaces` in lib/html.ts for the
* bargain it makes, and #290 for why the conservative default alone left
* essentially all HTML mail on a white card.
*/
themeStyledMessages: boolean;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
@@ -268,8 +313,10 @@ export const DEFAULT_SETTINGS: Settings = {
pageSize: 50,
markReadDelay: 0,
addedShares: [],
knownSigners: {},
imagePolicy: "ask",
themeMessageBody: false,
themeStyledMessages: false,
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* The drawer is a phone's only way to a folder, an event, a contact or a new
* message -- and each of those answers with a dialog or a full-screen composer
* that the drawer used to cover, because both were stacked below it. Nothing
* in a component test sees that: jsdom has no paint order, and the store is
* perfectly happy while the dialog sits behind the thing that raised it.
*
* So the guard is on the stylesheet, which is where the bug was.
*
* Read off disk, not imported: `?raw` comes back empty for a stylesheet under
* vitest, which would pass every assertion below on an empty string.
*/
const here = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url;
const css = readFileSync(resolve(dirname(here), "../app.css"), "utf8");
/** The `z-index` on the last rule for `selector`, which is the one that wins. */
function layer(selector: string): number {
const rules = [...css.matchAll(new RegExp(`(?:^|[,{}\\s])${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g"))];
expect(rules.length, `no rule for ${selector}`).toBeGreaterThan(0);
const zs = rules.map((r) => /z-index:\s*(\d+)/.exec(r[1]!)?.[1]).filter(Boolean);
expect(zs.length, `no z-index on ${selector}`).toBeGreaterThan(0);
return Number(zs[zs.length - 1]);
}
describe("stacking order", () => {
it("puts a dialog over the mobile drawer that opened it", () => {
expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".sidebar"));
});
it("puts a full-screen composer over the drawer that opened it", () => {
expect(layer(".composer-dock")).toBeGreaterThan(layer(".sidebar"));
});
it("keeps a dialog raised from inside a composer above it", () => {
expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".composer-dock"));
});
it("keeps the drawer above its own backdrop", () => {
expect(layer(".sidebar")).toBeGreaterThan(layer(".drawer-backdrop"));
});
it("keeps popovers, tooltips and toasts above dialogs", () => {
const dialog = layer(".dialog-backdrop");
expect(layer(".popover")).toBeGreaterThan(dialog);
expect(layer(".tooltip")).toBeGreaterThan(dialog);
expect(layer(".toast-host")).toBeGreaterThan(dialog);
});
});
+501 -2
View File
@@ -503,6 +503,456 @@
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* catppuccin (light) lifted for contrast: fg #4c4f69 -> #484b64 (6.57 -> 7.00); muted #6c6f85 -> #64677c (4.06 -> 4.58); faint #8b8e9e -> #656873 (2.67 -> 4.57); link #1e66f5 -> #1c5fe4 (4.04 -> 4.54); danger #d20f39 -> #d00f38 (4.46 -> 4.53); warn #fe640b -> #b24608 (2.45 -> 4.58); success #40a02b -> #307820 (2.75 -> 4.50); star #df8e1d -> #b97618 (2.15 -> 3.05); border-strong #b8bcc8 -> #83858e (1.56 -> 3.02); q1 #1e66f5 -> #1c5fe4 (4.04 -> 4.54); q2 #40a02b -> #307820 (2.75 -> 4.50); q3 #ea76cb -> #9a4e86 (2.17 -> 4.53) */
:root[data-palette="catppuccin"] {
--bg: #e6e9ef;
--bg-elev: #eff1f5;
--bg-sunken: #dce0e8;
--bg-hover: rgba(72, 75, 100, 0.06);
--bg-active: rgba(72, 75, 100, 0.11);
--fg: #484b64;
--fg-muted: #64677c;
--fg-faint: #656873;
--border: #ccd0da;
--border-strong: #83858e;
--accent: #8839ef;
--accent-fg: #ffffff;
--accent-soft: #d9d0ef;
--accent-soft-fg: #7632d0;
--danger: #d00f38;
--danger-soft: rgba(208, 15, 56, 0.15);
--warn: #b24608;
--warn-soft: rgba(178, 70, 8, 0.15);
--success: #307820;
--success-soft: rgba(48, 120, 32, 0.15);
--link: #1c5fe4;
--unread-bg: #ffffff;
--read-bg: #e1e4eb;
--selected-bg: #d9d0ef;
--focus-ring: 0 0 0 3px rgba(136, 57, 239, 0.4);
--star: #b97618;
--q1: #1c5fe4;
--q2: #307820;
--q3: #9a4e86;
--scrollbar: rgba(100, 103, 124, 0.35);
color-scheme: light;
}
/* catppuccin (dark) lifted for contrast: faint #7d829a -> #81869d (4.32 -> 4.55); border-strong #595c71 -> #66697c (2.49 -> 3.03) */
:root[data-theme="dark"][data-palette="catppuccin"] {
--bg: #1e1e2e;
--bg-elev: #313244;
--bg-sunken: #181825;
--bg-hover: rgba(205, 214, 244, 0.06);
--bg-active: rgba(205, 214, 244, 0.11);
--fg: #cdd6f4;
--fg-muted: #a6adc8;
--fg-faint: #81869d;
--border: #45475a;
--border-strong: #66697c;
--accent: #cba6f7;
--accent-fg: #1e1e2e;
--accent-soft: rgba(203, 166, 247, 0.16);
--accent-soft-fg: #cba6f7;
--danger: #f38ba8;
--danger-soft: rgba(243, 139, 168, 0.15);
--warn: #fab387;
--warn-soft: rgba(250, 179, 135, 0.15);
--success: #a6e3a1;
--success-soft: rgba(166, 227, 161, 0.15);
--link: #89b4fa;
--unread-bg: #313244;
--read-bg: #181825;
--selected-bg: rgba(203, 166, 247, 0.18);
--focus-ring: 0 0 0 3px rgba(203, 166, 247, 0.4);
--star: #f9e2af;
--q1: #89b4fa;
--q2: #a6e3a1;
--q3: #f5c2e7;
--scrollbar: rgba(166, 173, 200, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* solarized (light) lifted for contrast: fg #657b83 -> #47565c (4.13 -> 7.07); muted #93a1a1 -> #687272 (2.48 -> 4.59); faint #959a94 -> #6e726e (2.66 -> 4.53); link #2aa198 -> #217e77 (2.93 -> 4.51); danger #dc322f -> #d5302e (4.29 -> 4.54); warn #cb4b16 -> #c34815 (4.27 -> 4.57); success #859900 -> #687700 (2.97 -> 4.61); star #b58900 -> #b38800 (2.98 -> 3.02); border-strong #cecab8 -> #928f83 (1.53 -> 3.00); q1 #2aa198 -> #217e77 (2.93 -> 4.51); q2 #859900 -> #687700 (2.97 -> 4.61); q3 #6c71c4 -> #666ab8 (4.06 -> 4.51) */
:root[data-palette="solarized"] {
--bg: #fdf6e3;
--bg-elev: #fffdf6;
--bg-sunken: #eee8d5;
--bg-hover: rgba(71, 86, 92, 0.06);
--bg-active: rgba(71, 86, 92, 0.11);
--fg: #47565c;
--fg-muted: #687272;
--fg-faint: #6e726e;
--border: #e6dfc8;
--border-strong: #928f83;
--accent: #268bd2;
--accent-fg: #ffffff;
--accent-soft: #dfe7e1;
--accent-soft-fg: #1d6ba2;
--danger: #d5302e;
--danger-soft: rgba(213, 48, 46, 0.15);
--warn: #c34815;
--warn-soft: rgba(195, 72, 21, 0.15);
--success: #687700;
--success-soft: rgba(104, 119, 0, 0.15);
--link: #217e77;
--unread-bg: #ffffff;
--read-bg: #f8f1df;
--selected-bg: #dfe7e1;
--focus-ring: 0 0 0 3px rgba(38, 139, 210, 0.4);
--star: #b38800;
--q1: #217e77;
--q2: #687700;
--q3: #666ab8;
--scrollbar: rgba(104, 114, 114, 0.35);
color-scheme: light;
}
/* solarized (dark) lifted for contrast: fg #839496 -> #a8b4b6 (4.75 -> 7.06); muted #586e75 -> #809196 (2.79 -> 4.58); faint #5a7279 -> #7d9095 (2.94 -> 4.50); danger #dc322f -> #e56563 (3.25 -> 4.56); warn #cb4b16 -> #d67147 (3.26 -> 4.51); border-strong #245661 -> #4e767f (1.84 -> 3.02); q3 #6c71c4 -> #8488cd (3.43 -> 4.56) */
:root[data-theme="dark"][data-palette="solarized"] {
--bg: #002b36;
--bg-elev: #073642;
--bg-sunken: #001f28;
--bg-hover: rgba(168, 180, 182, 0.06);
--bg-active: rgba(168, 180, 182, 0.11);
--fg: #a8b4b6;
--fg-muted: #809196;
--fg-faint: #7d9095;
--border: #0d4552;
--border-strong: #4e767f;
--accent: #268bd2;
--accent-fg: #002b36;
--accent-soft: rgba(38, 139, 210, 0.16);
--accent-soft-fg: #56a5dc;
--danger: #e56563;
--danger-soft: rgba(229, 101, 99, 0.15);
--warn: #d67147;
--warn-soft: rgba(214, 113, 71, 0.15);
--success: #859900;
--success-soft: rgba(133, 153, 0, 0.15);
--link: #2aa198;
--unread-bg: #073642;
--read-bg: #001f28;
--selected-bg: rgba(38, 139, 210, 0.18);
--focus-ring: 0 0 0 3px rgba(38, 139, 210, 0.4);
--star: #b58900;
--q1: #2aa198;
--q2: #859900;
--q3: #8488cd;
--scrollbar: rgba(128, 145, 150, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* ayu (light) lifted for contrast: fg #5c6166 -> #52565b (5.93 -> 7.01); muted #828e9f -> #697381 (3.15 -> 4.56); faint #949ba5 -> #6e737a (2.66 -> 4.53); link #22a4e6 -> #1979aa (2.65 -> 4.57); danger #f07171 -> #b45555 (2.73 -> 4.56); warn #fa8532 -> #af5d23 (2.35 -> 4.51); success #86b300 -> #5e7d00 (2.35 -> 4.52); accent #f29718 -> #cb7f14 (2.16 -> 3.03); star #eba400 -> #be8500 (2.02 -> 3.04); border-strong #cacdd0 -> #8d9092 (1.51 -> 3.05); q1 #55b4d4 -> #3a7a90 (2.25 -> 4.56); q2 #86b300 -> #5e7d00 (2.35 -> 4.52); q3 #a37acc -> #8664a7 (3.21 -> 4.53) */
:root[data-palette="ayu"] {
--bg: #f8f9fa;
--bg-elev: #fcfcfc;
--bg-sunken: #ebeef0;
--bg-hover: rgba(82, 86, 91, 0.06);
--bg-active: rgba(82, 86, 91, 0.11);
--fg: #52565b;
--fg-muted: #697381;
--fg-faint: #6e737a;
--border: #dfe2e5;
--border-strong: #8d9092;
--accent: #cb7f14;
--accent-fg: #ffffff;
--accent-soft: #f2e8da;
--accent-soft-fg: #945d0f;
--danger: #b45555;
--danger-soft: rgba(180, 85, 85, 0.15);
--warn: #af5d23;
--warn-soft: rgba(175, 93, 35, 0.15);
--success: #5e7d00;
--success-soft: rgba(94, 125, 0, 0.15);
--link: #1979aa;
--unread-bg: #ffffff;
--read-bg: #f3f4f5;
--selected-bg: #f2e8da;
--focus-ring: 0 0 0 3px rgba(203, 127, 20, 0.4);
--star: #be8500;
--q1: #3a7a90;
--q2: #5e7d00;
--q3: #8664a7;
--scrollbar: rgba(105, 115, 129, 0.35);
color-scheme: light;
}
/* ayu (dark) lifted for contrast: muted #5a6378 -> #747c8e (3.16 -> 4.55); faint #555c6a -> #777d88 (2.83 -> 4.60); border-strong #34373e -> #5f6167 (1.60 -> 3.07) */
:root[data-theme="dark"][data-palette="ayu"] {
--bg: #0d1017;
--bg-elev: #10141c;
--bg-sunken: #070a0f;
--bg-hover: rgba(191, 189, 182, 0.06);
--bg-active: rgba(191, 189, 182, 0.11);
--fg: #bfbdb6;
--fg-muted: #747c8e;
--fg-faint: #777d88;
--border: #1b1f29;
--border-strong: #5f6167;
--accent: #e6b450;
--accent-fg: #0d1017;
--accent-soft: rgba(230, 180, 80, 0.16);
--accent-soft-fg: #e6b450;
--danger: #f07178;
--danger-soft: rgba(240, 113, 120, 0.15);
--warn: #ff8f40;
--warn-soft: rgba(255, 143, 64, 0.15);
--success: #aad94c;
--success-soft: rgba(170, 217, 76, 0.15);
--link: #59c2ff;
--unread-bg: #10141c;
--read-bg: #070a0f;
--selected-bg: rgba(230, 180, 80, 0.18);
--focus-ring: 0 0 0 3px rgba(230, 180, 80, 0.4);
--star: #ffb454;
--q1: #39bae6;
--q2: #aad94c;
--q3: #d2a6ff;
--scrollbar: rgba(116, 124, 142, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* kanagawa (light) lifted for contrast: fg #545464 -> #434350 (5.41 -> 7.09); muted #716e61 -> #636155 (3.73 -> 4.54); faint #8a8670 -> #636051 (2.67 -> 4.61); link #4d699b -> #47618f (4.02 -> 4.53); danger #c84053 -> #ac3747 (3.55 -> 4.51); warn #cc6d00 -> #934e00 (2.66 -> 4.59); success #6f894e -> #53673b (2.85 -> 4.54); border-strong #bfb997 -> #807c65 (1.44 -> 3.07); q1 #4d699b -> #47618f (4.02 -> 4.53); q2 #6f894e -> #53673b (2.85 -> 4.54); q3 #b35b79 -> #914a62 (3.26 -> 4.55) */
:root[data-palette="kanagawa"] {
--bg: #e5ddb0;
--bg-elev: #f2ecbc;
--bg-sunken: #dcd5ac;
--bg-hover: rgba(67, 67, 80, 0.06);
--bg-active: rgba(67, 67, 80, 0.11);
--fg: #434350;
--fg-muted: #636155;
--fg-faint: #636051;
--border: #d5cea3;
--border-strong: #807c65;
--accent: #624c83;
--accent-fg: #ffffff;
--accent-soft: #d3c9aa;
--accent-soft-fg: #604a80;
--danger: #ac3747;
--danger-soft: rgba(172, 55, 71, 0.15);
--warn: #934e00;
--warn-soft: rgba(147, 78, 0, 0.15);
--success: #53673b;
--success-soft: rgba(83, 103, 59, 0.15);
--link: #47618f;
--unread-bg: #ffffff;
--read-bg: #e0d8ad;
--selected-bg: #d3c9aa;
--focus-ring: 0 0 0 3px rgba(98, 76, 131, 0.4);
--star: #77713f;
--q1: #47618f;
--q2: #53673b;
--q3: #914a62;
--scrollbar: rgba(99, 97, 85, 0.35);
color-scheme: light;
}
/* kanagawa (dark) lifted for contrast: muted #727169 -> #898881 (3.33 -> 4.59); faint #696866 -> #888886 (2.94 -> 4.60); danger #e82424 -> #ed5050 (3.66 -> 4.56); border-strong #4f4e57 -> #696970 (1.99 -> 3.00) */
:root[data-theme="dark"][data-palette="kanagawa"] {
--bg: #1f1f28;
--bg-elev: #2a2a37;
--bg-sunken: #16161d;
--bg-hover: rgba(220, 215, 186, 0.06);
--bg-active: rgba(220, 215, 186, 0.11);
--fg: #dcd7ba;
--fg-muted: #898881;
--fg-faint: #888886;
--border: #363646;
--border-strong: #696970;
--accent: #7e9cd8;
--accent-fg: #1f1f28;
--accent-soft: rgba(126, 156, 216, 0.16);
--accent-soft-fg: #7e9cd8;
--danger: #ed5050;
--danger-soft: rgba(237, 80, 80, 0.15);
--warn: #ff9e3b;
--warn-soft: rgba(255, 158, 59, 0.15);
--success: #98bb6c;
--success-soft: rgba(152, 187, 108, 0.15);
--link: #7fb4ca;
--unread-bg: #2a2a37;
--read-bg: #16161d;
--selected-bg: rgba(126, 156, 216, 0.18);
--focus-ring: 0 0 0 3px rgba(126, 156, 216, 0.4);
--star: #e6c384;
--q1: #7fb4ca;
--q2: #98bb6c;
--q3: #d27e99;
--scrollbar: rgba(137, 136, 129, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* everforest (light) lifted for contrast: fg #5c6a72 -> #444e54 (4.66 -> 7.11); muted #939f91 -> #646c63 (2.30 -> 4.53); faint #8e9285 -> #686b61 (2.65 -> 4.53); link #3a94c5 -> #2c7096 (2.81 -> 4.53); danger #f85552 -> #b83f3d (2.73 -> 4.59); warn #f57d26 -> #a45419 (2.23 -> 4.53); success #8da101 -> #616f01 (2.42 -> 4.63); accent #8da101 -> #7d8f01 (2.42 -> 3.02); star #dfa000 -> #b07e00 (1.91 -> 3.00); border-strong #abb1a1 -> #84887c (1.84 -> 3.02); q1 #3a94c5 -> #2c7096 (2.81 -> 4.53); q2 #8da101 -> #616f01 (2.42 -> 4.63); q3 #df69ba -> #9e4b84 (2.55 -> 4.61) */
:root[data-palette="everforest"] {
--bg: #efebd4;
--bg-elev: #fdf6e3;
--bg-sunken: #e6e2cc;
--bg-hover: rgba(68, 78, 84, 0.06);
--bg-active: rgba(68, 78, 84, 0.11);
--fg: #444e54;
--fg-muted: #646c63;
--fg-faint: #686b61;
--border: #bdc3af;
--border-strong: #84887c;
--accent: #7d8f01;
--accent-fg: #ffffff;
--accent-soft: #dfdeb6;
--accent-soft-fg: #5a6701;
--danger: #b83f3d;
--danger-soft: rgba(184, 63, 61, 0.15);
--warn: #a45419;
--warn-soft: rgba(164, 84, 25, 0.15);
--success: #616f01;
--success-soft: rgba(97, 111, 1, 0.15);
--link: #2c7096;
--unread-bg: #ffffff;
--read-bg: #eae6d0;
--selected-bg: #dfdeb6;
--focus-ring: 0 0 0 3px rgba(125, 143, 1, 0.4);
--star: #b07e00;
--q1: #2c7096;
--q2: #616f01;
--q3: #9e4b84;
--scrollbar: rgba(100, 108, 99, 0.35);
color-scheme: light;
}
/* everforest (dark) lifted for contrast: muted #859289 -> #949f97 (3.84 -> 4.55); faint #757f7b -> #969e9b (3.02 -> 4.55); border-strong #5c6364 -> #787e7e (2.03 -> 3.02) */
:root[data-theme="dark"][data-palette="everforest"] {
--bg: #2d353b;
--bg-elev: #343f44;
--bg-sunken: #232a2e;
--bg-hover: rgba(211, 198, 170, 0.06);
--bg-active: rgba(211, 198, 170, 0.11);
--fg: #d3c6aa;
--fg-muted: #949f97;
--fg-faint: #969e9b;
--border: #475258;
--border-strong: #787e7e;
--accent: #a7c080;
--accent-fg: #2d353b;
--accent-soft: rgba(167, 192, 128, 0.16);
--accent-soft-fg: #a7c080;
--danger: #e67e80;
--danger-soft: rgba(230, 126, 128, 0.15);
--warn: #e69875;
--warn-soft: rgba(230, 152, 117, 0.15);
--success: #a7c080;
--success-soft: rgba(167, 192, 128, 0.15);
--link: #7fbbb3;
--unread-bg: #343f44;
--read-bg: #232a2e;
--selected-bg: rgba(167, 192, 128, 0.18);
--focus-ring: 0 0 0 3px rgba(167, 192, 128, 0.4);
--star: #dbbc7f;
--q1: #7fbbb3;
--q2: #a7c080;
--q3: #d699b6;
--scrollbar: rgba(148, 159, 151, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* primer (light) lifted for contrast: faint #889098 -> #6b7278 (3.04 -> 4.58); star #bf8700 -> #bd8600 (2.95 -> 3.00); border-strong #b7bfc5 -> #898f94 (1.75 -> 3.07) */
:root[data-palette="primer"] {
--bg: #f6f8fa;
--bg-elev: #ffffff;
--bg-sunken: #eff2f5;
--bg-hover: rgba(37, 41, 46, 0.06);
--bg-active: rgba(37, 41, 46, 0.11);
--fg: #25292e;
--fg-muted: #59636e;
--fg-faint: #6b7278;
--border: #d1d9e0;
--border-strong: #898f94;
--accent: #0969da;
--accent-fg: #ffffff;
--accent-soft: #d5e4f6;
--accent-soft-fg: #0861c9;
--danger: #cf222e;
--danger-soft: rgba(207, 34, 46, 0.15);
--warn: #9a6700;
--warn-soft: rgba(154, 103, 0, 0.15);
--success: #1a7f37;
--success-soft: rgba(26, 127, 55, 0.15);
--link: #0550ae;
--unread-bg: #ffffff;
--read-bg: #f0f2f4;
--selected-bg: #d5e4f6;
--focus-ring: 0 0 0 3px rgba(9, 105, 218, 0.4);
--star: #bd8600;
--q1: #0550ae;
--q2: #116329;
--q3: #8250df;
--scrollbar: rgba(89, 99, 110, 0.35);
color-scheme: light;
}
/* primer (dark) lifted for contrast: faint #697078 -> #767d84 (3.78 -> 4.54); border-strong #585f67 -> #5a6169 (2.93 -> 3.02) */
:root[data-theme="dark"][data-palette="primer"] {
--bg: #0d1117;
--bg-elev: #151b23;
--bg-sunken: #010409;
--bg-hover: rgba(240, 246, 252, 0.06);
--bg-active: rgba(240, 246, 252, 0.11);
--fg: #f0f6fc;
--fg-muted: #9198a1;
--fg-faint: #767d84;
--border: #3d444d;
--border-strong: #5a6169;
--accent: #58a6ff;
--accent-fg: #0d1117;
--accent-soft: rgba(88, 166, 255, 0.16);
--accent-soft-fg: #58a6ff;
--danger: #ff7b72;
--danger-soft: rgba(255, 123, 114, 0.15);
--warn: #e3b341;
--warn-soft: rgba(227, 179, 65, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--link: #79c0ff;
--unread-bg: #151b23;
--read-bg: #010409;
--selected-bg: rgba(88, 166, 255, 0.18);
--focus-ring: 0 0 0 3px rgba(88, 166, 255, 0.4);
--star: #d29922;
--q1: #79c0ff;
--q2: #56d364;
--q3: #d2a8ff;
--scrollbar: rgba(145, 152, 161, 0.35);
color-scheme: dark;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
}
/* === generated palettes: end === */
/* Accent variants */
@@ -618,6 +1068,9 @@ img { max-width: 100%; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: var(--shadow-1); transition: transform .15s var(--ease); }
.switch[aria-checked="true"] { background: var(--accent); }
.switch[aria-checked="true"]::after { transform: translateX(18px); }
/* A switch that depends on another one above it. Without this it reads as an
ordinary "off", and clicking it does nothing with no explanation. */
.switch:disabled { opacity: .45; cursor: not-allowed; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.switch-row:last-child { border-bottom: 0; }
.switch-row .switch-text { display: flex; flex-direction: column; gap: 2px; }
@@ -660,7 +1113,30 @@ a.menu-item:hover { color: var(--fg); }
:root[data-theme="dark"] .tooltip { background: #e5e9f0; color: #0b1220; }
/* Dialogs ---------------------------------------------------------------- */
.dialog-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); }
/*
The stacking order everything below is a point on, low to high:
700 mobile tab bar, FAB
940 drawer backdrop
950 mobile navigation drawer
955 composer dock
960 dialog backdrop
1000 popover
2000 tooltip
3000 toast
5000 drag ghost
The drawer is where a phone reaches everything, so it is also where a phone
starts a folder, an event, a contact and a message -- and each of those
answers with a dialog or a full-screen composer that the drawer then covered,
because both were stacked below it. A modal has to outrank the navigation
that opened it, or the drawer sits on top of the thing it just asked for and
the only way out is a press on the dimmed strip beside it.
Above the composer, not level with it: a dialog raised from inside a composer
-- the discard guard, the attachment picker -- has to clear the composer too.
*/
.dialog-backdrop { position: fixed; inset: 0; z-index: 960; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.dialog { background: var(--bg-elev); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); width: 100%; max-width: 520px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; animation: rise .18s var(--ease); border: 1px solid var(--border); }
@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
@@ -1067,7 +1543,8 @@ a.menu-item:hover { color: var(--fg); }
/* ==========================================================================
Composer
========================================================================== */
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 800; pointer-events: none; }
/* Above the mobile drawer, below a dialog -- see the stack by `.dialog-backdrop`. */
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 955; pointer-events: none; }
.composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); }
.composer.minimized { height: 44px; width: 280px; }
.composer.maximized { position: fixed; inset: 24px; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); }
@@ -1203,6 +1680,13 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.contact-row.active { background: var(--selected-bg); }
.contact-row .c-name { font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-row .c-email { color: var(--fg-muted); font-size: .85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-row.picked { background: var(--selected-bg); }
/* Out of the way until wanted, and never on a touchscreen, where there is no
hover to reveal it -- the same rule the message list's checkbox follows. */
.contact-check { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; flex: 0 0 auto; opacity: 0; transition: opacity .1s; cursor: pointer; }
.contact-row:hover .contact-check, .contact-row.picked .contact-check, .contacts-scroll.has-selection .contact-check { opacity: 1; }
.contacts-selbar .contact-check { opacity: 1; }
.contacts-selbar { gap: 12px; padding-left: 14px; }
.contact-letter { position: sticky; top: 0; background: var(--bg-sunken); padding: 4px 14px; font-size: .78em; font-weight: 700; color: var(--fg-muted); letter-spacing: .05em; z-index: 1; }
.contact-detail { overflow-y: auto; padding: 28px 32px 64px; }
.contact-hero { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
@@ -1576,6 +2060,7 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
}
@media (hover: none) {
.msg-row .msg-check { opacity: 1; }
.contact-row .contact-check { opacity: 1; }
.msg-row .msg-actions { display: none !important; }
.msg-row:hover .msg-meta .msg-date { display: inline; }
.nav-item .nav-more, .cal-list-item .nav-more { opacity: 1; }
@@ -1773,6 +2258,20 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
/* The banner naming a sender from outside the organisation. */
.remote-banner.external-banner { background: var(--warn-soft); border-color: var(--warn); }
/*
* The signature banner's colour is the claim it is making, so the quiet tones
* are deliberate. "Signed by somebody new" is grey, because an unknown
* certificate that verifies against itself has established nothing worth a
* green tick; green is kept for the one case that earned it, a signer matching
* what was pinned the first time. Red is for a signer that changed.
*/
.remote-banner.signature-banner { align-items: flex-start; }
.remote-banner.signature-banner.good { background: var(--success-soft); color: var(--success); }
.remote-banner.signature-banner.warn { background: var(--warn-soft); color: var(--warn); }
.remote-banner.signature-banner.danger { background: var(--danger-soft); color: var(--danger); }
.remote-banner.signature-banner.quiet { background: var(--bg-sunken); color: var(--fg-muted); }
.remote-banner.signature-banner .sessions-table td { padding: 2px 8px 2px 0; }
.remote-banner.signature-banner .hint { opacity: .8; }
/* The line offering the whole folder once the page is selected. */
.list-hint.select-all-hint { background: var(--accent-soft); color: var(--accent-soft-fg); }
+4 -2
View File
@@ -151,8 +151,10 @@ export function AppShell({ children }: { children: ReactNode }) {
Putting a close where the hamburger was means the second press
lands on the control that undoes the first, which is where the hand
is already going. It cannot be done by raising the top bar over the
drawer instead: it would then also sit over a full-screen composer,
which is stacked lower still.
drawer instead: the top bar sits under everything that takes the
screen -- see the stack by `.dialog-backdrop` -- and lifting it
past the drawer would put it in among the composer and the
dialogs, which it has no business covering.
*/}
{isMobile && (
<div className="drawer-head">
+3 -3
View File
@@ -13,7 +13,7 @@ import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { WEEKDAY_KEYS, weekdayOptions, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
import { availabilityWindow } from "@/lib/availabilityWindow";
import { askEditScope, droppedMessage, runScoped } from "./scope";
@@ -359,7 +359,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</select>
)}
{!oneDate && (
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAY_KEYS[(start.getDay() + 6) % 7]! }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">{translate("Does not repeat")}</option>
<option value="daily">{translate("Daily")}</option>
<option value="weekly">{translate("Weekly on {weekday}", { weekday: formatWeekday(start, "long") })}</option>
@@ -381,7 +381,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</div>
{customRule.frequency === "weekly" && (
<div className="row" style={{ gap: 4, marginTop: 8 }}>
{WEEKDAYS.map((w) => {
{weekdayOptions().map((w) => {
const on = customRule.byDay?.some((d) => d.day === w.key);
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
})}
+47 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Book, BookOpen, Download, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
import { Book, BookOpen, Download, Eraser, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { setErrorMessage } from "@/jmap/client";
import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings";
import type { AddressBook } from "@/jmap/types";
@@ -278,6 +279,51 @@ export function ContactsSidebar() {
/>
)}
<MenuSep />
{/*
The operation a migration actually asks for: import, notice
something is wrong, empty the book, correct the export, import
again. Offered on your own books only -- emptying somebody else's
is a write to their account, which this client cannot make.
Kept apart from Delete, which takes the book with it. A default
book cannot be deleted and can perfectly well be emptied, which
is most of why this is worth having as its own entry.
*/}
<MenuItem
danger
icon={<Eraser size={16} />}
label={t("Empty address book")}
onClick={async () => {
const n = Object.values(contacts.cards).filter((c) => c.addressBookIds?.[menuBook.id]).length;
if (!n) { toast.error(t("There is nothing in it to delete")); return; }
if (!(await confirmDialog({
title: t("Empty “{name}”?", { name: menuBook.name }),
message: plural(n, {
one: "{n} contact will be deleted. This cannot be undone.",
other: "{n} contacts will be deleted. This cannot be undone.",
}),
confirmLabel: t("Delete them"),
danger: true,
}))) return;
try {
const { destroyed, unfiled, refused } = await contacts.emptyBook(menuBook.id);
if (destroyed) toast.success(plural(destroyed, { one: "Deleted {n} contact", other: "Deleted {n} contacts" }));
/* Said out loud, because it is the one part of emptying a
book that is not a deletion and would otherwise look like
contacts that refused to go. */
if (unfiled) {
toast.show(plural(unfiled, {
one: "{n} was also in another address book and was only removed from this one",
other: "{n} were also in other address books and were only removed from this one",
}), { duration: 9000 });
}
if (refused) toast.error(t("Some could not be deleted: {error}", { error: setErrorMessage(refused) }));
else if (!destroyed && !unfiled) toast.error(t("Nothing was deleted"));
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
<MenuItem
danger
icon={<Trash2 size={16} />}
+115 -14
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users } from "lucide-react";
import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { setErrorMessage } from "@/jmap/client";
import { useCompose } from "@/store/compose";
import type { ContactCard } from "@/jmap/types";
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
@@ -24,12 +25,31 @@ export function ContactsView({ id }: { id?: string }) {
const bookId = sel.bookId;
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
const openCompose = useCompose((s) => s.open);
/*
* Ticked rows, and the last one ticked so a shift-click has something to
* reach back to. Kept here rather than in the store: this is the only list
* of contacts there is, and nothing outside this view acts on a selection.
*
* Only ever your own cards. Deleting somebody else's contact is a write to
* their account, which is not a thing this client can do -- see `readOnly`.
*/
const [picked, setPicked] = useState<Record<string, true>>({});
const lastPicked = useRef<string | null>(null);
const readOnly = Boolean(sel.accountId);
useEffect(() => {
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contacts.available, contacts.loaded]);
/* A selection belongs to the book it was made in. Carrying it across to
another book would leave a count on screen describing rows that are no
longer there, and a Delete button aimed at them. */
useEffect(() => {
setPicked({});
lastPicked.current = null;
}, [bookId, sel.accountId]);
useEffect(() => {
const onNew = () => setEditing({});
/*
@@ -88,6 +108,10 @@ export function ContactsView({ id }: { id?: string }) {
}
return out;
}, [list]);
/* Ticked *and* on screen. A selection outlives a search box being typed
into, and deleting rows that scrolled out of view is not what the count
on the bar promised. */
const pickedIds = useMemo(() => list.filter((c) => picked[c.id]).map((c) => c.id), [list, picked]);
if (!contacts.available) {
return <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>;
@@ -153,9 +177,10 @@ export function ContactsView({ id }: { id?: string }) {
else toast.success(imported);
/*
* Said separately, and after, because it is a different kind of fact.
* LDIF has no UID to match on, so nothing was updated and nothing was
* merged -- these are simply here twice now, and saying so is the whole
* of what can honestly be said without guessing (#223).
* These were not matched and are here twice now -- an LDIF entry whose
* `dn` moved between exports, or one imported before there was a `dn` to
* match on. Name-plus-email is enough to notice that and not enough to
* merge on, so it is reported and left alone (#223).
*/
if (alike) {
toast.show(plural(alike, {
@@ -168,18 +193,84 @@ export function ContactsView({ id }: { id?: string }) {
}
};
/* Ticking a box, with shift reaching back to the last one ticked. The range
is taken from `list`, so it is the rows as they are grouped and sorted on
screen rather than the order the store happens to hold them in. */
const tick = (cardId: string, on: boolean, range: boolean) => {
/* The anchor is read here and not inside the updater below. React runs an
updater when it gets round to rendering, by which time the ref has
already been moved to this row -- so the range would be measured from
the row that ended it and collapse to that one row. */
const anchor = range ? lastPicked.current : null;
const a = anchor ? list.findIndex((c) => c.id === anchor) : -1;
const b = list.findIndex((c) => c.id === cardId);
const ids = a >= 0 && b >= 0
? list.slice(Math.min(a, b), Math.max(a, b) + 1).map((c) => c.id)
: [cardId];
setPicked((prev) => {
const next = { ...prev };
for (const i of ids) { if (on) next[i] = true; else delete next[i]; }
return next;
});
lastPicked.current = cardId;
};
const clearPicked = () => { setPicked({}); lastPicked.current = null; };
const deletePicked = async () => {
const n = pickedIds.length;
if (!n) return;
if (!(await confirmDialog({
title: plural(n, { one: "Delete {n} contact?", other: "Delete {n} contacts?" }),
message: translate("This cannot be undone."),
confirmLabel: translate("Delete"),
danger: true,
}))) return;
try {
/* What the server confirmed, not what was asked. A refusal that took
half of them still deleted the other half, and saying "it failed"
sends you looking for contacts that are already gone. */
const { destroyed, refused } = await contacts.destroyCards(pickedIds);
clearPicked();
if (destroyed) toast.success(plural(destroyed, { one: "Deleted {n} contact", other: "Deleted {n} contacts" }));
if (refused) toast.error(translate("Some could not be deleted: {error}", { error: setErrorMessage(refused) }));
if (destroyed && id && pickedIds.includes(id)) navigate("/contacts");
} catch (err) {
toast.error((err as Error).message);
}
};
return (
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
<section className="contacts-list">
<div className="list-search row">
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={16} className="muted" />
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
{pickedIds.length ? (
/* The search box gives way rather than sitting alongside: what the
bar counts is what the search left on screen, so leaving the box
where it is invites narrowing the list under your own selection. */
<div className="list-search row contacts-selbar">
<input
type="checkbox"
className="contact-check"
checked={pickedIds.length === list.length}
ref={(el) => { if (el) el.indeterminate = pickedIds.length > 0 && pickedIds.length < list.length; }}
onChange={(e) => { if (e.target.checked) { setPicked(Object.fromEntries(list.map((c) => [c.id, true as const]))); } else clearPicked(); }}
aria-label={translate("Select all")}
/>
<span className="grow">{plural(pickedIds.length, { one: "{n} selected", other: "{n} selected" })}</span>
<button className="icon-btn" title={translate("Delete")} onClick={() => void deletePicked()}><Trash2 size={19} /></button>
<button className="icon-btn" title={translate("Clear selection")} onClick={clearPicked}><X size={19} /></button>
</div>
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
</div>
<div className="contacts-scroll">
) : (
<div className="list-search row">
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={16} className="muted" />
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
</div>
)}
<div className={`contacts-scroll ${pickedIds.length ? "has-selection" : ""}`}>
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
<Empty icon={<Users size={36} />} title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("Add a contact or import a vCard file.")}</Empty>
) : groups.map((g) => (
@@ -189,7 +280,17 @@ export function ContactsView({ id }: { id?: string }) {
const email = contactEmails(c)[0]?.email;
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
return (
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
{!readOnly && (
<input
type="checkbox"
className="contact-check"
checked={Boolean(picked[c.id])}
onClick={(ev) => { ev.stopPropagation(); tick(c.id, !picked[c.id], ev.shiftKey); }}
onChange={() => {}}
aria-label={translate("Select")}
/>
)}
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
<div className="grow" style={{ minWidth: 0 }}>
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> {translate("· group")}</span> : null}</div>
@@ -233,7 +334,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { await contacts.destroyCards([c.id]); toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div>
<div className="contact-hero">
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
@@ -0,0 +1,142 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ContactsView } from "../ContactsView";
import { useContacts } from "@/store/contacts";
import type { ContactCard, Id } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* Ticking rows in the contacts list.
*
* Written after the browser caught what the store tests could not: the range a
* shift-click covers was being measured inside the `setPicked` updater, which
* React runs when it gets round to rendering -- by which time the anchor ref
* has already been moved to the row that *ended* the range. Every shift-click
* selected exactly one row, and every assertion about the store still passed,
* because nothing was wrong below the component.
*/
const card = (id: string, full: string) => ({
id, uid: `uid-${id}`, name: { full }, emails: {}, kind: "individual",
addressBookIds: { book1: true },
}) as unknown as ContactCard;
/** Six people, in the order the list sorts them. */
const PEOPLE = ["Ada", "Bea", "Cal", "Dev", "Eve", "Fay"].map((n, i) => card(`c${i}`, `${n} Person`));
describe("selecting contacts in the list", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
await act(async () => { root.render(<ContactsView />); });
};
const boxes = () => [...host.querySelectorAll<HTMLInputElement>(".contact-row .contact-check")];
const click = async (el: Element, shiftKey = false) => {
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey }));
});
};
const ticked = () => boxes().filter((b) => b.checked).length;
beforeEach(async () => {
/* jsdom has no matchMedia, and the layout asks whether the window is
narrow before it draws anything. */
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false, media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {},
addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false,
}));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
useContacts.setState({
accountId: "a1", available: true, loaded: true, loading: false,
books: {}, sharedCards: {},
cards: Object.fromEntries(PEOPLE.map((c) => [c.id, c])) as Record<Id, ContactCard>,
selection: { accountId: null, bookId: "all" },
});
await render();
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("puts a checkbox on every row", () => {
expect(boxes()).toHaveLength(PEOPLE.length);
expect(ticked()).toBe(0);
});
it("ticks one row on a plain click", async () => {
await click(boxes()[0]!);
expect(ticked()).toBe(1);
});
it("takes the whole run on a shift-click", async () => {
// The regression. This was 2 before the anchor moved out of the updater.
await click(boxes()[0]!);
await click(boxes()[5]!, true);
expect(ticked()).toBe(6);
});
it("reaches backwards as readily as forwards", async () => {
await click(boxes()[4]!);
await click(boxes()[1]!, true);
expect(ticked()).toBe(4);
});
it("unticks a run when the row shift-clicked was already ticked", async () => {
await click(boxes()[0]!);
await click(boxes()[5]!, true);
await click(boxes()[2]!, true);
// Rows 2..5 come off, 0 and 1 stay.
expect(ticked()).toBe(2);
});
it("treats a shift-click with nothing ticked yet as an ordinary one", async () => {
await click(boxes()[3]!, true);
expect(ticked()).toBe(1);
});
it("moves the anchor to the row last clicked", async () => {
await click(boxes()[0]!);
await click(boxes()[2]!);
await click(boxes()[4]!, true);
// From 2, not from 0: rows 2,3,4 plus the 0 already ticked.
expect(ticked()).toBe(4);
});
it("shows a selection bar instead of the search box, counting what is ticked", async () => {
expect(host.querySelector(".contacts-selbar")).toBeNull();
await click(boxes()[0]!);
await click(boxes()[2]!, true);
expect(host.querySelector(".contacts-selbar")?.textContent).toContain("3");
expect(host.querySelector(".search-input")).toBeNull();
});
it("does not open the contact it just ticked", async () => {
// The checkbox sits inside the row, whose own click navigates.
await click(boxes()[0]!);
expect(host.querySelector(".contact-row.picked")).not.toBeNull();
expect(ticked()).toBe(1);
});
it("clears the selection when the book being shown changes", async () => {
await click(boxes()[0]!);
await click(boxes()[3]!, true);
expect(ticked()).toBe(4);
await act(async () => {
useContacts.setState({ selection: { accountId: null, bookId: "book1" } });
});
// A count describing rows from another book, with Delete aimed at them,
// is the thing this avoids.
expect(ticked()).toBe(0);
expect(host.querySelector(".contacts-selbar")).toBeNull();
});
});
+26 -10
View File
@@ -10,13 +10,15 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { SignatureBanner } from "./SignatureBanner";
import { useSignature } from "@/lib/smime/useSignature";
import { emlFilename } from "@/lib/emlName";
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
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, sanitizeEmailHtml } from "@/lib/html";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, textToHtml } from "@/lib/text";
@@ -47,6 +49,7 @@ interface Props {
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
const accountId = useMail((s) => s.accountId)!;
const signature = useSignature(e, accountId);
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
@@ -135,6 +138,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw);
const themeMessageBody = settings.themeMessageBody;
const themeStyledMessages = settings.themeStyledMessages;
// Inline images map
const cidMap = useMemo(() => {
@@ -155,12 +159,20 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
return null;
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]);
// Mail that paints itself keeps the light card it was designed for; the rest
// can follow the app theme when the user has asked for that.
const themed = useMemo(
() => themeMessageBody && Boolean(rendered) && !htmlDeclaresColors(rendered!.html, rendered!.bodyStyle),
[themeMessageBody, rendered],
/*
* Mail that paints itself keeps the light card it was designed for, unless
* the reader has asked for the theme over that too.
*
* `forced` is the second switch and is narrower than `themed`: it only turns
* on for mail that actually declares colours, so plain mail is themed the
* gentle way and never pays for the override rules.
*/
const declaresColors = useMemo(
() => Boolean(rendered) && htmlDeclaresColors(rendered!.html, rendered!.bodyStyle),
[rendered],
);
const themed = themeMessageBody && Boolean(rendered) && (!declaresColors || themeStyledMessages);
const forced = themed && declaresColors;
const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]);
const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]);
@@ -387,6 +399,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</button>
</div>
)}
<SignatureBanner state={signature} />
{externalSender && (
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
<ShieldAlert size={16} />
@@ -408,7 +421,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<div className="message-body">
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} forced={forced} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
@@ -486,7 +499,7 @@ function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => bool
const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"];
function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; onShowImages: () => void }) {
function HtmlBody({ html, bodyStyle, themed, forced, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; forced: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; onShowImages: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -526,9 +539,12 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { htm
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
host.classList.toggle("themed", themed);
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root${themed ? " themed" : ""}" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root${themed ? " themed" : ""}${forced ? " forced" : ""}" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
// Collapse quoted content
const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
// Tell the sender's painted surfaces apart from the sheets they sit on,
// before anything below reshapes the tree.
if (forced && container) markKeptSurfaces(container);
let found = false;
if (container) {
let q: Element | null = null;
@@ -588,7 +604,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { htm
* so a changing handler now costs a listener swap and nothing else.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, bodyStyle, themed]);
}, [html, bodyStyle, themed, forced]);
useEffect(() => {
const root = hostRef.current?.shadowRoot;
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { BadgeCheck, ShieldAlert, ShieldQuestion, ShieldX } from "lucide-react";
import { formatFingerprint } from "@/lib/smime/x509";
import type { SignatureState } from "@/lib/smime/useSignature";
import type { Reason } from "@/lib/smime/verify";
import { t, tNode } from "@/lib/i18n";
import { formatFullDate } from "@/lib/format";
/**
* What a checked signature is allowed to say on screen.
*
* The wording here is the feature. ihasmail has no certificate authority to ask
* and none is bundled, so the strong word "verified", full stop is never
* used: the certificate arrives inside the message, and on its own a good
* signature only shows that whoever wrote the message held the key attached to
* it. What can honestly be said is whether this is the same signer as last
* time, and that is what the banner leads with.
*
* Which means the *reassuring* case is deliberately the quiet one and the
* changed-signer case is the loud one. A green tick on a first sighting would
* be telling somebody that an unknown certificate is trustworthy because it
* verified against itself.
*/
export function SignatureBanner({ state }: { state: SignatureState }) {
const [open, setOpen] = useState(false);
if (state.status !== "done") return null;
const { crypto, trust, previous, warnings } = state.report;
if (crypto.kind === "none") return null;
if (crypto.kind === "unsupported") {
return (
<Banner tone="quiet" icon={<ShieldQuestion size={16} />}>
<span className="grow">
{t("This message is signed, and ihasmail could not check the signature.")} {explain(crypto.reason)}
{crypto.detail && <span className="hint"> {crypto.detail}</span>}
</span>
</Banner>
);
}
if (crypto.kind === "broken") {
return (
<Banner tone="danger" icon={<ShieldX size={16} />}>
<span className="grow">
<strong>{t("This signature does not check out.")}</strong> {explain(crypto.reason)}
</span>
</Banner>
);
}
const name = crypto.cert.subject.commonName || crypto.cert.emails[0] || t("an unnamed signer");
const changed = trust === "changed";
const mismatch = warnings.includes("address-mismatch");
const tone = changed || mismatch ? "danger" : warnings.length > 0 ? "warn" : trust === "same-as-before" ? "good" : "quiet";
return (
<Banner tone={tone} icon={changed || mismatch ? <ShieldAlert size={16} /> : trust === "same-as-before" ? <BadgeCheck size={16} /> : <ShieldQuestion size={16} />}>
<span className="grow">
{changed ? (
<>
<strong>{t("The signer has changed.")}</strong>{" "}
{tNode("Earlier messages from this address were signed by {previous}. This one is signed by {current}.", {
previous: <strong className="notranslate" translate="no">{previous?.name ?? t("a different certificate")}</strong>,
current: <strong className="notranslate" translate="no">{name}</strong>,
})}{" "}
{t("That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.")}
</>
) : mismatch ? (
<>
<strong>{t("The signature is not for this sender.")}</strong>{" "}
{tNode("It was made with a certificate belonging to {name}, which does not cover this address.", {
name: <strong className="notranslate" translate="no">{name}</strong>,
})}
</>
) : trust === "same-as-before" ? (
tNode("Signed by {name} — the same signer as before.", { name: <strong className="notranslate" translate="no">{name}</strong> })
) : (
<>
{tNode("Signed by {name}, seen here for the first time.", { name: <strong className="notranslate" translate="no">{name}</strong> })}{" "}
{t("ihasmail will tell you if a later message from this address is signed by anybody else.")}
</>
)}
{warnings.includes("certificate-expired") && <> {t("The certificate has expired.")}</>}
{warnings.includes("certificate-not-yet-valid") && <> {t("The certificate is not valid yet.")}</>}
</span>
<button onClick={() => setOpen(!open)}>{open ? t("Hide details") : t("Details")}</button>
{open && (
<table className="sessions-table" style={{ marginTop: 8, width: "100%" }}>
<tbody>
<tr>
<td>{t("Signer")}</td>
<td className="notranslate" translate="no">{name}</td>
</tr>
<tr>
<td>{t("Certificate covers")}</td>
<td className="notranslate" translate="no">{crypto.cert.emails.join(", ") || t("no address")}</td>
</tr>
<tr>
<td>{t("Issued by")}</td>
<td className="notranslate" translate="no">{crypto.cert.issuer.commonName || crypto.cert.issuer.organization || t("itself, or an issuer it does not name")}</td>
</tr>
<tr>
<td>{t("Valid until")}</td>
<td>{formatFullDate(crypto.cert.notAfter.toISOString())}</td>
</tr>
{crypto.signer.signingTime && (
<tr>
<td>{t("Signed at")}</td>
<td>
{formatFullDate(crypto.signer.signingTime.toISOString())} <span className="hint">{t("as claimed by the signer")}</span>
</td>
</tr>
)}
<tr>
<td>{t("Fingerprint")}</td>
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
{formatFingerprint(crypto.cert.fingerprint)}
</td>
</tr>
{previous && (
<tr>
<td>{t("Previous fingerprint")}</td>
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
{formatFingerprint(previous.fingerprint)} <span className="hint">{t("first seen {date}", { date: formatFullDate(previous.firstSeen) })}</span>
</td>
</tr>
)}
</tbody>
</table>
)}
</Banner>
);
}
/** The sayable version of why a check did not happen, or did not hold. */
function explain(reason: Reason): string {
switch (reason) {
case "openpgp":
return t("It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.");
case "rsa-pss":
return t("It uses a signature algorithm ihasmail cannot check yet.");
case "no-certificate":
return t("The signature carries no certificate that can be read.");
case "not-signed-properly":
return t("The signed part is missing either the message or the signature.");
case "digest-mismatch":
return t("The message does not match what was signed — it was altered after signing, or damaged on the way.");
case "signature-mismatch":
return t("The signature does not match the certificate sent with it.");
case "other":
return t("The signature could not be read.");
}
}
function Banner({ tone, icon, children }: { tone: "good" | "warn" | "danger" | "quiet"; icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className={`remote-banner signature-banner ${tone}`} style={{ margin: "0 16px 8px" }}>
{icon}
{children}
</div>
);
}
@@ -0,0 +1,130 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignatureBanner } from "../SignatureBanner";
import type { SignatureState } from "@/lib/smime/useSignature";
import type { Certificate } from "@/lib/smime/x509";
import type { SignerInfo } from "@/lib/smime/cms";
import type { SignatureReport } from "@/lib/smime/verify";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/*
* The wording is the feature here, so it is worth asserting rather than
* eyeballing. The rule this pins down: ihasmail has no certificate authority to
* ask, so a signature that merely verifies against the certificate travelling
* beside it must never be dressed as an endorsement. "Verified" full stop is
* the word that would be a lie, and the tone must not be the reassuring one
* until a previous sighting actually corroborates the signer.
*/
const cert = (over: Partial<Certificate> = {}): Certificate =>
({
fingerprint: "ab".repeat(32),
serial: "01",
subject: { commonName: "Ada Lovelace" },
issuer: { commonName: "Ada Lovelace" },
emails: ["[email protected]"],
notBefore: new Date("2026-01-01T00:00:00Z"),
notAfter: new Date("2030-01-01T00:00:00Z"),
spki: new Uint8Array(),
publicKey: { kind: "rsa" },
der: new Uint8Array(),
issuerDer: new Uint8Array(),
...over,
}) as Certificate;
const signer = { digest: "SHA-256" } as SignerInfo;
const done = (report: SignatureReport): SignatureState => ({ status: "done", report });
describe("what the signature banner says", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (state: SignatureState) => {
await act(async () => {
root.render(<SignatureBanner state={state} />);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows nothing at all for an unsigned message", async () => {
await render(done({ crypto: { kind: "none" }, warnings: [] }));
expect(host.textContent).toBe("");
});
it("shows nothing while the check is still running", async () => {
await render({ status: "checking" });
expect(host.textContent).toBe("");
});
it("does not congratulate a signer it has never seen before", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "first-seen", warnings: [] }));
expect(host.textContent).toContain("seen here for the first time");
// Grey, not green: an unknown certificate that verifies against itself has
// established nothing worth a tick.
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
expect(host.textContent).not.toMatch(/\bverified\b/i);
});
it("keeps green for the one case that earned it", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: [] }));
expect(host.textContent).toContain("the same signer as before");
expect(host.querySelector(".signature-banner")?.className).toContain("good");
});
it("is loud when the signer changed, and names both", async () => {
await render(
done({
crypto: { kind: "intact", cert: cert({ subject: { commonName: "Somebody Else" } }), signer },
trust: "changed",
previous: { fingerprint: "cd".repeat(32), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" },
warnings: [],
}),
);
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("The signer has changed");
expect(host.textContent).toContain("Ada Lovelace");
expect(host.textContent).toContain("Somebody Else");
// And tells the reader what to do about it, rather than only that it happened.
expect(host.textContent).toMatch(/some other route/);
});
it("is loud when a good signature is by a certificate for somebody else", async () => {
await render(done({ crypto: { kind: "intact", cert: cert({ emails: ["[email protected]"] }), signer }, trust: "first-seen", warnings: ["address-mismatch"] }));
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("not for this sender");
});
it("calls a failed check a failed check", async () => {
await render(done({ crypto: { kind: "broken", reason: "digest-mismatch" }, warnings: [] }));
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("does not check out");
expect(host.textContent).toContain("altered after signing");
});
it("says it could not check, rather than that the signature is bad", async () => {
await render(done({ crypto: { kind: "unsupported", reason: "openpgp" }, warnings: [] }));
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
expect(host.textContent).toContain("could not check the signature");
expect(host.textContent).toContain("OpenPGP");
// The distinction that matters: "cannot check" is not "does not check out".
expect(host.textContent).not.toContain("does not check out");
});
it("mentions an expired certificate without downgrading the signature", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: ["certificate-expired"] }));
expect(host.textContent).toContain("The certificate has expired.");
expect(host.querySelector(".signature-banner")?.className).toContain("warn");
});
});
+32 -1
View File
@@ -33,6 +33,30 @@ const PALETTE_PREVIEW: Record<PaletteId, { light: string; dark: string }> = {
light: "linear-gradient(135deg,#e6e7ed 0%,#d6d8df 55%,#2959aa 55%,#2959aa 78%,#8c4351 78%)",
dark: "linear-gradient(135deg,#1a1b26 0%,#1f2130 55%,#7aa2f7 55%,#7aa2f7 78%,#bb9af7 78%)",
},
catppuccin: {
light: "linear-gradient(135deg,#e6e9ef 0%,#eff1f5 55%,#8839ef 55%,#8839ef 78%,#ea76cb 78%)",
dark: "linear-gradient(135deg,#1e1e2e 0%,#313244 55%,#cba6f7 55%,#cba6f7 78%,#f5c2e7 78%)",
},
solarized: {
light: "linear-gradient(135deg,#fdf6e3 0%,#eee8d5 55%,#268bd2 55%,#268bd2 78%,#cb4b16 78%)",
dark: "linear-gradient(135deg,#002b36 0%,#073642 55%,#268bd2 55%,#268bd2 78%,#cb4b16 78%)",
},
ayu: {
light: "linear-gradient(135deg,#f8f9fa 0%,#ebeef0 55%,#f29718 55%,#f29718 78%,#55b4d4 78%)",
dark: "linear-gradient(135deg,#0d1017 0%,#10141c 55%,#e6b450 55%,#e6b450 78%,#39bae6 78%)",
},
kanagawa: {
light: "linear-gradient(135deg,#e5ddb0 0%,#f2ecbc 55%,#624c83 55%,#624c83 78%,#b35b79 78%)",
dark: "linear-gradient(135deg,#1f1f28 0%,#2a2a37 55%,#7e9cd8 55%,#7e9cd8 78%,#d27e99 78%)",
},
everforest: {
light: "linear-gradient(135deg,#efebd4 0%,#fdf6e3 55%,#8da101 55%,#8da101 78%,#df69ba 78%)",
dark: "linear-gradient(135deg,#2d353b 0%,#343f44 55%,#a7c080 55%,#a7c080 78%,#d699b6 78%)",
},
primer: {
light: "linear-gradient(135deg,#f6f8fa 0%,#ffffff 55%,#0969da 55%,#0969da 78%,#8250df 78%)",
dark: "linear-gradient(135deg,#0d1117 0%,#151b23 55%,#58a6ff 55%,#58a6ff 78%,#d2a8ff 78%)",
},
};
const MODES: Array<{ id: Mode; label: string }> = [
@@ -83,7 +107,7 @@ export function AppearanceSettings() {
})}
</div>
<p className="hint" style={{ marginTop: 10 }}>
{translate("Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.")}
{translate("Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.")}
</p>
<Switch
checked={s.themeMessageBody}
@@ -91,6 +115,13 @@ export function AppearanceSettings() {
label={translate("Apply the theme to messages too")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
/>
<Switch
checked={s.themeStyledMessages}
disabled={!s.themeMessageBody}
onChange={(v) => update({ themeStyledMessages: v })}
label={translate("Apply it even to mail that styles itself")}
hint={translate("Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.")}
/>
<h2>{translate("Accent color")}</h2>
<div className="swatches">