94639e842052a313b67fb874158aa5f0481e39e7
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
94639e8420 |
Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window. The folders did not line up. The rule that drops the twisty's 30px gutter was hung on the rows offering a drill, so only folders with children lost it -- they sat 18px left of every folder without any, and the column of icons came apart. Whether a folder has children is not a reason to hang it somewhere else. The class moves to the list, which is what the indent is a property of; icons now share one column and labels another, at every level and on the back row too. There was no obvious way back out of the drawer. It covers the top bar -- it is taller than it -- so the hamburger that opened it is underneath, and pressing the same place again did nothing at all, since that handler only ever set the drawer open. The dimmed strip beside the drawer was the only exit, and nothing says so. There is now a close where the hamburger was, moved by the same rule so it lands on exactly the same pixels, and the hamburger itself toggles rather than only opening. Escape closes it too, for a tablet with a keyboard. Raising the top bar over the drawer instead would have been the smaller change and is not available: the drawer is at 950 and a full-screen composer at 800, so a top bar above the first is also above the second. |
||
|
|
6c958b8609 |
Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable. The folder tree spent its width on depth. Four levels down, the 16px indent steps and the 18px twisty left a folder 85px of a 300px drawer to print its name in, and the twisty had walked far enough right that hitting it was luck -- a miss landed on the row, which is a link, so the wrong tap also cost a navigation. Under 768px the tree is now a drill-down: one level at a time, no indent, a back row above it, and a chevron at the right edge that is the same size in the same place on every row. Tapping the row still opens the folder; only the chevron changes what the list shows. The tree is untouched above 768px, where a wide sidebar can afford the indent and where dragging a folder onto another folder -- still the only way to reparent one -- needs both of them on screen at once. Every control in the top bar was under the 44px a fingertip covers: the icons at 36, the search filter at 30, the row menu at 24, and the hamburger 6px from the bezel in the corner a thumb is worst at. They keep the size they draw at and gain a transparent hit area, since growing the boxes would reflow a bar with no room to give; rows grow for real, because a 44px target inside a 36px row reaches into its neighbours. The one exception is the row menu, held to 36px wide: at a full 44 it overlapped the drill chevron by 4px, so its right edge silently drilled instead. Pinch was dead on the message list. `.msg-row` sets `touch-action: pan-y` to feed the swipe gesture the horizontal movement the browser is not using -- but naming any value drops every gesture not named, zoom included. It worked on an open message and died on the list, which reads as the zoom being broken at random rather than as a rule about rows. `pan-y pinch-zoom` keeps the swipe and gives the zoom back. |
||
|
|
ded2f4dc1b |
Ten languages, not nine: correct the count everywhere
The picker offers English plus nine translations. I wrote it up as nine in total with eight unread, which is off by one in the direction that undercounts the work and, worse, misstates how many catalogues are waiting for a speaker to read them. Nine of the ten are machine-made and unread — all of them, not all but one — so the sentence that matters reads more sharply than the wrong version did, not less. |
||
|
|
c26ca90e01 |
Document the nine languages, and what Beta means on them
The translations shipped today and the docs still said "no languages but English". They also need to say the harder thing, which is that eight of the nine have never been read by anybody who speaks them. - README gains the language list, with the Beta caveat in the same line rather than a footnote. - FEATURES.md gains an Interface language section: the list, why it is a separate setting from the date locale, and the two design properties that follow — a missing entry renders English, and plurals are asked of Intl.PluralRules rather than assumed, which is why Russian carries three forms and Japanese one. - ROADMAP.md no longer lists translations as "not yet". What replaces it is the half that is genuinely not done: a translation anybody has checked. RTL is split out as its own entry, because holding Arabic, Hebrew and Persian back is a layout decision and not a queue position. - KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the one thing on that page that cannot be closed by testing. And the coverage number that read 100% while two hundred strings rendered English in every language — recorded as a general lesson rather than an i18n one, since a coverage number measures what it can see and the rest is exactly what nobody is checking. |
||
|
|
635c4c7e52 |
Keep a settings change made before the first read, and wait for it
Two defects on the path that decides what language the app starts in. **A change made before the settings file came back was thrown away.** `queueSettingsPush` returned early while unarmed, dropping the value instead of holding it, so a language picked in the second or so after a page load was never written up: it survived until the next reload and no further. That is a better account of "sometimes it takes several clicks" than the remount race fixed in #160 — the click that stuck was one made after the read had finished. Keeping it is safe because hydrate already refuses to overwrite a key that is still queued. Proof it was real: before this, no `ihasmail` folder was ever created in the account's files, because the seed write never fired. After it, the folder appears. **Without a cached settings object the tree painted too early.** The cache is not read on an untrusted device, and it is cleared by the sign-out that every deploy causes, so in both cases the first frame is the defaults — and the defaults are English. Anything computed in that window is computed in the wrong language. The interface recovers, since it is rebuilt when the catalogue lands, but a string emitted once does not: this is why the stale-folder toast came out in English on an otherwise German screen. So without a cache the authenticated tree now waits for the account's settings and their catalogue, which costs nothing — there was nothing worth painting yet. With a cache it does not wait, and the first frame is as quick as it was. Neither fix makes the toast German yet: the account settings file is neither written nor read successfully in the mock, and both failures are swallowed. That is a third problem, and this commit does not touch it. |
||
|
|
ba4d2105a4 |
Merge main: the list subject stays notranslate and gains its translated fallback
Both sides of the conflict belong. The span holds a subject, which is the sender's words and not ours to machine-translate; the text shown when there is no subject is ours, and should follow the interface language. |
||
|
|
a6863e98cc |
Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
|
||
|
|
51dabd9cab |
Stop a language change undoing itself, and stop the translate prompt
Two reports, both about the language setting. **Picking a language sometimes took several clicks.** The subtree that reads the account's settings file is keyed on the language version, so choosing a language deliberately throws it away and builds it again. The remount re-read the settings file — which still held the old language, because the write is debounced by three seconds — and applied it, putting the old language back. The click that appeared to work was the one made after the previous write had landed, which is exactly the "sometimes" in the report. Worse than it looked: the queued push survived the remount, so the file was eventually written with the new language while the screen showed the old one. A reload then changed the language on its own. Fixed twice over, because either alone leaves a race. The file is read once per account per page load rather than once per mount, and hydrate now holds back any key with a change still queued — a change that has not been written up is newer than the file by definition. That rule is `mergeRemote`, pulled out as a pure function so it could be tested without a JMAP client. **Both browsers kept offering to translate an English page.** They were right to: `<html lang>` said English while the visible text was 6,289 message rows of marketing copy and brand names in whatever language the sender wrote in. The list is most of the text on the screen, so that is what the detector was reading. Sender, subject and preview in the list, and the thread subject and sender name in the reader, are now marked as what they are — content, not interface. Message bodies were already marked, so this is the same line drawn in the places the earlier pass missed rather than a new one. Whether it silences the prompt is Chrome's call and cannot be checked from inside the page; the marking is right either way. |
||
|
|
f94cc2ce51 |
Translate the labels the extractor could not see
Three places built user-visible English out of expressions rather than
writing it as JSX text, so the extraction codemod never found them and
they stayed English in all nine languages — including the five that have
been in production for weeks.
The calendar view switcher was the worst of them: it spelled its labels
as `v[0].toUpperCase() + v.slice(1)`, which is correct English and
untranslatable anywhere else. Day, Week, Month and Agenda were already in
every catalogue, sitting unused, because the buttons never asked for
them. They now come from a Record<View, () => string>, so TypeScript
makes the map exhaustive and adding a view forces adding its label. The
labels are functions rather than values: a module-level object would
capture whichever language happened to load first and keep it.
The other two needed new source strings, added to all nine catalogues:
the composer's title for an untitled draft ("New message"), its status
line ("Sending…", "Saving…", "Error", "Saved {when}", "Unsaved"), and the
agenda view's own title ("Agenda from {date}").
This is not the whole of it. A sweep for the same shape — template
literals, toast arguments, and dialog props rather than JSX text — turns
up roughly a hundred more strings, mostly toasts and confirmation
dialogs. Those are a second extraction pass rather than a fix, and are
left for one.
|
||
|
|
3ab2b02ad9 | Merge main: keep all four Phase 2 languages in the list | ||
|
|
2626b7a333 | Merge main: keep ru, uk and zh-Hans in the language list | ||
|
|
a2a339ce32 | Merge main: keep both ru and uk in the language list | ||
|
|
12f08acd32 |
Add a Japanese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the
languages before it: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English.
Plurals: there are none. Intl.PluralRules("ja") returns `other` for every
number, so each counted string carries one form. Counters do the work a
plural would — 通 for messages, 件 for conversations and items — which is
why "{n} messages" and "{n} conversations" are separate entries rather
than one pattern. The number alone does not decide the word after it.
Register is です・ます throughout, with the pronoun dropped: where English
says "your mailbox" this file usually just says メールボックス. Buttons
are the bare noun or verb stem — 送信, 返信, 削除 — not a sentence, which
is what every other mail client the reader has used does.
Script mixing is deliberate. Kanji for the noun carrying the meaning,
katakana for the loanword the reader already knows (メール, フォルダー,
アーカイブ), hiragana between them. Long vowels keep their ー.
Verified against the mock server: role folders localise and custom ones
are untouched, dates follow the language, and "{used} of {total}" comes
out reordered as "2.0 GB 中 700 MB" rather than word-for-word.
|
||
|
|
988e741b79 |
Add a Simplified Chinese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the five
Phase 1 languages: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English, so deleting a bad
line is a valid fix.
Two things differ in kind from the European catalogues, and the file
header records both so a later editor does not undo them.
Plurals: there are none. Intl.PluralRules("zh-Hans") returns `other` for
every number, so each counted string carries one form. Supplying `one`,
`few` or `many` would be filling in a distinction the language does not
draw, and none of them would ever be selected.
Script: this is Simplified, and the tag says so. A Traditional catalogue
would be a separate file rather than a character conversion of this one —
the vocabulary differs as much as the script does (软件/軟體, 文件/檔案),
and converting characters alone produces text that is readable and
obviously foreign.
Verified against the mock server: role folders localise and custom ones
are left alone, dates and the calendar follow the language, and the
catalogue code-splits into its own 41 kB chunk.
|
||
|
|
5737362621 |
Ukrainian, and not the Russian one with a different name on it
781 of 796 strings. Generated by AI, unreviewed, marked Beta. The thing this catalogue had to avoid is the reason it took the work it did. Ukrainian and Russian share a script and share a plural rule -- one, few, many, with 11 counting as many and 21 counting as one -- and share almost nothing else that matters in a mail client. «Вхідні» is not «Входящие», «Кошик» is not «Корзина», «Листування» is not «Цепочка». A Ukrainian catalogue produced by adapting the Russian one would pass every structural check in this repo and still be the wrong language, and a Ukrainian reader would notice in the first sentence and would be right to resent it. The vocabulary here was chosen against what Ukrainian software says, not against the neighbouring file. Two words worth naming: «тека» rather than «папка» for folder, which is the form Ukrainian software settled on; and «мітка» for label rather than Russian's «ярлык», which in Ukrainian means a shortcut and would be a small false friend on every screen. Plurals tested against the shipped catalogue at 1, 2, 4, 5, 11, 21 and 0, plus the check that every counted string carries all four categories. A missing "few" falls back to "other" silently and is grammatical often enough to go unnoticed. The cross-check that Ukrainian and Russian are actually different files is worth having but cannot live here: Russian is still an open pull request, so ru.ts does not exist on this branch. It belongs in a follow-up once both have landed. |
||
|
|
f9d521a412 |
Russian, and the first real use of the plural machinery
781 of 796 strings. Generated by AI, unreviewed, marked Beta.
This is the catalogue plural() was designed for. Russian needs three forms
where English has two, and the choice is not a question about the number 1:
1 is "one", 2-4 are "few", 5-20 are "many", 11-14 are "many" despite ending in
1-4, and 21 is "one" again. Intl.PluralRules knows all of that; a two-form
assumption would have shipped "5 письмо" and read as machine output however
good the vocabulary was.
Tested against the shipped catalogue rather than a fixture -- 1, 2, 3, 5, 11,
21, 22, 25 and 0 -- plus a check that every counted string carries all four
categories, because a missing "few" falls back to "other" silently and is
grammatical often enough to go unnoticed.
"Выбрано: {n}" for the selection count rather than an agreeing form: the
impersonal construction sidesteps agreement entirely and is what Russian
interfaces actually do there.
Register is "вы", lowercase. Capitalised «Вы» is correspondence style and
reads as a letter rather than as software, so it would be a small constant
wrongness on every screen. Most of the interface avoids the question anyway,
because Russian UI convention is the infinitive for actions.
«Письмо» rather than «сообщение» for a mail message, which is what Russian
mail clients call one; «сообщение» reads as a chat message. «Ярлык» for label,
Gmail's word in Russian -- a fifth answer to the same rule about using what the
reader will meet elsewhere.
A stray CJK character got typed into one Russian sentence during drafting and
was caught by sweeping the file for anything outside the expected scripts. Not
a mistake a spellcheck would find, and not one a reader would forgive.
|
||
|
|
ce28e014d3 | Merge main: keep both language entries | ||
|
|
70af64b126 | Merge main: keep both language entries | ||
|
|
fcbe268715 | Merge main: keep both language entries | ||
|
|
4ecfbd25a5 |
Register Dutch, and catch a catalogue nobody can select
nl.ts shipped without an entry in UI_LANGUAGES, so the language was never offered: the catalogue built, every test passed, the coverage check reported 98%, and the picker did not list it. The entry was added by a text replacement anchored on the French line, which does not exist on a branch cut from main, so the replacement was a silent no-op. A catalogue and a picker entry are two halves of one thing and either half alone is dead weight, so the checker now verifies both directions -- a catalogue with no entry, and an entry with no catalogue. Reverting the one-line fix makes it fail, which is the only way to know a check works. |
||
|
|
fc5c8dd4fa |
Portuguese (Brazil), completing Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example addresses. Generated by AI, unreviewed, marked Beta. This is Brazilian Portuguese specifically, and the tag says so rather than claiming "Portuguese". It is not a stand-in for European Portuguese: the vocabulary diverges in exactly the places a mail client lives -- arquivo against ficheiro, tela against ecrã -- and offering one variety as though it were the other is worse than offering English, because the reader cannot tell it was not meant for them. A pt-PT catalogue would be a separate file. Register is "você", and this is the one place Phase 1 deliberately breaks its own rule. The other four all took the formal address; Brazilian Portuguese has no comfortable equivalent. "O senhor" is deferential rather than merely polite and reads as stiff or sarcastic in software, while "você" is the neutral default Gmail, Outlook and every Brazilian bank use, carrying none of the familiarity "du" or "tu" would elsewhere. Following the rule here would have produced a worse translation by obeying a decision made about other languages. The rule was always "address the reader the way the language does it", and these five are what that looks like rather than five copies of one answer. "Marcador" for label, which is Gmail's word in Brazil: a fourth different outcome from the same rule about using what the reader will meet elsewhere, after English, Libellé and Etiqueta. Phase 1 is complete: de, fr, nl, es, pt-BR, each unreviewed and each marked Beta until a speaker signs it off. That review is the part nobody has done and the part that decides whether any of this was worth shipping. |
||
|
|
8d19108498 |
Spanish, fourth of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example addresses. Generated by AI, unreviewed, marked Beta. Register is "usted", following the other three. Spanish makes the decision cheaper than they did: most of an interface is infinitives and nouns -- "Eliminar", "Configuración" -- where the question never arises. It only shows in the sentences that address the reader directly, and those agree. This is peninsular Spanish where the varieties diverge, chosen deliberately rather than blended, because a blend reads worse than either. The file names the differences that actually matter in a mail client -- "correo" over "email", "ordenador" over "computadora" -- and notes that a Latin American catalogue would be a copy of this one with those changed, not a fresh translation. Worth writing down while the reasoning is fresh rather than rediscovering it if es-419 is ever asked for. "Etiqueta" for label, as in French: Gmail established it and a reader will find it there. That is the third application of the same rule -- use what the reader will meet elsewhere -- and the third different outcome, which is what a rule looks like when it is doing work. |
||
|
|
e9009f7aaf |
Dutch, third of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example addresses. Generated by AI, unreviewed, marked Beta, with the report link doing the job a native speaker would. Register is "u", following "Sie" and "vous". This is the one of the three most likely to be overturned, and the file says so: Dutch leans informal further and faster than German or French, and "je" is what most consumer software now uses. It is written down as a single consistent choice precisely so that changing it is a find-and-replace rather than an argument. Where a string can dodge the question it does, which is ordinary good Dutch UI. "Postvak IN" rather than "Inbox", because that is what Outlook and Thunderbird call it in Dutch. "Label" stays English as in German -- no Dutch client translates it -- which is the same rule that made French use "Libellé": use what the reader will meet elsewhere, rather than always translating or never. The folder-context separator needed inserting by script again, and in the opposite way to French: there it arrived as a raw control byte and had to be escaped, here it did not arrive at all. Both are the same underlying awkwardness -- U+0004 does not survive being written into a file by hand -- and the catalogue checker caught it both times by reporting the keys as stale, which is exactly the silent failure it exists for. |
||
|
|
53c38ed3c3 |
French, on the same terms as German
Second of Phase 1. Generated by AI, unreviewed, marked Beta, with the report link in Settings doing the job a native speaker would otherwise do. 781 of 796 strings; the fifteen left are product names, bare URLs and example addresses, which should stay English. Register is "vous", following the "Sie" decision and for the same reason: a mail client a workplace deployed has no business addressing anybody as "tu". One terminology decision goes the opposite way to German, deliberately. "Label" stays English in German because no German client translates it, and becomes "Libellé" in French because Gmail did and a French reader will meet it there. The rule being followed is "use what the reader will find elsewhere", not "always translate" or "never" -- which only looks inconsistent if the rule is mistaken for the outcome. French typography: the narrow no-break space before ? ! and : is the rule and is deliberately not used. It is invisible in a diff, trivially lost in an editor, and no French webmail actually ships it. Guillemets are used, because those are visible and do read as wrong when missing. Two things worth recording from doing this a second time. The folder-context separator arrived as a raw U+0004 byte rather than the \\u0004 escape the German file uses. It would have worked -- TypeScript accepts it -- and it is invisible in an editor and in a diff, which is exactly why the German catalogue writes it as an escape. Converted, so both files say the same thing in the same way. And the language tests named a real language as their example of one that is not shipped, so shipping German broke them, and shipping French broke them again. Both times the failure was the test being out of date rather than anything wrong. They derive an unshipped tag now, and assert over every shipped language rather than a hardcoded pair, so the third and fourth languages will not repeat it. |
||
|
|
71dd2e108f |
Folder names follow the language, and three bugs that found
Answering "can we ask Stalwart to serve German folder names": no, and it would not help if we could. The account locale exists in `x:AccountSettings`, and ihasmail already reads it -- that is what "Your mail server reports German" comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in user role does not carry; only an admin could. And even then it would change nothing, because folder names are stored data written once when the account is provisioned. No server renames them afterwards; every other client has them mapped. The role is the way through. JMAP tags the standard folders and ihasmail already trusts the role over the name everywhere it matters, so the *displayed* name can follow the interface language with nothing written to the server. A folder somebody made and called "Newsletters" keeps that name: those are their words, and translating them would name a folder they never created. The cost is real and worth stating: Thunderbird on the same account still shows "Deleted Items", because that is what the folder is called. Inside ihasmail it stays consistent -- everything that names a folder goes through one function, including the "moved to …" toast, which exists precisely so that message does not name somewhere the reader cannot find. Renaming still edits the server's own name, never the localised one. Three things fell out of it. The message list refreshed for ever after a language change, which is the one somebody noticed. The root keys its tree on the language version, so a publish remounts everything; remounting re-runs the effect that loads the account's settings, which calls applyLang, which called setCatalog again -- with an identical tag and an identical catalogue -- and publishing that non-change went round again. setCatalog now returns early when nothing changed. Measured rather than assumed: three consecutive five-second windows with no JMAP calls at all, against a pre-change count that never settled. Calendar months and weekdays stayed English, because formatting locale and interface language are separate settings and only the first feeds Intl. Keeping them separate is right -- German dates with an English interface is a real preference -- but somebody who picks German and is shown "September" has not got what they asked for. A chosen interface language now joins the *automatic* chain ahead of the server and the browser. Setting a formatting locale explicitly still wins, and English is not counted, so an English interface on a German browser keeps German dates exactly as before. And the Archive folder read "Archivieren", which is the verb. English uses one word for the button and the folder; German does not, and neither does "Important", which is also a priority tag. tc(context, source) keys the catalogue on both and falls back to the plain English, which was right in English all along -- the gettext approach, including the control character as separator so no real string can collide. The catalogue checker needed teaching about tc() twice: first it reported the eight contextual entries as stale, then it asked for the plain fallbacks as though they were a second obligation. A check that reports work which does not exist gets switched off, which is worse than not having one. |
||
|
|
87383440bb |
German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not flattering: no native speaker has read it. That is stated in the app rather than in a commit nobody reads, because it is the fact a reader needs to judge what they are looking at. Somebody told a translation is unchecked forgives an odd sentence and reports it; somebody told it was reviewed reasonably concludes the product is sloppy. The setting carries a link straight to a report, which is the whole review process here. `beta` is a property of the language, not of the catalogue's completeness. A file can be word-for-word finished and still read like a machine wrote it, and that is what the flag marks. Removing it is a person's decision. Register is "Sie", consistently, and written down in the file so the next language and the next contributor inherit the decision rather than re-take it. Thunderbird and Outlook use it; ihasmail is as often a company's mail as somebody's own, where "du" from software the workplace deployed reads as presumptuous. Where a string can dodge the question it does, which is ordinary good German UI. The glossary at the top of the file fixes the vocabulary once -- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency reads as amateur far more than an imperfect word choice does. "Label" and "Spam" stay English, since translating them would name things no German mail client calls that. 766 of 781 strings. The fifteen left are product names, bare URLs and example addresses, which should stay English and now do. Two things this turned up that the earlier work had hidden: Labels defined as module-level constants -- the entire settings navigation, the theme cards, the swipe choices, the date formats, the sharing permissions -- are evaluated once, before any catalogue loads, so they could only ever be English. Nothing failed; the German build simply had an English sidebar. They are translated where they render now, which keeps the constant as data and makes its English text the key. And the codemod's narrowed rule, which let it take 73 more strings last time, was too broad after all: text stranded after an inline <a> or <strong> came through as sentence fragments -- ", and what a new account starts on." Eight of them, rebuilt with tNode so the sentence stays whole and the element is a named hole a translator can move. scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found three keys invented that the code never asks for, which is the silent failure in a catalogue -- a translation that looks right, is never looked up, and renders English for ever. It also had to be taught about t(variable), because it cried wolf 33 times over the constants above, and a check that cries wolf gets switched off. Verified in the browser rather than only in tests, which is where the settings sidebar being English was visible and nowhere else. |
||
|
|
3f4b33cb51 |
Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.
A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.
The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.
Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.
The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.
596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
|
||
|
|
8ea611f7f7 |
Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
|
||
|
|
95dcb96086 |
Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it something to serve, and a way to measure the distance to the languages actually planned. The English text is the key. `t("Archive")` looks "Archive" up and returns the English when it is not there, which buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code, a missing translation that degrades to readable English rather than to `mail.list.archive`, and an extraction step that is wrapping a string rather than inventing a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost is that editing English copy orphans its translations, which is the right way round: the copy is the product, and a stale German sentence should fall back to the new English. `plural()` takes forms rather than (one, other), because two forms is an English assumption that does not survive phase two of the plan. Russian and Ukrainian need three, and choosing between them is not a question about the number 1. Intl.PluralRules knows the rule for every language the browser knows, so the catalogue supplies the forms and the runtime picks; a category the catalogue does not carry falls back to `other` rather than rendering undefined. Interpolation is named rather than positional for the same reason -- German moves the parts of a sentence around and means the same thing. Catalogues are dynamically imported, so a reader who never leaves English never downloads one, and English needs no fetch at all. `applyLang` sets the lang attribute before kicking the load, deliberately: lang is what stops Chrome offering to translate and should not wait on a network request to say something it already knows. `t()` is a plain function, not a hook, so the tree is keyed on a language version at the root and thrown away when the catalogue changes. Making every call site a subscriber would turn extracting a string from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. NotificationsSettings is extracted end to end as the reference -- it covers all four shapes, being JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56 files is too many to eyeball in review or carry in anyone's head. It reports 20 wrapped and 925 remaining, and it deliberately does not count punctuation and separators as untranslated -- a floor no amount of work could reach would make the number useless. A progress report rather than a gate: --check exits non-zero, for once the number is low enough for that to mean something. ROADMAP.md said translations were "English-only for now" on a page whose stated purpose is things the answer is "no" to. It now says what is actually happening, carries the phase order, and says why Arabic, Hebrew and Persian are on neither list: RTL is a layout and bidi problem rather than a longer catalogue, and shipping it as though it were the same kind of work is how an RTL build ends up unusable with nobody saying so. |
||
|
|
be1d787b5f |
Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the rendered DOM directly, wrapping text nodes in <font> elements React has never heard of, and the next update can then call removeChild against a parent whose children have moved (facebook/react#11538). This is the structural defence against that, plus the setting the served language will read from. The language setting is `uiLanguage`, and it is deliberately not the `locale` field that already exists. That one is a formatting choice -- what calendar, clock and numerals to use -- and folding the two together would silently rewrite everybody's date format the first time they picked a language. German dates with an English interface is a real preference, and so is the reverse. It defaults to English when absent, which covers both a new account and every settings file written before this, and Accept-Language is not consulted: a served locale should be something the reader chose rather than something guessed and then written down as though they had. Only languages with strings shipped are offered, which today means English alone -- a picker entry without a catalogue behind it would leave the page claiming a language it is not in, which stops a reader translating a page they cannot read. `<html lang>` is set where applyTheme is set: at store module load, from the localStorage cache, before createRoot() has rendered anything. Not in an effect -- a lang that is briefly wrong is enough to raise the translate prompt on a page that needed none. There is no server-rendered alternative to reach for here: ihasmail serves a static shell and holds no account state, and the settings file lives in the reader's own JMAP Files, so reading it before the page existed would mean authenticating to Stalwart on every page load. The static lang="en" in index.html covers the first bytes; the store only ever corrects a reader who chose otherwise. Both halves are tested. translate="no" and class="notranslate" go on the narrow boundaries only: rendered email bodies, raw message source, attachment text, the generated and hand-edited Sieve, the brand and the login name. Not on <body> -- someone whose language ihasmail does not speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a shadow root, so React never reconciles them and they were never a crash risk; the marker there is about not rewriting what a sender actually wrote. Twenty-four fragile interpolation points were found with the TypeScript parser rather than grep, and fifteen refactored. Pluralisation and "count + label" pairs are collapsed into a single expression so the text is a lone child React updates with textContent, rather than a text node with conditional siblings to insert around. One of them -- InviteCard's {method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either way and is simply gone. The boundary is scoped to the main content, so the header, folder tree and any open composer sit outside it and survive independently. It recovers by remounting the subtree, which costs nothing because everything inside re-derives from the stores, and it logs at info rather than error: a reader translating a page is expected and recovered from, and filing it as an error would put an entry in every console-reading reporter for behaviour that worked. It re-raises anything that is not a DOM mutation error, so a real bug still surfaces as one, and it gives up after three attempts rather than looping invisibly. Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping 207-249 React-managed text nodes in <font>, exactly as the translator does, then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since. So this lands as defence whose premise is weaker than assumed rather than as a fix for something observed here, and the boundary is insurance rather than a load-bearing part. The notranslate markers and the collapsed interpolations stand on their own merits either way. |
||
|
|
d255c20215 |
Keep push alive across a deploy, not just across a week
#143 added a device-local flag recording that background notifications were switched on in this browser, and made the renewal on app start key off it. It is not in KEEP_ON_SIGN_OUT, and that is the whole bug: clearSignedInData() runs on two different endings and only one of them is a sign-out. The other is a session expiring, which is what a deploy does to every signed-in browser at once. That path deliberately does not remove the push subscription -- there is no session left to remove it with -- so the subscription stays registered at Stalwart and the browser keeps its own. Losing the flag there left nothing to renew them: push would have gone quiet a week after every deploy, with the switch in Settings still reading as on because both ends of the subscription still existed. That is the exact failure #143 was written to prevent, reintroduced through a different door, and the first deploy carrying #143 would have been the thing that triggered it. Signing out for real still forgets it. That happens directly in unsubscribeThisDevice, next to destroying the subscription, and it happens even when the server cannot be reached -- a browser that goes on believing it has push would have renewal resurrect it on the next sign-in. Both halves are tested now, because they are one invariant seen from two sides: storage.test.ts covers the flag surviving an expiry, webpush.test.ts covers a sign-out clearing it with the server unreachable. |
||
|
|
562cee82ce |
Renew the push subscription, so it does not lapse in a week
Background notifications were built, verified against a live server, and then went quiet a few days later on every device that had them. A JMAP push subscription expires -- seven days is the ceiling -- and re-registering before it lapses is the client's job. Nothing did: enableWebPush() was reachable only from the switch in Settings, so the subscription was registered once, expired, and stayed expired. Nobody reports that as a bug. They report that push does not really work. It is renewed on every app start now, which is the only place it can be: the registration is a JMAP call and the service worker has no session cookie to make one with. So the guarantee is that push keeps working as long as ihasmail is opened now and again, and a two-day renewal window against a seven-day ceiling means once a week is enough. Registering is the same call as turning it on -- deviceClientId makes a repeat replace rather than accumulate -- so there is no second path to get wrong. Two more things in the same area, both of which produce the same silence: - webPushActive() asked whether the *account* had any subscription, so the moment one device had one, every other device showed the switch already on. A phone that had never successfully registered, or whose registration had since expired, read as on and delivered nothing. It matches on the device now. - Turning push on reused an existing browser subscription and gave up if there was none. A browser drops or rotates one on its own, and there is no tab open to hear the pushsubscriptionchange when it does, so that state was permanent. Renewal re-subscribes rather than bailing. Whether this browser has push on is now remembered locally, which is what renewal keys off. It is per browser rather than per account on purpose: a subscription is an endpoint and a device, and a phone having push says nothing about the desktop. It is not kept across sign-out, matching sign-out already destroying the subscription itself. The mock is the reason this was invisible in development: it handed back expires: null, so a client that never renewed worked perfectly against it forever. It expires a subscription in seven days now, which is what makes "does this client renew?" a question the mock can answer. Checked against the mock: a create returns an expiry seven days out that survives PushSubscription/get and parses, renewing the same deviceClientId replaces rather than accumulates, and a device with no registration of its own finds nothing where the old code saw two subscriptions and said yes. What the live Stalwart sets for expires is not confirmed -- if it sets none, renewal correctly does nothing and the other two fixes still stand. |
||
|
|
42dfdc5a44 |
Write down what the mail list does under a finger
FEATURES.md and the touch gestures were written at the same time on separate branches, so the inventory of everything ihasmail does landed knowing about none of them: the mobile layout was still one line about a tab bar, and the Appearance row of the settings table did not mention the two settings that had just been added to it. The gestures get a section of their own under Layout rather than a bullet, because the interesting part is not the list of five -- it is why they are gated on `(pointer: coarse)` rather than on width, why a direction with no meaning in this folder refuses to move rather than moving and doing nothing, and why the axis lock gives a diagonal drag to the scroller. The same reasoning is in the code; this is where somebody reads it without opening lib/touch.ts. Also here: the `dvh` and safe-area notes alongside the mobile layout, a long press added to the list's multi-select bullet, and a short note in the settings-sync section on why the swipe actions follow the account despite looking like a per-device setting. |
||
|
|
b2769b9011 |
Give the mail list the gestures a phone already has
ihasmail's mail list was built for a mouse. A row is clicked, right-clicked and dragged into a folder, and on a touchscreen two of those three do not exist -- so the phone layout had the shape of a mail app and none of the handling, and the things people reach for first simply did nothing. Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged: - Swipe a row sideways to act on it. Each direction is a setting -- right archives and left deletes by default, matching the app the phone came with -- and the strip revealed behind the row names what will happen in the folder it is happening in: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail, and nothing at all where the action is a no-op, in which case the row will not move that way. - Hold a row to select it. Selection was reachable already, by aiming at a checkbox beside an avatar, which is not how anyone selects mail on a phone. The selection toolbar gained an overflow menu at the same time: report spam, mark unread and label were hidden on narrow screens and had nowhere else to be, so touch selection could not reach them at all. - Hold a folder for the menu its ⋮ button opens. - Pull the list down to refresh, and drag in from the left edge of a conversation to go back. The toolbar's button and arrow both stay: a gesture with no visible control is one only the people who already know about it can use. The arithmetic behind them is in lib/touch.ts, away from the components and under test, because the numbers are the whole thing: an axis lock biased towards the vertical, so a diagonal flick stays a scroll rather than deleting whatever it passes over. Two layout bugs turned up while checking this on a 390px screen, both older than the gestures. The app shell is a grid with only its rows named, so it took an implicit auto column sized to the top bar's min-content -- about 470px -- and every message row ran off the right of the glass with its date beyond the edge. The column is now stated as minmax(0, 1fr), and the search field is allowed to shrink. Full-screen surfaces measure in dvh rather than vh, and the tab bar, drawer and compose button keep out from under the notch and the home indicator. |
||
|
|
f736bf0c34 |
Write down everything ihasmail does
The feature list lived in three places that each answered a different question: the site sells it, the docs teach the parts that surprise people, and the README summarises both in a paragraph. Nothing said, in one place and at full detail, what is actually built -- so evaluating ihasmail meant reading the source, and a feature that quietly stopped working had nowhere to be contradicted. FEATURES.md is that inventory, written from the code rather than the copy: the capability matrix and what each missing one removes, the search operators as the parser actually reads them, the Sieve tests and actions, every settings section and which of them follow the account, the shortcut set, the security posture, and the environment. Where a behaviour is odd it says why, because the reason is usually the stateless constraint. It also records what the docs had not caught up with: single-occurrence edit and delete are built, and the id renumbering that made them hard is described where someone changing that code will find it. |
||
|
|
34e37e1786 |
Deploy immutably by default
Forgetting IHASMAIL_IMMUTABLE handed back a writable container with a volume mounted, quietly, and then reported healthy. Nothing in the output said the immutability had gone -- `docker inspect` was the only place it showed, and only if you thought to look. That is the wrong way round for a posture the project leads with. The safe one is now what you get by default and giving it up is the half that has to be deliberate. Found by deploying prod: the running container had IMMUTABLE=1 and a read-only root, and reproducing that took passing the variable by hand because the script's default would have taken it away. |
||
|
|
06943fd473 |
Mock: let an override move an occurrence, as the server does
Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00 series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId` still at 09:00. The slot the rule made stays put; only the clock time moves. The mock set `start` from the slot after merging the override, so it clobbered any `start` the override carried and a moved occurrence did not move. Per-occurrence *time* editing - one of the main things the feature is for - therefore looked broken against the mock and correct against the server, which is the wrong way round for a mock to be wrong. It also confirms the choice of handle: `recurrenceId` is the one name for an instance that survives both a renumbering and a move, which is why the store re-resolves from it rather than from `start` or a cached id. |
||
|
|
91481965bc |
Calendar: never mutate an occurrence by an id we are holding
Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.
A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.
`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.
The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.
Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.
Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
|
||
|
|
5ced44ec13 |
Calendar: drop the per-event colour picker, which categories replaced
A category carries a colour. Offering a separate colour picker beside it made two ways to say the same thing, and they could disagree: an explicit colour wins over the category's in `eventColor`, so an event could be filed under Work and drawn in the Travel colour with nothing on the menu explaining why. Categories are the one that carries meaning, so the swatch grid goes and picking a category is how an event gets a colour. Clearing an explicit colour stays, and only appears when there is one to clear. An event that already has one - set before this, or by another client, or by CalDAV - would otherwise ignore its category for ever with no way to fix it from here. Same reasoning as leaving "Stop sharing" on a folder whose share nobody can see: the escape hatch is worth most exactly when the thing it undoes is invisible. Nothing reads differently for events without an explicit colour, and `CALENDAR_COLORS` is untouched - categories, labels and mailboxes all still pick from it. |
||
|
|
dd8998f178 |
Calendar: edit and delete a single occurrence
Closes #132. Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a `recurrenceOverrides` entry rather than touching the series, so editing one date of a recurring event is now something the server does and this does too. Editing asks the scope *before* the form opens, because it decides which event the form is even about: a form populated from the master shows the series' start date, so editing Wednesday's standup would have offered to move Monday's. Deleting asks in place of the old confirm. The patch is narrowed rather than posted hopefully. 0.16.20 sorts per-occurrence properties into three groups and only one is honest: ten are refused with `invalidProperties`, twelve more are dropped from the patch while the response still reports success, and the rest are applied. That silent middle group is how #26 reached a live server - a successful response is not evidence anything was written - so `occurrencePatch` throws on the first group, reports the second to the caller, and the editor leaves out the five it always sends. A patch that would be entirely dropped is not sent at all. The refusal for an occurrence of a this-and-future change offers the series instead of a bare error toast. Nothing here writes one of those, but an event synced from another client can carry one. Two things the scope prompt cost, both worth knowing. A dialog is queued in a store the moment it is asked for, so it outlives the effect that asked: without a ref guard a remount queues a second prompt the first answer cannot retract. And gating the *answer* on the effect's cleanup flag is worse - StrictMode runs mount, cleanup, mount, so the flag is already set by the time anyone clicks and the editor never opens. The mock expands recurrences for the first time, which is what makes any of this developable. It hands out synthetic ids for everything including one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces the refusals - including the silent drops, since a mock that applied them would let a client that sends them look correct everywhere but a real server. |
||
|
|
b0cb73a924 |
Stalwart has not reached 1.0 yet
Both the versioning comment and the README described 1.0 in the past tense, which reads as though it has shipped and the numbering was changed in response. It has not. The old `2.16.x` scheme was dropped over where it would end up, not where it ended up, and the surrounding conditionals are simplified to match: "would sort", "would read", rather than "would have". The badge quoted in the comment also said 0.16.19; it says 0.16.20 now. Comments and prose only -- no behaviour changes. |
||
|
|
373822c2ae |
Resolve the base event id in the calendar store, not at the call sites
Closes #133. `updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The `baseEventId ?? id` that made them hit the series lived at four call sites instead, and every one of them happened to be right. That was backstopped by the server until now. Through 0.16.19 a synthetic id reaching `destroy` came back as "Deleting synthetic ids is not yet supported" and the user saw a toast. 0.16.20 accepts it and removes one date instead, reporting success under a dialog that said "Delete all occurrences?" - so a forgotten `??` became silent data loss rather than an error. The three methods now take the event and a required `scope`, and there is exactly one place that turns an event into an id. A caller that wants the series cannot get an occurrence by forgetting anything; a caller that wants one occurrence has to say so. `rsvp` takes the event rather than an id for the same reason, and no longer looks it up: its patch is `participants/{key}/participationStatus`, which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed at an instance it would quietly mean "only that day". `findByUid` says in a comment that its query omits `expandRecurrences` on purpose, since InviteCard hands the result straight to `destroyEvent`. |
||
|
|
e41742a26c |
Stalwart 0.16.20 on the live instance
INBUXA moved 0.16.19 -> 0.16.20 on 2026-08-31 with eight seconds of downtime. A 0.16.x -> 0.16.x upgrade is a binary replacement: no data migration, no config change. Nothing ihasmail depends on changed. The session capabilities, blob, quota, submission and registry paths are untouched by the release, and `urn:stalwart:jmap` is still absent at session level, so the three-place lookup that sign-in turns on remains both correct and necessary. The Locale enum did move from POSIX names to BCP-47 (`en_US` -> `en-US`, `POSIX` dropped), which `normalizeLocale` already handled. The recurrence entry is rewritten rather than deleted. 0.16.20 added `CalendarEvent/set` support for synthetic ids, so per-occurrence editing is a thing the server allows and ihasmail does not do yet (#132) - and the refusal that used to catch a synthetic id reaching `destroy` is gone, which is why the base-id resolution wants moving into the store (#133). Dates on the existing entries are left at 0.16.19 on purpose: they record what was actually run, and the upgrade was read from the diff, not re-run. |
||
|
|
95f640b24c |
Point at the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. The old URLs redirect, so nothing was broken, but a redirect is not a correct address to publish. The SOURCE_URL defaults matter most: the AGPL asks whoever runs a modified version to offer that version's source, and the sign-in page and About screen show this link. It is in four places that have to agree -- the compose file, .env.example, the server default and the web fallback. The rest is documentation and issue links. |
||
|
|
2f55b1e3e1 |
Stop borrowing Stalwart's version number
The middle field was the Stalwart generation a build targeted -- 16 for 0.16 -- which leaves nowhere to go when Stalwart reaches 1.0. There is no honest value for it: 2.1 sorts below the 2.16 already deployed, so every image and About screen would have read as a downgrade. Tying our numbering to somebody else's was the mistake, and which Stalwart a build needs is said properly in the README badge and KNOWN-ISSUES, where it can be precise rather than one digit. The version is now the date of the commit it was built from, and the pull request moves after the + as build metadata. It is provenance rather than a rank: at the rate they merge here it climbs without bound and says nothing about how new a build is. Everything after the + is ignored when versions are compared, which reads correctly -- two builds from the same day differ in where they came from, not in age -- and nothing depends on that comparison anyway, since images are pruned by creation time and a rollback names a git ref. The date is the commit's own, so rebuilding an old commit gives the version it had the first time. package.json is no longer the source of anything and sits at 0.0.0, which is what an unversioned build reports and is meant to look wrong. The formatting is a pure function now, so the rules have tests. They had none while the version was the thing naming every image we ship. |
||
|
|
08fd08e6fe |
Link the project site from inside the app
ihasmail.org was linked only from the login screen footer -- a page a signed-in user sees once and then never again. From inside the app there was no way back to the project site at all; Documentation went to docs.ihasmail.org and that was the whole of it. "About ihasmail" now sits under Documentation in the account menu, where somebody looking for what this thing is would actually go. |
||
|
|
8844fc9836 |
Let a dry run answer without a terminal
The confirmation ran before the dry-run check, so a dry run over SSH was refused for having no terminal to confirm on -- and the refusal came out instead of the report it was asked for. Nothing was going to be deployed either way: it was asking whether to go ahead with something that was not going to happen. Print the report, stop there for a dry run, and gate only the real thing on the confirmation. The hold list still refuses a held commit under --dry-run, since that is an answer a dry run should give. --help printed a fixed line range, which the header edit above would have clipped. Print the leading comment block itself instead. |
||
|
|
8d475e2b07 |
Refuse to save a script we only partly read
The transport fix stops the truncation that caused #76, but the save path had no answer for a baseline that arrives incomplete. It is neither unknown nor empty, so every existing guard passes it through: it parses into a shorter rule list that looks exactly like a script with fewer rules, and saving writes that back over the real one. Check the script against the shape the generator emits instead. Every rule comment parses, every enabled rule has an if and a closed body under it, every block ends with a blank line. Structural rather than a re-serialize-and-compare, so a script written by an older version whose serializer differed is still editable. The rule editor reports a short script as unreadable rather than showing the rules that happened to parse, since a list that looks complete over a script that is not is the most dangerous thing it could offer. A cut at the end of a complete rule block is still a valid shorter script and cannot be told apart from one; that residual is the proxy's to cover. |
||
|
|
0277b5b6a8 |
Send the length of the bytes we are actually sending
A gzip response is decompressed before the blob proxy sees the body, but its content-length still describes the compressed bytes. Copying that header onto the longer body made the browser stop reading that many bytes in and call the download complete, so files arrived truncated with nothing reporting a failure. It took a hop that compresses to show up, and one that only compresses above a threshold to look like a race: a Sieve script stayed intact for two rules and came back cut off mid-rule once the third pushed it past 1 KiB. Ask upstream for identity, and forward no length at all rather than one that describes different bytes. |
||
|
|
bd3c4bf964 |
Change the copyright holder to Coffey Labs
Two lines in the README: the licence statement, and the "by" badge in the header. The badge mattered as much as the copyright line. It pointed at linuxexpert.org, which is now retired -- it 301s its articles to jcoffey.dev and answers 410 for everything else -- so "by LINUXexpert.org" sent a reader to a site that no longer claims this work. It now reads "by Coffey Labs" and points at coffeylabs.org. Everything else that says LINUXexpert-org is a github.com URL: the source link baked into .env.example, docker-compose.yml, web/src/lib/source.ts and server/src/config.ts, plus issue and release links in the docs. Those are the repository's real path and are unchanged -- the AGPL source offer in the app depends on that URL resolving. LICENSE untouched. Its only copyright is the FSF's on the AGPL text itself. |
||
|
|
b6327ffb98 |
Say where to report a code of conduct violation
The Contributor Covenant ships its enforcement section with a placeholder
for the contact address, and this copy never filled it in. The sentence
whose entire job is to tell someone where to report harassment read:
reported to the community leaders responsible for enforcement at
.
So the document existed, scored on GitHub's community profile, and
answered the question it was there to answer with a full stop. Anyone who
needed it would have had to go looking somewhere else, at the moment they
were least inclined to.
Uses the same obfuscated address as SECURITY.md and CONTRIBUTING.md, so
there is one contact for the project rather than a second one to keep in
sync. Found while porting this file to cairnobs, which is about to go
public and would have inherited the same gap.
|
||
|
|
d8fc47d765 | Clean up contributor metadata | ||
|
|
0b01956535 |
Ask whose computer this is, and believe the answer
Sign-out never cleared local storage. It stopped push, flushed settings and removed the subscription -- that last one reasoned explicitly that a browser left holding someone's mail becomes somebody else's next -- and then left the settings cache and the recently-addressed list on disk. That list is other people's addresses, and nothing ever removed it. Clearing it on sign-out is now unconditional, because lending a laptop is the same exposure as a public machine, only quieter. The keep-list is short and deliberate: lastUser, which only a trusted device writes; the trust flag; and the random push device id. Everything else goes, so a key added later is forgotten by default rather than by nobody having thought about it. "Keep me signed in on this device" defaulted to true, which assumed the answer most costly to get wrong -- someone on a library machine got a thirty-day cookie unless they noticed a ticked box. It now asks whose computer this is, defaults to not yours, and says what each answer does. Untrusted means a session cookie, nothing written locally, no push subscription, and a five minute idle sign-out. The idle timer is there because the alternative does not work: custom beforeunload text was removed from browsers years ago, and no event fires at all for walking away from a signed-in screen, which is the case that matters. A timer needs nobody's cooperation. Reads are gated as well as writes, since a machine trusted once still has the residue; an untrusted sign-in purges it outright. The wire keeps calling this `remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate every session file on upgrade for a change of vocabulary. Verified in a browser against the mock, not only in tests: untrusted sign-in leaves localStorage empty through a full session including folder expansion; trusted writes settings, recent and lastUser as before; sign-out clears recent and settings while keeping lastUser; an untrusted sign-in afterwards clears even that. |
||
|
|
1c678fabed |
Say where the 2FA entry came from, not that something is tracking it
The roadmap's preamble promised that anything with an issue number was tracked in the issue tracker, and the two-factor entry ended in a bare "Reported as #75". That issue was closed as completed on 2026-08-26, so the one entry the promise applied to was the one it was wrong about: a reader following the link finds a closed ticket and has to guess whether the work went with it. It did not. #75 reported a sign-in refused with nothing but "Invalid credentials", and that bug was fixed -- the message now says what is happening and points at app passwords. The OAuth work the report uncovered stayed behind on this page, which is exactly the case the preamble had no room for. So the preamble now says an issue number records where an entry came from rather than where it is tracked, and the entry says plainly that #75 is closed, what closing it fixed, and that there is no ticket to watch for the rest. |
||
|
|
490b15e8c6 |
Lead with what makes it different
"Gmail-class webmail" describes the client, and every webmail says something like it. What no other webmail for Stalwart says is that the container has nothing to persist: one optional write path, and with IMMUTABLE=1 switched on, no volume and no writable root filesystem at all. The Gmail comparison still earns its place -- it is what tells someone what the client feels like to use -- so it stays, one clause later, where it describes the app rather than the product. |
||
|
|
8f9d253939 |
Reload even when there is an unsent draft
Holding the reload back while a compose window had unsaved text protected the text, but it meant a tab could sit on a build the server no longer runs for as long as someone left a draft open -- which is not automatic, and automatic is the point. So the reload is unconditional once the versions differ, and this will sometimes take an unsent draft with it. The trade is deliberate: a tab talking to a server it does not match is the worse failure, and it fails quietly. |
||
|
|
fedd6ed161 |
Notice a new build without being told
Checking only on a 401 was not automatic, just deferred. It needs the tab to make a request, so one left open and idle went on running the old build until somebody touched it -- which is exactly the thing that cannot be relied on. The obvious signal turned out to be the wrong one, and testing is what showed it. A deploy kills the EventSource behind /api/events, which looks like the perfect cue, except it arrives while the container is still being replaced: the check that follows cannot reach the server, fails, and is never retried. Waiting for the stream to come back instead does not work either, because the session died with the old container, so the reconnect is answered with a 401 and never reaches "connected" at all. The drop is still watched, since it costs nothing and sometimes lands late enough to be useful, but nothing depends on it. What the guarantee rests on is a slow poll while the tab is visible, plus a check when it becomes visible again. Neither cares what the stream is doing or whether anyone is at the keyboard. /api/health touches nothing upstream, so a minute between checks costs one small request per open tab. Reloading is now something that happens to people rather than something they ask for, which makes it able to destroy work. A compose window holds text that has not reached the server, and after a deploy it cannot be saved at all -- the session went with the container. Reloading would be the difference between signing in again and pressing send, and losing what was written. So anything holding such state can say so, and compose does; the tab stays on the old build until the draft is dealt with, and catches up on the next check afterwards. |
||
|
|
e327df818a |
Reload when the server is running a newer build
Being signed out and picking up a new version are separate things, and only the first was happening. An immutable instance holds sessions in memory, so a deploy signs everyone out -- but a 401 only swaps the view to the sign-in form, client-side. The tab keeps the bundle it already has, and the old JavaScript goes on talking to the new server until someone happens to reload by hand. The pieces for fixing it were already there. index.html is served no-cache and the assets under it are content-hashed and immutable, so a reload is all it takes; Vite bakes the build's own version in as APP_VERSION; and /api/health reports the server's. What was missing was something to compare them. The check runs on a 401 rather than on a timer, which is the moment it matters and costs one small request. It compares versions rather than reloading on every 401, so an ordinary session expiry still lands on the sign-in form with the page intact. And it runs before the sign-in form is shown rather than after, because reloading a form someone has already started typing into would throw the password away. Failing to reach the server is not a reason to throw away what is on screen, so anything other than a clear answer leaves the page alone. The version that was reloaded for is remembered for the session, so a server that keeps reporting a version the bundle does not match -- a stale proxy cache, a half-finished deploy -- cannot put the tab in a reload loop. |
||
|
|
4cd7b895e9 |
Deploy immutably when asked to
IHASMAIL_IMMUTABLE=1 runs the container the way the README's "Running immutably" section describes: read-only root filesystem, no volume, sessions held in memory. Until now that shape could be run by hand but not deployed -- the run line mounted the data volume unconditionally, so a redeploy would have quietly put a mutable container back. The switch is one variable and nothing else. IMMUTABLE=1 is passed to the server too, which checks the claim rather than believing it, so a half-applied switch refuses to start instead of looking fine until the next redeploy signs everyone out. SESSION_FILE is cleared with -e rather than by editing the environment file, because -e wins over --env-file; that keeps going back a matter of changing the same one variable: IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes which reproduces the previous run line exactly. The named volume is never touched in either mode, so the sessions that were in it when the switch was thrown are still there to come back to. |
||
|
|
f72c67864e |
Let the container run with nothing writable
The server writes to one path and no other: SESSION_FILE, from sessions.ts. Everything else it touches on disk it only reads. So a container with a read-only root filesystem already works -- except that `VOLUME ["/data"]` quietly undid it. Docker acts on that directive: a container started without `-v` gets an anonymous volume mounted there anyway, writable even under `--read-only`. It persisted nothing across a redeploy, since each new container got a fresh empty volume, and it left an orphan behind every time one was replaced. Deployments that want the sessions to survive already say so themselves -- docker-compose.yml and deploy.example.sh both mount a named volume -- so removing the line changes nothing for them. IMMUTABLE=1 asserts that this is how the instance is running. It is checked rather than believed: the server refuses to start if SESSION_FILE is still set, or if the filesystem it is installed on turns out to be writable. Left unchecked the misconfiguration is silent, because persisting sessions is best-effort -- a read-only /data costs one warning at the first sign-in and nothing more until the instance is replaced and everyone is signed out. SessionBackend names what the rest of the server asks of a session store, and `sessions` in app.ts is typed as it. Nothing changes today; SessionStore is still the only implementation. It is there so the OAuth work is written against the interface rather than the class, and so the interface can record which of its methods a stateless backend could satisfy alone: create, resolve, reseal and destroy each touch one session, while listForUser and destroyAllForUser have to reach sessions other than the caller's. The second of those carries the guarantee that changing a password invalidates the sessions still holding the old one, which is why it needs a registry -- Stalwart's token registry, once sign-in goes through OAuth. |
||
|
|
0fe75b280b |
Link the documentation from the profile menu
docs.ihasmail.org is where installing, configuring and using ihasmail are explained, and nothing in the app pointed at it. The profile menu is where someone looks for the things that are about the app rather than about their mail, so it goes there, above Settings, and opens in a new tab: reading the docs is something you do beside your mail, not instead of it. `MenuItem` renders a real anchor when given an href, rather than a button calling window.open. The browser's own handling of a link comes with it -- middle-click, a modifier-click, "open in new tab", the address on hover, copying it -- none of which a button offers however carefully it is scripted, and all of which someone expects of a menu entry that leaves the app. Items without an href are the button they always were. It also needed a line of CSS. The global rule for `a` coloured and underlined the one entry that is a link, so the menu had a blue underlined item among four plain ones, which reads as a mistake rather than a distinction. Verified against the mock: the entry sits above Settings, is an anchor to https://docs.ihasmail.org with target=_blank and rel=noopener noreferrer, and computes to the same colour, size and decoration as Settings beside it. |
||
|
|
5a7cc5cc5a |
Say a missing folder is missing, not empty
A folder id the account does not have rendered the ordinary empty state -- "Nothing here. This folder is empty." That is a claim about a folder that is not there, so a stale link read as a folder that had emptied itself rather than one that was gone (#111). It now goes to the inbox and says why. Inbox is the kinder landing than a dead end for a bookmark that has outlived its folder, but swapping one folder for another without a word would be its own small lie, so it does not do that either. The condition worth writing a test around is not the unknown id, it is the one guarding it. The folder list arrives after the first paint, so for a moment *every* id is unknown, the right one included. Without that gate this redirects on every cold load, from the folder the reader actually asked for, and looks exactly like a flaky link -- a worse bug than the one being fixed and a harder one to see. `isUnknownMailbox` is a small pure function so that case can be pinned down rather than reasoned about. Only ever reachable from outside the app, which is why it went unnoticed: the sidebar links to ids that exist. A bookmark to a deleted folder, or a folder link passed between accounts, is where it bites. Verified against the mock: an unknown id lands on the inbox with the message and a full list rather than an empty one, and a cold load straight into a real folder stays in that folder with nothing said. Closes #111. |
||
|
|
6efac64b37 |
Take a screenshot of the recipient picker
The site says you can pick recipients by reading the address books rather than remembering a name. It had no picture of that, and a claim nobody can see is a claim nobody believes. Taken from the composer step, where a composer is already open. The obvious place was a step of its own later in the run, and that failed: navigating back to the mail list after the run has been through Files does not reliably render rows within any wait I was willing to give it. Worth knowing rather than rediscovering -- the earlier inbox step goes to the same route and is fine, so it is the state left behind, not the route. The shot ticks two people before firing, since a picker photographed empty shows a list rather than a choice. The filters step still times out waiting for its editor, as it did before this change. Everything up to it is written; filters.jpg is whatever the last successful run left. Still undiagnosed, and still not this. |
||
|
|
a2337f6ad8 |
Use an example address, and the right name, in the test fixtures
Two things, one of them not what it looked like. An organizer fixture was built from a real, routable address. Every other fixture in the codebase uses example.org or example.com, and this repository is public, so that one was a personal address sitting in public source for no reason -- the test asserts roles and participation status and never reads either value. It is [email protected] now. The names were wrong in the other direction. Three fixtures across two files said "John Ellis", which is not the maintainer's name; it is John Coffey. Being a name rather than a routable address, it leaked nothing, but it was simply incorrect, and incorrect in the sort of place nobody rereads. The address and the name are separate questions and got separate answers: the address is fictional because it is an address, and the name is real because it is right. A message from [email protected] signed John Coffey is exactly what these tests mean. Found while checking, at the maintainer's prompting, whether the repo leaked anything about the host it runs on. It does not -- the nginx and deploy files here are the generic examples they claim to be, and the real ones live in a private repository. |
||
|
|
e4b6413f46 |
Use an example address in the participants fixture
One test built its organizer from a real, routable address and a real name. Every other fixture in the codebase uses example.org or example.com, and this repository is public, so the odd one out was a personal address sitting in public source for no reason -- the test asserts roles and participation status and never looks at either value. Now [email protected], matching what the rest of the tests already use. Found while checking, at the maintainer's prompting, whether the repo leaked anything about the host it runs on. It does not: the nginx and deploy files here are the generic examples they claim to be, and the real ones live in a private repository. This was the only thing the search turned up that was worth changing. |
||
|
|
e3de0bd500 |
Take the files screenshot with the others
It was the one shot taken by hand, and it outlived two rewrites of the view it was meant to show -- a picture of a single-pane file list, still in the docs after the pane grew a folder tree beside it. Nothing was wrong with the process except that there wasn't one. The script takes it now, expanding the tree and opening a folder first, since a screenshot of Files with nothing open is a screenshot of a list rather than of a file manager. Anything the docs show should come from the mock. Otherwise it describes whatever the app looked like on the day somebody had a screenshot tool open, which is how this one got three versions out of date without anybody noticing. The other shots in docs/screenshots are refreshed by the same run. The filters step timed out waiting for its editor, so filters.jpg is the older one; that shot is untouched by anything here and the failure is not diagnosed, which is worth knowing before the next person runs this and assumes they broke it. |
||
|
|
4c4821b5db |
Ask for shareWith on mailboxes too
The third store fetching everything by asking for nothing. Same cause as the calendars and address books a commit ago: Stalwart does not return `shareWith` unless a client names it, so mail folders never looked shared either. This one has a narrow but real consequence. Sharing a mail folder is withdrawn, because Stalwart stores the share and never delivers it, and the only way left to clear one already made is the "Stop sharing" entry -- which appears only when a folder looks shared. Without the property it never did. The escape hatch built for exactly that situation could not be reached from the situation it was built for. Found by looking for the rest of them rather than waiting for the next report: `ids: null` with no `properties`, across the app. The others it turned up -- Sieve scripts, identities, the vacation response, quotas, participant identities, push subscriptions -- have no `shareWith` to lose, so mailboxes were the last. The mock hides it here as well now, so all three are honest. |
||
|
|
506865ca67 |
Ask for shareWith, or the server does not send it
Nothing was ever badged as shared, "Stop sharing" never appeared, and the share dialog opened on "not shared with anyone yet" over live shares. The sharing itself was fine. The client simply never learned about it. Stalwart does not return `shareWith` unless a client names it. A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all -- not null, not empty, absent -- confirmed against the live 0.16.19 on a calendar and an address book that really were shared with another account. Omit the list and there is no `shareWith`; name it and the sharee is right there. Both stores fetched everything by asking for nothing, and got less than they would have by asking. They name the properties now. The dialog is the part worth dwelling on. It seeds itself from the `shareWith` it was handed, so it has been showing an empty sharee list on collections that were shared -- the one screen whose whole job is managing sharing, and the one most confidently wrong about it. Someone looking there to see who had access, or to take it away, was told there was nobody. Files never had this: `fileNodeProps` has named the property since file sharing went in, for the same reason and after the same surprise. The two stores that fetched with `ids: null` and no properties are the two that were blind. The mock now omits it the same way. One that hands `shareWith` over unasked lets a client that never asks look correct everywhere except against a real server, which is exactly how this got here. Verified against that mock: sharing a calendar puts the sharee in the store, badges the row, adds "Stop sharing", and the dialog lists them -- while a `Calendar/get` with no properties still comes back without the field, so the mock is now failing the way the server does. |
||
|
|
9544fa5f12 |
Let the owner stop sharing a calendar or an address book
Revoking a share meant opening the share dialog, removing each person from it in turn, and saving. That is the right tool for changing who has access and the wrong one for withdrawing it altogether, which is the more urgent of the two and the one someone is likely to want in a hurry. Both now offer "Stop sharing" in the context menu, which clears the lot after a confirmation saying how many people lose access. It appears only when there is something to revoke, so the menu says whether a thing is shared as well as offering to change it. A calendar also says it is shared now. Address books have carried that badge since they gained sharing; calendars never did, so the only way to find out was to open the dialog and look -- which for the owner of a dozen calendars means opening a dozen dialogs. Both go through the existing update paths, so a server that refuses is reported rather than swallowed. Verified against the mock, both kinds: sharing one shows the badge and adds the entry, confirming clears `shareWith`, the badge goes, and the entry disappears with it since there is no longer anything to stop. |
||
|
|
3a2f60189f |
Load the contacts the recipient picker is meant to show
The picker opened on "No contacts in this address book" -- about an address book with contacts in it. Nothing was wrong with the button, and that is why it read as one: it opened, correctly, onto nothing. Contacts are fetched on demand. `loadAll` runs when the Contacts view mounts, and `suggest` kicks it off itself, which is why autocomplete has always worked from anywhere. The picker did neither, so opening a composer without having visited Contacts first -- which is most of the time, and every time in a fresh tab -- showed an empty list over a full account. Anyone who had been to Contacts that session saw it work, which is the sort of difference that reads as browser-specific when it is not. It asks for them now, and says it is loading rather than that there are none. While here: the picker decided which shared books to offer on `isSubscribed` alone. Stalwart refuses that flag on a book shared read-only, so those are recorded in settings instead -- for an address book it is the *only* record -- and filtering on the server's flag left every shared book out of the picker while the sidebar showed it. Both now ask the same question. Verified against the mock from a genuinely cold store -- cards emptied, `loaded` false, opening the picker as the first thing that wants them: eight rows, from the reader's own book and a shared one, where before there were none. |
||
|
|
25b51069a9 |
Keep the full copy of an email the server says changed
The reading pane emptied and refilled when a thread was marked read. On an HTML message that is a flash to the app's own background and out again, which is what remained of #100 once the message view stopped rebuilding its body. `applyChanges` dropped `fullIds` for every email the server reported as updated, so the next read would fetch it again. But the reading pane renders only the emails it holds in full. Dropping one took the message out of the open thread until the refetch at the end of the same function put it back -- and marking as read causes exactly that, because the server echoes our own change back as an update. The gap is a round trip, which is why it is plainly visible against a real server. Nothing is lost by keeping the copy. RFC 8621 makes every property of an Email immutable except `keywords` and `mailboxIds` -- the id is derived from the content, so a body cannot change beneath one -- and both are in LIST_PROPS, which the refresh immediately below merges over the cached copy. The eviction only ever cost the message its place in the thread. On the evidence, since I got this wrong once already by trusting a reproduction that did not exist. This is reasoned from the code and matched against the reported symptom -- "the pane empties and comes back", which is precisely what removing an email from the thread and refetching it looks like. It is not backed by a local reproduction: the mock never ran this path at all, because `Email/set` announced nothing and `Email/changes` always answered empty. That is being fixed separately, and it is why every check made here has been against a server that never reported the change being made. |
||
|
|
cd402a6ce4 |
Make the mock report what changed
Two silences, and between them the whole change-reconciliation path was untestable here. `Email/set` never announced anything. A real server pushes a state change after a set and the client acts on it -- `Email/changes`, then the store deciding what to do with the answer. The mock said nothing, so that path simply did not run. And `Email/changes` returned three empty arrays whatever had happened. So even when it was asked, the answer was that nothing had changed. Together they meant every version of the mark-read code has been checked against a server that never reported the change being made. That is how #100 reached production, and why the fix for it could be verified in the message view -- where the flicker partly was -- while whatever remains stayed invisible, because the code that runs when the server answers back has never run here at all. The mock now records what each set created, updated and destroyed against the state it happened in, answers `Email/changes` from that log, and broadcasts afterwards the way Stalwart does. This is a mock change on its own. It fixes nothing and is not meant to: it makes a path testable that was not, which is the prerequisite for finding what is left of #100 rather than guessing at it. I had a theory about `fullIds` eviction and reverted it -- three attempts to reproduce the symptom against this mock failed, which was itself the finding. |
||
|
|
c0fc0083ff |
Stop rebuilding the message body when it is marked read
Marking a thread read redrew the message pane: the mail vanished and came back, white to dark to white on an HTML message that brings its own colours, half a second after the reader started reading it. Worst with auto-mark set to "immediately", where it happens the moment the thread opens (#100). The pane was not re-mounting. The *body* was being thrown away and built again, and the reason is one dependency. `HtmlBody` writes the message into a shadow root in an effect, and that effect had the click handler in its dependency list. The handler is a `useCallback` over `onShowImages`, which the parent passed as an arrow created inline, so it was a new function on every render -- and therefore the effect ran on every render, and every render replaced the rendered message with an identical one. Marking as read is exactly such a render: the store hands back a new email object and the thread re-renders. The listener now lives in its own effect. It is attached to the shadow root rather than to the contents, which survives the rewriting anyway, so a handler that changes identity costs a listener swap and nothing else. `onShowImages` is stable now too, but the split is the fix: it is what makes the body immune to the next handler that changes. This also stops the quoted-text toggle collapsing. `setQuoteOpen(false)` lives in the same effect and had been resetting on every render, so expanding a quote and waiting for the timer put it away again. Measured rather than watched, since a flicker is exactly the thing an eye will agree with you about. Holding a node from inside the shadow root across the transition, on the same three-message thread with the delay at 0: before, 21 childList mutations on the root and the held node detached and replaced; after, no mutations at all and the same node still attached. Clicking a blocked image still reveals remote images, which is what the moved listener is for. Closes #100. |
||
|
|
5e5bec31b7 |
Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's answer to a sharee subscribing to a book shared read-only, and it is a fair one: `isSubscribed` lives on the collection rather than on the reader, so adding one is a write to the *owner's* account. The identical write on a shared calendar is accepted. The difference is the server's. So the flag is still asked for first -- a preference the server holds is one every client agrees about -- and when it is refused the answer goes in the reader's own synced settings instead, as `addedShares`, keyed by account and collection. Either record counts as added, and the rule has a test of its own because three components ask the question and they must not drift apart. Two things about how this hid. The refusal arrives as a *successful* response with the id in `notUpdated`, so the version that ignored it saw nothing wrong and the button simply did nothing -- fixed a commit ago, and it is what turned "the + does nothing in Firefox" into a sentence from the server. And it cannot be seen from the owner's account at all, where the write succeeds: it took two browsers signed in as two accounts to find, which is why it survived every check made from one. The mock refuses the same write for the same reason. One that accepted it would have gone on agreeing with the belief that shipped. Verified against it: adding the shared book is refused by the server, recorded in settings, and the book moves to "Shared with me" with its contacts reaching the To field; removing undoes all three; and it survives a full page reload, which is the point of putting it where the settings live rather than in this tab. |
||
|
|
3416a41de9 |
Say so when the server refuses a subscribe
Adding a shared address book did nothing in one browser and worked in another. The button was not broken; the refusal was invisible. Subscribing is the one call in the app that writes to somebody else's account, so it is the one a perfectly healthy server is entitled to say no to -- and JMAP says no to a `/set` by answering successfully with the object listed in `notUpdated`. Neither subscribe method looked. The promise resolved, the code carried on, the re-read came back unchanged, and the row stayed exactly where it was with nothing said. Every other `/set` in this codebase reads `notUpdated` and raises. These two were written without it, which is the whole defect: not a wrong answer, an unread one. Both now check it and say what the server said, which is the thing that was missing -- whatever the underlying refusal turns out to be, it can be read off the screen instead of guessed at from which browser was in front of you. |
||
|
|
5f32d3d82c |
Choose recipients from the address books
Addressing a message worked only if you already knew the name you were half-way through typing. Autocomplete answers "finish this for me"; there was no answer to "who is there?", which is the question someone has when they open a compose window and want the person from the team list whose surname they cannot summon. The To row now opens the address books -- from a button beside Cc and Bcc, where someone thinking about recipients is already looking, and from the To label itself for anyone who tries that first. Search across every book or narrow to one, tick as many people as the message needs, and send them to To, Cc or Bcc. Picking for a field that is hidden opens it, since a Bcc dropped somewhere invisible is worse than no Bcc. Every address is its own row rather than every person. Somebody with a work address and a personal one is a choice the writer has to make, and a picker that listed the card and quietly took the first address would be making it for them. Shared books are in it on the same footing as the reader's own -- that being the point of having added them -- with the account named on each row, so it is never a mystery whose list a name came from. Books that have not been added contribute nothing, the same rule the To field already follows. Verified against the mock: the picker lists the reader's contacts and the shared book's, each row naming its source; ticking one of each and choosing Cc opens the Cc row with both in it. |
||
|
|
0215255280 |
Add a shared calendar or address book, rather than being given it
An account linked for its files also offered its calendar and its address
book, and neither had been shared. That was not ihasmail inventing them:
asked about the other account, the live 0.16.19 returns every calendar
and every book it holds, each with full rights -- read, write, share,
delete, all true. There is nothing in the rights to tell "shared with me"
from "reachable at all", because the server does not distinguish them.
`isSubscribed` does, and it is the field JMAP has for exactly this: it
came back false on all of them. So a shared calendar or book is listed
under "Shared with me" once the reader has added it, and under "Available
to add" until then, with one button either way.
Nothing unsubscribed contributes anything. A calendar that has not been
added draws no events, and a book that has not been added lends no cards
to the To field -- which is the one that mattered most, since it is the
difference between offering a colleague's contacts and offering a
stranger's without anyone having asked.
The mock's shared calendar and address book now arrive unsubscribed, the
way the real server hands them over, so the adding is exercised rather
than skipped; and its `Calendar/set` and `AddressBook/set` route by
account, since subscribing to somebody else's is a write to their
account and the mock had nowhere to put it.
Verified against the mock: the shared calendar sits under "Available to
add" with no events in the grid, adding it moves it to "Shared with me"
and its events appear, removing it undoes both; and `suggest("katherine")`
finds nothing until the shared book is added, then finds her.
|
||
|
|
270fb3d32c |
Shared calendars in the calendar, and no more account switcher
Three things from using it on two real accounts. A calendar shared with you never appeared. Nothing was wrong with the share -- the calendar had nowhere to be shown. Calendars loaded from one account and one only, so the sharer's were reachable solely by switching the whole app to their account, which is the door being closed below. They now sit under "Shared with me" beside the reader's own, in their own colour, with their events in the grid and a click to hide them like any other calendar. Their events go through `instancesIn`, the one funnel every view already reads, so month, week, day and agenda got them without being touched. Events and calendars from another account are keyed by account as well as id, and hiding one is remembered under the same key: an id means nothing outside the account holding it, and two accounts sharing an id is ordinary rather than unlucky. An account that shared nothing was listed in Files as though it had. Every non-personal account was offered on the reasoning that its folders could speak for themselves -- but an account whose *calendar* was shared has no folders to speak with, and appeared as an invitation to open an empty pane. Each is now asked for one file before being listed, and silence is taken for an answer. And the account switcher is gone from the profile menu. It existed to reach what other people shared and was the wrong door: it moved the whole app to somebody else's account, and since Stalwart advertises every capability on a shared account, mail, calendar and contacts went with it and were refused. Everything it was for is now in the module the share belongs to, found without anyone needing to know an account was involved. What this does not prove is that Stalwart delivers a calendar share at all. The mock says the client handles one, which is the half that was missing; whether the server behaves like address books, which work, or like mail folders, which do not, needs the two accounts again. |
||
|
|
350f4f4197 |
Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it behaved like mail folder sharing. That was wrong -- it works -- and it is back, built the way Files is rather than the way it was. Three things it inherits from Files. Shared books are listed in the app's own left pane instead of behind an account switch in the profile menu. The reader's books and other people's sit under separate headings, since a book belonging to somebody else behaves differently and a single merged list would be quiet about whose contacts you are reading. And opening Contacts re-reads the session, so a book shared while the tab was open turns up without signing out and in again. The books pane the view kept to itself is gone, and with it the last module that ignored the sidebar it was given. The one thing Files does not need: shared contacts have to answer when somebody types a name into a To field, so they are loaded up front rather than when a book is opened, and they are offered by `suggest` and found by `lookupByEmail` alongside the reader's own. Their own cards win a tie, since a card someone wrote themselves should beat a colleague's copy of the same person. That is the difference between a shared book you can look at and one you can use. Cards from a shared account are held apart from the reader's rather than merged in, and keyed by account as well as id. Ids are only unique within an account -- two accounts each having a book `ab1` is ordinary -- and a flat map would have had one silently replace the other. The mock grew an address book in its shared account, with contacts in it, because none of this could be exercised otherwise. KNOWN-ISSUES records the withdrawal as the mistake it was rather than leaving it in the history looking like a finding. Mail folder sharing stays withdrawn: that one really is broken. |
||
|
|
1e2db95577 |
Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith` map, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with -- confirmed on the live 0.16.19 with a folder shared read-only to another account on the same server, which never saw it. Stalwart's sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing anywhere reports a failure, so a client that trusts what it reads back shows the share as live for ever, which is what happened. The entry point is withdrawn. Address book sharing goes with it on a report that it behaved the same way -- not reproduced, and contradicted by Stalwart's own docs, so that one is expected back; it is out because offering a share nobody can verify was worse than the gap. Files and calendars are untouched. Removing a share was impossible, for a reason worth writing down. The dialog rendered the list of who a thing was shared with *inside* the branch that runs when the directory has principals to offer. A server with `allowDirectoryQueries` off returns none -- that is the default, and it is how these shares came to be made in the first place -- so the dialog showed one line of hint and nothing else. The share was there, and there was no way to see it, let alone remove it. The list is now rendered whatever the directory says; only the control for adding somebody new depends on having somebody to add. So the withdrawn entry points do not strand what they created: a folder or book already shared still offers "Stop sharing", which is the one thing you want when the share is invisible everywhere else. The API was never the problem, which is worth recording since it was the first guess: `shareWith: null` is accepted and clears the map, tested against the live server on the stuck folder, which is now unshared. |
||
|
|
52299ce8ef |
Attach a file that is already in Files
Attaching meant uploading, even when the file was sitting in the account already -- picking it off disk again to send the server a copy of what it was holding. The composer can now attach from Files. A blob the account can already see needs no upload at all: an attachment carrying a `blobId` is what a forward produces, so the send path has always known what to do with one. Attaching a large file the server is already storing now costs nothing and takes no time. A file in an account somebody *shared* is different, because blobs belong to the account they were uploaded to and a draft in yours cannot reference one in theirs. Those are fetched and uploaded to your account, and the picker says so before you attach rather than leaving someone wondering why one file was instant and another was not. The picker borrows the Files store, so it browses what Files browses, shared accounts included, and puts the file manager back where it was on the way out -- a detour through somebody's shared folder to find an attachment should not leave Files somewhere else afterwards. Verified against the mock, and worth recording how, because the first attempt measured nothing: `client.upload` uses XMLHttpRequest, since it reports progress, so a counter wrapped around `fetch` sees no uploads whether or not any happen and agrees with you either way. Counted at XHR instead: attaching one's own file issues no upload, and attaching a shared one issues exactly one, to the reader's own account. |
||
|
|
ad94efb65b |
Reach shared folders from Files, not the profile menu
A folder somebody shared was reachable only by switching the whole app to their account from the profile menu -- which nobody would think to look in for files, and which pointed mail, calendar and contacts at them as well. The server refused all three, so nothing leaked; it was simply the app claiming to be somewhere it could not go. Files now lists shared accounts itself, under "Shared with me", and opens them in place. Only Files moves: `accountId` in its store is the account being browsed, `ownAccountId` is the reader's, and nothing else in the app notices. Which accounts hold shared files cannot be worked out from capabilities. Stalwart advertises the whole set on a shared account -- mail, calendars, contacts, sieve, the lot, identical to a personal one, whatever was actually shared (checked live on 0.16.19, 2026-08-27). That is why routing alone could never have fixed this, and why the list offers every account that is not the reader's own and lets its folders answer for themselves. The mock's shared account now advertises the same full set, because a mock that quietly advertised only what it shared would agree with a fix that cannot work. Shares also went unseen until the next sign-in. They arrive in the JMAP session, which is fetched once and refreshed only when a session-state change is pushed to that tab -- so a share granted while the tab was open stayed invisible, and one removed stayed on offer. That is the two browsers disagreeing about whether an account still existed. Opening Files now re-reads the session, throttled, and the section header carries a refresh for when someone is waiting on a share they have just been promised. The sidebar's button on Files was Compose, which wrote mail from the file manager. It uploads. Verified against the mock, which grew a second account to make any of this testable: "Shared with me" lists it, opening it shows its folders and not the reader's, the header says whose they are, "Back to my files" returns, and the profile menu is not involved at any point. |
||
|
|
e014521fb6 |
Keep your own settings out of someone else's account
Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.
ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.
There are two questions, and they had one answer:
- what am I looking at -- follows the switcher, because switching to a
shared account is how you read what was shared
- what is mine -- never does
So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.
A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.
What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
|
||
|
|
2360e40733 |
Show the folder tree in a shared account
Switching to an account somebody had shared showed an empty folder tree. Their files listed perfectly well; the sidebar beside them was blank, with nothing to say why. Switching accounts cleared `nodes` and `children` and stopped there. So `treeLoaded` stayed true from the account before -- the sidebar only asks for folders when it is false, and it never asked again -- while `dirIds` still named the previous account's folders, which no longer resolved against the cleared `nodes`. An empty tree either way, and no error, because nothing had failed. The fields that belong to one account are now named in one place, `emptyForAccount`, and the test asserts the whole set rather than the ones that come to mind. The bug was not bad logic, it was a field nobody remembered when two more were added a commit earlier, and asserting the set is the only guard that survives the next two. Found by the person it was built for, on a real share between two accounts, which is where it was always going to show up: the tree is built from a query that had already run for their own account, so it only breaks on the switch. |
||
|
|
f70eb184c2 |
A folder tree, and dragging things into it
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
|
||
|
|
6566f4c2d3 |
Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.
Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:
- `FileNode/get` returns `shareWith`, and `myRights` carries all six
rights, `mayShare` among them and true on one's own nodes. So the
menu entry has a real right to gate on -- unlike folder sharing,
which is offered ungated because `MailboxRights` has no such right
- `Principal/query` answers now that `allowDirectoryQueries` is on:
six individuals, no groups
- `ShareNotification/get` is implemented, which is worth knowing for
later; nothing here reads it yet
The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.
One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.
Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
|
||
|
|
650ba0020b |
Hold the opening scroll while the conversation settles
Opening an already-read conversation stopped 39px short of the bottom, every time (#89). The messages were all there and one scroll fixed it, but the pane was not where it meant to be. The scroll runs in an effect, which is too early. Message bodies go into shadow roots from the child effects underneath it, and the images in those load later still, so the pane goes on growing after the scroll has already happened -- and `scrollIntoView` clamps to the scroll range as it stands the moment it is called. The read-thread fallback aims at the last message, which no thread has the room to lift to the top, so that clamp *is* the whole of the range. Measuring it before the images landed measured it short. So the target is now held against the top of the pane while the thread settles: a ResizeObserver over the children of the scroller re-aligns it whenever one of them changes height. The hold ends the instant the reader touches the pane -- wheel, pointer, touch or any key -- and after two seconds regardless. A pane that re-scrolls under someone who has started reading is far worse than one that lands short, so it lets go on the first sign of them rather than waiting for the content to stop changing. Verified against the mock, on the same already-read seven-message thread, eight opens each way: before, all eight landed at scrollTop 96 of a 135 range; after, all eight land at 135. The #87 cases are unchanged -- an unread message mid-thread still comes to rest flush against the top of the pane, and a thread whose first message is the unread one still stays at 0 with the subject in view. Scrolling or pressing a key during the hold leaves the pane exactly where it was put. One correction to #89 while I am here: it reported the pane sometimes not moving at all. That was an artifact of measuring in a background tab, where Chrome suspends rendering and clamps timers -- the behaviour in a visible tab is the deterministic 39px above. The issue is real; that one observation in it was not. |
||
|
|
d64249b46d |
Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above that sat off the top of the pane with nothing to announce it, and the only way to find out was to scroll up -- by which time the auto-mark-read timer had marked the whole thread read anyway, so scrolling up meant scrolling up to mail already counted as seen (#87). Opening at the bottom is right when there is nothing to catch up on and wrong the moment there is. The pane now opens on the oldest message that was unread when the thread was opened, and falls back to the newest when the thread has already been read. mbunkus's out-of-order case is the one that rules out guessing at a position. A participant whose server could not connect for hours delivers a message long after it was written, and it lands in the middle of a conversation that has already moved past it -- so "second to last", or any other fixed offset from the end, finds nothing. Reading the unread set is the only thing that does. Two cases leave the pane where it is: - a single message, which is already the whole pane - the first unread being the first message, where the top of the pane shows it anyway, together with the subject; scrolling to it would push the subject off for nothing It reads the set captured when the thread was opened rather than live `$seen` state, for the same reason expansion does (#69): the mark-read timer must not change the shape of what you are looking at. That also makes the landing stable, because everything above the first unread message is a collapsed row of fixed height -- nothing up there reflows after the scroll. The mock grows a thread that reproduces it: seven messages with the unread one second, four more behind it. Verified against it. Opening the thread lands the unread message flush against the top of the pane at scrollTop 158; the old scroll to the newest message put it at 445, with 287px of the message -- header, sender and unread bar included -- above the fold. On a thread whose first message is the unread one the pane stays at 0 with the subject in view, where before it would have scrolled 333. Once the thread is read, reopening it goes back to the newest message. |
||
|
|
d15f64ada6 |
docs: put the Docker quick start back in the README
Slimming it left no way to try ihasmail without leaving GitHub. Restored in short form -- the four commands, the 2FA app-password note, and links to the install and configure guides for TLS and the full environment. ROADMAP.md's 2FA entry points back at that section again, rather than at the install docs it was redirected to when the section was gone. |
||
|
|
9618a0278c |
docs: slim the README, split out known issues and roadmap
The README had grown to 330 lines and was carrying three audiences at once: installing, using, and working on ihasmail. docs.ihasmail.org covers the first two now, and ihasmail.org covers the feature tour, so the README links there instead of restating them. - Known issues / pending QA → KNOWN-ISSUES.md, verbatim - Roadmap / not yet → ROADMAP.md, verbatim (its "see Quick start" pointer now aims at the install docs, since that section is gone) - Dropped the env-var table (docs.ihasmail.org/configure/), the shortcut list (/shortcuts/), the Docker quick start (/install/) and the long feature list (ihasmail.org/#features), leaving a nav table at the top and a six-line summary of what's in it - Kept and tightened what is only true of this tree: architecture, dev commands, the mock, version numbers, deploying - Version examples refreshed from 2.16.57 to the current 2.16.84 |
||
|
|
1527ebffc8 |
Remove the two-factor entry point from the login form
The field never worked here: Stalwart takes a TOTP code only through an OAuth flow, so a client posting a username and password had nothing to send it to. Offering the button advertised a feature the login path cannot honour, so it comes out until the flow works end to end. The login store still takes a totp argument and the server still accepts one; the form now passes an empty string, which the server reads as no code given. A failed sign-in no longer reveals the field, and the invalid-credentials message drops its mention of a verification code. |
||
|
|
c6db19de19 |
Stop offering to turn two-factor authentication on
Settings > Security & sessions still had the full enrolment flow -- QR code, secret, "Set up" -- for something that cannot be signed in with. Turning 2FA on there took a working account and made webmail unreachable from any device not already signed in, because ihasmail has nowhere to send a TOTP code: Stalwart accepts one through an OAuth flow alone and offers no password grant (#75). The one mercy was that enabling reseals the current session onto a fresh app password, so the browser doing it stayed in -- and the next sign-in elsewhere did not. So the enrolment path is gone until sign-in with a code works. Turning 2FA *off* stays. It is a plain registry write, it was verified live on 0.16.19, and anyone already enrolled -- here or in Stalwart's own settings -- needs a way back. That control is now the whole section, and it appears only for an account that has 2FA on; everyone else no longer sees the heading at all. The password form keeps its authenticator-code field on the same condition, since Stalwart demands a code on every credential write once 2FA is on. Nothing changes on the server: /api/account/2fa/begin and /enable are untouched and still tested, ready for the OAuth work that makes them usable. The sign-in page's code field is also untouched -- it already explains itself and points at app passwords. README no longer advertises enrolment by QR code, and the roadmap entry says which direction the setting still moves. |
||
|
|
133036a6c5 |
Hide identities from the compose picker
An account using a unique address per service, on a server with an alias domain, ends up with every local part twice over and a From picker nobody can use -- while only ever sending from a handful (#73). Identities can now be hidden from that picker, from Identities & signatures. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable, the way an unsubscribed folder is still a folder. That framing is mbunkus's own, and it is the right one -- this is a UI preference, not a change to the account. Three things it refuses to do, because a sender picker with nothing usable in it is worse than a cluttered one: - it will not hide the identity a draft is already using, which would leave the select with no matching option and move the From line under the writer - it will not hide the default, which is what a new draft starts on; the button is disabled there and says why - if every identity is somehow hidden -- reachable only through settings sync, since the UI will not do it -- they are all offered again The setting syncs, so the picker looks the same on every device, which follows from DEVICE_KEYS being a list of exceptions rather than a list of what travels. Verified against the mock with four identities and one hidden: the picker offers the other three, the hidden address is gone from composing, the default's hide button is disabled, and the row says the identity still receives. |
||
|
|
d4b39c06d6 |
Stop the reading view rearranging itself as you read
Opening a conversation with several unread messages showed them all expanded, each with its unread bar. The moment the auto-mark-read timer fired, every one of them collapsed except the last, and the bars vanished -- so the messages you had just been given were taken away again, and the only record of which ones they were went with them (#69). Both came from the same place: expansion and the bar were derived from `$seen`, live. Marking read on the server changed what the view thought it was looking at. Marking read is not the problem. Opening a thread is the signal that you are reading it, and mbunkus was explicit that turning the setting off is not the answer he wants. What was wrong was letting a change *this view caused* alter its own shape underneath the reader. The thread now remembers which messages were unread when it was opened, and uses that for expansion and for the bar. The set only grows while a thread is open -- a message arriving unread joins it -- and is discarded on the way to another thread. The server still gets marked read on the timer, exactly as before, and the message list still updates. It is accumulated during render rather than in an effect. It is derived purely from the messages already in hand and adding an id twice does nothing, while an effect would repaint a frame later -- which is the flicker this exists to remove. Verified against the mock with markReadDelay at 0, the harshest setting, where the timer fires immediately: six seconds after opening a three-message thread, the server reports all three seen while the view still shows all three expanded with their bars. Before, two of the three would have collapsed in the first instant. |
||
|
|
2e33b4c467 |
Move focus off a row that has been deleted
Two complaints in #71, one cause. Deleting from the keyboard left `focusId` pointing at a row that was no longer in the list, and nothing moved it. The confirmation appearing "every other message": `targetIds()` falls back to the focused id, so the second `#` re-targeted the message the first one had just deleted. The optimistic update had already moved that message into Deleted Items, so it read as a permanent delete -- and a permanent delete always confirms, whatever "Confirm before deleting" is set to. The dialog was correct about the message it was asked about; it was asked about the wrong one. `k` jumping to the top: `moveFocus` reads `ids.indexOf(focusId)`, which was -1 for the departed row, and -1 is treated as "before the first row". Adding -1 to that clamps to 0. Both explain why the mouse was fine: clicking sets focus to a row that exists. Focus now moves to whatever slid into the deleted row's place, honouring "After archiving or deleting" -- the row below by default, the one above when set to newer -- and clears when the folder empties. `moveFocus` also no longer reads a missing row as index 0; it falls back to where the list thinks it is. Verified in the browser against the mock, since arithmetic tests cannot prove the wiring: with confirmation off, two deletes in a row both go through silently, focus stepping e4 to e5 to e6 as rows close up; then `k` moves up exactly one instead of to the top of the list. |
||
|
|
087c856a46 |
Theme the dropdown a native select paints for itself
The From picker's drop-down rendered a light background under light text, unreadable in any dark theme (#70). A native <select>'s popup is painted by the browser from the element's own colours, not the page's. `.from-select` is deliberately transparent so it sits flush in the composer's From line, which left the popup with no background of its own: the browser drew a light one while the text kept the app's light foreground. Fixed by styling `option` rather than the control, so the popup gets a background without the closed select gaining a box. Verified: the select stays transparent, the options are now --bg-elev on --fg, which is 12.5:1 where it was light on light. Scoped to every select rather than this one. Nothing in the app styled options anywhere, so this was not one broken dropdown but the first one anybody happened to open in the dark -- and the next transparent select would have arrived with the same bug. |
||
|
|
9c37af7b07 |
Never overwrite filters we could not read
Adding a filter from a message reported success while the script on the server never held more than two rules (#76). Rules were being destroyed, and the confirmation was a lie. Three links, each defensible alone: 1. load() recorded a *failed* blob fetch as `contents[id] = ""`. 2. sieveToRules("") returns [] -- "this script has no rules", which is indistinguishable from "we could not read this script". 3. Saving rewrites the whole script from that baseline, so every rule already in it was deleted. The write itself succeeded, which is why the UI said so. No fetch failure was even required: rules() did `contents[id] ?? ""`, so a script whose content had not loaded yet read as empty too. And saveScript cached the content it had just written and then called load(), which replaced the whole map -- discarding it if the refetch came back short. The fix is to keep "unknown" and "empty" apart at every step: - a failed fetch leaves the key absent rather than storing "" - load() merges rather than replacing, so a reload cannot throw away what saveScript just wrote - rules() returns null for content it does not have, which every caller already treats as "do not touch this script" - saveRules refuses outright when the baseline is unknown. Refusing is recoverable; overwriting is not. rules() now also reports whether the script was read, because "written by hand" and "could not be read" want different advice -- one is permanent, the other is a reload away, and telling someone the wrong one sends them hunting for a problem they do not have. Ruled out on the way: the rule codec round-trips fine, eight rules in and eight out. sieveToRules reads the `# rule:` JSON comments rather than parsing Sieve, so the generated script's shape was never the issue. |
||
|
|
d98c425a9a |
Stop pretending the two-factor field can work
Signing in with a two-factor code failed with a bare 401 and "Invalid credentials", which sent the user off to check a password that was perfectly good (#75). It cannot work, and the app already knew. Stalwart accepts a TOTP code only through an OAuth flow -- its own web interface is an OAuth client, which is why signing in *there* succeeds -- and it offers only the authorization-code and device flows. There is no password grant, so a client holding a username and password has nowhere to exchange them plus a code for a token. The concatenated `password$code` form this README claimed was accepted is not a route the server has, and appears never to have been. What was verified live on 0.16.19 was enabling and disabling 2FA, never signing in with a code. The contradiction was already in the codebase: turning 2FA *on* mints an app password and reseals the session onto it, precisely because a plain password stops working from that moment. The sign-in page was the one place still assuming otherwise. Three changes, no new capability: - A 401 on a sign-in that carried a code now says what is happening and where to go instead, and says the password is probably fine. A sign-in without a code is untouched, so an ordinary typo still reads as an ordinary typo. - The field stays, and is honest about itself. Removing it would leave someone with 2FA finding nothing at all, which is worse than finding a field that explains the situation and points at app passwords. - The README's claim is corrected rather than quietly dropped, and real 2FA support is written into the roadmap as what it is: an OAuth implementation, handing sign-in to Stalwart and holding a refresh token instead of a sealed password. |
||
|
|
7726665a48 |
Say what "even when ihasmail is closed" actually means
Web Push works, confirmed end to end against the live 0.16.19: with Chrome open and every ihasmail tab closed, a notification arrives immediately and names the sender and subject. But "closed" means ihasmail, not the browser, and the switch did not say so. Web Push is delivered over a connection the browser holds, so something of it has to be running. Observed on 2026-08-26, with Chrome fully quit and "Continue running background apps" off: nothing arrived until Chrome was started again, at which point the queued notification was delivered. Turning that setting on keeps a process alive and restores immediate delivery. Worth knowing that the queue is not indefinite -- a Web Push message carries a TTL, and one that expires before the browser comes back is dropped rather than delivered late. Being an installed PWA does not change any of this on a desktop; it changes the window, not who holds the connection. On Android it would, since the push service can wake the browser from cold. None of this is ihasmail's to fix. It is what Web Push is, and the only thing worth doing about it is not implying otherwise -- which the notification switch was quietly doing. |
||
|
|
2263aa494f |
Send a filter the server can read
Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:
filter: { inMailbox: null, notKeyword: "$seen" }
`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.
The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.
Two reasons this got out, both worth fixing rather than just the bug:
- The tests checked the properties list and its ordering, and never
looked at the filter. There is now one that walks every condition
and fails on a null or undefined value, for both the known-inbox and
unknown-inbox cases.
- The mock accepted it happily, so nothing local disagreed with the
code. It now refuses a filter condition with a null value and
answers "Invalid filter.", which is what the live server said.
Reproduced: the old payload is rejected, the new one accepted.
|