Commit Graph
266 Commits
Author SHA1 Message Date
jcoffey-dev 45c8929697 Withdraw the key manager, and keep what probing it established
A Settings section for public keys is furniture, not a feature. Nothing
in ihasmail signs, encrypts, decrypts or verifies with a key, so the
page could only ever tell the reader in its own footnote that adding one
does nothing. It is withdrawn on that reasoning -- the same reasoning
that closed PR #67, reached again with the code in front of us.

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

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

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

ROADMAP now says plainly that key management has been built and
withdrawn twice, that the registry is not the obstacle, and that
verifying a signature -- which needs only public keys -- is the shortest
route to a key being worth having. Encryption at rest moves from "not
offered yet" to refused: it is a one-way door, since turning it off does
not decrypt what is already there, and that is not a switch to hand an
ordinary user however easy it would be to add.
2026-09-05 01:11:25 -07:00
jcoffey-dev e93d42d27e Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add,
rename and remove the OpenPGP public keys and S/MIME certificates
published on this account. Only public material -- no private key is
stored, requested or sent by any of this.

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

What the re-probe confirmed, unchanged from 0.16.19:

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

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

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

What it added, none of which was known before:

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

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

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

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

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

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

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

Verified in a browser against the mock, not only in tests: both
rejections reach the toast in the server's own words with the form still
filled in, a good key renders its card, the kind is labelled from the
armour header, renaming persists, removing asks first and empties the
list, and the whole section reads correctly in German.
2026-09-05 00:56:31 -07:00
jcoffey-dev cee107d948 Select contacts, and empty an address book
Raised on #174 as the other half of a migration -- import, notice
something is wrong, empty the book, correct the export, import again --
and tracked as #277.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things worth knowing:

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

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

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

FEATURES gains the re-import behaviour for both formats; it documented
neither.
2026-09-04 07:50:49 -07:00
jcoffey-dev 1070ee13bc Add seven plural forms no catalogue ever had
Found by widening the coverage check to plural() forms in every file rather
than the two being worked on. Seven counted strings in the Files view and the
event editor had never been in any of the nine catalogues, so they rendered in
English whatever language was chosen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -07:00
Coffey Labs 22f39a4507 Merge pull request #259 from Coffey-Labs/i18n-third-pass
Third pass: the strings libraries build and views render raw
2026-09-03 14:01:53 -07:00
Coffey Labs bf60fe6157 Merge pull request #258 from Coffey-Labs/i18n-second-pass
Second pass: four dialogs that were never wrapped
2026-09-03 14:01:49 -07:00
Coffey Labs 8d4063e921 Merge pull request #257 from Coffey-Labs/i18n-chinese-catch-up
Catch the Simplified Chinese catalogue up with what shipped after it
2026-09-03 14:01:44 -07:00
Coffey Labs ea6c098057 Merge pull request #256 from Coffey-Labs/i18n-ukrainian-catch-up
Catch the Ukrainian catalogue up with what shipped after it
2026-09-03 14:01:38 -07:00
Coffey Labs f7cbdb2e7a Merge pull request #255 from Coffey-Labs/i18n-russian-catch-up
Catch the Russian catalogue up with what shipped after it
2026-09-03 14:01:33 -07:00
Coffey Labs 379623614a Merge pull request #254 from Coffey-Labs/i18n-japanese-catch-up
Catch the Japanese catalogue up with what shipped after it
2026-09-03 14:01:27 -07:00
Coffey Labs 662385c6da Merge pull request #253 from Coffey-Labs/i18n-portuguese-catch-up
Catch the Brazilian Portuguese catalogue up with what shipped after it
2026-09-03 14:01:21 -07:00
Coffey Labs 0fb4d1e964 Merge pull request #252 from Coffey-Labs/i18n-dutch-catch-up
Catch the Dutch catalogue up with what shipped after it
2026-09-03 14:01:16 -07:00
Coffey Labs 898815eaca Merge pull request #251 from Coffey-Labs/i18n-french-catch-up
Catch the French catalogue up with what shipped after it
2026-09-03 14:01:09 -07:00
Coffey Labs 6ac5ee45dd Merge pull request #250 from Coffey-Labs/i18n-spanish-catch-up
Catch the Spanish catalogue up with what shipped after it
2026-09-03 14:01:04 -07:00
Coffey Labs f500261697 Merge pull request #249 from Coffey-Labs/i18n-german-catch-up
Catch the German catalogue up with what shipped after it
2026-09-03 14:00:59 -07:00
jcoffey-dev 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.
2026-09-03 13:56:25 -07:00
jcoffey-dev 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.
2026-09-03 13:56:05 -07:00
jcoffey-dev 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.
2026-09-03 13:55:45 -07:00
jcoffey-dev 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.
2026-09-03 13:55:43 -07:00
jcoffey-dev 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.
2026-09-03 13:55:19 -07:00
jcoffey-dev 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.
2026-09-03 13:55:17 -07:00
jcoffey-dev 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.
2026-09-03 13:54:53 -07:00
jcoffey-dev 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.
2026-09-03 13:54:41 -07:00
jcoffey-dev 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.
2026-09-03 13:53:49 -07:00
jcoffey-dev 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.
2026-09-03 13:52:24 -07:00
jcoffey-dev 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.
2026-09-03 11:58:17 -07:00
jcoffey-dev 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.
2026-09-03 11:54:32 -07:00