When the pages beside the open message were let go, each asked for its
conversation on its own, so every open sent two `Thread/get` requests
for its neighbours.
Conversations asked for in the same tick for the same account now go
out together: `getThreadsHeaders` takes several thread ids in one
back-referenced Thread/get → Email/get, and the cache batches its
callers into it. A conversation the server does not know, or a failed
request, still falls back to the copy held from before.
`getThreadEmails`, which downloaded every member's full body, had no
callers left; it is removed and its tests moved to `getFullEmails` and
`getThreadsHeaders`.
Refreshing an open folder that was already loaded took up to four
round trips in a row: `Email/queryChanges`, `Email/changes`, `Email/get`
of the added and changed rows, and a `Thread/get` for their
conversation sizes.
They now go in one request, the `Email/get` of the added messages and
the `Thread/get` of their threads chained by result reference to
`/added/*/id` and `/list/*/threadId`. The list's own query and Email
states still drive it. Each part can fail on its own: a query the
server cannot diff falls back to the full re-query as before, an Email
state it cannot diff is forgotten as before, and added messages the
chained `Email/get` refused (more than one call takes) are fetched
apart. Only further `Email/changes` pages and rows that changed in
place without being re-added cost more requests.
Coming back to the app first waited for a `Core/echo` (sometimes two:
the keep-alive timer that was due fired alongside) and for the event
stream to be set up before the catch-up sync started. A failed echo
only led to `retrySession`, which does nothing while the session is
live. On top of that, a `Core/echo` went out every 30 s although the
event stream's own pings already show the server is reachable.
- On resume the stream reconnects and the folders and open list are
synced right away; one echo runs alongside for the connection dot.
- The 30 s echo is only sent while the event stream is not open
(polling fallback, reconnecting, client certificate). An open stream
whose pings arrive (the live-updates handle's new `healthy`) marks
the account connected instead.
- Echoes never overlap, and a periodic one right after the resume one
is skipped.
Every sign-in and app start read the account's full name with
`x:Account/get`, and the first message opened (invitation or not)
looked up the account aliases with it too. Stalwart refuses that method
to everyone but admins, so for most users both were wasted requests
that always came back `forbidden`, and the full name was never
refreshed.
- The full name now comes from `x:AccountSettings/get` (its
`description`), which every user can read, like the webmail (#900).
- The alias lookup only runs for a message that carries a calendar
invitation (and the calendar).
- A refusal of `x:Account/get` is remembered per account for the
session, so it is asked at most once.
Every change the app made (read, star, move, send, a draft save) came
back as a push, and the push re-read the open folder and the whole
folder list: `Mailbox/changes`, `Mailbox/get`, the shared accounts'
`Mailbox/get`, `Email/queryChanges`, `Email/changes`, `Email/get` and
`Thread/get`, often twice. One send cost about 10 requests.
- `jmapClient.request` now logs the Email states each of our own
`Email/set` (also the implicit one of `onSuccessUpdateEmail`),
`Email/import` and `Email/copy` responses moved between, with what
they created, patched and destroyed.
- When a push reports an Email state that only our own writes led to
from the state the open folder was read at, the store applies those
writes to the rows (the optimistic updates mostly did already), moves
the list's state on and skips the refresh. It still re-reads when
anything else changed, when new mail was delivered, when a write adds
a message to the folder or changes a keyword the list is sorted on,
and when the folder's count shows a message it does not hold left.
A push that overtakes its write's response waits for it briefly.
- A folder push re-reads only the part of the folder list that
changed: the own folders or the shared accounts', and nothing for a
Mailbox state it already holds.
Every open went to the network for the full message, even for a message
the viewer had just shown, and the conversation fetch then downloaded
every member's full body again, the opened one included. The pager's
three panes each started their own detail and thread fetch, so one open
from the Unified Inbox fetched the body twice and the thread twice.
Messages and conversations now live in a shared cache
(`lib/email-detail-cache`), keyed by server, login and JMAP account:
- a held copy renders at once; bodies never change, so it is only
checked, with a keywords/folders-only `Email/get`, when the account's
Email state (from push or our own reads) moved on or the list row the
message was opened from disagrees with it;
- a copy in the offline cache is shown before anything goes out and is
checked the same way;
- a conversation is listed from its members' headers (`Thread/get` and
list properties in one request) and bodies are downloaded only for
the open cards, in one batched `Email/get`;
- concurrent loads of the same message or thread share one request.
The store's network-first `getEmailDetail` is gone; its offline-copy
refresh moved into the cache.
Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.
- `supportsSyntheticCalendarIds` probes once per server and account
with an update of an id that cannot exist (`notFound` means
supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
occurrences are fetched with `baseEventId` and hydrated with their
base event's rules, overrides, all-day flag and duration; duplicate
listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
exceeds") and older servers keep the device-side expansion, which
now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
(`resolveSeriesTarget`), including a master fetched for an
occurrence of a shared calendar, and reload the range afterwards
since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
A partial import dropped the refused events silently: the sheet said
"Imported 3 events" for a file of five, and the server's reason never
reached the user. The webmail reports how many events failed and why.
- `batchCreateEvents` returns the refused events by index with the
server's reason instead of just the created count
- `importEvents` resolves with `{ imported, refused }`, also counting a
refused link of a UID that lives in another calendar and every event
of a chunk whose request failed; when nothing got in it rejects with
an `ImportRefusedError` carrying the list, so the invitation banner
still never reads a refusal as "Already in your calendar"
- the import sheet shows the imported count and lists each refused
event with its date and reason
A tag sits on messages in the user's own account and in the shared
(group) accounts alike, but the tag view only asked the account of the
open folder, and the sidebar badge only counted the own account: with 2
own and 2 team messages tagged Red, the Red view showed 2, and which 2
depended on the folder it was opened from.
The tag view now fans out like the "All folders" search (#1082), which
it is: every folder of the own account and of each shared account with
folders in the sidebar, in one request, with rows stamped with their
account so opening, untagging and every other action reach the right
one. A search inside the tag view narrows it in every account.
The tag badges sum the counts of the same accounts; the query pairs for
all of them go out packed into as few requests as `maxCallsInRequest`
allows, and an account that can't be reached counts nothing instead of
failing the badges.
"All folders" sent its Email/query only to the account of the open
folder, so mail in a shared (group) account the same login reaches never
showed up: "zephyr" found the 3 own messages and missed the 5 in the team
inbox, although that inbox sits right there in the sidebar.
The search now asks the own account and every shared account with
folders in the sidebar, like the webmail's `searchAcrossAccounts`:
- one request carries Email/query, Email/get and Thread/get for each
account (`queryEmailPagesAcrossAccounts`), and the pages are merged
under the list order
- each row is stamped with its JMAP account (`Email.jmapAccountId`), so
opening a hit opens it in that account and read, star, tag, archive,
delete, spam and move act there, filing into that account's own
Archive, Trash or Junk; one undo restores rows in every account
- "load more" continues each account from its own rows on screen rather
than one shared offset, which would skip messages of the account with
more hits
- an account that fails leaves the others' hits on screen with a notice
- a push for any account re-runs the search
- list keys, thread grouping and conversation sizes are scoped by
account, as JMAP ids are only unique per account
- deleting a hit judges Trash and Junk by the hit's own folders, not the
open folder, both for the move and for the permanent-delete confirm
A search scoped to one folder still asks only that folder's account.
Deleting files sent every selected id in one FileNode/set. A selection
larger than the server's `maxObjectsInSet` was refused as a whole, so
nothing was deleted. The ids now go out in batches of that size; a node
already removed with its folder by an earlier batch counts as deleted,
and a refused item no longer stops the remaining batches.
Files shared into the app are copied into the cache as `upload-*`
before they are uploaded. When the app was killed mid-upload the copy
was never deleted. The launch-time sweep of stale export files now also
removes upload copies made by an earlier run of the app; copies of the
current run may still be uploading and are left alone.
A send creates the message in Drafts and submits it in the same request.
When the server refused the submission, the created copy stayed in
Drafts without anyone tracking it, so sending again created a second
copy and left the first behind as a stray draft. The copy is now
destroyed again when the submission is refused; the composer still
holds the message and the previous draft version is left alone.
A message with attachments only showed a paperclip, so reaching a file
meant opening the message and scrolling to the bottom. Rows now list the
attached files as chips under the preview, two at a time with the rest
as a count, like the webmail (0e1c34cd). A tap opens the file the way
the reader's chips do: the attachment click setting saves it or
previews it in-app, and other types go to the platform viewer. Inline
and Content-ID parts (signature images, spacers) are left out.
The list request still only asks for `hasAttachment`: the webmail found
that asking for `attachments` there makes Stalwart parse every message's
raw blob (#1089). Rows with a paperclip ask for their parts once
rendered; rows that render together are coalesced into one Email/get
per account and the answers are cached, so scrolling back costs nothing.
`deleteEvents` never looked at the `CalendarEvent/set` answer, so a
refused delete (read-only calendar, method error) removed the event
from the screen and said nothing until the next refresh brought it
back. Clearing a calendar ignored `notDestroyed`/`notUpdated`, and an
import the server refused entirely returned 0, which the invitation
banner showed as "Already in your calendar".
- check the destroy result in `deleteEvents` and `clearCalendarEvents`
with `assertSetResult`, and the unlink result as well
- throw from `batchCreateEvents` when nothing was created and the
server refused events; a partial import still returns its count
- show an alert when deleting an event or a task fails, and refresh
after a partly failed import or clear
Accepting an invitation looked the event up in the calendar store,
which only holds the window the calendar last showed. For an event
further out the lookup came back empty, no response was sent, and the
banner still said "Added to calendar".
- find the event on the server with a `uid` filter, like webmail's
`queryCalendarEvents({ uid })`, both when the banner opens (so it
shows as already in the calendar) and after importing it
- let `rsvpEvent` take the event when it isn't in the store, so the
organizer repair still applies
- show "Could not send your response" whenever no response went out
Tasks created over CalDAV can come back with their iCalendar STATUS
spelling or as `canceled` (stalwartlabs/calcard#20). The app compared
against the JSCalendar values only, so a completed CalDAV task counted
as pending, still raised its reminder, and a tap on it completed it
instead of reopening it.
- normalize the known spellings when events are read, like webmail's
`normalizeCalendarTask` (5f1d2795), and keep unknown values as they
are
- reopen a cancelled task on tap, since the tasks sheet shows it ticked
Rescheduling cancelled the original submission and created its
replacement in one `EmailSubmission/set`, whose create and update the
server applies independently. A refused replacement (a hold over the
server's limit, a quota) still cancelled the original, so the message
silently never went out, and a refused cancel (the original had just
gone out) still let the replacement go, so it was sent twice.
Ported the webmail's order:
- the replacement is created first; if it is refused, the original
stays scheduled
- the original is cancelled only once the replacement exists
- if the original can't be cancelled, the replacement is withdrawn
again, unless a cancel whose answer got lost did go through
- "Send now" is a 1-second hold, so the replacement can still be
withdrawn
The launch-time effect in `App.tsx` re-ran the full setup for the
active account whenever a relay URL was stored, and that URL is shared
by every account on the device. Turning push off for one account, or
revoking this device, lasted only until the next launch or token
rotation. A subscription revoked from another device was re-created
without asking.
- Turning push off in settings and revoking this device now go through
`disablePushForAccount`, which tears the registration down and marks
the account as opted out. Enabling push again clears the mark.
- The effect calls `resyncPushNotifications`, which skips opted-out
accounts. If the server no longer has the recorded subscription
although it still had a day or more to live, it treats the
subscription as revoked and turns push off for the account instead
of registering again. A subscription that lapsed is re-created as
before.
- Setup records the expiry the server reports: from the create
response (`createPushSubscription` now returns it) and from every
listing.
The PushSubscription calls read the first method response without
checking it, so an `error` response listed no devices, and a refused
verify or destroy (revoking another device) was reported as done. Route
every call through `requireMethodResult` and `assertSetResult`; a
destroy the server answers with `notFound` still counts as done.
`PushSubscription/get` ran with the default properties, which leave out
`emailPush`, so the stored filter never compared equal to the wanted one
and every launch sent it again. The calls also never named the
emailpush capability in `using`.
Ask for `emailPush` explicitly and send
`urn:ietf:params:jmap:emailpush` with every call that reads or writes
it, as the webmail's `lib/jmap/client.ts` does. Servers without the
capability keep the default properties and never see the property, so
the filter is only patched when it differs from what the server holds.
The emailPush map named every account with mailboxes. Stalwart refuses
the whole map as `forbidden` when it names an account the user neither
owns nor belongs to as a group member, so a user with a mailbox shared
by ACL had every refresh rejected. The refresh path then destroyed the
working subscription and the recreate was refused the same way, leaving
push off for good.
- On a `forbidden` refusal, retry with a narrower map: first the
accounts that look owned (Stalwart sets `mayCreateCalendar` and its
siblings only on the user's own and group accounts), then the primary
account alone. Stalwart only fans a subscription out to owned accounts,
so nothing is lost.
- Remember the refused accounts per account and leave them out of the
map on later runs; a forced re-register re-learns them.
- Keep the old subscription until its replacement is verified, so a
refused or failed replacement never leaves the device without push.
- `createPushSubscription` and `updatePushSubscription` now throw a
`JMAPMethodError` carrying the SetError type.
The JMAP part is shared by FCM and UnifiedPush, so both get the fix.
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.
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.
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.
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.
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.
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