Browsers subscribed to Email changes, so every read or move on any client arrived as a push the worker could only show as "New mail". They now subscribe to EmailDelivery, which changes only on delivery; Stalwart sends a delivery to a subscription with an emailPush filter as an EmailPush alone. The payload now names id and threadId, which Stalwart sends only when asked, so notifications carry their actions and open the message. The worker stays quiet while a focused window is open, and the page leaves notifications to the worker where push is on. Every renewal registered a new subscription, on the belief that a repeated deviceClientId replaces the old one. Stalwart keeps both and allows fifteen per account, which filled up. A browser now extends its subscription, clears its own duplicates, replaces them only when its endpoint changed, and on overQuota makes room among other browsers' subscriptions. The server names its subscriptions by installation and removes what its previous process registered, and extends rather than re-creates. Checked live on 0.16.22; the mock now keeps duplicates, enforces the limit and accepts an expiry update. Fixes #375.
55 KiB
Known issues and pending QA
What was checked, against which server, and when. For a failure you are hitting right now, start with Troubleshooting; for what is not built yet, see ROADMAP.md.
The live instance runs 0.16.22, and as of 2026-08-26 there is nothing left pending. Most entries below were exercised against 0.16.19 on the date they name, and the dates still say so: each upgrade since was read against the diff rather than re-run, and nothing in those diffs touches the session capabilities, blob, quota, submission or registry paths these entries describe. The calendar entries carrying a 2026-08-31 date were exercised against a live 0.16.20 directly, as were the public-key entries dated 2026-09-05.
0.16.21 was different and was re-run rather than read. It changed four
things a client can see, one of which resolved an entry below outright: an
occurrence of a recurring event is identified by its recurrence id rather than
its position in the series, so an id held across a write no longer names a
different date; Calendar/get and AddressBook/get return every property when
none are named; EventSource advertises its ping interval in seconds rather than
milliseconds; and a calendar write that asks for scheduling messages is refused
when the account may not send them. The mock reproduces all four. The app
was run against a real 0.16.21 with mail, calendar and contacts exercised by
hand, including editing one occurrence of a recurring series through the
interface and confirming the rest of the series stayed where it was.
0.16.22 (2026-09-13) was tested too. The app has been tested against it on
the live instance. Its changes a client can see are all in CalendarEvent/get
and ContactCard/get, and were read from its source before the mock was made
to follow them: baseEventId is null for an event read by its stored id,
recurrenceRule and recurrenceOverrides asked for on a synthetic id come back
null, useDefaultAlerts belongs to the reader and reads false until set,
and an empty properties list returns id alone. None of them contradicts an
entry below.
What remains here is not a list of unknowns but of things worth knowing — where Stalwart departs from a spec, where a setting has to be turned on for a feature to work, and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been wrong before: the 0.16 registry path was once recorded as verified live when a capability looked for in the wrong place meant it had never run at all.
Some entries record what a live 0.15.5 proved before that server was
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
stalwart-0.15-support.
-
Push subscriptions are not replaced by a repeated
deviceClientId, and an account holds fifteen. ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. Confirmed live (0.16.22, 2026-09-16): a second create with the samedeviceClientIdleaves both in place, the sixteenth create is refused withoverQuota, "There are too many subscriptions, please delete some before adding a new one.", andupdateofexpiresis accepted.PushSubscription/getdoes not returnurl(norkeys), so a subscription can only be matched by itsdeviceClientId. Atypesof[]ornullis stored as every type, not none. Read from the 0.16.22 source:EmailDeliverychanges only on delivery, a delivery reaches a subscription with anemailPushfilter as an EmailPush alone, and the payload carriesidandthreadIdonly when they are named inproperties. Browsers now subscribe toEmailDeliveryonly, extend rather than re-create, clear their own duplicates and make room onoverQuota; the server removes what its previous process registered. The mock follows all of it (#375). -
Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong. Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load:
x:Account/queryansweredunsupportedFilter - type. A registry filter is keyed by the property's name as it appears on the object, and the discriminator is@type, so{"type": "User"}names nothing the server knows and fails the whole query;{"@type": "User"}is accepted. The research that fed the build had listed the field astype, and the mock took it without complaint — which is how it shipped. Fixed in #336, and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was confirmed live (2026-09-13), mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:- Permissions come from
GET /api/accountin camelCase (sysAccountGet); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these. - The Basic credential ihasmail proxies with reaches the admin
x:methods, as it already reached the self-service ones. No separate token is involved. - An account reads back in the shapes the code expects:
credentialsas{"0": {"@type": "Password", …}}, aliases and group memberships as objects, the disk limit underquotas.maxDiskQuota. - A new domain gets automatic DKIM straight away — an Ed25519 and an RSA key, both
active, with their records already in the zone file — and manual DNS and certificates. dnsZoneFileis BIND text, one record per line asname IN TYPE value, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other.x:DkimSignature/queryaccepts adomainIdfilter.catchAllAddresswants a whole address. A bare local part is refused withinvalidPatch, "Invalid email address".- A domain its keys still name cannot be destroyed:
objectIsLinked, withlinkedObjectslisting each as{"object": "DkimSignature", "id": …}and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards. - A reserved TLD is refused:
exampleas a domain's top level comes backinvalidPatch, "Invalid domain name", namingname.
The last two were then tried by hand on the live server the same day and behaved as described. A password set by an administrator — written to the account's existing credential,
credentials/<index>/secret— signs in. The outranking guard held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason. - Permissions come from
-
The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session). The first probes guessed two of these wrong — filtering on
timestamp, and counting received mail frommessage-ingest.*— and the server and the 0.16.22 source agreed on the answers below:- The metric history filters on comparison names.
x:Metric/queryaccepts{"timestampIsGreaterThanOrEqual": …, "metric": [names]}; a baretimestamp,afterormetricas a string isunsupportedFilter. Sorting ontimestampworks. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500. - Received and sent are
queue.*counters, notmessage-ingest.*:queue.message-queuedfor received, andqueue.authenticated-message-queued+queue.dsn-queued+queue.report-queuedfor sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the*-timehistograms are cumulative, which is why nothing reads them. - Memory is the
server.memoryGauge, in bytes, one per interval. Counts come from/querywithcalculateTotal: trueandlimit: 0, which returned the whole total forx:Account(users only, via@type),x:Domainandx:QueuedMessage. x:Metrics/getis not the history. It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history isx:Metric.
Not confirmed live: that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses
x:Metricasforbidden. Both are read from the 0.16.22 source (query.rs,queued_message.rs,registry/mod.rs); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives. - The metric history filters on comparison names.
-
Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15) with a throwaway group on one of the server's domains, created and removed, its only member the administrator's own account:
- A group is created as
x:Accountwith@type: "Group", no credentials and no encryption setting, and reads back with roles{"@type": "Default"},permissionsInherit, alocaleofen-USandusedDiskQuota0. - Membership is the member's.
"memberGroupIds/<group>": trueon the user was accepted;{"@type": "User", "memberGroupIds": <group>}then found them with a total of 1, and the user's ownmemberGroupIdsread{"<group>": true}. The same pointer withnulltook them out again and left the set as it was before. - A group with members cannot be deleted:
objectIsLinked, withobjectIdas{"object": "Account", "id": <group>}andlinkedObjectslisting each member as{"object": "Account", "id": …}. With the member out, the delete went through and the group read back asnotFound.
The same run tried a throwaway mailing list before the Mailing lists section was written. It was created with
recipientsas a set,{"[email protected]": true}, and read back asname,domainId,description,aliases,memberTenantId,recipientsand a computedemailAddress."recipients/<address>": trueadded one and left the other; atextfilter found it; it was destroyed with nothing linked. Not tried: removing a recipient withnull(the same set patch as a group membership, which was), and how the server words a recipient that is not an address.Still from source only: that membership gives a member no permissions (
access_token.rsbuilds a user's permissions from their own roles), and that groups cannot nest. - A group is created as
-
Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15) with throwaway
ihasmail-role-testroles, created and removed:- A role is created with
description,roleIds,enabledPermissionsanddisabledPermissionsas sets, and reads back with them andmemberTenantId. - Pointers change one entry each:
enabledPermissions/<p>anddisabledPermissions/<p>withtrueornull,roleIds/<id>likewise, anddescriptionin the same update, all applied together. - A name that is not a permission fails the whole update as
invalidPatch, "Invalid value for object property", naming the pointer — which is how a probe using the mock's made-upjmapEmailSetfound that the mock had carried a permission Stalwart does not have since Accounts was built; it isjmapEmailUpdatenow, and the mock refuses unknown names. - A grant the caller does not hold is refused:
forbidden, "You are not authorized to grant permissions: scimAccess". - A role another role builds on cannot be deleted:
objectIsLinked,objectId{"object": "Role", …},linkedObjectsnaming the child. - The defaults read from
x:Authentication: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
The picker is stricter than the server for a few permissions.
GET /api/accountnever lists some permissions an administrator holds —sysLogCreateamong them, which was granted without complaint — so their Allow is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (permissions.rsunions enabled and disabled across the tree, then subtracts).GET /api/schemathrough ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364):/api/admin/permissionsanswered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed. - A role is created with
-
Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15) with throwaway
ihasmail-tenant-testtenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice:- A tenant is created and edited as built:
name,logo,roles,permissions,quotas;quotas/<name>pointers, a logo and a rename in one update; an unknown quota name isinvalidPatch. - Something in a tenant has to be on a domain in that tenant. A list in the tenant on a domain in none was refused,
invalidForeignKeywithobjectId{"object": "Domain", …}; the same list on a domain created in the tenant was accepted — and so was a list in no tenant on that domain. So an account's tenant choice offers only its domain's tenant, and a new account starts in the tenant of the domain it is made on. - A domain created in a tenant puts its DKIM keys in the tenant too, and they stay there. They count against
maxDkimKeysand keep the tenant from being deleted, so they are counted with everything else. - Stalwart lets a domain leave a tenant while the tenant still has things on it, leaving them in a tenant on a domain outside it. The panel refuses to take a domain out while any of the tenant's accounts are on it. Mailing lists cannot be filtered by domain, so a list is not checked.
- A tenant still holding anything is kept:
objectIsLinked,objectId{"object": "Tenant", …},linkedObjectsnaming a role, a list and DKIM keys. A role set tomemberTenantId: nullleft it, after which the tenant was deleted.
Still from source only: that only a caller outside every tenant may set
memberTenantId(set.rspassescan_set_tenantonly when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice. - A tenant is created and edited as built:
-
The permission labels in eight languages are machine translations awaiting native review. 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: principal (JMAP/DAV), throttles, listeners, lookups, milters, masked emails, samples (spam training), schedules (MTA delivery), email submission, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
-
A refused password shows the server's reason in English. Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
-
Administration is off for a device not marked as your own, and for an installation that says so. Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a
"x:in the text, or a\uescape that could spell one — so ordinary mail traffic is forwarded untouched. -
All nine translations have never been read by anybody who speaks them. They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked Beta in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What is verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by
npm run i18n:checkrather than sitting in the file looking correct and never being looked up. Plurals are asked ofIntl.PluralRulesrather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplyingonefor Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as 6289 листувань — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. -
npm run i18n:coveragereported 100% while about two hundred strings rendered English in every language. It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They weretoast.error(...)arguments,confirmDialog({ title, confirmLabel })props,title=andaria-label=attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labelsv[0].toUpperCase() + v.slice(1)— correct English, untranslatable anywhere else, and galling because Day, Week, Month and Agenda were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, andnpm run i18n:checkgrew a second half (scripts/i18n-literals.mjs) that accepts a string wrapped where it is written or present as a catalog key — the constant-table convention, whereSECTIONSholdslabel: "About"and the render site callst(s.label)— and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. The check had the same blind spot one level down (2026-09-14). It looked attitle=,aria-label=,placeholder=andalt=on elements, but not at props passed to components, so<MenuItem label={x ? "Collapse all" : "Expand all"}>passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props throught(), so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with--check, whichnpm run i18n:checknever passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with--check. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such asaria-label={`Remove ${email}`}, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (confirmed) in every language. -
A compressing hop in front of Stalwart truncated every blob download, and nothing said so. Node decompresses a gzip response before the code ever sees the body, but leaves the
content-lengthheader describing the compressed bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on #76 against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the second cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and thesettings.jsonsync. Settings degraded honestly by luck rather than design — a truncated file failsJSON.parse, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream foridentityand, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it usesnode:httpdirectly, sends noaccept-encoding, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every# rule:comment parses, every enabled rule has anifand a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers. -
Delete all spam destroys, and does not pass through Deleted Items — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing.
Email/set destroy, walked a page at a time so it survivesmaxObjectsInSetthe way emptying Deleted Items already had to. Confirmed live on 0.16.19 (2026-08-26): Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus. -
Sharing a mail folder is accepted and does nothing.
Mailbox/setwith ashareWithmap is applied,Mailbox/getreads it back, and the folder never appears for the account it was shared with — confirmed live on 0.16.19 (2026-08-27) with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is already shared still offers Stop sharing, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end. -
Address book sharing works, and was briefly withdrawn by mistake. It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
-
Stalwart lets a sharee subscribe to a shared calendar but not a shared address book. Subscribing is a write to the owner's account --
isSubscribedlives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only:AddressBook/setanswers successfully with the id innotUpdated,forbidden, "You are not allowed to modify this address book." The identicalCalendar/seton a shared calendar is accepted. Confirmed live on 0.16.19 (2026-08-27) from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (addedShares) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a successful response, so the code that ignorednotUpdatedsaw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped. -
shareWithis not returned unless a client asks for it by name. ACalendar/getorAddressBook/getwith nopropertiescomes back without the field at all — not null, not empty, absent — confirmed live on 0.16.19 (2026-08-27) against a calendar and an address book that were genuinely shared with another account: omit the list and there is noshareWith; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on "not shared with anyone yet" over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, becausefileNodePropshad always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a Stop sharing entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. 0.16.21 fixed this for calendars and address books: withpropertiesomitted,Calendar/getandAddressBook/getnow return every property,shareWithincluded — confirmed live on 0.16.21 (2026-09-06).Mailbox/geton the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere. -
Stalwart's
x:PublicKeyregistry works, and ihasmail deliberately does not expose it. A Settings section for it has been built twice — PR #67, closed 2026-08-26, and PR #285 — and withdrawn both times, for a reason that has nothing to do with the server: nothing in ihasmail signs, encrypts, decrypts or verifies with a key, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in ROADMAP.md because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was confirmed live on 0.16.20 (2026-09-05) from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.- An ordinary user may read and write their own keys, whatever the permissions table says: Stalwart documents every
sysPublicKey*permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused withinvalidPropertiesnamingkeyrather thanforbidden— a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first. - It takes S/MIME certificates as well as OpenPGP keys, and parses both. A self-signed X.509 certificate carrying
emailProtectionand anemail:SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: "Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…". Worth checking rather than assuming, because every other message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not. - A key can parse perfectly and still be refused, and says something different when it is. A sign-and-certify OpenPGP key with no encryption subkey — which is what
gpg --quick-generate-keyproduces — comes back "Could not find any suitable keys in OpenPGP public key", distinct from the parser's "Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…". Any client showing these must keep them apart: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. Certificates have no equivalent trap, since one issued for email use has key encipherment by construction. emailAddressescomes back as{}when empty — an object, where a JMAP list property should be an array. Nothing fails loudly: it is a plainGetresponse that type-checks against a hand-written interface and then throws injoin()while a list renders. A client must check the shape rather than trust the type.- A create answers with the id alone, no
createdAt, so anything that reads the date back out of the create response getsundefined. Patchingkeyon an existing entry is allowed, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keepscreatedAtmeaning what it says. expiresAtis the registry's own field and is not derived from the key. A certificate valid for a year registers withexpiresAt: null. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
- An ordinary user may read and write their own keys, whatever the permissions table says: Stalwart documents every
-
Signature checking is done here, and its trust model is deliberately small. Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does not do, because the gap is a design choice rather than an omission. No chain of trust is validated — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real
openssl smime -signoutput rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be. -
OpenPGP signatures cannot be checked at all, for a reason that is not effort. A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it:
x:PublicKeyholds the account's own keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says could not check rather than did not check out, which is a distinction worth keeping: one is ignorance and the other is an accusation. -
Two signature shapes are declined rather than attempted. SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as bad, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
-
Read receipts are built here, not by the server — JMAP has an extension for them, RFC 9007's
MDN/send, and Stalwart does not implement it:urn:ietf:params:jmap:mdnis not among its capabilities. So ihasmail assembles themultipart/reportitself and sends it the long way round — raw MIME uploaded as a blob,Email/import, thenEmailSubmission— which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than8bit, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit,$mdnsent), and confirmed live on 0.16.19 (2026-08-26): a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set$mdnsentso a second look does not offer to send another. -
Where 0.16 advertises
urn:stalwart:jmap— not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-levelcapabilitiesfrom a fixed list (Session::new, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears inprimaryAccountsand in each account'saccountCapabilities. ihasmail tested for it incapabilitiesalone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back toPOST /api/account/auth, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong into. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making. -
HTML signatures — Stalwart caps a signature at 2047 bytes (
value.len() < 2048on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline. -
Settings live in the account's Files, not the browser — every preference used to sit in
localStorage, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize (#54). They are now asettings.jsonin theihasmailfolder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store.x:AccountSettingswas the other candidate and does not fit; its schema islocale/timeZone/descriptionwith no free-form field, and writing it needssysAccountSettingsSet, where the built-in user role carries only the…Gethalf.localStoragestays on as a cache rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe this screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, sinceupdate()fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. Theihasmailfolder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. Confirmed live on 0.16.19 (2026-08-26): settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere —FileNode/querycannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again. -
Files on 0.16 — the pre-0.16 quirks this entry used to describe are gone with the support for them:
FileNode/querymasking directories out of its own results,nodeTypenot existing, and rights being a singlemayWrite. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node withnodeType, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are confirmed live on 0.16.19 (2026-08-26) as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about:ensureFolderandfindInFoldernow filter onparentId/isTopLevelalone and match names client-side, sincenameis not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice. -
Self-service credentials — the registry path is confirmed live against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
-
Scheduled send needs one setting turned on, and says nothing when it is off. Stalwart advertises the delay in the account's
urn:ietf:params:jmap:submissioncapability —maxDelayedSend: 2592000(30 days) andFUTURERELEASEamong itssubmissionExtensions, and note it is the account capability, not the session-level one, which is empty. But the MTA only honors a hold whenfutureReleaseis set under the session's MTA extensions, and that setting defaults tofalse. With it off, Stalwart takes theHOLDUNTILparameter, skips the hold and sends the message immediately without an error — the capability still says thirty days. So setfutureRelease(to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with aforbiddenMailFromnaming the limit.npm run dev:mock:no-future-releasereproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — aHOLDUNTILparameter on the envelope'smailFrom, since RFC 8621 makessendAtread-only and server-derived — and files the held message in a Scheduled folder, becauseonSuccessUpdateEmailwould otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in 0.16.17, below the live instance's 0.16.19:HOLDUNTILtaking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps),EmailSubmission/queryonundoStatusagreeing with/getabout held submissions, andEmailSubmission/getwithoutidsiterating the right index. The hold itself is now confirmed against the live 0.16.19 (2026-08-25), oncefutureReleasewas set to30dthere: a submission carrying aHOLDUNTILten minutes out came backpending, withsendAtequal to the time asked for and a250 2.1.5 Queuedfrom the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertisedmaxDelayedSend: 2592000andFUTURERELEASEwhile the setting was still off. Only a submission tells you. The rest of the journey is confirmed live too (2026-08-26): a hold expired and was delivered, and the Scheduled folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied. -
Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time. A participant's address lives in
calendarAddress, not RFC 8984'ssendTo/email; the organizer isorganizerCalendarAddress, notreplyTo; and a recurrence is a singlerecurrenceRule, not arecurrenceRulesarray. Addressed the RFC's way,CalendarEvent/setkeeps the event and discards the whole participant map without an error — guests disappeared on save and no invitation was ever sent, which is what #26 reported. The array form of the rule is refused honestly, withinvalidProperties, so recurring events could not be created at all and existing ones showed no repeat (#30). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (needs-action→declined, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again withnull, both work on the update path, as does RSVP — which patchesparticipants/{key}/participationStatus(andparticipationComment) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19CalendarEvent/setrefused a synthetic id with "Updating synthetic ids is not yet supported", which is why RSVP resolvesbaseEventIdfirst. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a new participant by patch is refused as well (Patch operation failed), so a changed guest list is written as the wholeparticipantsproperty. One more thing to know when reading this code: an expanded occurrence carries arecurrenceIdbut no rule of its own, andbaseEventIdis set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its stored id answersbaseEventId: nullrather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base. -
Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all. These are the two halves of the same finding, and the second is what makes the first safe. Confirmed live on 0.16.20 (2026-09-01) against the deployed instance:
Principal/getAvailabilitywas called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — noforbidden, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And aPrincipalcarries onlyid,type,name,descriptionandemail— noaccountId— so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. One thing this did not settle: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free. -
An override can move an occurrence, and then
startandrecurrenceIdmean two different times. The slot stays where the rule put it and only the clock time moves. Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00 series moved to 14:00 came backstart: 2027-06-14T14:00:00withrecurrenceIdstill2027-06-14T09:00:00. This is the right behavior and it is the reasonrecurrenceIdis the handle ihasmail holds: it is the one name for an instance that survives both a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override'sstartwith the slot time, so a moved occurrence did not move, and per-occurrence time editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces. -
A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21. Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a
recurrenceOverridesentry renumbered them. Confirmed live on 0.16.20 (2026-08-31): a five-week series came back ase i m q uover 03-01 … 03-29; one override written to 03-08 left the same five ids addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change —isimply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never anotFounda client would notice; it was a confident answer about the wrong day. 0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06): the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and nonenotFound. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, andupdateEventanddestroyEventstill re-resolve byrecurrenceIdimmediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record. -
A per-occurrence patch made only of inherited properties creates an override that loses the title. The twelve properties 0.16.20 drops from a per-occurrence patch are dropped after it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the
startanddurationthe server fills in and nothing else. Confirmed live on 0.16.20 (2026-08-31):{"privacy": "private"}aimed at one occurrence answeredupdated, leftprivacyuntouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle. -
Recurring events can be edited and deleted one date at a time, since 0.16.20. A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a
recurrenceOverridesentry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. Confirmed live on 0.16.20 (2026-08-31) end to end against a five-week series: a legal patch landed on the override withstartanddurationfilled in by the server;useDefaultAlertswas refused with "This property cannot be modified on a single occurrence."; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with "A base event and its instances cannot be modified in the same request." The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the series' start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up. -
Editable date boxes are always Gregorian and in Latin digits, even for locales whose display uses another calendar or numbering system (
fa-IR,th-TH,ar-EG) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. -
The account locale is read from
x:AccountSettings/get, whose permission the built-in user role has, falling back tox:Account/get(which needs the admin-onlysysAccountGet). Both are Stalwart 0.16 methods: on older servers neither is reachable — they do not implement the registry and reject a request that so much as names theurn:stalwart:jmapcapability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.