31f5e0627b702598d996e6764bf636bec22d87a5
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
05df758d0a |
Open the composer full screen, as a setting (#401) (#404)
Settings > General > Composing has a new switch, "Open the composer full screen". With it on, every new composer, whether a new message, reply, forward or reopened draft, starts maximized. Restore still shrinks it to a window. A draft put back after an undone or failed send keeps the size it had. It's off by default, and on a phone, where the composer already fills the screen, it changes nothing. One new string, translated in all nine catalogs. The count falling back to English stays at 16 in every language. Fixes #401 |
||
|
|
bd6a605d61 |
Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
|
||
|
|
d1731efdb9 | Use American English spelling throughout | ||
|
|
95395200a3 |
Let the contact list be resized, and give the contact the rest of the page
The contacts grid still had three columns from when the address books sat
inside the view: 220px for them, 280-360px for the list, the rest for the
contact. The books moved to the app's left pane in
|
||
|
|
abb07acc80 |
Let the sidebar be resized by dragging its edge
The sidebar's right edge is now a splitter, like the one between the message list and the reading pane: drag it between 240 and 480px, move it with the arrow keys, double-click to put it back. The width is a device setting, and stays null until someone drags, so a width set in the reader's own CSS through --sidebar-w is kept until they choose otherwise. Hidden on a phone, where the sidebar is a drawer, and while collapsed. Arrow keys on either splitter moved the pane and never saved it: the keyboard path called onResize without onEnd. It ends each key press now, and both views keep the in-progress size in a ref as well as state, so the end reads the value set in the same tick. The message-list splitter's accessible name was an untranslated literal. It goes through translate() now, and it and the sidebar's new name are in all nine catalogues. Closes #345 |
||
|
|
2464c9655f |
Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it themes an HTML message only when the message brings no colours of its own. That predicate is the right default and it almost never passes: one `color:#FFFFFF` on one button label opts a whole message out, so in real mail — receipts, shipping notices, anything from a template — the switch did nothing at all and the reader kept a bright white card on a dark UI. A second switch, off by default and only meaningful with the first on, forces the palette over the sender's colours. It cannot be done perfectly, which is why it is a separate, explicit choice: the same bargain a dark-reader extension makes. What it does is tell two kinds of colour apart. A *sheet* the design sits on — the white 600px wrapper — is neutralised, and a *painted surface* — a call to action, a footer banner — is kept whole so its label stays legible on it. Relative luminance decides, at 0.5: white wrappers sit at 1.0, a blue button near 0.09. Only the painted ones are marked, with data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else. Nothing the sender wrote is removed, so the switch is reversible, colours arriving from a <style> block are covered as well as inline ones, and print still pins the tokens to ink on white. The mock grew the message this is about: an outer wrapper on bgcolor="#ffffff", a <style> block, a coloured button, a grey footer. Without one, neither the bug nor the fix could be seen. Verified in a browser against the mock: with only the first switch on the card is still white; with both, the wrapper computes to transparent, body text follows the theme, and the button keeps white-on-blue. Two surfaces marked, which are the two the message paints. Closes #290 |
||
|
|
c84f190f76 |
Check S/MIME signatures, and remember who signed
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.
What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.
The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.
The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.
Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:
- OpenPGP, by name. The signature carries no key and there is nowhere
to get the sender's: x:PublicKey is the account's OWN registry, and
a keyserver or WKD lookup would tell a third party who you
correspond with -- the leak the image proxy exists to close.
- SHA-1. Not forgeable in practice today, still not something to put a
tick beside.
- RSA-PSS, whose salt length lives in parameters this does not read.
Guessing wrong would report a good signature as bad.
Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.
The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".
Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.
Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
|
||
|
|
c31a653a04 |
Apply installation policy changes once each, per account
The last third of #207, and the only part that remembers anything. An admin turns a setting on for people who are already here -- which a default cannot do, since a default only seeds an account that has none -- and readers may still turn it back off afterwards, which enforcement does not allow. The difference between the two is entirely in the remembering. Each change carries its own version, and an account stores the ones it has had in its own settings file. Ids rather than a high-water mark, so a change dated earlier than one already applied is not silently skipped -- the reporter's analogy is a schema migration, and this is that shape. Per account rather than per device, because ihasmail's settings are not browser-local: they live in a file in the reader's own JMAP Files, with the browser holding a cache. Signing in on a phone does not apply everything a second time. A change reaches somebody who had already decided otherwise. That is intended and confirmed on the issue: the point is to reach everybody who is already here. It is applied once, and their next decision sticks. One `update` for however many are pending, since each would otherwise push a settings file of its own. Enforced values still win, being applied after. A change whose settings this build does not have at all is dropped rather than recorded, or it would never run on the ihasmail that does have them. The reader is told. A setting moving under somebody without a word is the part of this worth being uneasy about, so the count is toasted with a way into Settings. README gains the Docker half the user asked for: a mounted policy file, the same thing as environment variables for a deployment with no volume, a compose fragment, and the fact that a policy is read once at startup so editing it means a restart. Closes #207. |
||
|
|
457ea53ca3 |
Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders" on for three thousand pupils cannot ask three thousand pupils, and the reporter is right that this is a company policy rather than a preference. Two powers, and the difference between them is the whole request. `defaults` seed an account that has never had settings of its own and can be changed afterwards like anything else -- a starting point, not a rule. `enforced` are reapplied on every load and cannot be changed at all. Enforced controls stay visible and go dead, with a line saying why. The issue asked for that by name: a control that is simply missing reads as a bug to somebody who has used ihasmail without a policy. The lock is in the settings store rather than only on the controls. There is one door -- `update` -- and putting it there means an imported settings file, a settings file synced from a device that predates the policy, and a control somebody adds later and forgets to check are all covered by construction. Reset goes back to the installation's answer rather than to ihasmail's, so it cannot be a way around a policy either. Configured by environment variable or by a file, because ihasmail's own production runs read-only with no volume: an installation that cannot mount a file can still set a variable. Keys this build does not have are dropped, the same rule an imported settings file already gets -- a policy written against a newer ihasmail must not put a setting nothing reads into everybody's synced settings file. Malformed JSON stops the server rather than quietly doing nothing, since a policy that silently did not apply is indistinguishable from the feature not working. Tier three -- enforcing a setting once while still letting readers change it afterwards -- is not here. It needs a decision the reporter and I have not made yet, and it is the only part that stores anything new. Refs #207. |
||
|
|
4d18d94b63 |
Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given as a link, which ihasmail could not show at all. Nothing is stored. The document is fetched when the calendar is opened and parsed in the browser; the server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either -- there is nowhere to run one -- so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is said plainly rather than implied. The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none of them send CORS headers. That makes it the second place this app knocks on a door somebody else chose, so the guard the image proxy has always had was lifted out and both now call it. A second SSRF implementation is how one of them ends up missing a case; this way there is one, and the extraction is covered by the image proxy's own tests still passing unchanged. webcal: is understood, because that is how these are published, and it is read as https: rather than waved past the checks -- a webcal URL pointing at loopback is refused exactly like an http one. Recurrence is deliberately not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. The parser is a subscription parser rather than an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit, which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time -- a DATE is built in local time rather than at UTC midnight, which would land on the day before for anyone west of Greenwich -- and never hang on a document somebody else wrote. Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the calendar they hang off reports no write rights, so everything that asks before offering an edit declines on its own. A subscription that cannot be read says so in the sidebar rather than drawing an empty calendar, which looks like a calendar with nothing in it. |
||
|
|
c838569638 |
Merge branch 'main' into feat/palettes
# Conflicts: # web/src/store/settings.ts |
||
|
|
9aa0eda0d5 |
Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value carried a whole palette and implied dark. That works for exactly one palette. The two questions now come apart: which palette, and which side. Classic is the plain light and dark this app has always had. ihasmail's own palette gains a day version, so the background of the dark one becomes the text of the light one and the two read as one palette from either end. Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects, used under the MIT licence, and taken from each project's own repository rather than from anyone's reimplementation. What was fetched is recorded in .palette-sources/ and credited in NOTICE. Giving ihasmail's palette a light half removed a whole special case. Nothing is one-sided any more, so a palette can no longer override the mode, the toggle no longer has to set a palette aside on the way to light and remember it, and the greyed-out control that explained all that is gone. The old lastDarkTheme, which existed only for that, is gone with it. The shades between the published colours are derived rather than guessed: these projects publish twelve to twenty values and ihasmail needs about thirty. scripts/build-palettes.py computes the tiers and then measures every text colour against the surface it sits on -- 4.5:1 for prose, 3:1 for borders and marks -- lifting anything short towards white on a dark ground and towards black on a light one, so a lifted tier keeps its hue. It refuses to write a palette that would not pass. Every one of the nine halves needed at least one lift. These palettes are built for code editors, not for prose at this size: Dracula's comment grey is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published would have quietly ended the WCAG AA claim. Two things caught while checking rather than while writing. The generated blocks were appended to the end of the stylesheet, which put them after the accent variants at equal specificity -- so choosing an accent over one of the new palettes did nothing at all. They now sit before those rules, where the existing ihasmail block's own comment says they have to. And that block was unqualified, so it would have shadowed the new light half; it is now explicitly the dark one. Settings written before this carry `theme` and no palette, and are read through the old enum. `theme` is still written back, derived, because a device on an older build reads it and would otherwise be stranded on a theme nobody chose. |
||
|
|
34fc5ab81f |
Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was wherever it happened to fall. Seven presets and up to three levels of your own. It covers the Inbox alone by default: unread-first is what people want in the folder they triage and confusing in Sent, where everything is read and the order that matters is when it went. Search keeps newest-first whatever the setting says, since a result list is already ordered by the question that was asked. The server does the sorting, over the whole folder, for the same reason search runs there: a list sorted in the browser is sorted only as far as the browser has loaded, which on a folder of ten thousand is the first fifty and a lie about the rest. Two details that are easy to get wrong and were worth pinning in tests. hasKeyword sorts a boolean and false comes before true, so "unread first" is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the other way round. Getting either backwards puts exactly the mail you were looking for at the bottom. And every order ends with newest-first as a tiebreak, because a sort whose last level is a keyword or a subject leaves every tie undefined, and an undefined order changes between two looks at the same folder for no reason the reader can see. Sorting on a keyword is optional in RFC 8621, and a server that will not do it fails the whole query rather than degrading it -- so this setting could turn a folder into one that does not open. The refusal is caught once, the keyword levels dropped and the query retried, and nothing is said: the reader asked for an order and got the closest the server can give, and a toast on every folder change would be the app complaining about its own request. The mock now honours the sort instead of always answering newest-first, which had it reproducing a server that silently returns a different order from the one asked for -- the one shape of wrongness a client cannot detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the keyword sorts, so the fallback can be developed against. |
||
|
|
6c7c6d19b3 |
Merge pull request #206 from Coffey-Labs/feat/birthday-calendar
Show birthdays from the address book as a calendar |
||
|
|
c9ab203b76 |
Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. |
||
|
|
f7ef886b45 |
Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was no way to keep one that matters occasionally without it holding a row for ever. A label can now sit under another, and each says whether it belongs in the sidebar always, only while it has unread mail, or never. Nesting is display only. The keywords stay flat on the message, which is what keeps them readable by every other client: moving a label under another rewrites nothing in the mailbox, and a client that knows nothing about ihasmail sees exactly what it always did. Both new fields are optional, so a settings file written before this parses unchanged and means what it did. Settings sync between devices, so the tree has to survive shapes that should not exist. A label whose parent was deleted on another device comes back to the top level rather than vanishing -- a label that disappears because something else was deleted is one the reader cannot get back. A cycle arriving from an older device is broken by treating the label that closes the loop as a root, so nothing is lost and nothing hangs. The parent picker will not offer a label's own descendants, so one cannot be built here in the first place. A label kept by the unread rule keeps its ancestors, whatever they were set to. A child cannot be drawn under a parent that is not there, and promoting it to the top level would silently rearrange the tree at the moment the reader is least able to explain why. The parent comes back as a container instead, and its own count still says whether it has anything of its own. Unread counts come from one request carrying a query per label rather than a request each, with limit 0 so the server does not send ids that would only be thrown away. They refresh on the same beat as the folder counts, since the things that move them are the same things, and a failure is swallowed: a count is decoration, and the sidebar draws the label without one. Also corrects the Labels page, which said names and colours are kept in this browser. They live in the account's own Files and follow it between devices, like every other setting that is not about this screen. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
95dcb96086 |
Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it something to serve, and a way to measure the distance to the languages actually planned. The English text is the key. `t("Archive")` looks "Archive" up and returns the English when it is not there, which buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code, a missing translation that degrades to readable English rather than to `mail.list.archive`, and an extraction step that is wrapping a string rather than inventing a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost is that editing English copy orphans its translations, which is the right way round: the copy is the product, and a stale German sentence should fall back to the new English. `plural()` takes forms rather than (one, other), because two forms is an English assumption that does not survive phase two of the plan. Russian and Ukrainian need three, and choosing between them is not a question about the number 1. Intl.PluralRules knows the rule for every language the browser knows, so the catalogue supplies the forms and the runtime picks; a category the catalogue does not carry falls back to `other` rather than rendering undefined. Interpolation is named rather than positional for the same reason -- German moves the parts of a sentence around and means the same thing. Catalogues are dynamically imported, so a reader who never leaves English never downloads one, and English needs no fetch at all. `applyLang` sets the lang attribute before kicking the load, deliberately: lang is what stops Chrome offering to translate and should not wait on a network request to say something it already knows. `t()` is a plain function, not a hook, so the tree is keyed on a language version at the root and thrown away when the catalogue changes. Making every call site a subscriber would turn extracting a string from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. NotificationsSettings is extracted end to end as the reference -- it covers all four shapes, being JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56 files is too many to eyeball in review or carry in anyone's head. It reports 20 wrapped and 925 remaining, and it deliberately does not count punctuation and separators as untranslated -- a floor no amount of work could reach would make the number useless. A progress report rather than a gate: --check exits non-zero, for once the number is low enough for that to mean something. ROADMAP.md said translations were "English-only for now" on a page whose stated purpose is things the answer is "no" to. It now says what is actually happening, carries the phase order, and says why Arabic, Hebrew and Persian are on neither list: RTL is a layout and bidi problem rather than a longer catalogue, and shipping it as though it were the same kind of work is how an RTL build ends up unusable with nobody saying so. |
||
|
|
be1d787b5f |
Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the rendered DOM directly, wrapping text nodes in <font> elements React has never heard of, and the next update can then call removeChild against a parent whose children have moved (facebook/react#11538). This is the structural defence against that, plus the setting the served language will read from. The language setting is `uiLanguage`, and it is deliberately not the `locale` field that already exists. That one is a formatting choice -- what calendar, clock and numerals to use -- and folding the two together would silently rewrite everybody's date format the first time they picked a language. German dates with an English interface is a real preference, and so is the reverse. It defaults to English when absent, which covers both a new account and every settings file written before this, and Accept-Language is not consulted: a served locale should be something the reader chose rather than something guessed and then written down as though they had. Only languages with strings shipped are offered, which today means English alone -- a picker entry without a catalogue behind it would leave the page claiming a language it is not in, which stops a reader translating a page they cannot read. `<html lang>` is set where applyTheme is set: at store module load, from the localStorage cache, before createRoot() has rendered anything. Not in an effect -- a lang that is briefly wrong is enough to raise the translate prompt on a page that needed none. There is no server-rendered alternative to reach for here: ihasmail serves a static shell and holds no account state, and the settings file lives in the reader's own JMAP Files, so reading it before the page existed would mean authenticating to Stalwart on every page load. The static lang="en" in index.html covers the first bytes; the store only ever corrects a reader who chose otherwise. Both halves are tested. translate="no" and class="notranslate" go on the narrow boundaries only: rendered email bodies, raw message source, attachment text, the generated and hand-edited Sieve, the brand and the login name. Not on <body> -- someone whose language ihasmail does not speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a shadow root, so React never reconciles them and they were never a crash risk; the marker there is about not rewriting what a sender actually wrote. Twenty-four fragile interpolation points were found with the TypeScript parser rather than grep, and fifteen refactored. Pluralisation and "count + label" pairs are collapsed into a single expression so the text is a lone child React updates with textContent, rather than a text node with conditional siblings to insert around. One of them -- InviteCard's {method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either way and is simply gone. The boundary is scoped to the main content, so the header, folder tree and any open composer sit outside it and survive independently. It recovers by remounting the subtree, which costs nothing because everything inside re-derives from the stores, and it logs at info rather than error: a reader translating a page is expected and recovered from, and filing it as an error would put an entry in every console-reading reporter for behaviour that worked. It re-raises anything that is not a DOM mutation error, so a real bug still surfaces as one, and it gives up after three attempts rather than looping invisibly. Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping 207-249 React-managed text nodes in <font>, exactly as the translator does, then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since. So this lands as defence whose premise is weaker than assumed rather than as a fix for something observed here, and the boundary is insurance rather than a load-bearing part. The notranslate markers and the collapsed interpolations stand on their own merits either way. |
||
|
|
b2769b9011 |
Give the mail list the gestures a phone already has
ihasmail's mail list was built for a mouse. A row is clicked, right-clicked and dragged into a folder, and on a touchscreen two of those three do not exist -- so the phone layout had the shape of a mail app and none of the handling, and the things people reach for first simply did nothing. Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged: - Swipe a row sideways to act on it. Each direction is a setting -- right archives and left deletes by default, matching the app the phone came with -- and the strip revealed behind the row names what will happen in the folder it is happening in: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail, and nothing at all where the action is a no-op, in which case the row will not move that way. - Hold a row to select it. Selection was reachable already, by aiming at a checkbox beside an avatar, which is not how anyone selects mail on a phone. The selection toolbar gained an overflow menu at the same time: report spam, mark unread and label were hidden on narrow screens and had nowhere else to be, so touch selection could not reach them at all. - Hold a folder for the menu its ⋮ button opens. - Pull the list down to refresh, and drag in from the left edge of a conversation to go back. The toolbar's button and arrow both stay: a gesture with no visible control is one only the people who already know about it can use. The arithmetic behind them is in lib/touch.ts, away from the components and under test, because the numbers are the whole thing: an axis lock biased towards the vertical, so a diagonal flick stays a scroll rather than deleting whatever it passes over. Two layout bugs turned up while checking this on a 390px screen, both older than the gestures. The app shell is a grid with only its rows named, so it took an implicit auto column sized to the top bar's min-content -- about 470px -- and every message row ran off the right of the glass with its date beyond the edge. The column is now stated as minmax(0, 1fr), and the search field is allowed to shrink. Full-screen surfaces measure in dvh rather than vh, and the tab bar, drawer and compose button keep out from under the notch and the home indicator. |
||
|
|
5e5bec31b7 |
Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's answer to a sharee subscribing to a book shared read-only, and it is a fair one: `isSubscribed` lives on the collection rather than on the reader, so adding one is a write to the *owner's* account. The identical write on a shared calendar is accepted. The difference is the server's. So the flag is still asked for first -- a preference the server holds is one every client agrees about -- and when it is refused the answer goes in the reader's own synced settings instead, as `addedShares`, keyed by account and collection. Either record counts as added, and the rule has a test of its own because three components ask the question and they must not drift apart. Two things about how this hid. The refusal arrives as a *successful* response with the id in `notUpdated`, so the version that ignored it saw nothing wrong and the button simply did nothing -- fixed a commit ago, and it is what turned "the + does nothing in Firefox" into a sentence from the server. And it cannot be seen from the owner's account at all, where the write succeeds: it took two browsers signed in as two accounts to find, which is why it survived every check made from one. The mock refuses the same write for the same reason. One that accepted it would have gone on agreeing with the belief that shipped. Verified against it: adding the shared book is refused by the server, recorded in settings, and the book moves to "Shared with me" with its contacts reaching the To field; removing undoes all three; and it survives a full page reload, which is the point of putting it where the settings live rather than in this tab. |
||
|
|
133036a6c5 |
Hide identities from the compose picker
An account using a unique address per service, on a server with an alias domain, ends up with every local part twice over and a From picker nobody can use -- while only ever sending from a handful (#73). Identities can now be hidden from that picker, from Identities & signatures. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable, the way an unsubscribed folder is still a folder. That framing is mbunkus's own, and it is the right one -- this is a UI preference, not a change to the account. Three things it refuses to do, because a sender picker with nothing usable in it is worse than a cluttered one: - it will not hide the identity a draft is already using, which would leave the select with no matching option and move the From line under the writer - it will not hide the default, which is what a new draft starts on; the button is disabled there and says why - if every identity is somehow hidden -- reachable only through settings sync, since the UI will not do it -- they are all offered again The setting syncs, so the picker looks the same on every device, which follows from DEVICE_KEYS being a list of exceptions rather than a list of what travels. Verified against the mock with four identities and one hidden: the picker offers the other three, the hidden address is gone from composing, the default's hide button is disabled, and the row says the identity still receives. |
||
|
|
9689ac8aae |
Let the toggle come back to the theme you were on
The top-bar toggle went to light from anything dark, and back to plain "dark" -- which quietly moved an ihasmail user onto a theme they had never chosen, two clicks and no way to tell what had happened. It did the same to "match system", which the toggle could not restore at all; the comment above it conceded as much and sent people to Settings. There is more than one way to be dark now, so the way back is remembered: lastDarkTheme holds whichever non-light theme was last chosen, and the toggle returns to that. The remembering lives in update(), the single path every way of setting a theme goes through -- the toggle, Appearance, an imported settings file -- so a fourth way to choose one cannot forget to record it. Light never overwrites it, since light is the side being toggled away from. The button's label follows: "Switch to the ihasmail theme", "Switch to your system theme", rather than claiming everything dark is "dark mode". Confirmed in a browser, not only in tests: from a fresh profile the round trip ihasmail -> light -> ihasmail returns to ihasmail, and system -> light -> system returns to system, with the label naming the destination each time. |
||
|
|
8487f561f6 |
Add the ihasmail theme, and make it the default
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.
It is a theme rather than an accent because it changes backgrounds,
borders and text as well as the highlight -- an accent could not.
It rides on data-theme="dark" and adds data-palette="ihasmail" on top,
so the eleven dark-only rules further down the stylesheet keep applying
without being duplicated for a second dark theme. Specificity then does
something deliberate: the palette block is 0,2,0 and the accent variants
are 0,3,0, so a chosen accent still wins over it -- and because the
default accent ("teal") has no rule of its own, ihasmail.org's accent is
what shows until someone picks another. Verified both ways in a browser.
It is now what a new account starts on, so the app looks like itself
before anyone has chosen anything. Only a default: a stored theme always
wins, which leaves everyone already using ihasmail where they are, since
the setting is saved whether or not they deliberately picked it.
While here, the theme-color meta tag was fixed. There were two, both
carrying media attributes, and applyTheme looks for
:not([media]) -- so it matched neither and the browser chrome had never
followed the chosen theme at all, only what the OS preferred. One tag
now, updated from JS, starting at the default theme's background so the
first paint is right too.
Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
|
||
|
|
0a9218f622 |
Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between devices. The sharpest edge is the default identity: with none set the address that sorts first wins, so mail goes out from an address the recipient may not recognise -- and someone who sets it at work finds it unset at home, with nothing to say so. Reported in #54. They now live in a settings.json in the account's own JMAP Files, beside the signature images already kept there. ihasmail itself stays stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. localStorage stays as a cache rather than the source of truth, so the first frame is painted from it and the file corrects it a moment later. A private window has no cache and shows defaults for that one frame, which is the trade for not gating the whole app on a network round trip. Not everything should follow the account. A list-pane width picked on a 27" monitor is wrong on a laptop, and the notification toggles track a permission the browser grants per-device, so claiming it elsewhere would be a lie. Those stay local, written as a list of exceptions so that a setting added later syncs by default -- which is what adding one almost always means. Writes are coalesced: update() fires on every frame of a splitter drag, so a change waits 3s and the newest value wins. A tab going away flushes first, as does signing out, so a setting changed seconds before either is not lost. The ihasmail folder is now hidden from the Files view, contents and all. Hiding the folder alone would have been worse than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would have spilled into the top level as if the user had put them there. Those images have been visible since signatures shipped. Requires 0.16 -- FileNode/query cannot see directories before that. On 0.15 settings stay local exactly as they were. Verified against the mock end to end: folder create, blob upload, node create, read back, update, re-read. Not yet exercised against the live 0.16.19. |
||
|
|
bbd6980cff |
Give a folder a colour from its right-click menu
Right-click a folder and pick one of the twelve colours the calendar already uses, or clear it again. The colour tints the folder's icon; the label keeps the sidebar's own contrast, which a dozen arbitrary colours would not reliably give it. Kept by mailbox id rather than by name, so a folder renamed or dragged somewhere else keeps its colour. Stored in settings, which live in this browser -- JMAP has nowhere on a Mailbox to put a colour, and every other colour in the app, labels and event categories included, already works this way. Worth knowing it does not follow you to another device. The cascade needed care: .nav-item svg sets the colour on the icon itself, so a colour inherited from a wrapper does nothing. Checking getComputedStyle on the wrapper said the icon was purple while the pixels stayed grey; the rule now targets the svg, and the check now reads the pixels. |
||
|
|
3310149fcc |
Send the read receipt the sender asked for
JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does not implement it, so ihasmail assembles the RFC 8098 multipart/report itself and sends it the long way round: raw MIME uploaded as a blob, imported, submitted. That is also why the receipt lands in Sent, which is where it honestly belongs. The plumbing is the easy half. A receipt tells whoever asked that the address is live and when the message was read, to an address the sender chose, so the refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two servers answer each other forever), nothing carrying Precedence bulk/list/junk or a List-Id, nothing already acknowledged, nothing that never arrived. A receipt aimed anywhere other than the sender is offered, but says so first. There is no "always send" setting, only ask or never. Sending is recorded with RFC 3503's $mdnsent keyword on the original rather than remembered locally, so a second look -- or another client entirely -- knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so nothing rests on 8BITMIME surviving every hop. Verified against the mock end to end: the blob uploads, the receipt imports and submits, and the original reads back marked. Not yet exercised against the live server. |
||
|
|
c145858bbe |
Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a permission the built-in `user` role is not given, so the setting silently fell back to the browser locale for exactly the people most likely to have set it. Stalwart 0.16 carries the same field on `x:AccountSettings`, whose `sysAccountSettingsGet` *is* part of that role. Both are now asked for in one request and whichever answers wins, so admins and older servers keep working. That pair of replies also says which generation we are talking to: only 0.16+ can parse the method name at all. About now reports that, plus the edition from /api/account where the server offers it. It does not report a version number because Stalwart does not publish one to clients — it hardcodes a public "1.0.0" and keeps the real version to its SMTP internals — so the screen says what was actually detected rather than inventing precision. Also adds a light/dark toggle to the top bar, left of the settings button. The stored setting is three-way, so the button acts on the theme actually on screen: whichever one you see, a click gives you the other. Choosing "match system" again stays in Settings › Appearance, where a three-way choice belongs. |
||
|
|
c3cecf9916 |
Let messages follow the app theme, at the user's choice
Messages render on a white card in every theme. That is deliberate for mail that styles itself, but #4 points out the case it gets wrong: a message with no styling of its own has nothing worth preserving, and flashing white at someone reading in the dark is a real cost. Appearance gains a switch under the theme cards, off by default so the current behaviour is unchanged. With it on, HTML mail that declares no colours follows the app theme; mail that sets a background or text colour still gets the light card it was designed for, because half-darkening someone else's design is worse than leaving it alone. Plain-text mail already followed the theme and is untouched by the switch. The themed palette is expressed in the app's own custom properties, which cross the shadow boundary, so switching theme repaints open messages without re-rendering them, and the accent-coloured link stays consistent. The host element takes color-scheme: inherit so form controls and scrollbars inside a message match too. htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or background declarations in style attributes and <style> blocks, while ignoring near-misses like border-color and ?color= in a URL. Closes #4 |
||
|
|
d82ff15921 |
Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1 |
||
|
|
645b8b510f |
ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI. |