Commit Graph
77 Commits
Author SHA1 Message Date
Linus Rath d987930034 fix: load each list page and search in one request
Opening a folder, searching or loading more took three sequential
round trips: `Email/query`, then `Email/get` of its ids, then a
`Thread/get` the list screen fired after rendering to size the
conversation badges.

- `queryEmailPage` sends all three in one request, chaining
  `Email/get` to the query's `/ids` and `Thread/get` to the fetched
  messages' `/list/*/threadId` (left out while threading is off). The
  page is capped at `maxObjectsInGet` so the chained get can't be
  refused, and a failed `Thread/get` keeps the page.
- The full refresh, search and load-more use it; load-more no longer
  needs its own chunked `Email/get`.
- The thread sizes now live in the email store (`threadCounts`,
  cleared on folder or account change) and the list screen reads them
  instead of fetching them itself. Rows restored from a snapshot or
  added by an incremental refresh get theirs with one `Thread/get`
  after the refresh.
- The chained `Email/get` also reports the Email state for an empty
  folder, so that list gets its own `Email/changes` baseline.
2026-09-24 19:04:37 +02:00
Linus Rath a86cd1d0eb feat: manage filters and vacation of shared and group accounts
The phone only ever read and wrote Sieve scripts and the vacation
responder of the user's own account, and the "Shared with me" list in
Account settings said to use the webmail for the rest. Port the
webmail's scoped settings (4c6c1aab) for these two panes:

- `api/sieve.ts` and `api/vacation.ts` take an optional account id and
  gate on the account's own `accountCapabilities` for
  `urn:ietf:params:jmap:sieve` and `urn:ietf:params:jmap:vacationresponse`,
  treating non-personal accounts as capable because Stalwart doesn't
  always advertise capabilities on them. Script blobs are uploaded and
  downloaded against the same account.
- Tapping a shared account in Account settings scopes Settings to it:
  only the Filters and Vacation Responder panes it supports are listed,
  behind a "Managing: <name>" banner that leads back to the own account.
  The scope is dropped when the signed-in account changes.
- The filter and vacation stores remember which account they hold,
  reset when it changes and drop replies for an account the user has
  already left, so one account's rules are never saved into another.
- The rule editor offers the managed account's folders as targets, and
  the "vacation active" banner only shows for the own account.
