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.
On a cold start the splash was followed by about a second of blank
white, then mail rows whose header sat under the status bar until it
popped in and pushed the list down.
- `AppTheme` had no `android:windowBackground`, so while JS loaded the
window showed the platform default (near-white, or grey at night).
It is now the app's own background, #ffffff by day and #09090b at
night (`values-night`), matching `src/theme/tokens.ts`.
- `NavigationContainer` had no theme, so React Navigation painted its
light default behind and between screens even in dark mode. It now
uses the palette's background.
- The mail list took its top inset from the native `SafeAreaView`,
which on Fabric only learns the insets a few frames after mounting.
It now pads with `useSafeAreaInsets()`, which the navigator's
safe-area provider fills from the initial window metrics on the first
render; the value is the same, it just arrives in time.
`index.ts` imported `App` at the top, so when an FCM data message
started the headless push task in a cold process, the bundle evaluated
every screen, store, catalog and icon before the task could show its
notification.
The headless task is still registered at the top of `index.ts`, but the
root component now requires `App` when the UI first renders. A headless
start therefore only evaluates the task and its own imports; a normal
launch evaluates the same modules as before, one render later.
Measured with `npx expo export --platform android` (modules reached
through top-level requires from the entry, i.e. what a headless start
evaluates): 1,516 modules, 7,238 KB -> 202 modules, 745 KB.
`src/lib/calendar-locale.ts` imported its 27 date-fns locales from the
`date-fns/locale` barrel, which re-exports all of date-fns' ~100
locales. Metro bundled every one of them and evaluated them at startup
through the calendar screens.
Each locale is now required from its own `date-fns/locale/<code>`
module the first time `getDateFnsLocale()` asks for it; en-US stays a
static import as the fallback. A test checks that every UI language
still maps to the right date-fns locale.
Measured with `npx expo export --platform android`:
- modules in the bundle: 2,469 -> 2,114 (date-fns locale modules:
531 -> 170, 1,213 KB -> 358 KB unminified)
- Hermes bundle: 8.19 MB -> 7.61 MB
- modules evaluated at startup: 2,038 -> 1,516 (date-fns locale
modules: 531 -> 10, i.e. only en-US)
93 files import icons from the `lucide-react-native` barrel. Metro has
no tree shaking, so the barrel pulled all 1,694 icons into the bundle
and evaluated every one of them at startup.
A small Babel plugin (`scripts/babel-plugin-lucide-imports.js`, wired
up in the new `babel.config.js` on top of `babel-preset-expo`) rewrites
each named import to the per-icon module the barrel re-exports, e.g.
`import { Mail } from 'lucide-react-native'` becomes a default import of
`lucide-react-native/dist/esm/icons/mail.js`. The name table is read
from the package's own barrel, so aliases such as `MailIcon` or renamed
icons keep resolving; an unknown name fails the build. The package's
`exports` map only lists the barrel, so the new `metro.config.js`
resolves these deep paths as plain files instead of warning about each.
A test checks that every icon imported in the app maps to an existing
module and that no default or namespace import brings the barrel back.
Measured with `npx expo export --platform android`:
- modules in the bundle: 3,995 -> 2,469 (lucide: 1,701 -> 175)
- Hermes bundle: 9.34 MB -> 8.19 MB
- modules evaluated at startup: 3,564 -> 2,038
Metro does not key its transform cache on the Babel config, so a warm
dev cache keeps serving the old barrel imports until
`npx expo start --clear`.
All 27 locale catalogs were imported statically by `src/i18n/index.ts`,
so every cold start evaluated about 4.3 MB of JSON before the first
frame, although only English (the fallback) and the active language are
ever read.
Each catalog is now required from a per-locale loader the first time
`translate()` or `getDictionary()` asks for it. `t()` stays synchronous;
after a language switch the next render loads the new catalog. English
stays a static import because every lookup falls back to it, and the RN
overlay is merged when a catalog is first built, as before.
Measured with `npx expo export --platform android` (unminified bundle,
counting modules reached through top-level requires from the entry):
- catalogs evaluated at startup: 28 modules, 4,287 KB -> 2 modules,
193 KB (English and the RN overlay; a non-English user also builds
their own catalog on the first render)
- modules evaluated at startup: 3,590 -> 3,564; their code: 15,219 KB
-> 11,122 KB
- the Hermes bundle keeps its size (9.34 MB); the catalogs are still in
it, they are just not evaluated until used
Stalwart only files spam into Junk when no rule decided where the
message goes, so the webmail now adds
`not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"`
to every move and copy rule when the server supports `spamtestplus`
and `relational`, with a per-rule "Also move messages marked as spam"
opt-in stored as `includeSpam` (6241ed61). Saving filters on the phone
dropped the guard and the opt-in, so every folder rule started pulling
spam out of Junk again, and opted-in rules lost their setting.
Port the guard and keep `includeSpam` in the rules metadata. The rule
editor gets the opt-in switch while the rule moves or copies mail.
The round-trip fixtures now cover the guard and a script with every
newer feature plus a hand-written rule. Both clients add two blank
lines before a script's external rules on each save, so that fixture is
compared with what the webmail itself writes when it saves it again.
The webmail now stores the target folder's id on move and copy actions
and writes `fileinto :mailboxid "<id>" "<path>"` when the server lists
`mailboxid`, so a rule keeps working after the folder is renamed. It
also offers "Keep a copy" on forwards (`redirect :copy`) and sets flags
before the move, because `fileinto` stores the flags set at that moment
(dadfb08f). The phone did none of this: saving filters on the phone
turned every `:mailboxid` target back into a bare path, forwards with a
copy into plain redirects, and a rule that stars and moves mail stopped
starring it if the move was picked first.
Port the generator and parser changes and pass the server's Sieve
extensions to the generator. In the rule editor:
- folders are picked by id, and saving stores the id next to the path
and refreshes the path from it, so older rules pick up the id;
- a folder that isn't listed stays selected instead of being replaced;
- forwards get the "Keep a copy" switch;
- changing an action's type drops the id and the copy flag it no
longer uses.
Stalwart runs one active Sieve script and turns the auto-reply on by
activating its own `vacation` script. The webmail therefore re-activates
the filters script with `include :personal :optional "vacation"` and
records `includeVacation` in the rules metadata (198a3c0d). The phone
dropped both, so saving any filter on the phone silently switched the
auto-reply off, and turning the auto-reply on from the phone switched
every filter off.
Port the webmail's handling:
- the generator writes the include (and `require "include"`) and the
parser reads the flag back, dropping the `include` require once the
auto-reply is off;
- loading filters while the vacation script is active keeps it through
the include, as does every save;
- switching the auto-reply on or off in the vacation settings adds or
removes the include, and the settings read the include as "on",
because VacationResponse reports it off in that state;
- the "vacation active" banner also shows for an include.
Scripts the webmail wrote with the include now save back unchanged;
`fixtures/webmail/` holds scripts from its current generator.
Back inside a Files subfolder switched to the Mail tab. It now closes an
open preview, then leaves selection mode, then goes up one folder, and
only switches tabs at the root. `filesBackStep` holds that order.
Tapping a file in the Files tab downloaded it and went straight to the
Android "Open with" chooser or the iOS share sheet. It now opens the
reader's attachment preview for the types that preview can show, and
hands only the rest to another app, as before.
- `filePreviewKind` (`src/lib/file-preview.ts`) builds on the reader's
`previewKindFor` and adds source code and config files as text; HTML,
SVG, images React Native cannot decode and PDFs on Android (no in-app
renderer) still go to another app
- files over a cap per type (text 512 KB, .eml 10 MB, images 25 MB,
PDFs 50 MB) open in another app instead; when a node has no size,
the downloaded file is checked
- the preview downloads the file once, with the same authenticated
download as Download, into a temp folder of its own; Open with, Share
and Download reuse that copy, so they work without another fetch
- the folder is deleted when the preview closes, or left to the
stale-export sweep, which now also clears preview folders, once
another app got the file
- the per-file menu offers Preview and "Open with…" instead of the
combined "Preview / Share"
- the shared preview gains a header Open button, an optional Download
button, labels on its icon buttons and a fallback when an image does
not decode; the .eml parsing moves into `emlPreviewFromBytes`, which
the reader uses too
Ported from webmail 8cbc5bc5 and 6f99c017. Stalwart can emit the
organizer both as the ORGANIZER-derived participant and as an ATTENDEE,
so the detail sheet listed them twice and counted a phantom
participant. It also drops the organizer's display name, leaving a bare
address, and the organizer without a status got the pending dot.
- fold participants with the same address into one entry, keeping a
real RSVP over needs-action, and count addresses in
`getParticipantCount`
- show the organizer as accepted when they have no status, with the
status label like everyone else
- fill missing names from contact cards (exact address, then the same
local part on a related domain), then the account and identity
names, via `useContactNameResolver`; the editor uses it for the
organizer name of a new invitation
`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
The editor reset its form whenever `calendars` or `currentUserEmails`
got a new identity, which happens when a calendar push refetches the
calendars or the alias lookup finishes, so the title and times the user
had typed were replaced mid-edit. Reset only when the editor opens or
moves to another event, and fill in the calendar separately when the
calendars arrive after a new-event form opened.
Reminders were rebuilt from the events of the window the calendar
showed, and everything else was cancelled, so looking at another month
dropped every pending reminder. The sync only started once the Calendar
tab opened, and without a notification handler a reminder that fired
while the app was open was never shown.
- load the events for reminders from their own window (now to a week
past the horizon), independent of the view, via `loadEventsInRange`
factored out of `fetchEvents`
- leave scheduled reminders alone when that window can't be loaded
(offline, no session yet), cancel only ours, and cancel them all on
sign-out
- start the sync at launch and rerun it after sign-in or an account
switch, on `Calendar`/`CalendarEvent` state changes, on in-app edits
and when the app returns to the foreground; only the Calendar tab may
ask for notification permission
- install a foreground handler that shows our reminders; mail push is
posted natively and doesn't go through expo-notifications
A task's `due` is wall time in the task's `timeZone`, but the tasks
sheet, the calendar chips and task reminders read it as device time,
so a task due at 17:00 Europe/Berlin showed and fired at 17:00
wherever the phone was. Events already go through `utcStart`.
- resolve the due in its zone with a new `getTaskDueDate` and use it
for the list, sorting, overdue, the chips and reminder times
- save an edited timed due with the effective time zone, as the event
editor does, so the converted local time isn't read back in the
task's old zone
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
Ticking a task sent `progressUpdated`, which JSCalendar 2.0 dropped and
Stalwart rejects with `invalidProperties`, so the whole update failed.
The checkbox fired the toggle with `void`, so the task silently stayed
open.
- drop `progressUpdated` from the toggle patch, like webmail 6b30e91c
- flip the checkbox optimistically and put it back when the update
fails
- show a "Failed to update task" alert with the server's reason
The composer offered 8 text colours and no background colour. It now
has the webmail's 2 x 8 palette (7e0a86a6, 3957b358) for text colour,
and a background colour button with the same palette and a "Remove
background color" action, applied with the editor's `hiliteColor`.
The webmail's font-size picker is not ported: the editor has no mark
model to scope a size to, so it needs a selection-rewriting command in
the page script that can only be checked on a device.
The editor page posted its full `innerHTML` on every input event and its
formatting state on every selection change, which fires on every
keystroke too. Each message re-rendered the whole composer, where a
`JSON.stringify` snapshot of the body ran on every render (PF8).
- typing now posts at most one `change` per 400 ms: the first keystroke
at once, so the composer knows it is dirty, then the latest content
when the window ends; toolbar commands and blur post straight away
- the formatting state is posted only when it changes
- the empty-placeholder check no longer serializes the body per
keystroke, and the dirty-tracking snapshot is memoized
- autosave reads the live editor DOM, like send, save-and-close and the
format switch already do, so the throttle can't make it save a
slightly older body
The body stays in state rather than a ref: the Send button and the
close guard render from it.
Quick replies went out with no signature at all. They are now signed
the way the composer signs a plain-text reply: between the reply and
the quote or at the end, following the signature position setting, with
the "-- " separator when it is on, and with the primary identity's
signature when the sending alias has none of its own.
The quick reply also resolves its sending identity like the composer
(webmail 4fc7b8b5): recipients in To, Cc, Bcc order, and a reply to our
own message from the identity that sent it. It still never takes the
catch-all From rewrite, having no From row to show it.
The composer's format came only from the "plain text only" setting, and
a reopened draft was forced into it: with plain text on, an HTML draft
was flattened on its first autosave, and with it off a text draft opened
in the rich-text editor.
Ported from the webmail (f6ad8a59, `compose-format.ts`):
- a reopened draft opens in the format it was written in; an empty
draft and new messages still follow the setting
- a toolbar button switches just this message between rich and plain
text, converting the body in place; the signature keeps its place
(end or above the quote) in the target format, so it is never
doubled or lost
- images inserted into the rich-text body stay on as ordinary
attachments after a switch to plain text instead of being dropped
A new message composed while a shared or group folder was open always
defaulted to the user's own identity: `findComposeIdentityId` was
ported from the webmail but never called.
The composer now reads the folder that was open when it was started and,
for a shared folder whose account name is an address the user has an
identity for, preselects that identity (webmail 129f545a, a971d5a1).
Own folders resolve to nothing and keep the preferred identity. Unlike
the webmail this isn't gated on "Reply from received address": the
webmail gates it only because it falls back to the login address, which
would override a chosen default identity (#507), and this path has no
such fallback.
Ported from the webmail (565d5c10). With "Reply from received address"
on, a new "Received address matching" choice limits the catch-all From
rewrite: "Exact address only" keeps replies to configured identities,
"Any address on my domains" (the default, and the old behaviour) also
sends from other addresses on an identity's domain. Deployments whose
extra addresses are distribution lists rather than aliases want exact.
The setting is `replyIdentityMatch`, the webmail's key, so it round-trips
through settings export and import. The toggle's description now says
that identities always match, since that no longer depends on it.
Replies went out from the default identity unless "Reply from received
address" was on, because that setting gated both the match against the
user's own identities and the catch-all From rewrite. Forwards took the
rewrite too, and a reply to one's own sent message resolved the identity
from the other party's address. Ported from the webmail (4fc7b8b5):
- the own-identity match is unconditional and applies to forwards;
only the catch-all From rewrite stays behind the setting, and never
on a forward
- a reply to our own message keeps the identity that sent it (#703)
- `findReplyIdentityId` scans recipients in To, Cc, Bcc order instead
of the identity list's, and prefers an untagged identity over a
differently tagged sibling
The composer's editor page had no Content-Security-Policy, so quoting a
message loaded every remote image and tracking pixel in it, even when
the viewer had shown that message with remote content blocked.
The editor page now carries a CSP that mirrors the viewer's: nothing
loads but our inline script, styles and images, and remote images and
fonts are blocked whenever the quoted original is one the viewer would
block (untrusted sender and a policy other than "allow"). A reopened
draft that carries a quote counts as untrusted. Inline and pasted
images (`data:`, `blob:`) keep working, and the sent HTML is unchanged.
"Save draft" returned early without saving when the identities hadn't
loaded (offline when the composer opened) or the account had no Drafts
folder, and the close dialog then closed the composer as if the save had
worked, dropping the text.
The close dialog now offers only Cancel and Discard in that case and
says why the draft can't be saved; the save path refuses to close too.
The close guard moves from a bare `beforeRemove` listener to
`usePreventRemove`, which also stops the iOS modal swipe-down that the
listener could not prevent.
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 mail list passed `onEndReached` and `onRefresh` as inline arrows,
so FlatList (a pure component) re-rendered on every render of the
screen, and without `strictMode` it then handed its cells a new render
function, so every mounted row re-rendered too. Each row also built a
fresh swipe `context` object and `onAction` closure, so its
`SwipeableRow` could never skip a render. Loading the next page while
scrolling flips `loading` and appends rows, re-rendering up to a couple
of hundred rows mid-fling.
- `onEndReached` and `onRefresh` are stable callbacks and FlatList runs
with `strictMode`, which the default React Native typings lack, so
`src/types/react-native-flatlist.d.ts` declares it
- each row is an `EmailListItem`, memoized on primitives and stable
callbacks, which builds its swipe context and callback once per row
- rows share one swipe callback that reads the current handler through
a ref, so rebuilding the handler no longer re-renders every row
Every row in the mail list rendered both swipe action bands behind its
content, with an icon, a label and four fresh `Animated` interpolations
on each render, although a band is only visible while the row is
dragged or held open. `SwipeableRow` was also not memoized.
- the bands now mount when a drag is granted and unmount once the row
springs back to rest (or has flown off), where they are covered and
fully transparent, so the swipe looks the same
- the band interpolations are built once per row
- `SwipeableRow` is memoized; `context` compares by its fields since
callers build it per row
Every mail row formatted its date through `toLocaleTimeString` and
`toLocaleDateString`, which build a new Intl formatter on each call; a
row from the last week built two, on every render. On Hermes that goes
through ICU over JNI and is the costly part of formatting a date.
`formatListDate` now keeps one `Intl.DateTimeFormat` per locale and
format in a module-level map. The output is unchanged, since those
methods format exactly as `Intl.DateTimeFormat` with the same options;
a test checks the two against each other across locales, formats and
ages. The cache starts over when the device's UTC offset changes, so a
new time zone still shows as it did when every call picked it up.
The notification only carried the local account it was posted for, so
tapping one for a message in a group or shared mailbox opened the
thread against the user's own JMAP account, where the message doesn't
exist.
The background task now passes the JMAP account the message was
fetched from as `jmapAccountId`. The native module stores it as an
intent extra, validated like the other ids, and hands it back with the
tap. `App.tsx` routes the open with it, the way the unified inbox does
for group messages. Notifications posted before this change have no
such extra and open against the account's own mail as before.
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.
The viewer fired its store actions with `void` and went back to the
list, so a refused delete, archive, move, spam report, star, tag or
read change failed silently, and nothing confirmed a sent message.
- each viewer action now reports a failure as an error toast, and a
refused star, tag or read change is taken back on screen
- the viewer mounts its own toast host, since the list's sits under it
- an immediate send from the composer or the quick reply box shows
"Email sent"; a send held for the undo delay keeps its undo bar
(webmail `ec67ab99`, `b03a0c1d`)
Viewer Archive looked the message up in the loaded list and silently
did nothing when it was not there: for messages opened from the Unified
Inbox, a notification or a deep link, and for any message past the
loaded page. It also resolved the Archive folder from the folder open
in the list, so it was only offered for mail of that folder's account.
`archiveEmail` now takes the viewed message like delete, move and spam
do: it archives the viewer's copy into the Archive folder of the
message's own account (year/month foldering included) and leaves the
open list alone when that is another account.
The viewer's Spam and Not spam buttons were a plain move into Junk or
Inbox: they sent no `$junk`/`$notjunk` flip, so the server's classifier
never learned from them, and they ignored the "trash-and-read" delete
action. They now call the store's `markSpam`/`unmarkSpam`, the path the
list and swipe actions use, which take the viewed message and file it
in its own account's Junk or Inbox, also when the list does not hold
it. Not spam is only offered when that account has an Inbox.
The viewer's pager followed the store's live list. New mail or a
refresh shifted the rows under the fixed scroll position, so the
screen switched by itself to another message while Star, Delete and
Archive kept acting on the one that had been opened.
The pages are now taken once when the viewer opens, in `viewerPages`,
so the page on screen and the message the toolbar acts on stay the
same. Delete, archive, move and spam leave the viewer as before, so no
page is left showing a message that is gone.