Commit Graph
699 Commits
Author SHA1 Message Date
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
v2026.9.3-pr272
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