Files
ihasmail-inbuxa/KNOWN-ISSUES.md
T
jcoffey-dev c26ca90e01 Document the nine languages, and what Beta means on them
The translations shipped today and the docs still said "no languages but
English". They also need to say the harder thing, which is that eight of
the nine have never been read by anybody who speaks them.

- README gains the language list, with the Beta caveat in the same line
  rather than a footnote.
- FEATURES.md gains an Interface language section: the list, why it is a
  separate setting from the date locale, and the two design properties
  that follow — a missing entry renders English, and plurals are asked of
  Intl.PluralRules rather than assumed, which is why Russian carries
  three forms and Japanese one.
- ROADMAP.md no longer lists translations as "not yet". What replaces it
  is the half that is genuinely not done: a translation anybody has
  checked. RTL is split out as its own entry, because holding Arabic,
  Hebrew and Persian back is a layout decision and not a queue position.
- KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the
  one thing on that page that cannot be closed by testing. And the
  coverage number that read 100% while two hundred strings rendered
  English in every language — recorded as a general lesson rather than an
  i18n one, since a coverage number measures what it can see and the rest
  is exactly what nobody is checking.
2026-08-31 15:18:01 -07:00

30 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.20, upgraded from 0.16.19 on 2026-08-31 with eight seconds of downtime, and as of 2026-08-26 there is nothing left pending. Every entry below was exercised against 0.16.19 on the date it names, and the dates still say so: the upgrade was read against the 0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session capabilities, blob, quota, submission or registry paths these entries describe. The calendar entries below carrying a 2026-08-31 date are the exception: those were exercised against the live 0.16.20 directly. 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.

  • Eight of the nine interface languages 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 — and every one 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:check rather than sitting in the file looking correct and never being looked up. Plurals are asked of Intl.PluralRules rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying one for 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 localise 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:coverage reported 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 were toast.error(...) arguments, confirmDialog({ title, confirmLabel }) props, title= and aria-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 labels v[0].toUpperCase() + v.slice(1) — correct English, untranslatable anywhere else, and galling because Day, Week, Month and Agenda were already in all nine catalogues 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, and npm run i18n:check grew a second half (scripts/i18n-literals.mjs) that accepts a string wrapped where it is written or present as a catalogue key — the constant-table convention, where SECTIONS holds label: "About" and the render site calls t(s.label) — and refuses one that is neither, because that is a string no catalogue 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.

  • 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-length header 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 the settings.json sync. Settings degraded honestly by luck rather than design — a truncated file fails JSON.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 for identity and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses node:http directly, sends no accept-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 an if and 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 survives maxObjectsInSet the 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/set with a shareWith map is applied, Mailbox/get reads 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 -- isSubscribed lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: AddressBook/set answers successfully with the id in notUpdated, forbidden, "You are not allowed to modify this address book." The identical Calendar/set on 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 ignored notUpdated saw 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.

  • shareWith is not returned unless a client asks for it by name. A Calendar/get or AddressBook/get with no properties comes 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 no shareWith; 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, because fileNodeProps had 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 now omits 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.

  • 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:mdn is not among its capabilities. So ihasmail assembles the multipart/report itself and sends it the long way round — raw MIME uploaded as a blob, Email/import, then EmailSubmission — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than 8bit, 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 $mdnsent so 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-level capabilities from 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 in primaryAccounts and in each account's accountCapabilities. ihasmail tested for it in capabilities alone, 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 to POST /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() < 2048 on 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 recognise (#54). They are now a settings.json in the ihasmail folder 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:AccountSettings was the other candidate and does not fit; its schema is locale/timeZone/description with no free-form field, and writing it needs sysAccountSettingsSet, where the built-in user role carries only the …Get half. localStorage stays 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, since update() fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The ihasmail folder 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/query cannot 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/query masking directories out of its own results, nodeType not existing, and rights being a single mayWrite. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with nodeType, 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: ensureFolder and findInFolder 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; 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:submission capability — maxDelayedSend: 2592000 (30 days) and FUTURERELEASE among its submissionExtensions, and note it is the account capability, not the session-level one, which is empty. But the MTA only honours a hold when futureRelease is set under the session's MTA extensions, and that setting defaults to false. With it off, Stalwart takes the HOLDUNTIL parameter, skips the hold and sends the message immediately without an error — the capability still says thirty days. So set futureRelease (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 a forbiddenMailFrom naming the limit. npm run dev:mock:no-future-release reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a HOLDUNTIL parameter on the envelope's mailFrom, since RFC 8621 makes sendAt read-only and server-derived — and files the held message in a Scheduled folder, because onSuccessUpdateEmail would 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, cancelled ones back to Drafts. Three fixes this depends on landed in 0.16.17, below the live instance's 0.16.19: HOLDUNTIL taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), EmailSubmission/query on undoStatus agreeing with /get about held submissions, and EmailSubmission/get without ids iterating the right index. The hold itself is now confirmed against the live 0.16.19 (2026-08-25), once futureRelease was set to 30d there: a submission carrying a HOLDUNTIL ten minutes out came back pending, with sendAt equal to the time asked for and a 250 2.1.5 Queued from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised maxDelayedSend: 2592000 and FUTURERELEASE while 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 cancelled 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's sendTo/email; the organizer is organizerCalendarAddress, not replyTo; and a recurrence is a single recurrenceRule, not a recurrenceRules array. Addressed the RFC's way, CalendarEvent/set keeps 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, with invalidProperties, 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-actiondeclined, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with null, both work on the update path, as does RSVP — which patches participants/{key}/participationStatus (and participationComment) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 CalendarEvent/set refused a synthetic id with "Updating synthetic ids is not yet supported", which is why RSVP resolves baseEventId first. 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 whole participants property. One more thing to know when reading this code: an expanded occurrence carries a recurrenceId but no rule of its own, and baseEventId is 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.

  • An override can move an occurrence, and then start and recurrenceId mean 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 back start: 2027-06-14T14:00:00 with recurrenceId still 2027-06-14T09:00:00. This is the right behaviour and it is the reason recurrenceId is 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's start with 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 is only true until the next write, and a stale one is wrong rather than invalid. Stalwart's expanded-occurrence ids encode a position in the series, and writing a recurrenceOverrides entry adds a component that renumbers it. Confirmed live on 0.16.20 (2026-08-31): a five-week series came back as e i m q u over 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 — i simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a notFound a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. recurrenceId is the stable name for a slot in a series — it is the date — so updateEvent and destroyEvent look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.

  • 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 start and duration the server fills in and nothing else. Confirmed live on 0.16.20 (2026-08-31): {"privacy": "private"} aimed at one occurrence answered updated, left privacy untouched 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 recurrenceOverrides entry 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 with start and duration filled in by the server; useDefaultAlerts was 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 to x:Account/get (which needs the admin-only sysAccountGet). 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 the urn:stalwart:jmap capability — 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.