ddd1bbf9b3570951445e6d68668b590075749512
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0b01956535 |
Ask whose computer this is, and believe the answer
Sign-out never cleared local storage. It stopped push, flushed settings and removed the subscription -- that last one reasoned explicitly that a browser left holding someone's mail becomes somebody else's next -- and then left the settings cache and the recently-addressed list on disk. That list is other people's addresses, and nothing ever removed it. Clearing it on sign-out is now unconditional, because lending a laptop is the same exposure as a public machine, only quieter. The keep-list is short and deliberate: lastUser, which only a trusted device writes; the trust flag; and the random push device id. Everything else goes, so a key added later is forgotten by default rather than by nobody having thought about it. "Keep me signed in on this device" defaulted to true, which assumed the answer most costly to get wrong -- someone on a library machine got a thirty-day cookie unless they noticed a ticked box. It now asks whose computer this is, defaults to not yours, and says what each answer does. Untrusted means a session cookie, nothing written locally, no push subscription, and a five minute idle sign-out. The idle timer is there because the alternative does not work: custom beforeunload text was removed from browsers years ago, and no event fires at all for walking away from a signed-in screen, which is the case that matters. A timer needs nobody's cooperation. Reads are gated as well as writes, since a machine trusted once still has the residue; an untrusted sign-in purges it outright. The wire keeps calling this `remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate every session file on upgrade for a change of vocabulary. Verified in a browser against the mock, not only in tests: untrusted sign-in leaves localStorage empty through a full session including folder expansion; trusted writes settings, recent and lastUser as before; sign-out clears recent and settings while keeping lastUser; an untrusted sign-in afterwards clears even that. |
||
|
|
e327df818a |
Reload when the server is running a newer build
Being signed out and picking up a new version are separate things, and only the first was happening. An immutable instance holds sessions in memory, so a deploy signs everyone out -- but a 401 only swaps the view to the sign-in form, client-side. The tab keeps the bundle it already has, and the old JavaScript goes on talking to the new server until someone happens to reload by hand. The pieces for fixing it were already there. index.html is served no-cache and the assets under it are content-hashed and immutable, so a reload is all it takes; Vite bakes the build's own version in as APP_VERSION; and /api/health reports the server's. What was missing was something to compare them. The check runs on a 401 rather than on a timer, which is the moment it matters and costs one small request. It compares versions rather than reloading on every 401, so an ordinary session expiry still lands on the sign-in form with the page intact. And it runs before the sign-in form is shown rather than after, because reloading a form someone has already started typing into would throw the password away. Failing to reach the server is not a reason to throw away what is on screen, so anything other than a clear answer leaves the page alone. The version that was reloaded for is remembered for the session, so a server that keeps reporting a version the bundle does not match -- a stale proxy cache, a half-finished deploy -- cannot put the tab in a reload loop. |
||
|
|
e014521fb6 |
Keep your own settings out of someone else's account
Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.
ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.
There are two questions, and they had one answer:
- what am I looking at -- follows the switcher, because switching to a
shared account is how you read what was shared
- what is mine -- never does
So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.
A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.
What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
732fdac78b |
Close the colour menu on a pick, and make the push dot readable
Two cosmetics. Picking a folder colour left the menu open, which every other action in it does not. It closes now, the way the calendar's colour menu already did. The live-updates indicator was an 8px flat speck in --fg-faint, near invisible in either theme, and it had two states where the code has three. The push client only ever said connected or not, which cannot tell "retrying with a backoff" from "stopped": it now reports connecting, connected or disconnected, and the retry path says connecting rather than going dark. pushConnected stays for the callers that only want the boolean. The dot is 12px and raised -- a white highlight over a solid colour with a soft halo, so one bead reads on light and dark alike without a per-theme variant. Green connected, amber reconnecting with a slow pulse, red disconnected. The pulse respects prefers-reduced-motion, and the indicator is labelled for a screen reader rather than hidden from it, since it carries real information. |
||
|
|
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. |