Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add, rename and remove the OpenPGP public keys and S/MIME certificates published on this account. Only public material -- no private key is stored, requested or sent by any of this. This is PR #67 revived. That branch was built against 0.16.19, closed unmerged on 2026-08-26, and shares no ancestry with main after the email scrub, so it is ported rather than rebased: the four files it added are carried over, the three it edited are applied by hand, and everything it claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n work is new -- nine catalogues landed on 2026-08-31, after that branch was written. What the re-probe confirmed, unchanged from 0.16.19: - An ordinary user may read *and* write their own keys, though the permissions table lists every sysPublicKey* permission as administrative. get and query both answered for a normal account, and a malformed create came back invalidProperties naming `key` rather than forbidden -- a rejection of the key, not of the person. - The server parses the key and says precisely what is wrong. So ihasmail does not validate key material; the server's message is shown verbatim, as password-policy rejections already are. - urn:stalwart:jmap is still absent from the session's top-level capabilities and present per-account, so the check that reads all three places is still the one that works. What it added, none of which was known before: - A key can parse perfectly and still be refused, with different words: a sign-and-certify key with no encryption subkey -- what `gpg --quick-generate-key` produces -- gets "Could not find any suitable keys in OpenPGP public key". That is the rejection somebody exporting from GnuPG will actually meet, and it is not a paste error, so collapsing both to "invalid key" would send them back to the clipboard for a problem that is in the key. - emailAddresses comes back as {} when empty -- an object where a JMAP list property should be an array. It type-checks, then throws in join() while the list renders. normalize() checked the shape already; there is now a test saying why, and the mock answers {} the same way, because one that helpfully returned [] would let that crash ship. - A create answers with the id alone, no createdAt, so adding a key reloads rather than believing the response. - destroy works and leaves the registry empty. PR #67 shipped that path untested -- its live probe was refused before anything was created, so there was nothing to destroy. - Patching `key` is allowed by the server. The mock still refuses it, now deliberately rather than for want of evidence: ihasmail replaces a key by adding one and removing the old, which keeps createdAt meaning what it says. x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and its value is a typed object ({"@type":"Disabled"}) rather than the bare string ROADMAP described. Nothing here writes it. An empty description is now sent as empty rather than filled in with "Key". The description is stored on the server, so a default invented in the client would be whichever language the adder happened to be using; the list labels a blank one at render time instead. Verified in a browser against the mock, not only in tests: both rejections reach the toast in the server's own words with the form still filled in, a good key renders its card, the kind is labelled from the armour header, renaming persists, removing asks first and empties the list, and the whole section reads correctly in German.
This commit is contained in:
+6
-1
@@ -11,7 +11,8 @@ 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.
|
||||
were exercised against the live 0.16.20 directly, as are the public-key entries
|
||||
dated 2026-09-05.
|
||||
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.
|
||||
@@ -38,6 +39,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
- **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.
|
||||
- **`x:PublicKey` is readable and writable by an ordinary user, whatever the permissions table says.** Stalwart documents every `sysPublicKey*` permission as administrative. **Confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights: `x:PublicKey/get` and `x:PublicKey/query` both answered, a create with a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person — and a create with a usable key, a rename, and a destroy all succeeded. Had the documentation been right, Settings › Encryption keys would have been useless to everybody but an administrator, which is why it was probed before it was built.
|
||||
- **A key can parse perfectly and still be refused, for a different reason.** `gpg --quick-generate-key` makes a sign-and-certify key; exporting that and registering it 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…"*. **Confirmed live on 0.16.20 (2026-09-05)**, both wordings. The distinction matters to whoever pasted it: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. ihasmail shows the server's sentence verbatim rather than reducing both to "invalid key". The mock reproduces both, the second behind a `SIGNONLY` marker in the block, because a branch nothing can reach is a branch nobody has seen.
|
||||
- **`emailAddresses` on a public key comes back as `{}` rather than `[]`.** An object where a JMAP list property should be an array, and only when it is empty. **Confirmed live on 0.16.20 (2026-09-05)** on a key registered without addresses. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while the list is rendering. ihasmail checks the shape rather than trusting the type, the mock answers `{}` the same way, and a test pins it — a mock that helpfully returned `[]` would let that crash ship.
|
||||
- **Creating a public key answers with the id alone.** No `createdAt` in the `created` object, so a client that read one back out of the create response would show `undefined` for the date it was added. **Confirmed live on 0.16.20 (2026-09-05).** ihasmail reloads the list after adding rather than believing the response. Patching `key` on an existing entry, incidentally, *is* allowed by the server; ihasmail does not do it and the mock refuses it, so that replacing a key stays add-then-remove and `createdAt` keeps meaning what it says.
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'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.
|
||||
|
||||
Reference in New Issue
Block a user