25fd6404f27aa54f8f17b2f45d8ccd4eb408ffa1
20
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
350f4f4197 |
Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it behaved like mail folder sharing. That was wrong -- it works -- and it is back, built the way Files is rather than the way it was. Three things it inherits from Files. Shared books are listed in the app's own left pane instead of behind an account switch in the profile menu. The reader's books and other people's sit under separate headings, since a book belonging to somebody else behaves differently and a single merged list would be quiet about whose contacts you are reading. And opening Contacts re-reads the session, so a book shared while the tab was open turns up without signing out and in again. The books pane the view kept to itself is gone, and with it the last module that ignored the sidebar it was given. The one thing Files does not need: shared contacts have to answer when somebody types a name into a To field, so they are loaded up front rather than when a book is opened, and they are offered by `suggest` and found by `lookupByEmail` alongside the reader's own. Their own cards win a tie, since a card someone wrote themselves should beat a colleague's copy of the same person. That is the difference between a shared book you can look at and one you can use. Cards from a shared account are held apart from the reader's rather than merged in, and keyed by account as well as id. Ids are only unique within an account -- two accounts each having a book `ab1` is ordinary -- and a flat map would have had one silently replace the other. The mock grew an address book in its shared account, with contacts in it, because none of this could be exercised otherwise. KNOWN-ISSUES records the withdrawal as the mistake it was rather than leaving it in the history looking like a finding. Mail folder sharing stays withdrawn: that one really is broken. |
||
|
|
ad94efb65b |
Reach shared folders from Files, not the profile menu
A folder somebody shared was reachable only by switching the whole app to their account from the profile menu -- which nobody would think to look in for files, and which pointed mail, calendar and contacts at them as well. The server refused all three, so nothing leaked; it was simply the app claiming to be somewhere it could not go. Files now lists shared accounts itself, under "Shared with me", and opens them in place. Only Files moves: `accountId` in its store is the account being browsed, `ownAccountId` is the reader's, and nothing else in the app notices. Which accounts hold shared files cannot be worked out from capabilities. Stalwart advertises the whole set on a shared account -- mail, calendars, contacts, sieve, the lot, identical to a personal one, whatever was actually shared (checked live on 0.16.19, 2026-08-27). That is why routing alone could never have fixed this, and why the list offers every account that is not the reader's own and lets its folders answer for themselves. The mock's shared account now advertises the same full set, because a mock that quietly advertised only what it shared would agree with a fix that cannot work. Shares also went unseen until the next sign-in. They arrive in the JMAP session, which is fetched once and refreshed only when a session-state change is pushed to that tab -- so a share granted while the tab was open stayed invisible, and one removed stayed on offer. That is the two browsers disagreeing about whether an account still existed. Opening Files now re-reads the session, throttled, and the section header carries a refresh for when someone is waiting on a share they have just been promised. The sidebar's button on Files was Compose, which wrote mail from the file manager. It uploads. Verified against the mock, which grew a second account to make any of this testable: "Shared with me" lists it, opening it shows its folders and not the reader's, the header says whose they are, "Back to my files" returns, and the profile menu is not involved at any point. |
||
|
|
f70eb184c2 |
A folder tree, and dragging things into it
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
|
||
|
|
6566f4c2d3 |
Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.
Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:
- `FileNode/get` returns `shareWith`, and `myRights` carries all six
rights, `mayShare` among them and true on one's own nodes. So the
menu entry has a real right to gate on -- unlike folder sharing,
which is offered ungated because `MailboxRights` has no such right
- `Principal/query` answers now that `allowDirectoryQueries` is on:
six individuals, no groups
- `ShareNotification/get` is implemented, which is worth knowing for
later; nothing here reads it yet
The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.
One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.
Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
|
||
|
|
d64249b46d |
Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above that sat off the top of the pane with nothing to announce it, and the only way to find out was to scroll up -- by which time the auto-mark-read timer had marked the whole thread read anyway, so scrolling up meant scrolling up to mail already counted as seen (#87). Opening at the bottom is right when there is nothing to catch up on and wrong the moment there is. The pane now opens on the oldest message that was unread when the thread was opened, and falls back to the newest when the thread has already been read. mbunkus's out-of-order case is the one that rules out guessing at a position. A participant whose server could not connect for hours delivers a message long after it was written, and it lands in the middle of a conversation that has already moved past it -- so "second to last", or any other fixed offset from the end, finds nothing. Reading the unread set is the only thing that does. Two cases leave the pane where it is: - a single message, which is already the whole pane - the first unread being the first message, where the top of the pane shows it anyway, together with the subject; scrolling to it would push the subject off for nothing It reads the set captured when the thread was opened rather than live `$seen` state, for the same reason expansion does (#69): the mark-read timer must not change the shape of what you are looking at. That also makes the landing stable, because everything above the first unread message is a collapsed row of fixed height -- nothing up there reflows after the scroll. The mock grows a thread that reproduces it: seven messages with the unread one second, four more behind it. Verified against it. Opening the thread lands the unread message flush against the top of the pane at scrollTop 158; the old scroll to the newest message put it at 445, with 287px of the message -- header, sender and unread bar included -- above the fold. On a thread whose first message is the unread one the pane stays at 0 with the subject in view, where before it would have scrolled 333. Once the thread is read, reopening it goes back to the newest message. |
||
|
|
2263aa494f |
Send a filter the server can read
Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:
filter: { inMailbox: null, notKeyword: "$seen" }
`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.
The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.
Two reasons this got out, both worth fixing rather than just the bug:
- The tests checked the properties list and its ordering, and never
looked at the filter. There is now one that walks every condition
and fails on a null or undefined value, for both the known-inbox and
unknown-inbox cases.
- The mock accepted it happily, so nothing local disagreed with the
code. It now refuses a filter condition with a null value and
answers "Invalid filter.", which is what the live server said.
Reproduced: the old payload is rejected, the new one accepted.
|
||
|
|
96bc7b53d7 |
Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.
Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.
Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:
- the session publishes a real applicationServerKey, so no key
generation or server configuration is needed
- PushSubscription/get answers an ordinary user rather than refusing
- emailpush is advertised, and its draft defines a filter, an ordered
properties list and an urgency -- so the payload can carry sender and
subject, and the server drops properties from the end when it will
not fit rather than failing the notification
Three things this gets right that are easy to get wrong:
- The verification handshake. A JMAP subscription delivers nothing
until the client echoes back a code the server pushed, and the
service worker cannot answer it -- no credentials in that context.
It forwards the code to a tab, or leaves it in the cache when no tab
was open to forward it to.
- Key encoding. The W3C Push API produces unpadded base64url and
Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
on the way out. The VAPID key needs padding on the way *in* for
atob; getting that backwards fails at subscribe() with an opaque
error, so it lives in one named function with tests.
- Sign-out. A subscription belongs to the account, not the session.
Without tearing it down, a shared machine keeps notifying for a
mailbox nobody is signed into -- which is somebody else's mail.
The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".
Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
|
||
|
|
18e493bcd1 |
Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.
The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.
emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.
The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.
Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:
- The mock called its folders "Trash" and "Sent". Stalwart's defaults
follow the Exchange convention -- "Deleted Items", "Sent Items" --
so anything built from a folder's name read differently against the
mock than against a real server, and every screenshot in the README
showed a folder list no user has.
- Worse, and in shipping code: the undo toast took a hardcoded label
in preference to the folder's actual name, so deleting a message
announced "moved to Trash" on a server whose folder is called
"Deleted Items", and reporting spam said "moved to Spam" where it is
"Junk Mail". The one message whose job is saying where mail went was
naming somewhere that does not exist. It now prefers the mailbox's
own name and keeps the hardcoded word only as a fallback.
Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
|
||
|
|
94bf42cfda |
Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean. |
||
|
|
c1ef19849e |
Say it in the words Stalwart 0.16 answers to
Guests added to an event vanished on save and no invitation was ever sent. Not a guard in the editor, and nothing the server complained about: ihasmail addresses a participant the way RFC 8984 does, with sendTo and email, and Stalwart 0.16 keeps that address under calendarAddress. Handed the RFC's spelling it stores the event, drops the entire participant map, and reports success. Six shapes were tried against a live 0.16.19, down to sendTo and roles alone; all six were dropped, and patching a participant onto an existing event fails outright with "Patch operation failed". The same disagreement runs through two more properties. The organizer is organizerCalendarAddress, not replyTo. A recurrence is a single recurrenceRule, not a recurrenceRules array — and that one Stalwart refuses honestly, with invalidProperties, so no recurring event could be created at all and existing ones showed no repeat. So writes now use Stalwart's names and reads accept either, since a mailbox may hold events written by other clients. The mock now refuses what the real server refuses and drops what it drops: advertising the RFC spelling is exactly how this reached a live server unnoticed, the same way the capability-placement bug did. Verified against 0.16.19: participants, organizer and rule all survive a create, an update and a re-read, with the roles kept as sent. Fixes #26 Fixes #30 |
||
|
|
be75a02181 |
Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet in the same two files. The mock keeps `urn:stalwart:jmap` out of the session-level capabilities and hands it out per-account, as a real server does, while the submission capability it grew for scheduled send lives per-account beside it; the client keeps both accessors, one asking whether a capability is advertised anywhere and one reading the object itself. |
||
|
|
14125a0799 |
Look for Stalwart's capability where Stalwart advertises it
Self-service credentials, the About page and Files all keyed off `urn:stalwart:jmap`, and all three looked for it in the session-level `capabilities`. Stalwart has never put it there. `Session::new` builds that list from a fixed set the capability is not part of, in any 0.16.x from 0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in `primaryAccounts` and in each account's `accountCapabilities`. So every real 0.16 server read as pre-0.16. Password changes, 2FA and app passwords fell back to `POST /api/account/auth`, which 0.16 removed, and reported that the server offers no self-service credential management. About named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType` and listing the tree through get. Look in all three places, on both sides. Two nearby soft spots go with it: a transport error while probing the registry no longer downgrades a server to the legacy path -- which would have posted the current password to an endpoint that is not there -- and a locale request that is merely refused no longer discards a generation the capability had already settled. The mock advertised the capability in the session, which is why no test ever caught this; it now advertises it where the real server does, and validates `using` by the urn rather than by the session, as Stalwart does. Put the old lookup back and nine tests fail. Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a fixed "1.0.0" -- so About continues to report the generation and edition, which are now the right ones. |
||
|
|
e720623895 |
Hold a message in the server's queue until the time you asked for
Scheduled send, which the README listed as needing server support that Stalwart has had all along. The delay cannot be asked for directly -- RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back the time it settled on. Stalwart advertises this in the *account* capability, not the session-level one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE` among its `submissionExtensions`. The composer offers scheduling only when both are there, and never offers a time the server would refuse. A held message goes to a Scheduled folder rather than Sent, because `onSuccessUpdateEmail` would otherwise file it as sent the moment the submission is created, and it has not been sent. Nothing moves it out when the hold expires, so the folder is reconciled on the way in: released messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate `Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as an Email id and not, as the RFC says, a submission id. The mock grows the whole lifecycle, and learns to resolve creation references while it is there -- it had been quietly declining to create any submission at all, since sending names its message as `#m`. Because Stalwart's own `futureRelease` setting defaults to off and then drops the hold in silence, `npm run dev:mock:no-future-release` reproduces that. Verified end to end against the mock; not yet against the live server. |
||
|
|
7095968282 |
Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one Email/set, so every id in the folder arrived in a single call. Stalwart refuses the whole call over maxObjectsInSet with requestTooLarge, which left a folder of 5192 messages impossible to empty at all. Walk the folder a page at a time instead: the filter re-runs each pass, so the next page is whatever is still there. A pass that destroys nothing stops the loop and reports the server's error rather than spinning. The same defect sat in two neighbours. Delete-forever on a large selection sent every id in one Email/set, and mark-all-read fed up to 5000 ids into an Email/get that only echoed them back - past maxObjectsInGet, and for no gain, since the query already returned them. Both now page through the same ceiling, read from the session rather than hardcoded. The mock advertised maxObjectsInSet but never enforced it, so none of this could fail in a test. It now rejects oversized get and set calls the way Stalwart does. While here, restrict emptying to Deleted Items. It was offered on Junk too, where a permanent one-shot clear is harder to justify; Junk is now select-all plus Delete, which takes the batched path. |
||
|
|
0845c93c4c |
Teach the mock to impersonate Stalwart 0.15
MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the
generation before the registry. It is not a cut-down mock: it reproduces the
specific ways that generation differs, and every one of them is a thing the
server does not report as an error.
- urn:stalwart:jmap is not a capability it knows, and naming one it cannot
parse fails the whole request rather than the one call
- x: methods do not exist; credentials live at POST /api/account/auth
- FileNode/query masks out containers, so it returns files and never folders
- FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare
Both modes now also enforce the 2047-byte signature cap, and `using` is
validated in both — the gap that let the Identity capability bug in #12 ship.
This gives the legacy credential adapter its first automated coverage: it was
the least-tested code here, checked only by hand against the live server. The
new tests also pin the mock's own fidelity, so it cannot quietly drift back to
being 0.16-shaped in the places that matter.
|
||
|
|
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. |
||
|
|
0f1fbcff93 |
Manage your own password, app passwords and 2FA
Settings › Security grows three working sections instead of a note telling people to use Stalwart's own portal. Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has the x:AccountPassword singleton and x:AppPassword registry objects over JMAP, while 0.15.x has the /api/account/auth REST endpoint. Which one answers the probe is the only reliable way to tell them apart, and the result is cached per session. The built-in `user` role already grants sysAccountPassword* and sysAppPassword*, so no administrator setup is needed. Two problems are worth calling out, because both would bite a user hard: Stalwart validates the credentials already on the account when 2FA is turned on and never checks the new secret, so an authenticator that was mistyped or out of step would lock someone out of their mailbox at the next sign-in. We verify a code against the new secret ourselves first (RFC 6238, tested against the spec's vectors) and only then ask the server to store anything. Every proxied call re-authenticates with the credential sealed into the session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with it — which we cannot produce between requests. Turning 2FA on would therefore sign the user out of the browser they just turned it on in. App passwords authenticate without a second factor, so the session is moved onto one minted for this browser, and the session cookie is re-sealed with it. The order matters: it is minted while the old credential still works, and revoked again if enabling then fails. Password changes re-seal this session too and drop the others, whose sealed copies of the old password would fail on their next call. The mock now enforces what a real server does — current password, password policy, a TOTP code on every request once 2FA is on, app passwords exempt — so the whole flow is exercised in tests rather than only by hand. |
||
|
|
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 |
||
|
|
de1b33e2aa |
Folder pane: align icons, mark-read incl. subfolders
- Move the expand chevron into a gutter left of the folder icon so folders with and without subfolders line up on their icon; labels share the column. - Add "Mark all as read, incl. subfolders" to the folder context menu, with the affected count and a per-folder fallback for servers without filter operators. - Re-measure the virtualised message list when row height changes. - Mock: seed unread mail in a subfolder. |
||
|
|
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. |