2026-09-24 19:01:24 +02:00
Linus Rath 72f48db4e5 fix: list and cancel scheduled sends held in shared accounts
A send from a shared or group account's identity (quick reply with the
undo-send delay, or a message scheduled in the webmail) is held in that
account. The Scheduled screen only looked in the primary account, so it
never listed such a message, and it could not be cancelled, rescheduled
or edited before it went out (webmail #874).

The Scheduled list now covers every account that advertises submission
and can hold mail. Each entry carries its account, and cancel,
reschedule, "Send now" and edit act there. Edit restores the message
into that account's Drafts and opens it as a shared draft. A shared
account that fails to answer is skipped, so the user's own scheduled
mail still shows.
2026-09-24 19:00:09 +02:00
Linus Rath 4e7ae2fa30 fix: only offer scheduled-send times the server accepts
Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA
rejects any hold beyond its `futureRelease` limit, 7 days by default.
Now that scheduled send works on Stalwart, a time 8 to 30 days ahead
passed the checks and then failed with the raw "501 5.5.4 Requested
hold time exceeds maximum of 604800 seconds" reply.

Ported from the webmail:
- on Stalwart the advertised 30 days count as 7
- a submission or reschedule refused for its hold time throws
  `ScheduleTooLateError`, and the limit it names replaces the advertised
  one for that server
- the composer and the Scheduled screen show "Too far ahead" for it
  instead of the SMTP reply

The composer's and the Scheduled screen's presets now only list times
within the limit, and the undo-send hold, from the composer and from
quick reply, is capped at it.
2026-09-24 19:00:08 +02:00
Linus Rath 476780553c fix: show large message bodies in full instead of a cut-off stub #884
Full-message fetches cap body values at 512 KB and never read
`isTruncated`, so a bigger body rendered whatever the server cut off
with no notice; for a 586 KB HTML report Stalwart returned a 59
character stub and the viewer showed a heading, one line and "…".

Port the webmail's refetch: when the first HTML or text body part
comes back truncated, `getFullEmail`, `getFullEmails` and
`getThreadEmails` fetch the HTML and text body values of those messages
again in one batched `Email/get` with an 8 MB cap, keeping the values
of other parts. Truncated text attachments don't trigger it. If the
body is still truncated, or the refetch fails, the viewer now says the
message is too large to show in full.
2026-09-24 18:50:55 +02:00
Linus Rath 50418971f4 fix: stop mark-read, star, pin and tag from erasing other keywords
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.
2026-09-24 18:44:28 +02:00
Linus Rath 859770b814 fix: stop signing out on launch when a redirect drops the auth header
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.
2026-09-24 18:43:56 +02:00
Linus Rath c51a848dfb fix: stop reporting a send as failed when only filing it into Sent failed
`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.
2026-09-24 18:43:56 +02:00
Linus Rath e67947aa93 fix: keep cc and bcc recipients when rescheduling a 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.
2026-09-24 18:43:56 +02:00
Linus Rath e28e8b4d9c fix: turn scheduled send and the undo-send delay back on for Stalwart #57
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.
2026-09-24 18:43:56 +02:00
Linus Rath cd4ce9d08b fix: upload files shared into the app with their real name and type
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
2026-09-24 18:43:43 +02:00
Linus Rath caea3d0c60 fix: offer sharing files and calendars with other users on Stalwart
`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.
2026-09-24 18:41:23 +02:00
Linus Rath 00ad2f49fb fix: show an error when the server refuses a file rename, move or delete
`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.
2026-09-24 18:41:23 +02:00
Linus Rath 4eb8c47670 fix: list every file when the account holds more than 500 nodes #1069
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.
2026-09-24 18:41:23 +02:00
Linus Rath 5f3f584ca8 fix: auto-send read receipts only for the message on screen and clean up failed ones
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
2026-09-24 18:36:15 +02:00
Linus Rath a02e7f8a49 test: add an opt-in live Stalwart integration suite 2026-08-29 22:28:42 +02:00
Linus Rath ea1f53f550 fix: keep the created message id when the server answers a send with an implicit filing update 2026-08-29 22:28:41 +02:00
Linus Rath 3f2dfe83ee chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40) 2026-08-29 20:16:33 +02:00
Linus Rath 8e1d03c746 feat: fire calendar and task reminders as local notifications 2026-08-29 18:41:06 +02:00
Linus Rath 6ae21d5690 feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855) 2026-08-29 18:39:45 +02:00
Linus Rath 5b72c4d314 feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163) 2026-08-29 18:28:38 +02:00
Linus Rath 8206893860 feat: assess invitation trust, read the iTIP method from the ICS and pick the target calendar in the invitation banner 2026-08-29 18:22:00 +02:00
Linus Rath dae5700688 feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions 2026-08-29 18:14:48 +02:00
Linus Rath 42ab246cb9 feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api 2026-08-29 18:12:15 +02:00
Linus Rath 490355c65e feat: list logged-in accounts, storage quota and auth method in account settings 2026-08-29 18:11:13 +02:00
Linus Rath 58e3ef6827 fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907) 2026-08-29 18:05:56 +02:00
Linus Rath 4bebf70482 feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out 2026-08-29 18:05:16 +02:00
Linus Rath 431facef0b feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater 2026-08-29 18:04:21 +02:00
Linus Rath 6f747db858 fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627) 2026-08-29 18:03:24 +02:00
Linus Rath 69bedf0b26 feat: manage public keys and encryption at rest through the Stalwart API 2026-08-29 18:03:23 +02:00
Linus Rath a0ce9258cd feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563) 2026-08-29 17:59:44 +02:00
Linus Rath 3fd5f10afe fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762) 2026-08-29 17:57:54 +02:00
Linus Rath edc26ce1ca fix: reconnect live updates after network blips, token refresh, account switch and app resume 2026-08-29 17:57:33 +02:00
Linus Rath 032732cea1 fix: check the account-level Stalwart capability and keep the stored password in step after a change (#47) 2026-08-29 17:57:33 +02:00
Linus Rath 00faceb25f fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45) 2026-08-29 17:53:53 +02:00
Linus Rath 5dc40706fa fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731) 2026-08-29 17:52:26 +02:00
Linus Rath 26a34d5900 feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907) 2026-08-29 17:50:44 +02:00
Linus Rath 580e010b33 fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644) 2026-08-29 17:50:44 +02:00
Linus Rath ccbe67c1bc fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850) 2026-08-29 17:49:23 +02:00
Linus Rath 58a6f45c15 fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805) 2026-08-29 17:45:43 +02:00
Linus Rath 0b1c240802 fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907) 2026-08-29 17:42:32 +02:00
Linus Rath a63ba7cb81 Merge pull request #35 from lucletoffe/fix/reply-threading-msgid
fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
2026-08-06 16:25:28 +02:00
Linus Rath beec3322b8 Merge pull request #39 from koolninad/fix/jmap-session-ios-auth-drop
fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
2026-08-06 16:25:12 +02:00
lucletoffe 32b5298cdd fix(calendar): page a server-side date filter through CalendarEvent/query
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).
2026-08-06 09:20:40 +02:00
Ninad Chandorkar da1ff62725 fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
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).
2026-08-05 23:34:53 +05:30
Linus Rath be82bfe814 feat: show shared mailboxes (group accounts) in the sidebar #37 2026-08-02 20:17:29 +02:00
lucletoffeandClaude Opus 5 93761e67ef fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
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
2026-07-31 12:39:01 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
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
2026-07-13 09:05:45 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00