a35f3609521318e6cb8f06bc3e615ab886e0561b
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
3a74f0a715 |
Add GitHub Sponsors funding config
Point the repository Sponsor button at the live LINUXexpert-org GitHub Sponsors listing. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
9245fc5b1e |
Add the third-pass strings to Ukrainian
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
9ed64bea88 |
Add the third-pass strings to Russian
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
21d0320765 |
Add the third-pass strings to Simplified Chinese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
2a5c6a5c18 |
Add the third-pass strings to Japanese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
e5c8594901 |
Add the third-pass strings to Brazilian Portuguese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
d844786e14 |
Add the third-pass strings to Dutch
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
1b75580da2 |
Add the third-pass strings to French
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
c0faed2c2a |
Add the third-pass strings to Spanish
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
bb5a26c343 |
Add the third-pass strings to German
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
be088ed78d |
Third pass: the strings libraries build and views render raw
A sweep of lib/ and store/ for user-facing English, after the views were done. The pattern here is the one #248 found: a module returns an English sentence and the view renders it without asking for a translation. Scheduled send was entirely untranslated. The four presets -- Later today, Tomorrow morning, Tomorrow afternoon, Monday morning -- were rendered raw, and scheduleError() returned three English sentences straight to the picker. describeSpan() built "30 days" by appending an "s" unless the count was one, which is English grammar written into the code: it produces the right German only by the two languages happening to agree, and Russian needs three forms. It is plural() now. scheduleError translates in place rather than returning a key, because it composes a sentence around that span. Read receipts: refusalText() returns five explanations, all rendered raw in MessageView, and the offered case was a template literal -- "Requested, to x@y. Never sent automatically." -- with the address concatenated in. It takes a placeholder now, so the sentence can be reordered. Compose: the sending toast and its Undo, the Open draft action, the two attachment failures, and three thrown errors that surface to the reader as toasts. Two swipe labels, Add star and Remove star, are rendered through t(desc.label) and have never been in any catalogue -- the coverage scan that found the other 176 only looked at t("literal") sites, so labels reaching t() through a variable were invisible to it. Every such table is now enumerated and all 61 values checked: these two were the only ones missing. 17 strings and 3 plural forms are new and land with each language. Verified: typecheck clean, 1000 tests pass. |
||
|
|
20b6475f18 |
Second pass: four dialogs that were never wrapped
A sweep for UI text still rendering in English, after the nine catalogues were
brought up to date. Four dialogs were building their own English:
- The delete confirmation in MailView, entirely: both titles, both messages
and the confirm label. Its counts read "message(s)", which is a
parenthesis standing in for agreement -- every language that inflects got
the wrong form. They are plural() calls now.
- Rename, in the Files tree.
- New address book, and its Name placeholder.
- New category, and its Name placeholder. The button opening that dialog was
already translated, which is how it went unnoticed: the label read right
and the dialog it opened did not.
Rename, New address book, New category, Name and Delete are already in all
nine catalogues. Delete?, Delete forever? and the two plural forms are new and
land with each language.
Verified: typecheck clean, 1000 tests pass.
|
||
|
|
31feb4114a |
Catch the Simplified Chinese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: 您 where the reader is addressed directly and the pronoun dropped everywhere it can be, and the fixed terminology, so 收件箱, 文件夹, 邮件, 会话, 标签, 已删除邮件, 草稿 and 设置 read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from zh-Hans.ts. |
||
|
|
2280df1ea9 |
Catch the Ukrainian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: ви in lowercase, the infinitive for actions, and the terminology that keeps this a Ukrainian catalogue rather than the Russian one with a different name on it -- Тека and not папка, Мітка and not ярлик, which in Ukrainian means a shortcut. Вхідні, Лист, Листування, Кошик, Чернетки and Налаштування read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from uk.ts. |
||
|
|
083039b27e |
Catch the Russian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: вы in lowercase rather than correspondence-style «Вы», the infinitive for actions, and the fixed terminology, so Входящие, Папка, Письмо, Цепочка, Ярлык, Корзина, Черновики and Настройки read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from ru.ts. |
||
|
|
d525b18f6d |
Catch the Japanese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: です・ます throughout, no あなた, bare noun or verb stem on buttons, and the deliberate script mixing -- kanji for the noun carrying the meaning, katakana for the loanword the reader knows, long vowels keeping their ー. So 受信トレイ, フォルダー, メール, スレッド, ラベル, ゴミ箱, 下書き and 設定 read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from ja.ts. |
||
|
|
f5ba09ef77 |
Catch the Brazilian Portuguese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: você rather than the formal address the other Phase 1 languages took, and the fixed terminology, so Caixa de entrada, Pasta, Mensagem, Conversa, Marcador, Lixeira, Rascunhos and Configurações read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from pt-BR.ts. |
||
|
|
3b77fb85fd |
Catch the Dutch catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: u throughout, and the fixed terminology, so Postvak IN, Map, Bericht, Gesprek, Label, Prullenbak, Concepten and Instellingen read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from nl.ts. |
||
|
|
b05cd178e6 |
Catch the French catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: vous throughout, guillemets for quoted names, a plain space before ? and : rather than a narrow no-break one, and the fixed terminology, so Boîte de réception, Dossier, Message, Libellé, Corbeille, Brouillons and Paramètres read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from fr.ts. |
||
|
|
f2437b6904 |
Catch the Spanish catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: usted throughout, peninsular Spanish, and the fixed terminology, so Bandeja de entrada, Carpeta, Mensaje, Conversación, Etiqueta, Papelera, Borradores and Configuración read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from es.ts. |
||
|
|
ba16067529 |
Catch the German catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. A native speaker reviewing this file reported it (#247), which is the review the language was marked Beta waiting for. 176 entries: 153 from features that shipped after the translation pass -- privacy and safety settings, the message ordering controls, subscribed and birthday calendars, label nesting, the file preview, the unsaved-changes prompts -- and 23 keyboard bindings, whose group and description are registered in English at the call site and are translated at render. Follows the decisions this file already pins: Sie throughout, and the fixed terminology, so Ordner, Nachricht, Konversation, Posteingang, Papierkorb, Entwürfe and Label read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English, which is the correct rendering for them: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. A translator should not be invited to translate example.com. Verified: typecheck clean, 1000 tests pass, and a coverage pass over every t() call site reports nothing missing from de.ts. |
||
|
|
b10149fd54 |
Translate three lists the code was rendering raw
A native speaker reviewing the German catalogue reported strings appearing in English (#247). Three of the reported areas turned out not to be missing translations at all: the strings were there, and the code was rendering the English source instead of asking for one. The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly. All sixteen are already in every catalogue -- "Subject" has been "Betreff" in de.ts all along, which is exactly the inconsistency the reporter noticed against the Out-of-office page, where it already reads Betreff. Wrapping the two dropdowns fixes nine languages at once and adds nothing to any catalogue. The keyboard shortcut panel rendered each binding's group and description directly. Those are registered in English at the call sites and should stay that way -- the binding table is data and the English is the catalogue key -- so the panel translates them at render instead. A binding added anywhere is then translatable without its registrar knowing i18n exists. The palette grid marks every name translate="no". That is right for ihasmail, Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an adjective describing the theme, not a name, so PaletteMeta gains a `translatable` flag for the one entry that is a word. Flagging the exception beats dropping the attribute from all six. No catalogue changes here: the strings the first two need are already present in all nine. "Classic" needs an entry, which lands with each language. |
||
|
|
4b2c97df4e |
Prune old images, and keep every release
Two artefacts, opposite answers. Releases stay. They carry no assets -- the image lives in GHCR -- so one costs a tag, a title and generated notes, and with no CHANGELOG in this repository those notes are the only changelog there is. Deleting one destroys history that cannot be reconstructed, and saves nothing. Images accumulate: a multi-architecture build a week, and the by-digest push leaves two untagged per-architecture manifests behind each time on top of the tagged index. Ten tagged versions are kept, which is roughly a quarter of releases and far more than anything anyone rolls back to. The obvious tool for this is a trap. delete-package-versions with `delete-only-untagged-versions` will delete the per-architecture manifests that a multi-arch tag points at, because they are untagged by design, and nothing appears to break: the tag still resolves and pulls simply start failing for one architecture. This action understands manifest lists and leaves a retained index's children alone, `validate` re-checks every multi-arch manifest against the registry afterwards, and `latest` is excluded from consideration entirely. It is pinned to a commit rather than a major tag. It holds `packages: write` and its whole purpose is deletion, so a tag repointed upstream is not a risk worth carrying for the convenience. Kept in its own file and dispatchable, so a dry run can show exactly what would go without rebuilding and re-pushing an image to find out. |
||
|
|
5cb0b2f3ea |
Cut a release once a week, and only when there is something in it
Publishing on release is the right trigger only if releases happen. They had not: main ran 184 commits ahead of the last one, so `:latest` described a build that neither the demo, nor production, nor anyone building from source was running. This is the part that makes the trigger true without anyone having to remember. Mondays at 09:00 UTC. A run with no commits since the last release does nothing at all -- an empty release moves `:latest` to an identical build, spends a version number and mails every watcher about nothing. The decision is written to the run summary either way, so a quiet week reads as a decision rather than as a workflow that failed silently. The awkward part is that a release created with GITHUB_TOKEN raises no `release` event: GitHub refuses to let a token trigger another workflow, to stop a workflow looping on its own output. A scheduled job that cut a release and left publish.yml to notice would tag the commit and never build an image, which is the kind of failure that looks like success. So publish.yml gains a `workflow_call` trigger and this calls it directly. The alternative was a personal access token kept as a secret; this needs no credential. Two smaller decisions. Drafts are excluded when looking for the last release, because an unpublished draft is not a release anybody has and counting from it would hide commits that never shipped. And if the tag a release names has gone, the count falls back to the whole history -- over-counting cuts a release that was due anyway, where under-counting skips one that was not. |
||
|
|
1734ed0439 |
Publish the image the docs have been telling people to pull
README has said `docker run ... ghcr.io/coffey-labs/ihasmail:latest` since the Docker instructions were written, and the docs site repeats it in four places. Nothing ever pushed that image. `docker pull` answers `denied`, because the package does not exist: .github/workflows held ci.yml and nothing else, and there is no reference to ghcr.io, docker/build-push or docker push anywhere in this repo. The instructions have been wrong the whole time. Adds the workflow that makes them true. It fires on a published release, and by hand for a ref -- the same dispatch trigger ci.yml carries, and the only way to build an image for the tags that predate this file. Two architectures on native runners rather than one build under QEMU. Emulated arm64 runs `npm ci` and the Vite build through instruction translation, which takes tens of minutes and sometimes exhausts memory; ubuntu-24.04-arm is free for public repositories and does it at native speed. The cost is pushing by digest and joining the two into one manifest at the end, which is what the third job does. `latest` moves only for a real release. A prerelease that moved it would hand every `:latest` deployment an unfinished build, and a dispatch run has to ask for it deliberately. Also documents the images in README: which tags exist, that the dated tag is the one to pin, and that building it yourself is still fully supported -- `docker compose up --build` is unchanged and the image is a convenience, not a new requirement. Worth knowing before the first run: GHCR creates a new package **private**, even for a public repository, so an anonymous pull will still be refused until the visibility is changed by hand. That is written at the top of the workflow, because it is the failure that looks like success. |
||
|
|
cfcaf5f573 |
Call the instance what it calls itself, on the page that matters most
APP_NAME is a runtime variable and two of the three places showing the name ignored it. The sign-in page fetched /api/config, received the name and used only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one page a new user meets first. The top bar had it written in. Only the document title read it, and it had been reading it from the session all along. The rebranding guide documents both as things to patch yourself, one of them with "if you change nothing else on this page, change this". It should not have to. The sign-in page takes the name from the answer it was already getting. The top bar takes it from the session, where the title has taken it from since it was written. Neither is a new request. One shared default rather than the string written out at three call sites, because three copies of a default is how two of them end up stale. It stands if the config request fails, since a sign-in form with no name on it would be worse than one with the wrong name -- and an empty or non-string name falls back too, so a deployment that sets APP_NAME= does not get a nameless page. Confirmed with APP_NAME set to something else: sign-in heading, top bar and tab title all read it. |
||
|
|
9f4bd65fd5 |
Update a contact on re-import rather than skipping it
#228 skipped a vCard whose UID the book already held. The reporter asked for the opposite on #174 and he is right: the reason to import a file a second time is usually that the first one was not right, so skipping means a corrected export corrects nothing. A merge, not a replacement. Properties the file carries overwrite what is here; properties it does not mention are left alone, so a phone number added in ihasmail after the first import survives a re-import of the original file. The cost is that a field genuinely deleted at the source stays here, which is the better way to be wrong -- the other way round loses work nobody asked to lose. Worth confirming with him rather than assuming. `addressBookIds` is left off the patch. The card is already in this book, so saying it again says nothing, and saying it on a card that is also in another book would move it. Creates and updates now share one batch budget. Stalwart counts every object in a /set together, so batching the halves separately would send 300 new and 300 changed as two calls of 300 and be refused for a limit of 500 that neither half exceeds. LDIF is untouched and still reports look-alikes without acting on them, since what it should match on is the question still open on #223. Both imports keep one answer shape so a caller need not know which it called; LDIF's `updated` is always 0, which is the honest number rather than a missing field. The message a vCard attached to a message shows changes with it: the newer copy now wins instead of being dropped, so it says the contact was brought up to date rather than that nothing was added. Refs #223. |
||
|
|
171c11fc92 |
Choose the Stalwart by the domain somebody signs in with
One ihasmail in front of several Stalwarts, from #238. STALWART_URL stays required and stays the default, so an installation that sets nothing behaves exactly as it always has -- the mapping only adds domains that go elsewhere. An unlisted domain goes to the default. So does a bare username, which Stalwart accepts and which has no domain to map at all. A listed domain never falls back. If its server is unreachable that sign-in fails rather than retrying against the default, because falling back would authenticate somebody against a server their domain was deliberately routed away from -- and if the same account name existed there, they would land in another tenant's mailbox. The fallback is a decision about unmapped domains, taken before any network call, not a recovery path. Smaller than it sounds because only four places read config.stalwartUrl, all in upstream.ts. The upstream session now records which server issued it, since the relative URLs inside it only mean anything against that server, and every route already holding a session gets the right upstream without a second lookup. The client is untouched: it talks to one proxy and never learns there is more than one server behind it, which is exactly why this is small and several-servers-at-once is not. The upstream is derived from the username rather than stored on the session, so a mapping change takes effect on restart instead of being frozen into sessions that outlive it. Validated at boot the way the settings policy is: malformed JSON, a duplicate domain once normalised, a missing file or a value that is not an http(s) URL all stop the server. Domains are lower-cased and stripped of a trailing dot, because that is how one arrives off a username and comparing them any other way means a mapping that silently never matches. The servers themselves are not contacted -- a mapping is a routing table, not a health check, and one customer's outage must not stop ihasmail starting for the other four. Eight tests on the routing, two on the shipped example, and the four refusals checked by hand against a real config load. |
||
|
|
607afeb4ad |
Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right: a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none of which reads as a rejected password. What it got wrong was the counting. RateLimiter.check() consumes an attempt when it is called, and it is called before the upstream is contacted; reset() only runs on success. So every try against an unreachable server burned a credential attempt, and after ten of them the person was locked out for the rest of the fifteen-minute window -- including after the server came back. A thirty-second blip became a quarter-hour lockout, and the second failure was entirely ihasmail's own doing. A 401 is a judgement about the password and stays counted. A 502 or 504 is the upstream failing to answer, says nothing about the credentials, and is now refunded -- one attempt back, not the key cleared, so a run of real failures with an outage in the middle still adds up. The old-server refusal refunds too: those credentials were accepted. Both guessing keys are refunded, not just the username one. Refunding only that would not have fixed it -- ten retries still spend the per-address budget, and behind one office NAT that budget belongs to the whole building, so a company-wide outage would lock out the company. Which needs a backstop, because "not counted" must not mean "unlimited": each attempt still costs an outbound connection that may sit there until UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to abuse. So there is a second ceiling per address, twenty times looser and never refunded. A person retrying will not come near it; something hammering will. Both messages now say the quiet part -- "This is not a problem with your password" -- for somebody already worried they have forgotten it. Closes #239. |
||
|
|
b10ce2f9dd |
Make "Show original" in the headers dialog the action, not a description
The hint at the foot of Message headers named an action and left you to go find it. Requested in #236, and the reporter is right that it is the shape of the thing rather than the size: telling somebody a feature exists is half a job when the other half is one element away. Clicking it now closes the headers dialog and opens the original, so it reads as going deeper rather than as opening a second window. `tNode` rather than a sentence chopped either side of a button: the sentence stays whole for whoever translates it, and a language that puts the verb somewhere else can move the hole rather than being handed two fragments. The link style needed unscoping to work, which turned out to be a bug of its own. `.link-btn` was written for the composer's To and Cc labels and scoped to `.composer-field label`, so the two callers outside it -- the trusted-domain list in Privacy settings, and now this -- rendered as default button chrome in the middle of a sentence. The rule is now unscoped and Privacy is fixed by the same change. Checked in a browser: the hint reads as a sentence with a dotted-underlined link in it, clicking swaps one dialog for the other, and the raw message is there. Closes #236. |
||
|
|
483aac849a |
Go to a folder by name, with g then o
Requested in #233. The `g` shortcuts cover the handful of folders every account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve rule fills, which is where somebody with a real folder tree spends their time. `g o` opens the picker, you type part of a name, and you are there. The picker is the one the move action already uses, with one difference that only shows up on shared mail: it selected folders by `mayAddItems`, which is right for a destination and wrong for a place to go. A shared folder you may read but not file into is somewhere you can visit. The right is now a parameter, named for what it is asking rather than for which caller wants it. Hosted in AppShell rather than in the mail view, because the `g` shortcuts are global and the mail view is not mounted to hear about it -- pressing this from the calendar should still take you to a folder, and now does. `o` on its own opens a conversation and does not clash: a pending prefix is tried before a bare key. That was already true and nothing said so, so there are now five tests for the sequence machinery -- including that an abandoned prefix costs the prefix and not the keystroke after it, which is the nicer behaviour of the two and was undocumented. Checked in a browser against the mock: opened from the calendar, filtered to a nested folder, landed on it, and `o` still opened a conversation afterwards. Closes #233. |
||
|
|
9622875659 |
Say how much an LDIF re-import duplicated, without acting on it
The half of #223 that can move while the matching question is still open. Mozilla's schema defines no UID, so the import invents one and a re-import duplicates everything. Whether to guess an identity from a name and an address instead is the reporter's call and he has not made it -- but the harm that was actually reported was confusion rather than duplication: somebody imports a file twice and cannot tell what happened. So the import now counts how many of the entries look like contacts the book already held, and says so in a second message. Every card is still imported. Nothing is skipped and nothing is merged, which is the point: counting is a different act from matching, and it takes no decision away from the person who still owes us one. The likeness key is name plus one address, and it is wrong in both directions by design -- two colleagues sharing a name and an alias collapse, somebody whose address changed since the last export looks like a stranger. That is tolerable for a number on a toast and would not be tolerable for a merge, which is exactly why the number is all it does. The scan the vCard import already makes for UIDs now collects names and addresses on the same request, so this costs no extra round trip. It is read before anything is created, so a file that repeats a person twice counts as two new cards rather than as a duplicate of itself. If the answer comes back "match on name and email", the matching is written and becomes a skip instead of a count. Refs #223. |
||
|
|
a24b4c5538 |
Ship an example settings policy, and name the variables in .env.example
#231 added the policy but nothing to copy. The repo already answers this the same way four times over -- Caddyfile.example, deploy.example.sh, nginx.example.conf, .env.example -- and the new feature was the one thing configurable here with no example beside it. settings-policy.example.json carries all three sections with the reasoning in it, including the part worth being deliberate about: a `changes` entry overrides a decision a reader has already made, and if you want it to stay put regardless that is `enforced` instead. JSON has no comments, so the commentary is in `_`-prefixed keys, which is safe because the server reads three names and ignores everything else. A test asserts the shipped example stays valid against the rules the parser enforces -- unique versions, settings objects, no comment key colliding with a real section. An example that has drifted is worse than none: somebody copies it, the server refuses to start, and the first experience of the feature is a crash loop. .env.example gains the four variables, commented out, with the file form and the inline form and the note that the file wins over the variables. Confirmed against the real image on the deploy host rather than reasoned about: an immutable container -- --read-only, IMMUTABLE=1, SESSION_FILE= empty -- starts and serves the policy both with a read-only file mount and with the environment variables alone. The feature costs nothing in immutability, because the only thing it writes is the applied-changes stamp, and that goes in the reader's own settings file on Stalwart like every other setting. |
||
|
|
c31a653a04 |
Apply installation policy changes once each, per account
The last third of #207, and the only part that remembers anything. An admin turns a setting on for people who are already here -- which a default cannot do, since a default only seeds an account that has none -- and readers may still turn it back off afterwards, which enforcement does not allow. The difference between the two is entirely in the remembering. Each change carries its own version, and an account stores the ones it has had in its own settings file. Ids rather than a high-water mark, so a change dated earlier than one already applied is not silently skipped -- the reporter's analogy is a schema migration, and this is that shape. Per account rather than per device, because ihasmail's settings are not browser-local: they live in a file in the reader's own JMAP Files, with the browser holding a cache. Signing in on a phone does not apply everything a second time. A change reaches somebody who had already decided otherwise. That is intended and confirmed on the issue: the point is to reach everybody who is already here. It is applied once, and their next decision sticks. One `update` for however many are pending, since each would otherwise push a settings file of its own. Enforced values still win, being applied after. A change whose settings this build does not have at all is dropped rather than recorded, or it would never run on the ihasmail that does have them. The reader is told. A setting moving under somebody without a word is the part of this worth being uneasy about, so the count is toasted with a way into Settings. README gains the Docker half the user asked for: a mounted policy file, the same thing as environment variables for a deployment with no volume, a compose fragment, and the fact that a policy is read once at startup so editing it means a restart. Closes #207. |
||
|
|
457ea53ca3 |
Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders" on for three thousand pupils cannot ask three thousand pupils, and the reporter is right that this is a company policy rather than a preference. Two powers, and the difference between them is the whole request. `defaults` seed an account that has never had settings of its own and can be changed afterwards like anything else -- a starting point, not a rule. `enforced` are reapplied on every load and cannot be changed at all. Enforced controls stay visible and go dead, with a line saying why. The issue asked for that by name: a control that is simply missing reads as a bug to somebody who has used ihasmail without a policy. The lock is in the settings store rather than only on the controls. There is one door -- `update` -- and putting it there means an imported settings file, a settings file synced from a device that predates the policy, and a control somebody adds later and forgets to check are all covered by construction. Reset goes back to the installation's answer rather than to ihasmail's, so it cannot be a way around a policy either. Configured by environment variable or by a file, because ihasmail's own production runs read-only with no volume: an installation that cannot mount a file can still set a variable. Keys this build does not have are dropped, the same rule an imported settings file already gets -- a policy written against a newer ihasmail must not put a setting nothing reads into everybody's synced settings file. Malformed JSON stops the server rather than quietly doing nothing, since a policy that silently did not apply is indistinguishable from the feature not working. Tier three -- enforcing a setting once while still letting readers change it afterwards -- is not here. It needs a decision the reporter and I have not made yet, and it is the only part that stores anything new. Refs #207. |
||
|
|
0e58b886b5 |
Define the zones an export names, instead of only naming them
#227 emitted TZID with the IANA name and nothing defining it, on the reasoning that every client resolves those names and that generating a definition would mean shipping a zone database. Both halves were wrong. Measured, not assumed. Run an export through ical.js -- Mozilla's own iCalendar library, the one Thunderbird's calendar uses -- and a TZID with no VTIMEZONE beside it does not resolve: it falls back to floating time. A 09:00 in Phoenix then reads as 09:00 wherever the file is opened, seven hours out, silently, on every timed event in every export. as exported | zone: floating | UTC: 09:00Z with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z The database was already here, too. The browser has IANA behind Intl, and an offset for an instant is a formatting question: format the instant into the zone, read the clock back, and the difference is the offset. Transitions are found by walking month by month for the ones where the answer changes and bisecting inside them -- no rules are known, so none can be got wrong. Each transition is its own dated sub-component rather than an RRULE. More lines and no cleverness: a derived rule that is subtly wrong moves somebody's meeting, while a list of dates can only be incomplete at its ends, which is what the window is for -- the year before the earliest event to ten years past the latest, an open-ended weekly meeting being the case that needs it. A zone Intl does not know is left undefined rather than described from nothing; the TZID stays on the event, which is where it was. TZNAME is dropped where Intl offers "GMT+9", which only repeats the offset beside it. Confirmed the same way it was found. Berlin now resolves to +0200 in September and +0100 in December, so the transitions are being applied and not just an offset. Refs #216. |
||
|
|
a1fe4fea1a |
Skip vCards on re-import that the address book already has
The contacts half of the rule that shipped for events, and only the half that can be decided. A vCard carries a UID its author meant, so a card whose UID this book already holds is that card, and re-importing an export left a second copy of every one of them. Reported on #174 by the reporter's colleague, and decided on #173: skip on a UID that is already here, import what arrives without one, since nothing can be matched on an identity that is not there. LDIF is deliberately untouched and now says so in the type. Mozilla's schema defines no UID and the dn is not an identity outside the directory it came from, so the import invents a UID that can never match one already present. Guessing instead from a name and an address is the open question on #223, and a guess that merges two people who share a name is worse than a duplicate somebody can see and delete. Both imports answer with the same shape, so a caller does not have to know which one it called. LDIF's skipped is always 0, which is the honest number rather than a missing field. The UIDs are asked of the server rather than read from the cards in the store. The store's copy is complete once the view has loaded, and importing does not wait for a view. Two callers, two messages. The contacts import reports both counts, as the calendar import does: "Imported 3 contacts" over a file of two hundred reads as a failure when the rest were already here. And a vCard attached to a message -- usually one you have been sent before -- now says it is already in your contacts rather than reporting that it added none. Refs #223; the LDIF half stays open. |
||
|
|
1a6158aa70 |
Export a calendar as an iCAL file
The mirror of the import from #173, and the last thing contacts had that calendars did not -- an address book could always be exported, a calendar never could. It is written here rather than asked for. The import hands parsing to the server because Stalwart has a CalendarEvent/parse and reimplementing an .ics reader in a browser would be foolish; there is no method the other way, in Stalwart or in the JMAP calendar drafts, so the file is built from the RFC 8984 objects the server already returns. Most of that is renaming: 8984 was written as a restatement of 5545, and the comments say which way it went wherever the two disagree. The masters, not the occurrences. The query runs without expandRecurrences, so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a year of identical ones -- an export that had flattened the rule would import somewhere else as a pile nobody can maintain. A changed occurrence goes out as its own VEVENT with the same UID and a RECURRENCE-ID, which is how iCalendar has always said it; a cancelled one becomes an EXDATE. Three decisions worth stating rather than leaving to be found: No VTIMEZONE components. A TZID names the IANA zone the server holds and nothing defines it beside it, because defining it means shipping a zone database to describe rules the reader's own system already knows. Every client that matters resolves IANA names. The alternative -- converting to UTC -- would be worse than a validator's complaint: a weekly 09:00 that becomes 08:00 for half the year is a wrong calendar. UNTIL follows DTSTART's kind, a date for an all-day series and a UTC instant otherwise. Sending a local time there is the usual way to make a series stop a day early in another timezone. Overrides are applied at the top level only. A recurrence override is a JSON patch, and one addressing locations/x/name is not something this flattens. Closes #216. |
||
|
|
8badf48c4a |
Give the address books the menus the calendars have
Two remarks from the reporter's colleague, both the same underlying thing: contacts and calendar grew their menus at different times and it shows. The dots button on hover. The calendar has offered its per-item menu two ways since it was written -- the button and right-click -- and contacts only had right-click, which is undiscoverable and unavailable on touch. The rows are already .nav-item, which has carried the hover-reveal rule for mail folders all along, so this is the button and no CSS. Import and export move into those menus. As a pair of buttons at the foot of the sidebar they did not say which address book they acted on -- they meant "whatever is selected", which is not something a button can tell you. The calendar settled this already: its iCAL import lives in the calendar's own menu, because that is where "which one?" is answered by where you clicked. The events they dispatch now name the book instead of meaning the selection. Exporting a book now exports that book, rather than the list on screen. The old one handed you whatever was showing, so a search box with something in it quietly narrowed the export -- fine while the button sat under that list, wrong from a menu in the sidebar. Two things that would otherwise have been lost with the buttons. "All contacts" gets the same menu, so exporting everything still has a home; and a book somebody shared gets a menu rather than the bare X, since it can be exported too and losing that would have been a regression dressed as a tidy-up. The X moves inside as "Remove from my contacts". Closes #224. |
||
|
|
74f6d1d0aa |
Skip events on re-import that the calendar already has
Importing an export twice left second copies of everything. The import has kept the file's own UID since it was written -- inventing one only where an event arrives without -- so what was needed to recognise an event that is already here was there all along, and nothing looked at it. Asked for on #173 after the reporter's colleague hit the duplication in testing, and decided there: skip on a UID the calendar already holds, import what arrives without one. An event with no UID is not one anything can match to, and a softer match -- title and time, say -- guesses in both directions. The UIDs are read once per import rather than once per event. CalendarEvent/ query does take a uid filter, which is what findByUid uses, but a file of two thousand events would be two thousand queries. Read without expandRecurrences so a weekly series is one event with one UID rather than one per occurrence, and narrowed to the target calendar from calendarIds rather than through an inCalendar filter this client has not confirmed the server supports. Matching is per calendar. A UID is what makes an event the same event across calendars, so the same event being in two of them is not a duplicate and the second calendar still gets its copy. importIcs now answers with both counts. "Imported 40 events" over a file of 240 reads as a failure when 200 of them were simply already there, and a re-import of an unchanged file would otherwise report importing nothing at all rather than saying everything was already here. The three import toasts are translated in all nine catalogues while the messages were being written -- the plural for the existing one had never been added and was falling back to English. Closes #222. |
||
|
|
5e6e049eef |
Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs the Archive role on the server, which was true, and gave you nothing to do about it here. Roles were shown in Folders settings and never settable. Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an ordinary user through the proxy, with no admin API: setting role "archive" on a folder that had none returned updated and the folder began working as the Archive immediately. Stalwart parses the role names in SpecialUse::parse, "archive" among them, refuses a second holder of a role, and refuses to move the role of Inbox, Junk or Trash. So the toast now carries the fix. "No Archive folder is set yet." with a Create one that makes the folder and then completes the archiving that could not happen -- rather than leaving someone to select the same messages again. A folder already named Archive and carrying no role is adopted rather than duplicated. That is the state #217 was reported from, and a second Archive beside the first would be its own confusion. One named Archive that is really the Sent folder is left alone: taking its role to fix archiving would break sending. Folders settings gains a Role column. Archive, Drafts and Sent are offered, being the roles this client's behaviour depends on and the server will move; Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20 refuses. A role another folder holds is left out of the list rather than offered and refused, so freeing it is a deliberate two steps. The folder is created with the server's own name, never the localised one, for the reason renaming already writes back the server's: a German session must not create "Archiv" that an English one cannot find. Closes #217 properly. |
||
|
|
1611ae6918 |
Say the Archive folder needs the role, not the name
Archiving looks the folder up by its special-use role and by nothing else --
roleId("archive"), falling back to roleId("all") -- and then, finding
neither, told you to create a folder named "Archive". Naming a folder does
not give it a role, and ihasmail has no way to assign one: Folders settings
shows the role beside a folder and offers no way to set it. So the advice
sent someone round a loop that could not end. They make the folder, it still
does not work, and the message says the same thing again.
It now says what is actually required and where it lives: a folder needs the
Archive role on the server, and naming it "Archive" is not enough.
All nine catalogues carry the correction rather than falling back to English,
and they need the same native review the rest of them do.
The existing test asserted only that archiving complained. It now checks what
the complaint says, since the words were the whole bug.
Closes #217.
|
||
|
|
4a99b77bc3 |
Highlight saving, not discarding, on the unsaved-changes guard
The guard shipped with "Discard changes" as the only choice carrying a colour -- a filled red button, against a plain outlined "Save changes" -- which made losing the work the loudest thing in a dialog whose entire purpose is to stop that. The emphasis belongs on the safe answer. A dialog choice can now be marked `primary`, and Save is. Discard keeps its `danger` flag, but a danger choice is drawn the way `.menu-item.danger` already is: a red label on the ordinary surface. In a list of answers a filled red button is not "this one is destructive", it is "this one is the default", which is the opposite of what it meant here. That rendering change reaches the other choice dialog too -- the calendar's "this occurrence or the whole series", where both answers are marked danger because both delete something. Two filled red buttons become two red labels and nothing is highlighted, which is right: neither answer there is the safe one, so neither should look like it. Checked in the browser against the mock, in both themes. Light: #dc2626 on white, 4.8:1. Dark: the theme's own --danger, which every palette already tunes for contrast on this surface. Reported on #175 by the reporter's colleague, who is right that the non-destructive action is the one that normally gets the highlight. |
||
|
|
4121f9263b |
Set contact cards in batches the server will take
The same bug the calendar import had, in the three places contacts write more than one card at once. ContactCard/set is refused whole over maxObjectsInSet -- requestTooLarge, nothing created -- so a large enough vCard or LDIF file imported nothing, and "select all, delete" over a large address book deleted nothing and reported it in JMAP's words. Nobody has hit it. It was found by looking, after #215 fixed the calendar, and it is promised on #173. Both imports now go through one createCards, which splits by the ceiling the session advertises and falls back to 500. That is what the LDIF import's comment -- "from ContactCard/set down they are the same" -- was already claiming, and is now true of. destroyCards splits the same way, and takes off the list the ids the server said it destroyed rather than everything that was asked for. It removed all of them before, which was harmless while there was one call and wrong the moment a later batch can fail: deleted contacts must not stay on screen, and live ones must not disappear from it. One behaviour change beyond the batching. A vCard import the server accepted no card of returned 0, and the view reported importing no contacts -- which reads as an empty file rather than as a refusal. It now says why, which is what the LDIF import has always done. A file with genuinely nothing in it still says so, earlier and separately. |
||
|
|
17bd548524 |
Import an iCal file in batches the server will take
An 800 KB export imported nothing at all. Every event in the file went out in a single CalendarEvent/set, and Stalwart refuses a method call carrying more objects than maxObjectsInSet -- the whole call, with requestTooLarge, creating none of it -- so the import failed at exactly the size that makes importing worth doing. A two-event invitation was fine; a real calendar was not. The events now go out maxObjectsInSet at a time, which the client already reads off the session and defaults to 500 where a server does not say. That is the same ceiling and the same helper the mail store batches deletes and flag changes by; nothing new had to be learned about the limit, and there is no need to ask anyone to split an .ics by hand at an arbitrary line. Still batches rather than a call per event: createEvent invalidates on the way out and invalidating re-fetches every cached range, which is why the import writes its own set calls in the first place. One invalidate, after the last batch. A batch that fails after earlier ones have been filed now says how many got in -- "1000 of 1200 events were imported before this happened" -- and re-reads the calendar so they are visible. Reporting only that the import failed would send someone looking for events that are already there. The mock enforced this ceiling all along, on both /get and /set; nothing had exercised it with a file big enough to cross it. Reported on #173. |
||
|
|
7b3069e41b |
Move an event by the days the hand moved it, not to the date dropped on
Dragging an event across the month grid wrote the date of the cell it landed on into the event's stored start. Those are the same date only while the event's time zone is the reader's. An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at 23:00 the previous evening. Dropped on the 11th, it was written as the 11th in Tokyo -- which is the 10th on screen. It went where its own calendar said rather than where the pointer did, one day short, every time. Moving by the difference between the two local days instead moves it exactly as far as the hand did, and adding whole days to a stored wall clock leaves the time of day alone without touching the zone -- so the frame the rest of this path is careful about is still not crossed. Found by giving the mock an event in a zone that is not the machine's. Every other fixture used the machine's own, which cannot tell a correct conversion from no conversion at all: the case that works is the one the fixtures were all testing. |
||
|
|
44b676c55d |
Name the push verification entry absolutely, from both sides
A JMAP push subscription stays silent until the client echoes back a verification code. When the code arrives with no tab open, the service worker leaves it in the cache for the next tab to collect. Both sides named that entry relatively, and a relative key is resolved against the URL of whoever is asking. The worker lives at <base>/sw.js, so it wrote under <base>/; a tab at /mail/inbox/abc looked under /mail/inbox/. They agreed only when the open page happened to be the root, which is why this survived: the case that works is the one people try first. The failure is quiet in the worst way. A subscription that never gets its code back simply never delivers, which is indistinguishable from push not working at all -- there is no error anywhere to notice. Both sides now build the key from the mount: the worker from the BASE it already derives from its own location, the page through withBase. Found while adding BASE_PATH, where the two disagree at every route rather than only at deep ones; left alone then because it was pre-existing and unrelated to that change. |
||
|
|
1b4788c0a7 |
Apply the upload limit only where something is uploaded
FEATURES has always said attach-from-Files works "however large", because a blob the account already holds is attached by reference and nothing is sent. The code checked every file against maxSizeUpload regardless, so the two disagreed and the code was the one that was wrong. maxSizeUpload is what the server will accept for a single upload (RFC 8620). It bears on a file that is about to be uploaded and on nothing else. Applying it to a by-reference attachment refused a 60 MB message the server was already storing, on the grounds that it could not have been uploaded -- which it was not being. Forwarding a large message as an attachment hit exactly that. A file from somebody else's account is fetched and re-uploaded into this one, because a message can only carry blobs from the account sending it. That upload is real and the limit is real for it, so it still applies there. |
||
|
|
4d18d94b63 |
Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it. |
||
|
|
a30f96f76b |
Drag an event to move it, and its edge to resize it
The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving. Every other surface a finger or a pointer drives already answers to a drag. In the day and week grids an event moves by dragging it and changes length by dragging its bottom edge, snapping to fifteen minutes. In the month grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer -- changing the hour as well would answer a question nobody asked. It goes through the same path a menu edit takes. A recurring event is asked which dates it means, and the answer runs through runScoped, so a date the server will only change as part of a whole series offers that rather than failing. Three things do not offer a drag, and the reasons are checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a birthday -- which is derived from a contact and has nothing on the server to move. The reserved classes the swipe gesture was told to keep out of are exactly the ones that are draggable here, which is what that reservation was for. Invitations are not sent. A drag is a scheduling gesture, and mailing every guest on each nudge of a block is not what the hand was asking for; a change that should go out with notice goes through the editor. The new time is computed in the event's own frame rather than through an instant. Working it out from the reader's local hours and then re-expressing it in the event's zone converts twice, and the two do not cancel: caught in the browser, where an event moved two hours the first time it was dragged in the month grid and then sat still, because after that its stored time and the reader's agreed. Parsing the stored string into its parts and adding minutes to those touches no zone at all, and a resize sends only a duration, so the question does not arise there either. |
||
|
|
c838569638 |
Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts |
||
|
|
9aa0eda0d5 |
Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value carried a whole palette and implied dark. That works for exactly one palette. The two questions now come apart: which palette, and which side. Classic is the plain light and dark this app has always had. ihasmail's own palette gains a day version, so the background of the dark one becomes the text of the light one and the two read as one palette from either end. Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects, used under the MIT licence, and taken from each project's own repository rather than from anyone's reimplementation. What was fetched is recorded in .palette-sources/ and credited in NOTICE. Giving ihasmail's palette a light half removed a whole special case. Nothing is one-sided any more, so a palette can no longer override the mode, the toggle no longer has to set a palette aside on the way to light and remember it, and the greyed-out control that explained all that is gone. The old lastDarkTheme, which existed only for that, is gone with it. The shades between the published colours are derived rather than guessed: these projects publish twelve to twenty values and ihasmail needs about thirty. scripts/build-palettes.py computes the tiers and then measures every text colour against the surface it sits on -- 4.5:1 for prose, 3:1 for borders and marks -- lifting anything short towards white on a dark ground and towards black on a light one, so a lifted tier keeps its hue. It refuses to write a palette that would not pass. Every one of the nine halves needed at least one lift. These palettes are built for code editors, not 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 things caught while checking rather than while writing. The generated blocks were appended to the end of the stylesheet, which put them after the accent variants at equal specificity -- so choosing an accent over one of the new palettes did nothing at all. They now sit before those rules, where the existing ihasmail block's own comment says they have to. And that block was unqualified, so it would have shadowed the new light half; it is now explicitly the dark one. Settings written before this carry `theme` and no palette, and are read through the old enum. `theme` is still written back, derived, because a device on an older build reads it and would otherwise be stranded on a theme nobody chose. |
||
|
|
34fc5ab81f |
Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: unread-first is what people want in the folder they triage and confusing in Sent, where everything is read and the order that matters is when it went. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: a list sorted in the browser is sorted only as far as the browser has loaded, which on a folder of ten thousand is the first fifty and a lie about the rest. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. Sorting on a keyword is optional in RFC 8621, and a server that will not do it fails the whole query rather than degrading it -- so this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: the reader asked for an order and got the closest the server can give, and a toast on every folder change would be the app complaining about its own request. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against. |
||
|
|
1ed8531764 |
Line up selectedAll with the rest of the object it sits in
It came in at two spaces inside a six-space set({ ... }), which reads as
if it belonged to an outer scope. Whitespace only.
|
||
|
|
c9ab203b76 |
Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. |
||
|
|
be8b89f5ab |
Merge branch 'main' into feat/nested-labels
Both sides added a field next to the mail store's selection: the label counts the sidebar draws, and the flag for a selection that means the whole query rather than the loaded page. They are independent, so the resolution keeps both. |
||
|
|
9a474ef2c8 |
Open winmail.dat
Outlook sending in Rich Text packs every attachment into one TNEF blob. Every other client shows a single unopenable winmail.dat, and the files inside it are gone as far as the reader is concerned -- which is a decoding problem rather than a mail one. Written from the published format: a signature, a key, then a flat run of attributes, each one a level byte, a 32-bit id carrying its own type, a length, the data and a checksum. Attachments are delimited by attAttachRenddata rather than named, which is why the parse is a small state machine. The MAPI property stream inside attAttachment is read for two properties: the long filename and the MIME type. attAttachTitle carries an 8.3 name, so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC there and correct here. The stream stops at a named property (id >= 0x8000) rather than guessing past it, since those carry a GUID before their value and nothing after one can be trusted to stay aligned. Decoded in the browser, on request. The server never sees the contents and has nowhere to keep a decoded copy; doing the work on sight would spend the bandwidth whether or not anybody wanted what is inside. A blob that goes wrong part-way through keeps what was read before that point, whether it ran out or the checksum stopped matching. Half the attachments beats none: the alternative is a reader who can see the file is there and cannot have it. The original stays attached either way. The message body is deliberately not decoded. TNEF can also carry it as compressed RTF, which is a second format again for a body the reader already has in plain text or HTML nine times in ten. The mock now sends one, built by its own encoder rather than by the parser's fixtures, so the two are independent implementations of the same description. |
||
|
|
f7ef886b45 |
Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was no way to keep one that matters occasionally without it holding a row for ever. A label can now sit under another, and each says whether it belongs in the sidebar always, only while it has unread mail, or never. Nesting is display only. The keywords stay flat on the message, which is what keeps them readable by every other client: moving a label under another rewrites nothing in the mailbox, and a client that knows nothing about ihasmail sees exactly what it always did. Both new fields are optional, so a settings file written before this parses unchanged and means what it did. Settings sync between devices, so the tree has to survive shapes that should not exist. A label whose parent was deleted on another device comes back to the top level rather than vanishing -- a label that disappears because something else was deleted is one the reader cannot get back. A cycle arriving from an older device is broken by treating the label that closes the loop as a root, so nothing is lost and nothing hangs. The parent picker will not offer a label's own descendants, so one cannot be built here in the first place. A label kept by the unread rule keeps its ancestors, whatever they were set to. A child cannot be drawn under a parent that is not there, and promoting it to the top level would silently rearrange the tree at the moment the reader is least able to explain why. The parent comes back as a container instead, and its own count still says whether it has anything of its own. Unread counts come from one request carrying a query per label rather than a request each, with limit 0 so the server does not send ids that would only be thrown away. They refresh on the same beat as the folder counts, since the things that move them are the same things, and a failure is swallowed: a count is decoration, and the sidebar draws the label without one. Also corrects the Labels page, which said names and colours are kept in this browser. They live in the account's own Files and follow it between devices, like every other setting that is not about this screen. |
||
|
|
8bee0eb3c8 |
Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand that is fifty of them, and the only way to act on the rest was to scroll until they loaded and tick again. A line now offers the rest by name once the page is selected, and taking it is a separate press. A checkbox that silently meant ten thousand when the screen shows fifty would be the worst of both, so each option says what it actually covers. The wider selection is a query rather than a list of ids. What it reaches is resolved from the server when an action runs, walked a page at a time, because a folder holds far more than one call returns and Email/set refuses more ids than maxObjectsInSet in one go -- which setEmails already chunks for. It resolves uncollapsed, unlike the list: "everything in this folder" means every message rather than one per thread, and expanding threads the way a click does is impossible here anyway, since that walks loaded Email objects and these are the ones that were never loaded. Two things this exposed. Undo is now withheld once a move reaches messages that were never loaded. It restores the folders each message was in, taken from what the browser holds, and for an unloaded message that is nothing -- so the undo would have written an empty mailboxIds and left the message in no folder at all, which is worse than the move it was undoing. move() and archiveByDate() both did this; both now drop the offer rather than restore something wrong. And an action consumes the wider selection. The optimistic paths cleared the selected ids but not the flag, so the next action would have silently reached the whole folder again. |
||
|
|
5b18f1d5d7 |
Warn about outside senders, large sends and links that mislead
Four warnings, in Privacy & safety, and all of them start switched off. That is not timidity. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing. The outside-sender warning could not be on by default in any case: it measures against the domains that count as yours, and with nothing configured every message in the mailbox is from outside. Your own identity domains are always internal and are not configuration. An account signed in as [email protected] warning that example.com is external would be absurd, and making it be typed in first is a foot-gun that leaves the feature useless the moment it is enabled. Configured domains are additional, and cover their subdomains -- matched on a dot boundary, so example.com covers mail.example.com and not notexample.com, which is exactly the domain somebody registers on purpose. The four: A banner names the sender's domain on a message from outside. Sending outside names the outside recipients and asks, rather than refusing. "This is going outside" is a rule and not something the sender can check; a list of addresses is. It reads the full identity list rather than the visible one, since hiding an identity from the From menu does not make its domain somebody else's. Sending to a large group asks once the count crosses a threshold, which is what catches a reply-all onto a long thread. It counts people rather than headers, so one address in To and nine in Cc is a message to ten. Opening a link asks when the destination is not trusted, and always when the link's text names one domain while its destination is another -- even where that destination is trusted, because being trusted is not the same as being the place the text claimed. On that mismatch the offer to trust the domain is withheld: what would be trusted is the destination, and the destination is not the thing in question. Anything that is not http or https is left alone, since warning about a mailto: is noise and noise is how a warning stops being read. Both bodies are covered, because a link in a plain-text mail is linkified by us and points wherever it likes just as readily as one the sender marked up. The click is cancelled and the navigation re-issued after the answer, since there is no way to hold a real navigation open across a dialog. The reopen runs in the continuation of the dialog's own click, which is still the gesture a popup blocker wants to see. |
||
|
|
93d0a32af2 |
Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
|
||
|
|
e4b82783c2 |
Merge branch 'main' into feat/privacy-safety-settings
# Conflicts: # web/src/styles/app.css |
||
|
|
790213cc17 |
Merge branch 'main' into feat/forward-as-eml
# Conflicts: # web/src/store/compose.ts # web/src/views/mail/MessageView.tsx |
||
|
|
97f8b34e8b |
Merge branch 'main' into feat/spam-score-panel
# Conflicts: # web/src/store/mail.ts # web/src/styles/app.css |
||
|
|
b83de657d7 |
Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without an obvious home ended up. Remote images were filed under "Reading", the read-receipt policy under "Composing", the undo-send window beside the default message format. They are the same kind of decision -- what reaches a sender, and what asks before something happens -- and they were the hardest settings in the app to find. Privacy & safety now holds all six, in three groups: remote content, read receipts, and the things that ask before it is too late. General keeps what it is actually about and is thirty lines shorter. The line against Security & sessions is worth stating, because two similar words next to each other in a nav is how a menu becomes something people hunt through. Security & sessions is credentials and access: password, two-factor state, app passwords, live sessions. Privacy & safety is how the app behaves towards the reader and towards senders. Nothing moved in storage. Settings are a flat object in settings.json and sections are only how they are grouped on screen, so this is a UI change with no migration and no key renames. Two things beyond the move. The senders trusted with remote images are now listed and can be withdrawn one at a time. A sender was added from a message and could then only be removed by finding another message from that same sender, which is not a way to review a list you cannot see. And General's lead said settings are stored in this browser, which is only true when the server has no FileNode support. They normally live in the account's own Files and follow it between devices, so the sentence contradicted the feature it sat above. |
||
|
|
7d9a01cb93 |
Swipe the calendar sideways to step a day or a month
The calendar had next and previous as toolbar buttons and n/p, and nothing for a thumb. Every other surface in the app that a finger drives already answers to a swipe. Day and month only. Those are the two views where a period is a page; week and agenda scroll through a range rather than turning to the next one, so there is nothing a sideways flick would obviously mean. Dragging left pulls the next period in from the right, the way paper and every phone do it. Three things it deliberately does not do. It draws nothing while the finger moves. The row swipe slides the row open because the strip underneath has to name which of six actions is about to happen; stepping a calendar has two outcomes and the direction of the finger already says which. Translating the grid would also break the sticky day header, since a transform makes a containing block. The threshold is reported by the vibration motor instead, which is what the haptics are for. It does not start on an event. Which gesture was meant is decidable at the moment the finger lands and only then, so dragging an event stays available to be built later without having to be untangled from this first. And it asks for a longer drag than a row swipe -- not because the consequence is bigger, since stepping back undoes it while a swiped row has already been archived, but because this gesture has no way to change its mind. A row reveals what it will do and can be let go early, and offers Undo after. This shows nothing and offers nothing, so the distance is the only chance to not mean it. The axis lock is the shared one, keeping its bias towards the vertical: the day grid scrolls through the hours, and a scroll misread as a swipe throws the reader into another day. The toolbar buttons and n/p stay, because a gesture with no visible control is one only the people who already know about it can use. |
||
|
|
785570a41d |
Archive into a dated folder
Archiving put everything in one folder, so an Archive that has been collecting for years is a single flat list with no way to narrow it except search. Archive by year and Archive by month file into Archive/2026 and Archive/2026/09, creating the folders as needed and reusing them after that, including folders made by hand or by another client. The names are numeric and zero-padded rather than month names, because these are real server-side mailboxes rather than anything of ihasmail's. Every other client sees them: a folder created as "September" by someone reading in English stays "September" for the same account read in Japanese, since the name is stored and not translated. And 09 sorts between 08 and 10 where a name does not. The date is read in the reader's own timezone rather than UTC so it agrees with the date shown against the message in the list. A message that arrived at 00:30 UTC on 1 September is dated 31 August in New York, and filing it under 09 while the list says August would be the app disagreeing with itself. A message whose date cannot be read goes to Archive itself rather than to a folder named after a guess. A selection spanning two months is two destinations, not one. The moves are made silently and one toast names where everything went -- the folder where there is a single answer, the count where there is not -- because each group raising its own toast with its own Undo would mean undoing a third of a move. One Undo restores the whole selection to wherever each message came from, captured before anything moved. The menu labels name the destination where there is one, so it reads "Archive to 2026/09" rather than describing the rule, and falls back to "Archive by month" for a selection with no single answer. |
||
|
|
0db795371e |
Forward a message as an attachment
Forwarding quoted the original into a new message, which is the right thing for passing on something to be read and the wrong thing for passing on something to be looked at. Quoting rewrites the body, drops the headers, and re-parents the attachments, so a bounce, a phishing report or anything else where the message itself is the evidence arrived altered. Forward as attachment sends the message whole, as a message/rfc822 part. It costs no upload at all: a message's own blobId is its RFC822 blob and already lives in the account, so this goes through the same by-reference path as attach-from-Files and a 40 MB message attaches as fast as a small one. It is in the message's own menu, the list's right-click menu, and the overflow on the reply strip at the foot of a thread, which is the one a thumb finds on a phone. Two things fixed on the way, both exposed rather than introduced by this. The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without the u flag is ASCII: every character of a Russian, Japanese or Chinese subject failed the class, so those messages downloaded as a row of underscores. What is actually unsafe in a filename is much shorter than "not ASCII" -- path separators, the names Windows reserves, the control range -- so the rule now keeps letters from any script and drops only those. It lives in one place and the .eml download uses it too. And the composer's attachment chip set overflow/text-overflow on a span, where neither does anything, so the name never truncated and the size ran on after it on the same line. Only long names showed it, which is every .eml named from a subject. |
||
|
|
63c2839602 |
Show what the spam filter said, in the message details
The filter in front of the mailbox scores every delivered message and writes its working into headers, and none of it was being read. A message in Junk gave no reason for being there. Nothing here scores anything. The headers are parsed and shown, so this cannot disagree with the filter that actually made the decision. Two formats cover what sits in front of a Stalwart mailbox in practice: the SpamAssassin-shaped X-Spam-* set, which Stalwart's own filter writes, and Rspamd's X-Spamd-Result. A header in neither shape is left unread rather than guessed at, since a misparsed score shown confidently is worse than no panel at all. Mail that arrived without any of them shows nothing. Rules are listed largest mover first and signed, because which way a rule pushed is the point, and the biggest contributor is the answer to why the message scored what it did. Two things it deliberately will not do. A score is always given the threshold it was measured against, because 6.7 is damning against 5 and unremarkable against 15 -- the number alone is not something a reader can act on; where no threshold was stated, it says so rather than implying one. And where the filter recorded no verdict, none is derived from score against threshold: the filter applies policy we cannot see, and putting a verdict in its mouth would be inventing one. The mock writes the same headers at delivery -- spam in Junk, clean in the Inbox, nothing on mail this account wrote -- so the panel can be developed and demoed against it. |