Every keyword change sent the message's whole `keywords` map, which the
server takes as a replacement. Opening a message that wasn't in the
loaded list sent `{"$seen": true}` and wiped its star and tags; starring
a listed message sent the list's stale map and reverted keywords another
client had changed since the list loaded.
Keyword changes now go out as `keywords/<name>` patch pointers (`true`
to set, `null` to clear) everywhere:
- mark read/unread, star, pin and tagging in the email store
- spam / not spam, trash-and-read, and the spam undo, which now puts
back only the keywords the action touched
- star, tag and unread from the message viewer
- the offline outbox, whose keyword ops carry a patch and merge per
keyword when coalesced
Ops queued by earlier builds still hold a whole map. They replay as a
patch that only sets the keywords in it (clearing the other half of
`$junk`/`$notjunk`), so an unread or unstar queued before the update is
lost but nothing gets erased. The list and the offline cache apply the
same patch locally instead of overwriting their keyword maps.
When a proxy redirects the session request (an http to https upgrade, a
trailing slash, another port), iOS and OkHttp follow it without the
Authorization header. The server then answers 401 or, like Stalwart, an
empty session, which the client reported as invalid credentials, so a
restored account was evicted and its stored credentials deleted. The
retry that was meant to catch this keyed on `response.redirected`,
which React Native's fetch never sets.
Session discovery now also detects a redirect by comparing the final
`response.url` with the requested URL, and on a 401 or an empty session
refetches the final URL with the header. Credentials only follow within
the same host or its https upgrade, the rule `secureFetch` applies; a
redirect elsewhere fails as a discovery error, which `loadAccount`
turns into a `NetworkError`, so the stored account is kept.
`sendEmail` threw on any method error in the response. Stalwart appends
one for the implicit `onSuccessUpdateEmail` Email/set that moves the
sent message from Drafts to Sent, so a message that had already gone
out showed "Send failed" and a retry sent it a second time. A method
error that follows a created submission is now returned as a filing
warning, like the record-level filing failures already were; errors
before the submission exists still fail the send.
Rescheduling a held message rebuilt the replacement submission's
envelope from the Email's To only, so Cc and Bcc recipients silently
stopped getting the message, and a catch-all From was used as the
envelope sender the server refuses. The replacement now reuses the held
submission's envelope (`rcptTo` and `mailFrom`), falling back to the
message's To, Cc and Bcc when the server returns none.
RFC 8621 puts the submission limits (`maxDelayedSend`,
`submissionExtensions`) and the mail limits
(`maxSizeAttachmentsPerEmail`) under each account's
`accountCapabilities`. Stalwart advertises an empty object for them at
session level, which is the only place the client looked, so scheduled
send was refused, the undo-send delay silently fell back to an instant
send, the Scheduled view listed nothing and the per-message attachment
cap was never enforced.
The client now reads these from the account that sends (the account's
own submission capability, else the session's primary submission
account, like the webmail), falling back to the session-level object
for servers that only advertise it there. Quick reply, which can send
from a shared account, now checks that account before holding the
message for the undo-send delay.
Sharing a photo or document from another app into Bulwark failed every
time: the upload task was handed the share's `content://` URI, but it
can only stream from a file path, so it failed with "Directory for
'/external/images/media/…' doesn't exist". The attachment was also named
after the URI's last segment (a MediaStore row id such as "1000000027"),
and SEND_MULTIPLE shares only carried a wildcard type like `image/*`.
- `uploadBlob` copies `content://` URIs into the cache, uploads the copy
and deletes it afterwards, also when the upload fails
- `ShareIntentStore` asks the providing app for the display name, size
and MIME type when the intent arrives, falling back to the URI segment
plus an extension for the MIME type
- the composer gets the real name, type and size, so the size limit
check now applies to shared files too
`supportsSharing()` required `urn:ietf:params:jmap:principals:owner`,
which Stalwart advertises nowhere; its session only has `principals` and
`principals:availability`. The file menu therefore never showed
"Sharing & access", and `getPrincipals()` returned nothing, so the
calendar share sheet had no one to offer either.
- Gate sharing on `urn:ietf:params:jmap:principals`, like the webmail's
`supportsPrincipals()`.
- Keep `principals:owner` out of FileNode requests unless the server
advertises it, since an unknown capability in `using` fails the whole
request (why the gate had been tied to it).
- Page the principal lookup by `maxObjectsInGet`, as the webmail does:
one unbounded `Principal/query` fed every id to `Principal/get`, which
the server refuses once the directory has more than 500 entries.
`updateFileNode` and `deleteFileNodes` only looked for `notUpdated` and
`notDestroyed` in the first method response. A method-level `error`
reply (e.g. `forbidden`) has neither, so a refused rename, move or delete
reported success and the Files tab silently refreshed to the old state.
Every `FileNode/set` in `src/api/files.ts` now goes through
`requireMethodResult`, so the error reaches the existing alert in the
Files screen and the share sheet with the server's description.
The Files tab read the whole drive with `FileNode/get ids:null`, which
Stalwart silently caps at `maxObjectsInGet` (500 by default). Anything
past that, including folders created after the first 500 files and
everything inside them, never showed up, both for the own drive and for
shared accounts.
A full first page now counts as truncated, as in the webmail: the
remaining ids come from paging `FileNode/query`, are fetched in
`maxObjectsInGet`-sized `FileNode/get` batches packed into as few
requests as `maxCallsInRequest` allows, and parents that are still
unknown afterwards are fetched by id, which recovers folders on Stalwart
before 0.16.6 where the query returns files only. If the paging fails,
the first page is kept as before.
With "Always send" the banner's auto-send ran in every page the viewer
pager keeps mounted, so opening one message also answered the receipt
requests of its unseen neighbours. The receipt is imported into Sent
before it is submitted, so a refused submission left a "Read: ..." copy
there, and the failure cleared the per-instance guard, so the next
render or remount imported another one.
- the pager marks the page on screen `active`, and the banner only
auto-sends there
- an automatic receipt is tried once per message per session, keyed by
account and id, so a failure or a remounted page does not send again
- `sendReadReceipt` destroys the imported copy when the submission
fails, which also covers the manual "Send receipt" button
queryEvents used to ignore its after/before arguments and ask Stalwart
for every event in the account with limit:1000. When an account holds
more than one page of events, the response was silently capped and the
grid rendered empty for the visible month — the missing events sat past
the truncation, never fetched. Meanwhile any recurring master whose
base start predates the window was returned exactly the same way, so
the client-side recurrence expander (already wired) never saw it.
Now the query sends after/before as a real filter ANDed with the
inCalendar restriction, and iterates position by page size until a
short page comes back (or 20 pages, as a bug backstop). Stalwart's
matcher accepts a master when any occurrence overlaps the window, so
the expander gets the raw event and produces the visible occurrences.
The refactor factors buildEventFilter() so the calendar / no-calendar
and window / no-window combinations produce the right filter shape
(single condition, AND, or absent). Tests updated to lock the new
filter shape and to cover the paging path (regression: 1000 events
that used to become 1000, now 1002).
Picked up from Jim Preston's uncommitted diff in the sister worktree
during the 2026-08-03 diagnostic session (artifact PkOWkT8C).
Signing in from the iOS build against a Stalwart server fails with "No
account found in JMAP session".
Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to
/jmap/session. iOS NSURLSession strips the Authorization header when it
auto-follows that redirect, so the app gets an unauthenticated session
back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers
keep the auth header on same-origin redirects, which is why the webmail
never saw this; it only bites the native app on iOS.
Request /jmap/session directly so the header stays attached, falling
back to /.well-known/jmap on 404 for servers that don't expose the
Stalwart path.
Also treat a 200 with no accounts as an auth failure. Stalwart answers
that way for missing or invalid credentials instead of sending a 401, so
a user who fat-fingers their password currently gets "No account found
in JMAP session" rather than "Invalid credentials".
Tests updated for both behaviours; full suite passes (the pre-existing
auth-store.test.ts parse failure on main is unrelated).
Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` —
into In-Reply-To/References. No recipient can match that against any
Message-ID, so the conversation splits in two: the original sits alone in
its thread while the reply (and everything answering it) starts a fresh one.
Proven against a live Stalwart account: the reply carried
`In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed
in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no
match.
Fetch `messageId`/`inReplyTo`/`references` with the full email and build the
headers from them (bracketed, ancestors first, parent closing the chain).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.
Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.
Fixes#14