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.
The unified inbox reloaded page 1 of every account each time it came
back into focus, so opening a message and going back cost one
`Email/query` per account even when nothing had changed.
It now reloads on focus only when something may have changed since the
last load: the inputs or the active account changed, the live session
reported an `Email` state change, the opened message was unread (the
thread screen marks it read), the app was in the background (the event
stream is closed there), or accounts without a live session are shown
and the last load is over a minute old. An `Email` change that lands
within a few seconds of a focus that skipped the reload (the echo of an
archive or delete from the thread screen) reloads then.
The placeholder cards held for a loading conversation looked its size up
by bare thread id whenever the open folder was the message's account.
Rows of an "All folders" search or a tag view now come from several
accounts and the list keys their conversation sizes by account, so the
viewer looks the size up the same way, and only for pages that are the
list's own rows.
Only the tab screens and the viewer had a toast host, so a viewer
action that failed after going back to the Unified Inbox or a contact
(both stack screens of their own) raised a toast nobody saw.
Every stack screen now gets a host through the navigator's
`screenLayout`, and only the focused screen's host renders, so the
screen kept live under the top one never shows the same toast a second
time. The tab screens' and the viewer's own hosts are gone.
Opening the viewer while it was on top, e.g. by tapping a notification,
did not push a new screen: React Navigation handed the new target to
the same route as new params, and the viewer, which fixes its message,
account and page list when it opens, ignored them. The tap did nothing.
The route now mounts the viewer afresh for every new target it is
given, keyed by the message, its account and the open, so the new
message is shown with its own page snapshot. Params that stay the same
keep the viewer as it is.
Every body started as a 120 px strip that grew once the page had
measured itself, so an open showed a skeleton, then a clipped strip of
the body, then the full body, and the quick reply box below it jumped
down as the WebView grew. A conversation arriving after the message
swapped the single-message layout for cards, which reloaded the body
and pushed it down under a "Loading conversation" row.
- `lib/body-heights` remembers the last measured height of each body
(per width); a body shown before starts at that height.
- A page showing one message stretches its body over the space below
the header until it has measured itself, and the quick reply sits at
the bottom of the page, so it does not move whether the body turns
out shorter or longer. Conversation cards start from an estimate
(most of a screen for HTML, the wrapped line count for plain text).
- A single message is rendered through the conversation's card list,
so its body stays mounted when the conversation arrives; while it
loads, the size the folder list knows for it holds the older cards'
places, and the loading indicator sits in the subject row.
- An open card whose body has not arrived shows its header over
placeholder lines, with its reply buttons disabled until it has.
The body view reset its inline-image map to a new empty object right
after mounting, which rebuilt the sandboxed document, so every body was
sanitised twice per mount. A body that mounted again (the page beside
the open one becoming the open one, a card closed and reopened, the
message opened a second time) was sanitised from scratch as well.
The document is now built by `lib/email-body-document`, which keeps the
last ten per message, body and rendering settings and hands back the
same one when they match, and the mount no longer replaces the empty
map.
The pager renders the pages before and after the open one, and each
fetched its message and conversation and mounted its own WebView at the
same time as the message the user had tapped, which competed with it
for the network, the JS thread and the WebView process.
The neighbouring pages now show their header and placeholder lines
until the open message's body has reported its height, the user starts
to swipe, or 1.5 s have passed. Their details are then loaded together
in one request, and their conversations and WebViews follow.
The viewer and the read-receipt banner each sent `Identity/get`
whenever the identity list was empty. Identities are not persisted, so
the first open of every session asked twice, and an account the server
lists no identities for asked on every open.
The settings store now remembers which signed-in account (server,
login, JMAP account) its identities were read for, and the viewer and
the banner only ask when they are not held for the current account.
Concurrent reads share one request. Switching accounts drops the old
account's identities instead of letting the quick reply send through
one the new account does not have.
The detail and conversation requests only went out once the viewer had
mounted and its effects ran, after the navigation had rendered.
A tap on a row in the folder list, the Unified Inbox or a contact's
activity, and a notification tap, now start the message's detail and
its conversation (when threading is on) through the shared cache, and
the viewer joins the requests already in flight. It runs on the tap and
not on press-in, because on a phone a touch that starts a scroll
presses rows too.
The Unified Inbox and contact activity also leave their rows in the
cache, so pages handed over by id get their header painted from the row
(and the right thread id) instead of a skeleton.
The viewer showed a full skeleton until the message's detail had come
back, although the list row it was opened from already holds the
subject, sender, date and avatar. The header appeared about 0.4 s after
the tap.
A page whose list row is known now renders the real subject and header
from that row straight away, with placeholder lines where the body
goes; the body (and the star, reply and quick reply that need the full
message) follows when the detail arrives. Pages handed over by id only
(notification, deep link) keep the skeleton.
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.
An answer to an invitation always went to the whole series, even when
given on one occurrence. The webmail asks which occurrences it covers
(#1086); the app now does too.
- answering on an occurrence opens the scope dialog with "This event
only" and "All events" (an attendee cannot split the organizer's
series, so "This and following" is not offered)
- "This event only" writes the answer to that occurrence: through its
synthetic id, or as an override on the base event for one expanded
on the device, with the whole participant map, the organizer and
the sequence (`buildOccurrenceRsvpPatch`), so Stalwart replies with
the occurrence's RECURRENCE-ID
- "All events" and answers on single events work as before
"This event only" wrote a `recurrenceOverrides/<id>` pointer on the
master for every occurrence. That pointer fails while the series has
no overrides yet, replaced whatever the occurrence already overrode,
and left the new override without the occurrence's title, place or
reminders. Deleting one occurrence went the same way.
The store now keeps a change to an occurrence on that occurrence, the
way the webmail's `resolveMutationTarget` does:
- a server-expanded occurrence is updated or destroyed through its
synthetic id; a server that refuses synthetic ids gets the same
change as an override on the base event, and is remembered
- an occurrence expanded on the device becomes an override on its base
event that keeps the existing override, pins start and duration,
and is sent as the whole map when the series has none yet
- a new override carries the occurrence's details and sequence
(`withNewOverrideDetails`), so it neither loses them nor ranks below
the series
- "All events" and "This and following" still go to the master
The calendar's scope dialog hands "This event only" to `updateEvent`
and `deleteEvent` with the occurrence's id, and
`buildRecurrenceOverridePatch` is gone.
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 task due without a time zone was read as device wall time, while
Stalwart computes a floating event's start in the calendar time-zone
setting the app sends with every query. With that setting away from
the device zone, a floating task and a floating event at the same
wall time showed hours apart, and task reminders fired at the device
reading.
`getTaskDueDate` now resolves a floating timed due in the effective
calendar zone; dues with their own zone and date-only or all-day dues
are unchanged. The device zone is cached for 30 seconds, since task
sorting resolves a due per comparison and building an
`Intl.DateTimeFormat` is slow on Hermes.
Tapping a reminder notification only brought the app to the front:
nothing listened for expo-notifications responses, so the event never
opened.
- reminders now carry the event's server id, account, occurrence,
start and the signed-in account alongside the store id
- `startCalendarReminderTapHandling` picks up the reminder that
launched the app and any tapped while it runs; it only acts on
reminders it scheduled, and mail push taps keep coming through the
native tap store
- the app switches to the reminder's account if needed, parks the
target in `pending-calendar-open` and opens the Calendar tab, which
shows the event in the detail sheet or the task in the tasks sheet
- the event is looked up by its server identity, first in the loaded
window, then in the day around its start, then as the stored event,
so an occurrence outside the visible month still opens; a deleted
one says "This event is no longer available."
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
Saving in the tasks sheet ran `onCreate`/`onUpdate` in a `try/finally`
without a `catch`, so a refused create or update became an unhandled
rejection: the spinner stopped, nothing was shown, and the user could
not tell whether the task was saved.
The editor state and the save logic move to `src/lib/task-editor.ts`.
`submitTaskEditor` returns the server's reason instead of throwing; the
sheet shows it in an alert and leaves the editor untouched so the input
can be fixed or saved again. It is cleared only after a successful save.
The attachment chips on list rows fetched every row's parts from the
account of the open folder. In an "All folders" search or a tag view the
rows come from several accounts, so a hit from the team inbox asked the
user's own account, which answers for another message with the same id,
or not at all.
A row that carries an account stamp now loads its chips, and opens an
attachment from them, in its own account; the chips a row remembers are
keyed by that account too.
Every drawer open sent the whole set of tag count queries again (14
Email/query calls for 7 tags, twice that with a shared account), although
nothing had changed since the last open.
The counts now live in a small store, keyed by the login, the accounts
and the tags they were fetched for. Opening the drawer only fetches when
there are no counts for that key yet or when an Email change was
reported for one of the accounts since (push or the poll fallback); a
change that arrives while the drawer is open refreshes them there and
then. Overlapping opens share one request, and a change that lands while
the counts are in flight leaves them due for another fetch.
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.
The viewer's star, tag and mark-unread buttons called the API
directly, so offline they failed and the list row behind the viewer
kept its old state. They now go through the email store's keyword
action like the list's selection actions: offline the change waits in
the outbox, and the list row follows. The store action takes the
viewed message and its account, so it only touches the list when the
list holds that account and never a same-id row of another one.
The reader's attachment preview wrote every file to the same
`bulwark-exports/<name>`. React Native caches images by path, so a
second attachment with the same name (such as Outlook's `image001.png`)
could show the first one's picture. The preview now uses a temp folder
of its own per preview, as the Files tab does, also for parts unpacked
on the device, and deletes it when the preview closes unless the file
was shared to another app. A download that finishes after its preview
was closed no longer reopens it.
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.
The mail list, the unified inbox, a contact's recent emails and new-mail
notifications showed a hard-coded English "(no subject)". They now use
the webmail's `email_viewer.no_subject` (and `contacts.activity.no_subject`
for the contact view). The notification task reads the in-app language
override itself, as it cannot use the locale store.
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.
The webmail's Settings screen has a search field; the app had none, so
finding a setting meant opening panes one by one. Settings now has a
search field above the pane list that narrows the list to the panes
that match and lists up to six matching settings under each.
The index follows the webmail's `lib/settings-search.ts`: per pane, the
translation keys it renders plus a few English keywords, walked for
label/description pairs. The paths list what the native panes show
rather than the webmail's, the walker prefers the `*_mobile` variants
the panes render, and every label goes through t() for the current
language. A test checks that each result is a key its pane renders,
so results do not point at webmail-only settings.
Tapping a result opens the pane, scrolls to the matching row and tints
it briefly. Back clears the search before leaving Settings.
The Themes pane offered four of the webmail's built-in themes. The
webmail also ships Roundcube Elastic and Aurora Glass, so their colour
tokens are now mapped onto the native palette the same way as the
other four, for both light and dark.
Aurora Glass dark uses translucent surfaces that the webmail frosts
with a backdrop blur. RN has no blur, so those fill tokens are
composited onto the theme background instead of letting the content
underneath show through. The skins (fonts, radii, Elastic's dark task
rail, Aurora's gradient mesh) and the "Flat fields" theme are not
ported.
The RN overlay (`locales/rn/en.json`, the strings the webmail catalog
does not carry) only existed in English, so those 600 or so labels
showed in English whatever language the app was set to.
- load `locales/rn/<lang>.json` for every language next to its
vendored catalog; an untranslated key still falls back to English
- a test keeps each translation to English keys and the same ICU
arguments
- `node scripts/rn-missing-translations.mjs [lang]` lists what a
language still lacks
The drawer showed "All inboxes" and the Unified section as soon as a
second account was signed in, but with cross-account off the unified
views only cover the active account, so they repeated its own folders.
They now follow the webmail's rule: shown with cross-account on and
several accounts, or when group inboxes are merged in.
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.
Ports the webmail's app-icon badge (`useAppBadge`): the icon shows the
unread count of the active account's own inbox, and a new "Unread count
on app icon" toggle (on by default, exported as the webmail's
`faviconUnreadBadge`) turns it off. Signing out of the last account
clears it.
This is iOS only. On Android expo-notifications badges through
ShortcutBadger, which few launchers honour, and implements a count of 0
as `NotificationManager.cancelAll()`, so clearing the badge would also
dismiss other accounts' mail notifications and calendar reminders.
Android launchers already badge the icon from the notifications on
display. For the same reason the Android-only background push task
does not update the badge.
The list resolved the Archive folder with a loose name match ("Archives",
"Archived" or any name containing "archive"), while the store's archive
actions only accept the `archive` role or a folder named exactly
"Archive". With a role-less "Archives" folder the swipe and bulk archive
were offered but silently did nothing.
`findArchiveMailbox` now uses the webmail's rules (role first, then the
exact name, case-insensitive) and both store archive actions resolve
through it, so the list offers archiving only when it will run and
otherwise shows the existing "no Archive folder" message.
The audit listed the four hydration waits in `restoreSession()` as
serial. They are already awaited together with `Promise.all`, and
restore only reads the stores after all four have settled, so the order
they finish in does not matter. The remaining queueing is inside
AsyncStorage, whose native side runs reads one at a time.
This test pins the concurrent wait: with all four stores stuck, restore
gives up after one `HYDRATION_TIMEOUT_MS`, not four.
The tab navigator subscribed to the whole `mailboxes` array to show one
badge. Every mailbox fetch sets a new array, so each refresh re-rendered
the navigator and, through its inline render callback, the mail list,
also while a message was open on top of it. It now selects the inbox's
unread count, a number that only changes when the badge does.
Both navigators also set `freezeOnBlur`, so tabs out of view stop
re-rendering until they are shown again, and so do stack screens more
than one level below the top. On Fabric, native-stack deliberately
keeps the screen directly below the top live so the back transition
can show it, so the list under an open message still re-renders
through its own store subscriptions.
The push onboarding card was laid out above the tab navigator. It
arrives 1.5 s into a launch, so it pushed the whole mail list down, and
it drew under the status bar because it ignored the top inset.
It now floats above the tab bar like the webmail's card and the toasts,
respecting the bottom inset, and fades and slides in (instantly when
animations are off), so nothing else moves when it appears or goes.