7b3069e41b6b593d1ab92491ed38256d4fa183ef
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7b3069e41b |
Move an event by the days the hand moved it, not to the date dropped on
Dragging an event across the month grid wrote the date of the cell it landed on into the event's stored start. Those are the same date only while the event's time zone is the reader's. An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at 23:00 the previous evening. Dropped on the 11th, it was written as the 11th in Tokyo -- which is the 10th on screen. It went where its own calendar said rather than where the pointer did, one day short, every time. Moving by the difference between the two local days instead moves it exactly as far as the hand did, and adding whole days to a stored wall clock leaves the time of day alone without touching the zone -- so the frame the rest of this path is careful about is still not crossed. Found by giving the mock an event in a zone that is not the machine's. Every other fixture used the machine's own, which cannot tell a correct conversion from no conversion at all: the case that works is the one the fixtures were all testing. |
||
|
|
4d18d94b63 |
Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it. |
||
|
|
a30f96f76b |
Drag an event to move it, and its edge to resize it
The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving. Every other surface a finger or a pointer drives already answers to a drag. In the day and week grids an event moves by dragging it and changes length by dragging its bottom edge, snapping to fifteen minutes. In the month grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer -- changing the hour as well would answer a question nobody asked. It goes through the same path a menu edit takes. A recurring event is asked which dates it means, and the answer runs through runScoped, so a date the server will only change as part of a whole series offers that rather than failing. Three things do not offer a drag, and the reasons are checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a birthday -- which is derived from a contact and has nothing on the server to move. The reserved classes the swipe gesture was told to keep out of are exactly the ones that are draggable here, which is what that reservation was for. Invitations are not sent. A drag is a scheduling gesture, and mailing every guest on each nudge of a block is not what the hand was asking for; a change that should go out with notice goes through the editor. The new time is computed in the event's own frame rather than through an instant. Working it out from the reader's local hours and then re-expressing it in the event's zone converts twice, and the two do not cancel: caught in the browser, where an event moved two hours the first time it was dragged in the month grid and then sat still, because after that its stored time and the reader's agreed. Parsing the stored string into its parts and adding minutes to those touches no zone at all, and a resize sends only a duration, so the question does not arise there either. |
||
|
|
c838569638 |
Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts |
||
|
|
9aa0eda0d5 |
Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value carried a whole palette and implied dark. That works for exactly one palette. The two questions now come apart: which palette, and which side. Classic is the plain light and dark this app has always had. ihasmail's own palette gains a day version, so the background of the dark one becomes the text of the light one and the two read as one palette from either end. Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects, used under the MIT licence, and taken from each project's own repository rather than from anyone's reimplementation. What was fetched is recorded in .palette-sources/ and credited in NOTICE. Giving ihasmail's palette a light half removed a whole special case. Nothing is one-sided any more, so a palette can no longer override the mode, the toggle no longer has to set a palette aside on the way to light and remember it, and the greyed-out control that explained all that is gone. The old lastDarkTheme, which existed only for that, is gone with it. The shades between the published colours are derived rather than guessed: these projects publish twelve to twenty values and ihasmail needs about thirty. scripts/build-palettes.py computes the tiers and then measures every text colour against the surface it sits on -- 4.5:1 for prose, 3:1 for borders and marks -- lifting anything short towards white on a dark ground and towards black on a light one, so a lifted tier keeps its hue. It refuses to write a palette that would not pass. Every one of the nine halves needed at least one lift. These palettes are built for code editors, not for prose at this size: Dracula's comment grey is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published would have quietly ended the WCAG AA claim. Two things caught while checking rather than while writing. The generated blocks were appended to the end of the stylesheet, which put them after the accent variants at equal specificity -- so choosing an accent over one of the new palettes did nothing at all. They now sit before those rules, where the existing ihasmail block's own comment says they have to. And that block was unqualified, so it would have shadowed the new light half; it is now explicitly the dark one. Settings written before this carry `theme` and no palette, and are read through the old enum. `theme` is still written back, derived, because a device on an older build reads it and would otherwise be stranded on a theme nobody chose. |
||
|
|
34fc5ab81f |
Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: unread-first is what people want in the folder they triage and confusing in Sent, where everything is read and the order that matters is when it went. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: a list sorted in the browser is sorted only as far as the browser has loaded, which on a folder of ten thousand is the first fifty and a lie about the rest. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. Sorting on a keyword is optional in RFC 8621, and a server that will not do it fails the whole query rather than degrading it -- so this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: the reader asked for an order and got the closest the server can give, and a toast on every folder change would be the app complaining about its own request. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against. |
||
|
|
1ed8531764 |
Line up selectedAll with the rest of the object it sits in
It came in at two spaces inside a six-space set({ ... }), which reads as
if it belonged to an outer scope. Whitespace only.
|
||
|
|
c9ab203b76 |
Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. |
||
|
|
be8b89f5ab |
Merge branch 'main' into feat/nested-labels
Both sides added a field next to the mail store's selection: the label counts the sidebar draws, and the flag for a selection that means the whole query rather than the loaded page. They are independent, so the resolution keeps both. |
||
|
|
9a474ef2c8 |
Open winmail.dat
Outlook sending in Rich Text packs every attachment into one TNEF blob. Every other client shows a single unopenable winmail.dat, and the files inside it are gone as far as the reader is concerned -- which is a decoding problem rather than a mail one. Written from the published format: a signature, a key, then a flat run of attributes, each one a level byte, a 32-bit id carrying its own type, a length, the data and a checksum. Attachments are delimited by attAttachRenddata rather than named, which is why the parse is a small state machine. The MAPI property stream inside attAttachment is read for two properties: the long filename and the MIME type. attAttachTitle carries an 8.3 name, so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC there and correct here. The stream stops at a named property (id >= 0x8000) rather than guessing past it, since those carry a GUID before their value and nothing after one can be trusted to stay aligned. Decoded in the browser, on request. The server never sees the contents and has nowhere to keep a decoded copy; doing the work on sight would spend the bandwidth whether or not anybody wanted what is inside. A blob that goes wrong part-way through keeps what was read before that point, whether it ran out or the checksum stopped matching. Half the attachments beats none: the alternative is a reader who can see the file is there and cannot have it. The original stays attached either way. The message body is deliberately not decoded. TNEF can also carry it as compressed RTF, which is a second format again for a body the reader already has in plain text or HTML nine times in ten. The mock now sends one, built by its own encoder rather than by the parser's fixtures, so the two are independent implementations of the same description. |
||
|
|
f7ef886b45 |
Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was no way to keep one that matters occasionally without it holding a row for ever. A label can now sit under another, and each says whether it belongs in the sidebar always, only while it has unread mail, or never. Nesting is display only. The keywords stay flat on the message, which is what keeps them readable by every other client: moving a label under another rewrites nothing in the mailbox, and a client that knows nothing about ihasmail sees exactly what it always did. Both new fields are optional, so a settings file written before this parses unchanged and means what it did. Settings sync between devices, so the tree has to survive shapes that should not exist. A label whose parent was deleted on another device comes back to the top level rather than vanishing -- a label that disappears because something else was deleted is one the reader cannot get back. A cycle arriving from an older device is broken by treating the label that closes the loop as a root, so nothing is lost and nothing hangs. The parent picker will not offer a label's own descendants, so one cannot be built here in the first place. A label kept by the unread rule keeps its ancestors, whatever they were set to. A child cannot be drawn under a parent that is not there, and promoting it to the top level would silently rearrange the tree at the moment the reader is least able to explain why. The parent comes back as a container instead, and its own count still says whether it has anything of its own. Unread counts come from one request carrying a query per label rather than a request each, with limit 0 so the server does not send ids that would only be thrown away. They refresh on the same beat as the folder counts, since the things that move them are the same things, and a failure is swallowed: a count is decoration, and the sidebar draws the label without one. Also corrects the Labels page, which said names and colours are kept in this browser. They live in the account's own Files and follow it between devices, like every other setting that is not about this screen. |
||
|
|
8bee0eb3c8 |
Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand that is fifty of them, and the only way to act on the rest was to scroll until they loaded and tick again. A line now offers the rest by name once the page is selected, and taking it is a separate press. A checkbox that silently meant ten thousand when the screen shows fifty would be the worst of both, so each option says what it actually covers. The wider selection is a query rather than a list of ids. What it reaches is resolved from the server when an action runs, walked a page at a time, because a folder holds far more than one call returns and Email/set refuses more ids than maxObjectsInSet in one go -- which setEmails already chunks for. It resolves uncollapsed, unlike the list: "everything in this folder" means every message rather than one per thread, and expanding threads the way a click does is impossible here anyway, since that walks loaded Email objects and these are the ones that were never loaded. Two things this exposed. Undo is now withheld once a move reaches messages that were never loaded. It restores the folders each message was in, taken from what the browser holds, and for an unloaded message that is nothing -- so the undo would have written an empty mailboxIds and left the message in no folder at all, which is worse than the move it was undoing. move() and archiveByDate() both did this; both now drop the offer rather than restore something wrong. And an action consumes the wider selection. The optimistic paths cleared the selected ids but not the flag, so the next action would have silently reached the whole folder again. |
||
|
|
5b18f1d5d7 |
Warn about outside senders, large sends and links that mislead
Four warnings, in Privacy & safety, and all of them start switched off. That is not timidity. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing. The outside-sender warning could not be on by default in any case: it measures against the domains that count as yours, and with nothing configured every message in the mailbox is from outside. Your own identity domains are always internal and are not configuration. An account signed in as [email protected] warning that example.com is external would be absurd, and making it be typed in first is a foot-gun that leaves the feature useless the moment it is enabled. Configured domains are additional, and cover their subdomains -- matched on a dot boundary, so example.com covers mail.example.com and not notexample.com, which is exactly the domain somebody registers on purpose. The four: A banner names the sender's domain on a message from outside. Sending outside names the outside recipients and asks, rather than refusing. "This is going outside" is a rule and not something the sender can check; a list of addresses is. It reads the full identity list rather than the visible one, since hiding an identity from the From menu does not make its domain somebody else's. Sending to a large group asks once the count crosses a threshold, which is what catches a reply-all onto a long thread. It counts people rather than headers, so one address in To and nine in Cc is a message to ten. Opening a link asks when the destination is not trusted, and always when the link's text names one domain while its destination is another -- even where that destination is trusted, because being trusted is not the same as being the place the text claimed. On that mismatch the offer to trust the domain is withheld: what would be trusted is the destination, and the destination is not the thing in question. Anything that is not http or https is left alone, since warning about a mailto: is noise and noise is how a warning stops being read. Both bodies are covered, because a link in a plain-text mail is linkified by us and points wherever it likes just as readily as one the sender marked up. The click is cancelled and the navigation re-issued after the answer, since there is no way to hold a real navigation open across a dialog. The reopen runs in the continuation of the dialog's own click, which is still the gesture a popup blocker wants to see. |
||
|
|
93d0a32af2 |
Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
|
||
|
|
e4b82783c2 |
Merge branch 'main' into feat/privacy-safety-settings
# Conflicts: # web/src/styles/app.css |
||
|
|
790213cc17 |
Merge branch 'main' into feat/forward-as-eml
# Conflicts: # web/src/store/compose.ts # web/src/views/mail/MessageView.tsx |
||
|
|
97f8b34e8b |
Merge branch 'main' into feat/spam-score-panel
# Conflicts: # web/src/store/mail.ts # web/src/styles/app.css |
||
|
|
b83de657d7 |
Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without an obvious home ended up. Remote images were filed under "Reading", the read-receipt policy under "Composing", the undo-send window beside the default message format. They are the same kind of decision -- what reaches a sender, and what asks before something happens -- and they were the hardest settings in the app to find. Privacy & safety now holds all six, in three groups: remote content, read receipts, and the things that ask before it is too late. General keeps what it is actually about and is thirty lines shorter. The line against Security & sessions is worth stating, because two similar words next to each other in a nav is how a menu becomes something people hunt through. Security & sessions is credentials and access: password, two-factor state, app passwords, live sessions. Privacy & safety is how the app behaves towards the reader and towards senders. Nothing moved in storage. Settings are a flat object in settings.json and sections are only how they are grouped on screen, so this is a UI change with no migration and no key renames. Two things beyond the move. The senders trusted with remote images are now listed and can be withdrawn one at a time. A sender was added from a message and could then only be removed by finding another message from that same sender, which is not a way to review a list you cannot see. And General's lead said settings are stored in this browser, which is only true when the server has no FileNode support. They normally live in the account's own Files and follow it between devices, so the sentence contradicted the feature it sat above. |
||
|
|
7d9a01cb93 |
Swipe the calendar sideways to step a day or a month
The calendar had next and previous as toolbar buttons and n/p, and nothing for a thumb. Every other surface in the app that a finger drives already answers to a swipe. Day and month only. Those are the two views where a period is a page; week and agenda scroll through a range rather than turning to the next one, so there is nothing a sideways flick would obviously mean. Dragging left pulls the next period in from the right, the way paper and every phone do it. Three things it deliberately does not do. It draws nothing while the finger moves. The row swipe slides the row open because the strip underneath has to name which of six actions is about to happen; stepping a calendar has two outcomes and the direction of the finger already says which. Translating the grid would also break the sticky day header, since a transform makes a containing block. The threshold is reported by the vibration motor instead, which is what the haptics are for. It does not start on an event. Which gesture was meant is decidable at the moment the finger lands and only then, so dragging an event stays available to be built later without having to be untangled from this first. And it asks for a longer drag than a row swipe -- not because the consequence is bigger, since stepping back undoes it while a swiped row has already been archived, but because this gesture has no way to change its mind. A row reveals what it will do and can be let go early, and offers Undo after. This shows nothing and offers nothing, so the distance is the only chance to not mean it. The axis lock is the shared one, keeping its bias towards the vertical: the day grid scrolls through the hours, and a scroll misread as a swipe throws the reader into another day. The toolbar buttons and n/p stay, because a gesture with no visible control is one only the people who already know about it can use. |
||
|
|
785570a41d |
Archive into a dated folder
Archiving put everything in one folder, so an Archive that has been collecting for years is a single flat list with no way to narrow it except search. Archive by year and Archive by month file into Archive/2026 and Archive/2026/09, creating the folders as needed and reusing them after that, including folders made by hand or by another client. The names are numeric and zero-padded rather than month names, because these are real server-side mailboxes rather than anything of ihasmail's. Every other client sees them: a folder created as "September" by someone reading in English stays "September" for the same account read in Japanese, since the name is stored and not translated. And 09 sorts between 08 and 10 where a name does not. The date is read in the reader's own timezone rather than UTC so it agrees with the date shown against the message in the list. A message that arrived at 00:30 UTC on 1 September is dated 31 August in New York, and filing it under 09 while the list says August would be the app disagreeing with itself. A message whose date cannot be read goes to Archive itself rather than to a folder named after a guess. A selection spanning two months is two destinations, not one. The moves are made silently and one toast names where everything went -- the folder where there is a single answer, the count where there is not -- because each group raising its own toast with its own Undo would mean undoing a third of a move. One Undo restores the whole selection to wherever each message came from, captured before anything moved. The menu labels name the destination where there is one, so it reads "Archive to 2026/09" rather than describing the rule, and falls back to "Archive by month" for a selection with no single answer. |
||
|
|
0db795371e |
Forward a message as an attachment
Forwarding quoted the original into a new message, which is the right thing for passing on something to be read and the wrong thing for passing on something to be looked at. Quoting rewrites the body, drops the headers, and re-parents the attachments, so a bounce, a phishing report or anything else where the message itself is the evidence arrived altered. Forward as attachment sends the message whole, as a message/rfc822 part. It costs no upload at all: a message's own blobId is its RFC822 blob and already lives in the account, so this goes through the same by-reference path as attach-from-Files and a 40 MB message attaches as fast as a small one. It is in the message's own menu, the list's right-click menu, and the overflow on the reply strip at the foot of a thread, which is the one a thumb finds on a phone. Two things fixed on the way, both exposed rather than introduced by this. The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without the u flag is ASCII: every character of a Russian, Japanese or Chinese subject failed the class, so those messages downloaded as a row of underscores. What is actually unsafe in a filename is much shorter than "not ASCII" -- path separators, the names Windows reserves, the control range -- so the rule now keeps letters from any script and drops only those. It lives in one place and the .eml download uses it too. And the composer's attachment chip set overflow/text-overflow on a span, where neither does anything, so the name never truncated and the size ran on after it on the same line. Only long names showed it, which is every .eml named from a subject. |
||
|
|
63c2839602 |
Show what the spam filter said, in the message details
The filter in front of the mailbox scores every delivered message and writes its working into headers, and none of it was being read. A message in Junk gave no reason for being there. Nothing here scores anything. The headers are parsed and shown, so this cannot disagree with the filter that actually made the decision. Two formats cover what sits in front of a Stalwart mailbox in practice: the SpamAssassin-shaped X-Spam-* set, which Stalwart's own filter writes, and Rspamd's X-Spamd-Result. A header in neither shape is left unread rather than guessed at, since a misparsed score shown confidently is worse than no panel at all. Mail that arrived without any of them shows nothing. Rules are listed largest mover first and signed, because which way a rule pushed is the point, and the biggest contributor is the answer to why the message scored what it did. Two things it deliberately will not do. A score is always given the threshold it was measured against, because 6.7 is damning against 5 and unremarkable against 15 -- the number alone is not something a reader can act on; where no threshold was stated, it says so rather than implying one. And where the filter recorded no verdict, none is derived from score against threshold: the filter applies policy we cannot see, and putting a verdict in its mouth would be inventing one. The mock writes the same headers at delivery -- spam in Junk, clean in the Inbox, nothing on mail this account wrote -- so the panel can be developed and demoed against it. |
||
|
|
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. |
||
|
|
00b580bad8 |
Select more than one file at a time
Moving or deleting five files meant doing it five times, each with its own confirm. Rows now select the way they do in a file manager: a plain click replaces the selection, ctrl or cmd adds and removes one, shift takes the run from the last row clicked, clicking past the last row or pressing Escape clears it. Two or more selected raises a bar with Move to... and Delete, and the row menu offers the same for the whole selection. The move is one `FileNode/set` rather than a loop, and not only for the round trip: a loop would apply half the moves and then throw, leaving a selection split across two folders with nothing saying which half went. One call is one answer, and `notUpdated` names whatever the server refused. Right-clicking inside the selection acts on all of it; right-clicking outside means you meant that row, so the selection follows the pointer rather than the menu quietly applying to something off-screen. A drag carries the whole selection the same way, which is why the payload is now a list -- and why a drop is refused unless every file in it can land, since a drag that moves four of five and skips the fifth is worse than one that will not start. A selection belongs to the folder it was made in, so changing folder or account drops it: rows left selected off-screen make the delete two folders later a surprise. |
||
|
|
a4c0e04ab9 |
Edit a text file where you are already reading it
v2 of the viewer: Edit, on text and Markdown, in the dialog and on the row menu. Save is explicit -- every save mints a new blob, so autosave would burn quota and multiply the conflicts it cannot see. Two people editing one file is the case worth getting right. `saveText` re-reads the node and compares the blob the editor started from: if somebody else saved in the meantime it refuses, says so, and leaves the work in the box to copy out. `ifInState` is the obvious tool and the wrong one -- it is the state of every FileNode in the account, so an unrelated upload in another folder would fail the save, and a warning that cries wolf is a warning people click through. Editing is not offered where saving would lose something: a file truncated for display would have its tail written away, and one that did not decode as UTF-8 would have mojibake written over whatever encoding it really is. Both open read-only and say which. Nor is it offered without mayModifyContent -- a read-only share just has no Edit. Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and mail attachments are unaffected: they pass no onSave, because a message part is not a thing that can be written back. |
||
|
|
15f2c3d357 |
Read a Markdown file as the document it is
A .md previewed as its own source, which is reading the punctuation rather than the notes. It now opens rendered, with Rendered | Source in the dialog footer for anyone who wants what the file actually says. Markdown only; a .txt has nothing to toggle between. Rendering is `marked`, sanitised by DOMPurify -- the one the app already carries for mail. Markdown is not a safe subset of anything: raw HTML passes through it by design, so a <script> in a file somebody uploaded or shared into the account is a script tag unless something takes it out. Images become links rather than pictures. An image in a Markdown file is either a relative path, which has no base to resolve against here, or a URL somewhere else, which fetches on open and tells that server the file was read -- the tracking pixel this app blocks in mail. The link keeps the alt text and the address, so nothing vanishes silently. Fixes the PDF preview while here, which never worked: securityHeaders put X-Frame-Options: DENY on every response including the blob route, so the iframe showed Chrome's "refused to connect" where the file should have been -- in Files today and in mail attachments long before that. The middleware now leaves a header the route has set, and a PDF served inline says SAMEORIGIN. Nothing else on the server is framable. |
||
|
|
984f0474e3 |
Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on disk and left you to find it. The viewer for this already existed -- images, PDFs and text, in the attachment preview in MessageView -- it was just wired to one screen. It is now a component both screens use. Two things it needed before it was any use on Files. The type detection falls back to the file name: an upload carries whatever the browser guessed, which for anything unusual is application/octet-stream, so the old exact-type check saw nothing to show in a .md that had just been uploaded. And text is read with fetch, which ignores Content-Disposition, so Markdown previews even though the server will not serve it inline. Whether we can show a file and whether the server will serve it inline are separate questions, and lib/preview.ts answers them separately: `openableInTab` mirrors the isInlineSafe allowlist in the blob route, because navigating to a blob the server will not inline just starts a download. SVG is left out of both -- it carries script, and how to show one safely is its own question, not a detail of a file lister. Printing goes with it. A picture or a text file prints from the dialog with everything else dropped; a PDF prints itself from its own iframe, since the page around it cannot paginate someone else's document. Hiding `.app` alone was not enough there -- `#root` kept its height and printed a blank first page, the same trap as the message card. |
||
|
|
a33260966c |
Print the message you asked to print
The Print in a message's own menu called window.print() bare, so it printed the whole conversation -- every message on the page -- which is what the toolbar's "Print conversation" is already for. Opening one message's menu and asking to print it is not a request for the other eleven. It now marks the card it was opened from and the print stylesheet drops the siblings for the duration. The subject heading stays: a printed message with no subject on it is a page nobody can file. The "3 messages" count beside it goes, since only one of them is on the paper. The label is left as "Print" rather than made more specific -- it is translated in all nine shipped catalogues, and the toolbar entry beside it already says "conversation". |
||
|
|
0fb7fa09f6 |
Print on white, and start on page one
Printing carried whatever theme was on screen: a dark reader printed a dark sheet, message body included -- that renders in a shadow root, and follows the app palette through inherited custom properties, so no rule in this stylesheet could reach it. The print block now pins the palette tokens themselves to a light, unpainted set, which reaches the body the same way the theme does. Backgrounds go white rather than the light theme's greys; a printer should not lay ink over the whole page. Page one was also blank apart from the subject. `break-inside: avoid` on the message card cannot be honoured by a message taller than a sheet, and Chrome answers by moving the card to a fresh page and breaking it there anyway. Only the header is indivisible now, kept with the body that follows it. |
||
|
|
f25b559cb5 |
Take the message you were reading into the selection
Ctrl-clicking a second message selected only the second. The first stayed highlighted, because it was the one open -- which is a different state wearing a similar colour -- and was never actually selected. So both looked picked, one was, and every action that followed applied to half of what the screen showed, silently. The cause is visible once the two rules sit together: shift-click took the whole run *including* the row it started from, and ctrl-click took only the row clicked. Two branches of one handler that had drifted apart, with nothing asserting they agreed. So they are one function now, and tested. Ctrl-click brings the current row with it while nothing is selected yet; once there is a selection it toggles exactly one row, which is what it is for. Shift-click is unchanged, and keeps its anchor where it is so extending a range twice grows it from the same place rather than from wherever it last reached. The last test asserts the property that failed rather than the branches: whichever modifier begins a selection, the anchor is in it. |
||
|
|
8116cd0393 |
Point at the demo from the top of the README
Somebody deciding whether to spend an evening on a mail client wants to see it before they read about it. The link sits under the logo, above the badges, because a demo answers the question the badges are only evidence for. The line under it says what the demo is -- invented mail, nothing kept -- so nobody arrives expecting to sign in with an address of their own. |
||
|
|
0871d3291b |
Write down what the last week's features actually do
FEATURES.md had nothing on three of them. The scheduling panel and what it will not show, the filter editors no longer discarding work in silence, and where compose-as-new can be reached from — including the reply strip, which is the one a thumb finds. KNOWN-ISSUES.md gains the free/busy finding, which is the sort of thing that page exists for: it was checked against the live server rather than assumed, free/busy between accounts turns out to need no sharing set up, and a principal offers no route to its calendars at all. Also what the check did *not* settle, and which way ihasmail errs in the meantime. ROADMAP.md gains the two things left deliberately unbuilt: a scheduling view you can visit with no event in hand, and per-message actions from the message list on a touchscreen, where the gesture that would open them already means "select". |
||
|
|
e380e168cd |
Put "compose as new" where a thumb can find it
It was never absent on a phone: it sits in the menu behind the ⋮ at the top of a message card. But that is not where anybody looks. On a phone you act on a thread from the strip at the bottom -- Reply, Reply all, Forward -- and what is not there is, for practical purposes, not there. The strip gets an overflow of its own, with compose-as-new in it. A fourth labelled button does not fit; this does, and it says what it is once opened. Measuring to place it turned up something else. Three labelled buttons want about 390px, and with the overflow rather more: enough for a 430px phone and not for a 390, 360 or 320 one. The strip was already over that line on the smaller ones before today, and simply overflowed. It now wraps, and the spacer that would push the overflow onto a line of its own is dropped on narrow screens so the buttons wrap as a group. Closes #181 |
||
|
|
e1402e472f |
Schedule against the whole guest list, and say who cannot be read
The availability bar showed the guests it had free/busy for and quietly left out the ones it did not. In one row that is survivable. As a grid it would be a lie: a row with nothing in it reads as a diary with nothing in it, and "we cannot see this person's calendar" is the one thing that must not look like "this person is free". So everyone the event concerns now gets a row, and the ones with no free/busy to read are drawn hatched rather than empty, with a line under the grid saying how many and why. You get a row too, first. Scheduling around the other people and not around yourself is how two things end up at the same time, and the organiser was the one calendar the panel never showed. Your own row is never unknown. Where the directory does not list you under the address your identity sends from -- an alias, a login that differs from the address -- the account is still yours to read, and Stalwart answers for it under the account's own id. The window steps backwards and forwards a screenful at a time without touching the event, which is the "movable forwards & backwards" the report asks for, and offers its way back when you have wandered off. And the bars are somewhere to put the event rather than only something to read: the pointer shows the half hour it is over, and a click moves the event there keeping its length. Clicking while stepped away moves the event to where you clicked and returns the view to it, so it lands where you were looking instead of jumping. Free/busy is answered per principal, and only the server's own accounts are principals. Somebody at another domain has none to read -- which is not a gap to be closed, it is what the protocol can see -- so the grid says so rather than drawing them blank. Closes #172 |
||
|
|
c4649e0084 |
Say what the availability bar is showing, and show all of it
The bar was a day wide whatever it was drawing. It began at midnight on the event's start day and stopped 24 hours later, so an event running over two days showed availability for the first of them and gave no sign there was more. And it carried no marks at all, which left "is this the whole day or only working hours" unanswerable without dragging the event about to see where its own outline moved. It now covers whole days from the day the event starts to the day it ends, and the free/busy lookup asks for the same range it draws. Above the bars is an axis: hours every three across a single day, every six across two, day names beyond that. The marks are drawn down the bars too, so a busy block can be read against the hour it starts at rather than guessed at. Whole days, always. A bar starting at the event's own start time would move under the reader every time they adjusted it, and "busy from about a third of the way along" is not a time anybody can read. A week is as far as it goes. Something running longer is not an event anybody is hunting a free slot in, and a month at eight pixels a day would say nothing; it says how many days it left out instead. The span is measured between two real midnights rather than counted in 24-hour days, because twice a year they differ, and every position on the bar is a fraction of it. The mock answered with a single busy block on the first day whatever range it was asked for -- all a day-wide bar could show -- which would have left a multi-day bar looking like everyone was free from the second day on. It now answers across the range. This is parts 1 and 2 of #172. The separate multi-day scheduling view it also asks for is still open, and needs an answer first on what to show for participants who have no free/busy to read. |
||
|
|
fc0e2b2b3e |
Import an address book in LDIF
Somebody arriving from SOGo, Thunderbird or an LDAP directory has their contacts in LDIF, and until now the only way in was vCard. Nothing on the server reads LDIF, so this reads it here, in two pieces that are two different problems. `ldif.ts` is RFC 2849 and nothing else: folded lines, base64 values, case-insensitive attribute names, options, comments, `version:` headers, change records. It knows no attribute by name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's address book schema, which is what Thunderbird and SOGo write and what the issue asks for by name. LDIF says nothing about what any attribute means, so a file is only readable against a schema, and keeping the two apart is what would let a second schema be added without touching the reader. Work and home addresses, which the schema keeps in two separate sets of attributes, come across as two addresses. So do every phone kind, the second email, the organisation and its units, job title, nickname, web pages and the AIM handle. The four custom fields have no equivalent in JSContact and are appended to the note, labelled as Thunderbird labels them: keeping something somebody chose to write down is worth more than the tidiness of dropping it. An entry with neither a name nor an address is skipped rather than imported as a blank row that is impossible to identify and tedious to find again to delete. The distinguished name is not used as the contact's uid: it says where an entry sat in somebody else's directory. One import control takes either format and decides by what is in the file rather than by what it is called, because an address book exported as LDIF arrives as .ldif, .ldi, .txt or with no extension at all. Closes #174 |
||
|
|
02c0332d8f |
Send a message again as a new one
A mail the far end rejected, or one that went to an address with a typo in it, is a mail you want to send again -- not forward, and not reply to. Doing it by hand meant a new message and copying five fields across. "Compose as new" sits with Reply and Forward, in the message menu and in the list's right-click menu. It opens a composer holding the recipients the message had, its Reply-To, its subject with nothing prefixed to it, its body with nothing wrapped around it, and its attachments. What makes it a new mail is what it leaves behind. `draftId` stays null, or sending would destroy the message it was made from. `inReplyTo`, `references`, `relatedEmailId` and `relatedKeyword` stay null, so it hangs off no thread and sending it marks the original neither answered nor forwarded. The Message-ID is the server's and the date is stamped at build time, so both are new without anything asking for them -- the send path needed no changes at all. A message you sent is composed as the identity you sent it as. One somebody else sent has no identity of yours to match, and guessing from whom it was addressed to would put the resend behind an alias that was only ever the receiving end, so that case takes the account's default. No signature is added. The body is the one that was sent, which already ends in whatever signature went with it, and appending the identity's would give it two. Closes #176 |
||
|
|
6431ec87f5 |
Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing system a customer invited, a colleague's export, a booking confirmation forwarded on -- and until now the only events ihasmail could take were the ones attached to a message it had received. The calendar's own menu now offers "Import iCAL file…", which files everything in the file into that calendar. No global button: the issue is right that this is not a frequent enough thing to earn one. The parsing is the server's, through the same CalendarEvent/parse an emailed invitation already goes through. An .ics is not a format worth reimplementing in a browser, and Stalwart's reader handles what a hand-rolled one would not. Every event goes out in a single CalendarEvent/set. The round trips are the smaller half of the reason: createEvent invalidates on the way out and invalidating refetches every cached range, so a year of events imported one at a time would refetch the calendar a few hundred times. Nothing is mailed to anyone named in the file. Importing is filing something you already have, and scheduling messages would be a surprise to its participants. The mock's parser read the whole file with one regex and returned one event, which is all an invitation ever needed. It now reads per VEVENT, so a multi-event file can be tested against it, and it invents an organiser and an attendee only for events that carry a METHOD -- a plain export is not addressed to anyone. Closes #173 |
||
|
|
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 |
||
|
|
1e9d7bb596 |
Say in the README that a message can become an event
The mail bullet in "What's in it" listed everything a message can turn into -- a reply, a filter, a receipt -- except the newest one. It now names the event too, and says the guests come with it, which is the half that is not obvious from the feature's name. |
||
|
|
b862f61cde |
Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender and everyone it was addressed to already in the guest list, so a thread becomes a meeting without retyping the room. Two things are deliberately left out. The reader's own addresses, since they are the organiser and an organiser among their own guests is an invitation to your own appointment. And a blind copy, on a message the reader sent: a guest list is visible to every guest, so promoting a Bcc to a guest would tell the room about a copy the sender chose to hide. That is not something a menu item may do quietly. *Send invitation emails to guests* now starts off when the guests were inherited rather than typed, and on everywhere else -- which is every event whose guests somebody chose one at a time. The reason is the case the issue opened with: a reminder made out of a bill carries the biller and everyone else on the mail. Left on, the primary button reads Send invites and the first press mails all of them an invitation to what was meant as a note to self. The switch sits right there under the list and says what it does, so inviting them is one deliberate click. Un-sending is not. |
||
|
|
7ba749148f |
Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar entry, the way a bill or a task becomes a reminder. Nothing clever, and deliberately so -- the subject becomes the title, the body becomes the description, and the reader supplies the one thing the message cannot. A due date is exactly that thing. "Due on the 14th" in an invoice is not a date a parser could be trusted with, and a wrong guess quietly scheduled is worse than no guess at all, so the editor opens on the next half hour for an hour and the reader fixes it. Forward rather than now, because a start time that has already passed by the time they press Create is one more thing to correct. The body is capped at 5000 characters. A newsletter is a message too, and its whole body would be stored on the event, synced to every device, and shown in a three-row textarea; what is worth keeping -- the amount, the account, the address -- is near the top. The cut is marked, so a truncated bill is not read as the whole of it. One message only. The list menu acts on the selection everywhere else, but there is no sensible event to make out of five mails, and the mobile entry appears only when exactly one row is held. The editor lives inside CalendarView and the reader is in the mail view when they ask, so the draft waits in the calendar store until that view mounts and takes it -- once, or it would reopen on every later visit. It seeds a form rather than an event: the dialog still says New event and still has to be pressed. Called *Create event…* rather than "appointment", which is the word the issue used: it opens the New event dialog, and each catalogue already has its own settled noun for that -- Termin, événement, 日程. Reachable three ways, since a phone has no right-click: the row context menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely where the account has no calendar. |
||
|
|
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. |