Settings all lived in localStorage, so none of them followed the user 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.
Where they go now
A settings.json in the account's own JMAP Files, in the ihasmail folder that already holds signature images. That keeps ihasmail itself stateless — no volume, no SQLite, no Valkey, nothing to back up separately — and the settings ride along with whatever backs up the mail store.
x:AccountSettings was the other candidate and does not fit: its schema is locale/timeZone/description, with no free-form field, and writing needs sysAccountSettingsSet where the built-in user role only carries the …Get half.
What syncs, and what deliberately does not
Everything follows the account except things that describe this screen or this browser, where syncing would be wrong rather than merely unhelpful:
A pane width picked on a 27" monitor is wrong on a laptop, and the notification toggles track a permission the browser grants per-device — syncing them would claim something untrue elsewhere. The mailbox-expansion state and the mailto: handler flag stay local for the same reason.
The split is written as a list of exceptions, so a setting added later syncs by default. There is a test that holds that line.
The three details that matter
Write volume.update() fires on every frame of a splitter drag. Changes are coalesced behind a 3s debounce, newest value wins, and a device-only change never puts a request in the air at all. A tab going away (pagehide/visibilitychange) flushes first, and so does signing out — while the session can still write.
Boot flash.localStorage is demoted to a cache rather than removed: the first frame paints from it and the file corrects it a moment later, so startup is not gated on a round trip. A private window has no cache and shows defaults for that one frame — the trade for not blocking the app, and it fixes the throwaway-window case mbunkus raised, just one beat in.
Hidden folder. The ihasmail folder is now hidden from Files, contents and all. Hiding the folder alone would be worse than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would spill into the top level looking like the user's own files. Those images have been visible since signatures shipped; this hides them too.
Scope
Requires Stalwart 0.16 — FileNode/query cannot see directories before that. On 0.15 settings stay local exactly as they were, so this is not a regression there.
Two things are deliberately left out: conflict resolution is last-write-wins, and a change made on one device does not live-refresh another that already has the app open (it lands on next sign-in). Both are worth doing only if they turn out to bite.
Testing
17 new tests (241 web + 88 server, all passing), typecheck clean.
Round-tripped against the mock end to end: folder create, blob upload, node create, read back, update, re-read. The create response carries no blobId, which is why the write re-reads the node — worth knowing if the live server differs.
Closes #54.
Settings all lived in `localStorage`, so none of them followed the user 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.
## Where they go now
A `settings.json` in the account's own JMAP Files, in the `ihasmail` folder that already holds signature images. That keeps ihasmail itself stateless — no volume, no SQLite, no Valkey, nothing to back up separately — and the settings ride along with whatever backs up the mail store.
`x:AccountSettings` was the other candidate and does not fit: its schema is `locale`/`timeZone`/`description`, with no free-form field, and writing needs `sysAccountSettingsSet` where the built-in user role only carries the `…Get` half.
## What syncs, and what deliberately does not
Everything follows the account **except** things that describe this screen or this browser, where syncing would be wrong rather than merely unhelpful:
`listPaneWidth`, `listPaneHeight`, `density`, `fontSize`, `sidebarCollapsed`, `desktopNotifications`, `notificationSound`
A pane width picked on a 27" monitor is wrong on a laptop, and the notification toggles track a permission the browser grants per-device — syncing them would claim something untrue elsewhere. The mailbox-expansion state and the `mailto:` handler flag stay local for the same reason.
The split is written as a list of exceptions, so a setting added later syncs by default. There is a test that holds that line.
## The three details that matter
- **Write volume.** `update()` fires on every frame of a splitter drag. Changes are coalesced behind a 3s debounce, newest value wins, and a device-only change never puts a request in the air at all. A tab going away (`pagehide`/`visibilitychange`) flushes first, and so does signing out — while the session can still write.
- **Boot flash.** `localStorage` is demoted to a cache rather than removed: the first frame paints from it and the file corrects it a moment later, so startup is not gated on a round trip. A private window has no cache and shows defaults for that one frame — the trade for not blocking the app, and it fixes the throwaway-window case mbunkus raised, just one beat in.
- **Hidden folder.** The `ihasmail` folder is now hidden from Files, contents and all. Hiding the folder alone would be *worse* than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would spill into the top level looking like the user's own files. Those images have been visible since signatures shipped; this hides them too.
## Scope
Requires Stalwart **0.16** — `FileNode/query` cannot see directories before that. On 0.15 settings stay local exactly as they were, so this is not a regression there.
Two things are deliberately left out: conflict resolution is last-write-wins, and a change made on one device does not live-refresh another that already has the app open (it lands on next sign-in). Both are worth doing only if they turn out to bite.
## Testing
17 new tests (241 web + 88 server, all passing), typecheck clean.
Round-tripped against the mock end to end: folder create, blob upload, node create, read back, update, re-read. The create response carries no `blobId`, which is why the write re-reads the node — worth knowing if the live server differs.
**Not yet exercised against the live 0.16.19.**
**Merged** 2026-08-26 as coffey-labs/ihasmail@fa10563ed047
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #54.
Settings all lived in
localStorage, so none of them followed the user 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.Where they go now
A
settings.jsonin the account's own JMAP Files, in theihasmailfolder that already holds signature images. That keeps ihasmail itself stateless — no volume, no SQLite, no Valkey, nothing to back up separately — and the settings ride along with whatever backs up the mail store.x:AccountSettingswas the other candidate and does not fit: its schema islocale/timeZone/description, with no free-form field, and writing needssysAccountSettingsSetwhere the built-in user role only carries the…Gethalf.What syncs, and what deliberately does not
Everything follows the account except things that describe this screen or this browser, where syncing would be wrong rather than merely unhelpful:
listPaneWidth,listPaneHeight,density,fontSize,sidebarCollapsed,desktopNotifications,notificationSoundA pane width picked on a 27" monitor is wrong on a laptop, and the notification toggles track a permission the browser grants per-device — syncing them would claim something untrue elsewhere. The mailbox-expansion state and the
mailto:handler flag stay local for the same reason.The split is written as a list of exceptions, so a setting added later syncs by default. There is a test that holds that line.
The three details that matter
update()fires on every frame of a splitter drag. Changes are coalesced behind a 3s debounce, newest value wins, and a device-only change never puts a request in the air at all. A tab going away (pagehide/visibilitychange) flushes first, and so does signing out — while the session can still write.localStorageis demoted to a cache rather than removed: the first frame paints from it and the file corrects it a moment later, so startup is not gated on a round trip. A private window has no cache and shows defaults for that one frame — the trade for not blocking the app, and it fixes the throwaway-window case mbunkus raised, just one beat in.ihasmailfolder is now hidden from Files, contents and all. Hiding the folder alone would be worse than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would spill into the top level looking like the user's own files. Those images have been visible since signatures shipped; this hides them too.Scope
Requires Stalwart 0.16 —
FileNode/querycannot see directories before that. On 0.15 settings stay local exactly as they were, so this is not a regression there.Two things are deliberately left out: conflict resolution is last-write-wins, and a change made on one device does not live-refresh another that already has the app open (it lands on next sign-in). Both are worth doing only if they turn out to bite.
Testing
17 new tests (241 web + 88 server, all passing), typecheck clean.
Round-tripped against the mock end to end: folder create, blob upload, node create, read back, update, re-read. The create response carries no
blobId, which is why the write re-reads the node — worth knowing if the live server differs.Not yet exercised against the live 0.16.19.
Merged 2026-08-26 as coffey-labs/ihasmail@fa10563ed0
Rebuilt from: git history, session transcript.