d1731efdb9a9e60e48236e87d34c544942bbdf4e
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d1731efdb9 | Use American English spelling throughout | ||
|
|
6ba89696ee | Spell license the US way | ||
|
|
17a24fe880 |
Merge pull request #296 from Coffey-Labs/palettes-twelve
Six more palettes, taking the picker to twelve |
||
|
|
befe1dbf53 |
Six more palettes, taking the picker to twelve
Catppuccin, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark variant its own project publishes: Latte and Mocha, Lotus and Wave, and so on. Values were fetched from each project's own repository and recorded in .palette-sources/palettes-upstream.md, with the two tiers no project publishes marked derived rather than passed off as upstream. Four candidates were rejected rather than adapted. Nord and Synthwave '84 publish no light variant, and inventing one is not porting a theme. Monokai is proprietary and its licence forbids redistribution. Material Theme has become a commercial product whose repository no longer publishes a palette at all. Body text is now lifted for contrast like every other text tone rather than exempted and merely checked. Most of these palettes target their own ~4.5:1 for body text where ihasmail asks 7:1, so the old rule would have rejected five of the six on a bar their designers never aimed at. Nudging the published colour along its own hue is what the script already does for muted text, links and accents, and every shift is printed in the generated CSS: Solarized light moves 4.13 to 7.07, Primer needed nothing at all. Primer is named for the design system, not for GitHub. The colour values are MIT; the name and the logo are trademarks, and NOTICE says plainly that nothing here is endorsed. The picker grid already wrapped on its own, so twelve cards needed no layout change. |
||
|
|
276ecfccff |
Stop naming every palette in the credit line
The hint under the theme picker listed the third-party palettes by name. That sentence is translated into nine languages, so every palette added meant rewriting it, retranslating it nine times, and leaving the previous version behind as a stale key nothing looks up. It now describes the rule instead of enumerating the cases: a palette named after another project is that project's work, used under its own licence. True of the four here, true of the next one, and true without saying "MIT" for a palette that might not be. The names are already in Settings beside each swatch and in NOTICE with their copyright lines, which is where a credit belongs. Swapped rather than added in all nine catalogues, so the old key is gone rather than left stale: 1,255 of 1,279 translated per locale, unchanged, and the 41 pre-existing stale keys are neither added to nor cleaned up here. |
||
|
|
2464c9655f |
Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it themes an HTML message only when the message brings no colours of its own. That predicate is the right default and it almost never passes: one `color:#FFFFFF` on one button label opts a whole message out, so in real mail — receipts, shipping notices, anything from a template — the switch did nothing at all and the reader kept a bright white card on a dark UI. A second switch, off by default and only meaningful with the first on, forces the palette over the sender's colours. It cannot be done perfectly, which is why it is a separate, explicit choice: the same bargain a dark-reader extension makes. What it does is tell two kinds of colour apart. A *sheet* the design sits on — the white 600px wrapper — is neutralised, and a *painted surface* — a call to action, a footer banner — is kept whole so its label stays legible on it. Relative luminance decides, at 0.5: white wrappers sit at 1.0, a blue button near 0.09. Only the painted ones are marked, with data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else. Nothing the sender wrote is removed, so the switch is reversible, colours arriving from a <style> block are covered as well as inline ones, and print still pins the tokens to ink on white. The mock grew the message this is about: an outer wrapper on bgcolor="#ffffff", a <style> block, a coloured button, a grey footer. Without one, neither the bug nor the fix could be seen. Verified in a browser against the mock: with only the first switch on the card is still white; with both, the wrapper computes to transparent, body text follows the theme, and the button keeps white-on-blue. Two surfaces marked, which are the two the message paints. Closes #290 |
||
|
|
bf60fe6157 |
Merge pull request #258 from Coffey-Labs/i18n-second-pass
Second pass: four dialogs that were never wrapped |
||
|
|
20b6475f18 |
Second pass: four dialogs that were never wrapped
A sweep for UI text still rendering in English, after the nine catalogues were
brought up to date. Four dialogs were building their own English:
- The delete confirmation in MailView, entirely: both titles, both messages
and the confirm label. Its counts read "message(s)", which is a
parenthesis standing in for agreement -- every language that inflects got
the wrong form. They are plural() calls now.
- Rename, in the Files tree.
- New address book, and its Name placeholder.
- New category, and its Name placeholder. The button opening that dialog was
already translated, which is how it went unnoticed: the label read right
and the dialog it opened did not.
Rename, New address book, New category, Name and Delete are already in all
nine catalogues. Delete?, Delete forever? and the two plural forms are new and
land with each language.
Verified: typecheck clean, 1000 tests pass.
|
||
|
|
b10149fd54 |
Translate three lists the code was rendering raw
A native speaker reviewing the German catalogue reported strings appearing in English (#247). Three of the reported areas turned out not to be missing translations at all: the strings were there, and the code was rendering the English source instead of asking for one. The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly. All sixteen are already in every catalogue -- "Subject" has been "Betreff" in de.ts all along, which is exactly the inconsistency the reporter noticed against the Out-of-office page, where it already reads Betreff. Wrapping the two dropdowns fixes nine languages at once and adds nothing to any catalogue. The keyboard shortcut panel rendered each binding's group and description directly. Those are registered in English at the call sites and should stay that way -- the binding table is data and the English is the catalogue key -- so the panel translates them at render instead. A binding added anywhere is then translatable without its registrar knowing i18n exists. The palette grid marks every name translate="no". That is right for ihasmail, Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an adjective describing the theme, not a name, so PaletteMeta gains a `translatable` flag for the one entry that is a word. Flagging the exception beats dropping the attribute from all six. No catalogue changes here: the strings the first two need are already present in all nine. "Classic" needs an entry, which lands with each language. |
||
|
|
457ea53ca3 |
Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders" on for three thousand pupils cannot ask three thousand pupils, and the reporter is right that this is a company policy rather than a preference. Two powers, and the difference between them is the whole request. `defaults` seed an account that has never had settings of its own and can be changed afterwards like anything else -- a starting point, not a rule. `enforced` are reapplied on every load and cannot be changed at all. Enforced controls stay visible and go dead, with a line saying why. The issue asked for that by name: a control that is simply missing reads as a bug to somebody who has used ihasmail without a policy. The lock is in the settings store rather than only on the controls. There is one door -- `update` -- and putting it there means an imported settings file, a settings file synced from a device that predates the policy, and a control somebody adds later and forgets to check are all covered by construction. Reset goes back to the installation's answer rather than to ihasmail's, so it cannot be a way around a policy either. Configured by environment variable or by a file, because ihasmail's own production runs read-only with no volume: an installation that cannot mount a file can still set a variable. Keys this build does not have are dropped, the same rule an imported settings file already gets -- a policy written against a newer ihasmail must not put a setting nothing reads into everybody's synced settings file. Malformed JSON stops the server rather than quietly doing nothing, since a policy that silently did not apply is indistinguishable from the feature not working. Tier three -- enforcing a setting once while still letting readers change it afterwards -- is not here. It needs a decision the reporter and I have not made yet, and it is the only part that stores anything new. Refs #207. |
||
|
|
5e6e049eef |
Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs the Archive role on the server, which was true, and gave you nothing to do about it here. Roles were shown in Folders settings and never settable. Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an ordinary user through the proxy, with no admin API: setting role "archive" on a folder that had none returned updated and the folder began working as the Archive immediately. Stalwart parses the role names in SpecialUse::parse, "archive" among them, refuses a second holder of a role, and refuses to move the role of Inbox, Junk or Trash. So the toast now carries the fix. "No Archive folder is set yet." with a Create one that makes the folder and then completes the archiving that could not happen -- rather than leaving someone to select the same messages again. A folder already named Archive and carrying no role is adopted rather than duplicated. That is the state #217 was reported from, and a second Archive beside the first would be its own confusion. One named Archive that is really the Sent folder is left alone: taking its role to fix archiving would break sending. Folders settings gains a Role column. Archive, Drafts and Sent are offered, being the roles this client's behaviour depends on and the server will move; Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20 refuses. A role another folder holds is left out of the list rather than offered and refused, so freeing it is a deliberate two steps. The folder is created with the server's own name, never the localised one, for the reason renaming already writes back the server's: a German session must not create "Archiv" that an English one cannot find. Closes #217 properly. |
||
|
|
4d18d94b63 |
Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it. |
||
|
|
c838569638 |
Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts |
||
|
|
9aa0eda0d5 |
Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value carried a whole palette and implied dark. That works for exactly one palette. The two questions now come apart: which palette, and which side. Classic is the plain light and dark this app has always had. ihasmail's own palette gains a day version, so the background of the dark one becomes the text of the light one and the two read as one palette from either end. Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects, used under the MIT licence, and taken from each project's own repository rather than from anyone's reimplementation. What was fetched is recorded in .palette-sources/ and credited in NOTICE. Giving ihasmail's palette a light half removed a whole special case. Nothing is one-sided any more, so a palette can no longer override the mode, the toggle no longer has to set a palette aside on the way to light and remember it, and the greyed-out control that explained all that is gone. The old lastDarkTheme, which existed only for that, is gone with it. The shades between the published colours are derived rather than guessed: these projects publish twelve to twenty values and ihasmail needs about thirty. scripts/build-palettes.py computes the tiers and then measures every text colour against the surface it sits on -- 4.5:1 for prose, 3:1 for borders and marks -- lifting anything short towards white on a dark ground and towards black on a light one, so a lifted tier keeps its hue. It refuses to write a palette that would not pass. Every one of the nine halves needed at least one lift. These palettes are built for code editors, not for prose at this size: Dracula's comment grey is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published would have quietly ended the WCAG AA claim. Two things caught while checking rather than while writing. The generated blocks were appended to the end of the stylesheet, which put them after the accent variants at equal specificity -- so choosing an accent over one of the new palettes did nothing at all. They now sit before those rules, where the existing ihasmail block's own comment says they have to. And that block was unqualified, so it would have shadowed the new light half; it is now explicitly the dark one. Settings written before this carry `theme` and no palette, and are read through the old enum. `theme` is still written back, derived, because a device on an older build reads it and would otherwise be stranded on a theme nobody chose. |
||
|
|
34fc5ab81f |
Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: unread-first is what people want in the folder they triage and confusing in Sent, where everything is read and the order that matters is when it went. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: a list sorted in the browser is sorted only as far as the browser has loaded, which on a folder of ten thousand is the first fifty and a lie about the rest. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. Sorting on a keyword is optional in RFC 8621, and a server that will not do it fails the whole query rather than degrading it -- so this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: the reader asked for an order and got the closest the server can give, and a toast on every folder change would be the app complaining about its own request. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against. |
||
|
|
6c7c6d19b3 |
Merge pull request #206 from Coffey-Labs/feat/birthday-calendar
Show birthdays from the address book as a calendar |
||
|
|
c9ab203b76 |
Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. |
||
|
|
f7ef886b45 |
Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was no way to keep one that matters occasionally without it holding a row for ever. A label can now sit under another, and each says whether it belongs in the sidebar always, only while it has unread mail, or never. Nesting is display only. The keywords stay flat on the message, which is what keeps them readable by every other client: moving a label under another rewrites nothing in the mailbox, and a client that knows nothing about ihasmail sees exactly what it always did. Both new fields are optional, so a settings file written before this parses unchanged and means what it did. Settings sync between devices, so the tree has to survive shapes that should not exist. A label whose parent was deleted on another device comes back to the top level rather than vanishing -- a label that disappears because something else was deleted is one the reader cannot get back. A cycle arriving from an older device is broken by treating the label that closes the loop as a root, so nothing is lost and nothing hangs. The parent picker will not offer a label's own descendants, so one cannot be built here in the first place. A label kept by the unread rule keeps its ancestors, whatever they were set to. A child cannot be drawn under a parent that is not there, and promoting it to the top level would silently rearrange the tree at the moment the reader is least able to explain why. The parent comes back as a container instead, and its own count still says whether it has anything of its own. Unread counts come from one request carrying a query per label rather than a request each, with limit 0 so the server does not send ids that would only be thrown away. They refresh on the same beat as the folder counts, since the things that move them are the same things, and a failure is swallowed: a count is decoration, and the sidebar draws the label without one. Also corrects the Labels page, which said names and colours are kept in this browser. They live in the account's own Files and follow it between devices, like every other setting that is not about this screen. |
||
|
|
d300107be0 |
Merge pull request #202 from Coffey-Labs/feat/sender-link-warnings
Warn about outside senders, large sends and links that mislead |
||
|
|
255616341b |
Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath |
||
|
|
5b18f1d5d7 |
Warn about outside senders, large sends and links that mislead
Four warnings, in Privacy & safety, and all of them start switched off. That is not timidity. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing. The outside-sender warning could not be on by default in any case: it measures against the domains that count as yours, and with nothing configured every message in the mailbox is from outside. Your own identity domains are always internal and are not configuration. An account signed in as [email protected] warning that example.com is external would be absurd, and making it be typed in first is a foot-gun that leaves the feature useless the moment it is enabled. Configured domains are additional, and cover their subdomains -- matched on a dot boundary, so example.com covers mail.example.com and not notexample.com, which is exactly the domain somebody registers on purpose. The four: A banner names the sender's domain on a message from outside. Sending outside names the outside recipients and asks, rather than refusing. "This is going outside" is a rule and not something the sender can check; a list of addresses is. It reads the full identity list rather than the visible one, since hiding an identity from the From menu does not make its domain somebody else's. Sending to a large group asks once the count crosses a threshold, which is what catches a reply-all onto a long thread. It counts people rather than headers, so one address in To and nine in Cc is a message to ten. Opening a link asks when the destination is not trusted, and always when the link's text names one domain while its destination is another -- even where that destination is trusted, because being trusted is not the same as being the place the text claimed. On that mismatch the offer to trust the domain is withheld: what would be trusted is the destination, and the destination is not the thing in question. Anything that is not http or https is left alone, since warning about a mailto: is noise and noise is how a warning stops being read. Both bodies are covered, because a link in a plain-text mail is linkified by us and points wherever it likes just as readily as one the sender marked up. The click is cancelled and the navigation re-issued after the answer, since there is no way to hold a real navigation open across a dialog. The reopen runs in the continuation of the dialog's own click, which is still the gesture a popup blocker wants to see. |
||
|
|
93d0a32af2 |
Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
|
||
|
|
e4b82783c2 |
Merge branch 'main' into feat/privacy-safety-settings
# Conflicts: # web/src/styles/app.css |
||
|
|
b83de657d7 |
Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without an obvious home ended up. Remote images were filed under "Reading", the read-receipt policy under "Composing", the undo-send window beside the default message format. They are the same kind of decision -- what reaches a sender, and what asks before something happens -- and they were the hardest settings in the app to find. Privacy & safety now holds all six, in three groups: remote content, read receipts, and the things that ask before it is too late. General keeps what it is actually about and is thirty lines shorter. The line against Security & sessions is worth stating, because two similar words next to each other in a nav is how a menu becomes something people hunt through. Security & sessions is credentials and access: password, two-factor state, app passwords, live sessions. Privacy & safety is how the app behaves towards the reader and towards senders. Nothing moved in storage. Settings are a flat object in settings.json and sections are only how they are grouped on screen, so this is a UI change with no migration and no key renames. Two things beyond the move. The senders trusted with remote images are now listed and can be withdrawn one at a time. A sender was added from a message and could then only be removed by finding another message from that same sender, which is not a way to review a list you cannot see. And General's lead said settings are stored in this browser, which is only true when the server has no FileNode support. They normally live in the account's own Files and follow it between devices, so the sentence contradicted the feature it sat above. |
||
|
|
c85525f3ed |
Fill placeholders when a template is inserted
Templates were a fixed subject and body, so anything that changed per message -- who it is going to, today's date -- had to be typed over afterwards. Eight names are recognised: recipientName, recipientFirstName, recipientEmail, myName, myEmail, subject, date and time. Dates and times go through datetime.ts rather than toLocaleDateString, so a template follows the date order and clock the app was already told to use. Filling happens on insert rather than on send. What a placeholder came to is then visible in the composer and can be edited, instead of the message changing between writing it and sending it. Two things are deliberately left alone. A placeholder that cannot be answered yet -- a recipient's name on a draft nobody has addressed -- stays in the body as written, because substituting an empty string produces "Hi ,", which is wrong rather than visibly unfinished; leaving the name says which word is still missing and can be typed over. And a name that is not a placeholder is left as written too, since a body that quietly ate an unrecognised token would be worse than one that shows it. Values are escaped on the way into HTML: a display name comes from a contact card or a typed address and is not trusted markup. |
||
|
|
d3e173c9c1 |
Ask before the filter editors lose your changes
Both editors on the Filters & rules page kept their edits in component state, so every way out of the page threw them away without a word: a settings link, the app rail, even the Rules/Scripts switch. The only sign there had been anything to lose was a Save button that a screenful of rules had already pushed below the fold. Editors now register what they have pending, and every in-app navigation asks first -- offering to save, rather than making "leave without saving" the easy answer and saving the one you have to back out and find. Wouter routes links, redirects and navigate() through one place, so the guard holds for the app rail and the settings nav without either knowing an editor exists. The Rules/Scripts switch asks for itself, since it never reaches the router. Reload and tab close get the browser's own prompt. The save bar is pinned to the foot of the pane, so "Unsaved changes" is on screen whether or not the rules fit in the window. Two things fixed on the way past, both in the raw script editor: saving cleared only the selection, which left the editor open with the name unlocked so a second save created a duplicate script instead of updating the one just written; and the pair of identical nested conditions that decided whether the editor was open at all is now the one question it was asking twice. Fixes #175 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
96bc7b53d7 |
Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.
Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.
Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:
- the session publishes a real applicationServerKey, so no key
generation or server configuration is needed
- PushSubscription/get answers an ordinary user rather than refusing
- emailpush is advertised, and its draft defines a filter, an ordered
properties list and an urgency -- so the payload can carry sender and
subject, and the server drops properties from the end when it will
not fit rather than failing the notification
Three things this gets right that are easy to get wrong:
- The verification handshake. A JMAP subscription delivers nothing
until the client echoes back a code the server pushed, and the
service worker cannot answer it -- no credentials in that context.
It forwards the code to a tab, or leaves it in the cache when no tab
was open to forward it to.
- Key encoding. The W3C Push API produces unpadded base64url and
Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
on the way out. The VAPID key needs padding on the way *in* for
atob; getting that backwards fails at subscribe() with an opaque
error, so it lives in one named function with tests.
- Sign-out. A subscription belongs to the account, not the session.
Without tearing it down, a shared machine keeps notifying for a
mailbox nobody is signed into -- which is somebody else's mail.
The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".
Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
|
||
|
|
8487f561f6 |
Add the ihasmail theme, and make it the default
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.
It is a theme rather than an accent because it changes backgrounds,
borders and text as well as the highlight -- an accent could not.
It rides on data-theme="dark" and adds data-palette="ihasmail" on top,
so the eleven dark-only rules further down the stylesheet keep applying
without being duplicated for a second dark theme. Specificity then does
something deliberate: the palette block is 0,2,0 and the accent variants
are 0,3,0, so a chosen accent still wins over it -- and because the
default accent ("teal") has no rule of its own, ihasmail.org's accent is
what shows until someone picks another. Verified both ways in a browser.
It is now what a new account starts on, so the app looks like itself
before anyone has chosen anything. Only a default: a stored theme always
wins, which leaves everyone already using ihasmail where they are, since
the setting is saved whether or not they deliberately picked it.
While here, the theme-color meta tag was fixed. There were two, both
carrying media attributes, and applyTheme looks for
:not([media]) -- so it matched neither and the browser chrome had never
followed the chosen theme at all, only what the OS preferred. One tag
now, updated from JS, starting at the default theme's background so the
first paint is right too.
Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
|
||
|
|
bf70ba9df0 |
Give builds a version number
ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:
ihasmail v2.16.57
| | |
| | the pull request the commit came from
| the Stalwart generation this build targets -- 0.16
ihasmail's own major
The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.
The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.
.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.
scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.
Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.
Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
|
||
|
|
94bf42cfda |
Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean. |
||
|
|
7b05322577 |
Make the AGPL's source offer point at the source being run
Three things a licence audit turned up. None of them is a conflict -- every one of the 182 installed packages is permissive, and the relicence was within the copyright holder's gift -- but all three are ways the AGPL fails to stick. The offer was hard-coded to this repository. Section 13 asks whoever runs a modified version to offer *that* version's source, so every deployment with a patch in it was pointing at the wrong tree, and would have gone on doing so unless its operator noticed and edited the About page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in page and About read it. The offer was also only visible after signing in. Whoever is looking at the sign-in form is interacting with the program over a network too, so the footer carries it now. And the two workspace packages declared no licence at all. Private, so npm never minded, but anything reading the tree saw a blank where the rest of the project says AGPL-3.0-or-later. Checked both ways round: with SOURCE_URL set to a fork, the sign-in page and About both point at the fork; with it unset, both fall back to this repository. |
||
|
|
8cbbc9cc6c |
Relicense to the AGPL, version 3 or later
ihasmail is webmail: it is nearly always run as a network service rather than handed to anyone as a binary, which is the case the plain GPL does not reach. The AGPL's section 13 does — anyone running a modified ihasmail for other people has to offer them its source. LICENSE is the full AGPL-3.0 text from gnu.org. The SPDX identifier changes from GPL-3.0-or-later to AGPL-3.0-or-later in package.json, the lockfile's own entry for it, and the About screen. Old commits and tags are left exactly as they were; this is the license from here on. |
||
|
|
696b3713ed |
Let a filter rule be dragged into place
Twenty-five rules and two buttons that move one place at a time meant a rule pushed to the wrong end cost ten clicks to bring back. It can now be dragged. A grip on the left of each card arms the drag, so the switch, the name and the buttons still take a plain click, and the up and down buttons stay for the keyboard. The card being dragged fades; the one under the pointer draws a line on the edge the rule would land on, top half or bottom. The guard against dropping a rule onto itself reads a ref rather than state: dragstart and the first dragover can arrive in the same frame, and a stale read there drew a drop line on the card being dragged. Found by driving the real thing in a browser, and covered by a test that fires the two events back to back. |
||
|
|
b4fd3d3ab4 |
Merge pull request #29 from LINUXexpert-org/sieve-custom-header-operator
Give a hand-typed header its own box |
||
|
|
0c334a113a |
Give a hand-typed header its own box
Picking "Other header…" in the filter dialog took the comparator away. The condition row has three columns — field, comparator, value — and the box for the header name was rendered into the comparator's, so the comparator disappeared along with any way to change it. Whatever it had been when you switched, contains, was what the rule got: matching a header exactly, or on a regex, could not be expressed at all. The header name now has a column of its own and the comparator keeps its, on a row that widens to hold both. Fixes #23 |