Release APKs are obfuscated since R8 was turned on, and nothing kept
the mapping that decodes their stack traces. The workflow now copies
`android/app/build/outputs/mapping/release/mapping.txt` to
`bulwark-mobile-<version>-<commit>-mapping.txt`, uploads it as a
workflow artifact and attaches it to the release next to the APK. The
in-app updater only picks `.apk` assets, so it ignores the new file.
If R8 is on (`android.enableMinifyInReleaseBuilds=true`) and no mapping
was written, the step fails instead of publishing an APK whose crashes
cannot be read. docs/android-release.md explains how to decode a trace
with `retrace`.
The splash background was #09090b in both modes, so in light mode a
cold start went from a near-black splash to the white first frame.
It is now #ffffff by day and #09090b at night (`values-night`), as
`splash.backgroundColor` and `splash.dark.backgroundColor` in
app.config.js already ask for, so a later `expo prebuild` writes the
same colours. Checked on the API 35 emulator: light mode stays white
from splash to login screen, dark mode is unchanged.
Every background push kept the app awake for the full 30 s timeout of
the BulwarkPushTask headless task, however quickly the task was done.
AppRegistry reports a finished task through the native
HeadlessJsTaskSupport module, and React Native 0.81 registers that
module only for the old bridge (CoreModulesPackage); the bridgeless
runtime's CoreReactPackage leaves it out. JS could never say it was
done, so the service, or the job, and its wake lock waited for the
timeout. BulwarkHeadlessJsTaskSupportModule provides the module under
the same name when the app runs bridgeless.
The push path also leaked a wake lock now and then. startHeadlessTask
called HeadlessJsTaskService.acquireWakeLockNow on the FCM worker
thread while the service took the same static lock on the main thread.
The method is not thread-safe, so both could create a lock, and the one
that was overwritten stayed held. The FCM thread now takes its own
wake lock, timed at 10 s, and the service drops it as soon as it holds
React Native's.
Measured on the API 35 emulator with the R8 release build:
- push handled by the job: 30,000 ms before, 75-155 ms now
- push handled by the service: the service ran 31 s before, it is
gone within a second now
- `com.facebook.react.i` wake lock still held 5 s after a push: 2 of
4 pushes before, 0 of 8 now
A push that arrived while the app was in the background started
BulwarkPushTaskService with `startService`. Android 8+ allows that only
while the app is temporarily allowlisted, which a high-priority FCM
message grants. The relay does send FCM data messages at HIGH priority,
but FCM delivers some at normal priority anyway (it downgrades apps
whose high-priority messages rarely end in a notification), and a
UnifiedPush distributor may not raise the app at all. The call then
threw `BackgroundServiceStartNotAllowedException` and the app crashed,
from the FCM thread or, for UnifiedPush, on the main thread.
The service start is kept as the first choice, so a high-priority push
works as before. When Android refuses it, the same headless task now
runs from a JobScheduler job (BulwarkPushJobService): expedited on
Android 12+, which runs at once while the app has expedited-job quota
and as a regular job after that, and a regular job on Android 8-11.
JobScheduler is a platform API, so nothing is added to the build;
WorkManager is not in the dependency tree. A generic "new mail"
notification built natively was the other option, but the relay's
payload cannot tell new mail from other changes and the native side
knows neither the notification settings nor what was already shown.
Checked on the API 35 emulator with the R8 release build, the process
killed and the push delivered past the allowlist:
- FCM data message: before, FATAL EXCEPTION on
Firebase-Messaging-Intent-Handle; now the job is scheduled 18 ms
after the refusal and runs the task
- UnifiedPush message (connector 3.3.5, sent the way a distributor
does): before, FATAL EXCEPTION on main; now the same job path
- allowlisted FCM message: still starts BulwarkPushTaskService
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
The files screen filled `files.selected_count` and
`files.delete_items_title` with its own `{name}` substitution, a
leftover from before t() took arguments, so they could not be
plurals. Both are ICU plurals now and every message on the screen gets
its values through t(). The upload progress line is one message
instead of "Uploading..." followed by the numbers.
The bottom tabs showed their route names ("Mail", "Calendar", …) in
every language; they now use the webmail's navigation names, and an
unavailable tab says so with one translated message instead of an
English "(unavailable)" suffix. The first loading screen is
translated as well.
The Unified Inbox selection toolbar, its back and clear-search buttons,
and the drawer's close, expand/collapse and remove-account buttons get
screen reader labels. Its unknown-sender fallback, selection count and
"account(s) could not be loaded" banner are proper translations and
plurals now.
The key harvest and the translation coverage test did not look at
App.tsx, which sits next to src/; they do now.
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
The week view's "all-day" label and its "Untitled" fallback were
hard-coded; they now use the catalog's "All day" and "(No title)" like
the scrolling time grid. The calendar's menu, tasks, view switch, new
event and previous/next buttons get screen reader labels (the view
buttons also say which one is selected), and a calendar i18n test
covers the calendar views.
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
A quick reply held by the undo-send delay could not be undone: the
undo bar lived only on the mail list, and the quick reply never
recorded the held send for it.
- The undo bar is now hosted on every stack screen through the
navigator's `screenLayout`, next to the toasts, and only the focused
screen's renders, so it shows wherever the send started and never
twice. The list's own instance is gone; archive, delete, move and
spam undo use the same bar as before. The composer gets none, since
its Undo reopens the composer.
- The quick reply records a held send through the same store action as
the composer, including the account it went out of, so Undo and
Send now reach a shared account's submission and Undo restores the
reply into that account's Drafts.
- The quick reply dismisses the keyboard after sending, which would
otherwise cover the bar.
Release builds had R8 and resource shrinking off, so every class of
every dependency went into the APK unminified: the published 0.1.62 is
42.8 MB for arm64, 25.85 MB of it dex (PF9 in the audit).
Turn both on through the properties build.gradle already reads
(`android.enableMinifyInReleaseBuilds` and
`android.enableShrinkResourcesInReleaseBuilds`). The consumer rules of
React Native, Expo, Firebase, ML Kit and the other libraries cover what
they load by reflection. The app's own package is kept whole, which
costs a few KB and keeps its frames readable in an obfuscated stack
trace.
Measured on a local x86_64 release build:
- APK: 44.7 MB -> 38.0 MB (download size 23.8 MB -> 17.2 MB)
- dex: 30.4 MB in 4 files -> 11.3 MB in 2 files
- classes: 27,631 -> 16,724
With `-dontobfuscate` the APK would be 38.8 MB (13.5 MB of dex), so
obfuscation stays on. Library frames in a release stack trace are now
obfuscated; retrace them with that build's
`android/app/build/outputs/mapping/release/mapping.txt`.
Checked on the emulator: cold start in light and dark mode, German and
Russian strings, SENDTO and VIEW mailto and SEND share intents, every
sign-in screen, the QR scanner with ML Kit, and an FCM data message
starting the headless push task.
Tick the parity items the fix pass finished (server-side recurrence,
filters and vacation for shared accounts, in-app file preview, the
trusted-senders toggle, UnifiedPush, sender taps in the viewer), and
note on the already ticked ones what this pass changed, such as the tag
view and search reaching shared accounts or contact photos as avatars.
Items that only moved forward say what is left.
Fix hints and "verified" lines that named removed functions
(`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`,
`buildRecurrenceOverridePatch`) or the old whole-map keyword writes now
point at what replaced them.
PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are
done, 36 open.
Apply the audit's "Stale parity checklist" section to docs/parity:
- ticked items the audit found broken keep their tick and name the
commit that fixed them; the date-locale item is unticked again, since
there is still no `dateLocale` setting
- the items that were done but unticked are ticked with where they
landed
- natural-language event input, print contact and the Sieve editor
gutter move to the N/A lists
- the text about linking, push types, RTL, `useTypography`, the Sieve
"Keep" and `FileNode/get ids:null` now describes main
The audit gains a "Fix pass, 2026-09-24" section after the summary. It
says how the pass was done and tested, and lists what the fix agents
could not settle from the code alone:
- the device and server checks that matter before a release
- the product calls, starting with the release signing key and secrets
that have to exist before the next version bump
- the follow-ups the agents left, one line each
Every audit item a commit on main fixed is ticked with the commit that
fixed it, including the "fix these ten first" list and the stale parity
entries that the fixes resolved. Items fixed only in part keep their box
and say what is left; U8, PF9, B2 and the product calls stay open.
The filter rule reorder, delete and remove-condition/action buttons,
the filter, Sieve and folder editor close buttons and the attachment
keyword remove buttons had no label. The Sieve editor's line count now
uses an ICU plural, and the settings i18n test covers every pane.
The Notifications pane shows a push setup failure as "<phase>: <message>"
with a translated phase but an English message, and the open-link
confirmation and the new-mail notification title were English too.
They now go through the locale store's t() (translate() in the
background task), with the technical detail passed as an argument.
The sign-in screen showed the auth store's raw English error ("Session
expired", "Invalid username or password") as the notice title. It now
runs it through describeLoginError like every other sign-in failure,
which also learns to explain an expired session with the webmail's
login.session_expired copy. The back and close buttons of the sign-in
steps get screen reader labels, and the email placeholders use the
webmail's translated example address.
Group recipient chips, the group size in suggestions, the upload
progress and the upload/send failure fallbacks were English or glued
together from a translated word and a number; they now use ICU
messages. The close, attach, schedule and sender-options buttons, the
recipient and attachment remove buttons and the From override switch
get screen reader labels (audit U8).
The filter sheet, the .eml import result, the selection count, the
folder totals and the offline sync banner were English literals or
built by gluing translated words to numbers. They now use t() with ICU
arguments and plurals; filter chips go through a "{field}: {value}"
message so translators control the order. The invitation trust
warnings used keys that no catalog had, so they are now literal t()
calls and harvested.
For screen readers (audit U8) the hamburger, select-all checkbox,
filter, sort and compose buttons, the selection toolbar, search and
chip clear buttons, and the close buttons of the move, tag, template,
identity, QR and action sheets get translated labels and roles.
TemplateSettings calls the translate function `tr` because `t` is a
template variable there, and both the key harvester and the coverage
test only looked for `t('...')`. Ten of its keys were never added to
locales/rn/en.json, so they could not be translated and the test did
not notice. Both now also match `tr('...')`, and the missing keys are
added.
The share sheet asked for files.share_role_* keys that no catalog has,
so Viewer, Editor and Manager stayed English everywhere; it now uses
the webmail's sharing.preset names and its "Share \"{name}\"" title.
The per-file menu, back and clear-selection buttons get screen reader
labels, and the "sharing is not available" error is translated.
The contact list, detail and edit screens, the group screen, the
import, picker and tag sheets and the contacts drawer were written in
English, including the activity panel ("Unknown", "Upcoming events"),
alert titles and every form label and option. They now go through
t(), reusing the webmail's contacts.* keys where the meaning matches,
with ICU plurals for member, contact and import counts instead of
`contact${n === 1 ? '' : 's'}`. JSContact context, phone feature,
personal-info and gender values on the detail screen are shown with
their translated labels instead of the raw keys.
Icon-only buttons on these screens (back, edit, more, bulk actions,
remove, close, clear search) get screen reader labels, and chips,
checkboxes and collapsible sections report their state.
A settings switch had a role and a checked state but no label, so
TalkBack only announced "switch, off". A switch inside a SettingItem now
takes that row's label, and the switches outside one (filter rules,
expanded view, template and sidebar app editors, filter rule options)
get their visible label passed explicitly.
The Contacts and Content & Senders panes, the swipe behaviour row in
Layout, the device time zone option in Calendar and the identity editor
placeholders were written as English literals, so they stayed English
in every language. They now go through t(), reusing the webmail's
settings.contacts, contacts.address_books and
settings.email_behavior.trusted_senders keys where the wording matches.
Counts use ICU plurals instead of `contact${n === 1 ? '' : 's'}`, and the
address book icon buttons get screen reader labels.
The settings i18n test now parses each pane with the TypeScript compiler
and also flags JSX text, alerts and accessibility labels, and covers
the newly cleaned panes.
The calendar placed events by the calendar time-zone setting, but
"today", the now-line and the "Today" button still went by the device
clock. With the setting away from the device zone, the grid could
mark yesterday as today, draw the now-line hours off and jump to the
wrong day.
- the views, the agenda's "Today"/"Tomorrow" headers and today anchor,
the selected day, the scroll window's start and the "Today" button
use `displayNow`, `isDisplayToday`, `isDisplayTomorrow` and
`displayNowMinutes`
- a long press at an hour already hands the editor a calendar-zone
time, which it converts when it saves
- changing the setting re-buckets the days and reloads the loaded
range, since the server reads floating times and the range bounds
in that zone
With the calendar time-zone setting away from the device zone, the
event and task editors showed device-local times but saved them
labelled with the calendar zone, so opening an event and saving it
unchanged moved it by the difference between the two zones.
The calendar now works in display dates like the webmail
(`toDisplayDate` / `fromDisplayDate`): Dates whose local fields read
as the wall clock in the calendar zone. They are the identity while
the calendar follows the device.
- `getEventStartDate` / `getEventEndDate` return display dates, so the
views, the detail sheet and the editor show calendar-zone times
- the editors convert back through `eventTimeFieldsToSave` and
`buildTaskEditorChanges`: a start, end or due the user left alone is
saved exactly as stored, a changed one is converted into the zone the
event or task lives in (new, floating and formerly all-day ones take
the calendar zone), and durations count whole days as calendar days
in that zone
- `getTaskDueDate` stays the real instant for reminders; the tasks
sheet uses `getTaskDueDisplayDate`, `displayNow` and
`isDisplayToday`
- wall clocks are parsed from their digits, so one inside the device's
own DST gap still converts right
- duplicating an event keeps its wall clock in its own zone instead of
reading the device time
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`.
The invitation banner loaded the .ics whenever it was handed a new
message object, so marking an invitation read or starring it
downloaded and parsed the calendar part again (and fetched its raw
text when the method had to be read from it).
It now loads once per message, account and calendar part
(`calendarInvitationKey`), and reads the current message only when the
load runs.
The viewer's message cache, the rendered documents of their bodies and
their measured heights stayed in memory after signing out or removing
an account. Their keys name the server and login, so no other account
saw them, but a signed-out user's mail should not stay around.
Signing out (one account or all) and removing an account now clear all
three. A load still in flight when the cache is cleared no longer
stores what it brings back.
An "All folders" search or a tag view holds rows of the own and the
shared accounts, and Stalwart hands out ids per account, so an own and a
team message can share an id. The list still named its rows by bare id:
selecting one of such a pair selected both, a batch action hit both, and
a swipe or single-row action took whichever came first, in the other
account.
The list now names every row by `rowKeyOf` (its id, scoped by its
account in a list spanning accounts; the bare id in a folder, so nothing
changes there) end to end:
- FlatList keys, row presses, swipes, selection and select-all
- thread expansion of a selection (`expandThreadSelection`)
- every store action the list calls: read, unread, star, pin, the
per-account keyword batch, move, archive, delete and spam, and the
rows they drop or keep in view
- the viewer's pages, which now come only from the rows of the opened
message's account (`listRowsOfAccount`), and its mark-read, which
goes through the store when the list holds that account's row
A key whose row is no longer loaded is ignored rather than guessed onto
the open folder's account. The unified inbox already keys its rows by
account and needed no change.
`buildTimedFullDayWeekSegments` tested every event against every day it
was given. That was fine for the paged week, but the scrolling week and
day grids hand in months of days. It now tests each event only on the
days it spans, with the same `isTimedEventFullDayOnDate` check, so the
strip and the timed grid still agree on every day (DST days included).
The time grid's own copy of this is gone.
The week view showed one week and only the arrows moved it, and there
was no day view at all (a "day" default view opened the agenda). With
free scrolling on (the default) both are now one strip of day columns
that scrolls sideways, as in the webmail (15a5497a):
- the week view shows seven columns and snaps to single days; the new
day view shows one column and moves a day per swipe; the title
follows the first day in view and the arrows step from it
- reaching either end adds days and loads only their events; days added
at the start keep the columns on screen in place
- the day headers and the all-day strip follow the sideways scroll on
the native thread; all-day bars are cut per week (per day in the day
view) so each part carries its title
- columns are windowed and lay out their events once per change
The day view is offered as a default view again. With free scrolling
off, or in right-to-left layouts, the week view pages as before and the
day view shows a single day.
The month view showed one month at a time and only the arrows moved it.
With the new "Free scrolling" setting (on by default, the webmail's
`calendarFreeScroll`) it is one continuous list of weeks, as in the
webmail (15a5497a, ae403b06):
- the month under the upper part of the grid is drawn at full strength,
names the title and is where the arrows step from; the first day of
each month carries the month's name
- reaching the top or the bottom adds weeks there and loads only their
events; weeks added above keep the rows on screen in place
- rows have a fixed height and snap to whole weeks, so the list stays
windowed and navigation lands exactly on the month
Turning the setting off brings back the paged month and a fixed 30-day
agenda. The week view still pages.
The agenda listed 30 days from the current date and the arrows paged
through it 30 days at a time. It now keeps a window of days that grows
as the user scrolls, like the webmail's agenda (60e05c00):
- reaching the end loads further ahead, doubling that side up to a year
- "Show earlier events" loads the past without moving what is on screen
- "Today" and the arrows scroll to their day while it lies inside the
window and start a fresh window there otherwise; the title follows the
day in view
- the "Today" row only anchors the list when today was actually fetched
Growing the window fetches only the new days: `extendRange` in the
calendar store loads the missing part and merges it into the loaded
window (up to about three years) instead of refetching all of it.
Navigation now goes through one focus for every view, so the week
view's arrows move the grid (it kept showing the selected day's week)
and the month arrows carry the selected day along.
Every language now carries its own `locales/rn/<lang>.json` with the
roughly 600 strings the webmail catalog does not have, so settings,
sign-in, files, calendar and push screens no longer fall back to
English.
Terms and the form of address follow each language's vendored webmail
catalog, and plural messages use the language's own CLDR categories.
Wording that had no precedent in the catalog would benefit from a
native speaker's review.
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.
Signing in sent two full `Mailbox/get`s before the inbox loaded, and
the first only went out once the main screen had mounted and painted.
There is no deliberate delay behind the gap: the mail screen's mount
effect started the folder load, and the push setup read the same
folders again with a `Mailbox/get` of its own (plus the shared
accounts') just to find the Junk folders for its filter. The latter
also happened on every start with push notifications on.
- Sign-in (password and OAuth/pairing) starts the folder load as soon
as the session is up, like a restored session already did.
- The mail screen's mount now joins a load on its way
(`ensureMailboxes`) instead of queueing another behind it.
- The push filter takes the folders from the mail store (joining its
load) and only fetches them itself when the store has none for the
account.
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.
The list sorts pinned (and unread, starred or tagged, with an order
preset) messages first with `hasKeyword` comparators, and Stalwart reads
their direction the other way round (#718). To find out, the app sent a
probe request before the first list load of every start, because the
verdict was only kept in memory.
A conclusive verdict (RFC order, inverted, or keyword sorts refused) is
now stored per server and account under a versioned key and reused on
later starts. It is probed again once it is a week old, since a server
upgrade may change it. An inconclusive probe is still only kept in
memory and retried after a few minutes.
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.
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.
Stalwart ids are only unique per account. When its opener named no
account, the viewer used the account of the folder open in the list,
and it resolved Trash, Junk and move targets from that folder as well.
Deleting a team message from the Unified Inbox therefore sent
`Email/set` to the user's own account and trashed an unrelated message
with the same id, and a message opened from a notification, deep link
or contact while a shared folder was open went to the shared account.
- the list names the open folder's account when it opens the viewer,
and the viewer takes the account from its opener only, fixed at open
- Trash, Junk, Inbox and move targets come from the message's account,
and delete and move take the message out of its own folder
- the store's delete and move take the viewed message: they only touch
the list's row when the list shows that account, and refuse folders
of any other account
- another account's message is marked read on the server directly
instead of through the open folder's list
- quick reply, unsubscribe and read receipts file into the message's
account's folders
- undo no longer slots another account's message into the open list
- Archive is only offered for mail of the open folder's account, since
it still resolves the archive folder from there
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.
Rows read in the Unread view (or unstarred in Starred, untagged in a
tag view) stay on screen, but they have left the server's result.
`loadMoreEmails` still counted them in `position`, so the next page
started that many messages too late, and the `emails.length >= total`
guard could stop paging while server rows were still unloaded.
Page from the number of loaded rows the query still returns, like the
webmail, and take the total from the load-more query so a list that
shrank under the user doesn't keep asking for a page that isn't there.
`inMailbox` is a mutable filter, so Stalwart's `Email/queryChanges`
reports every updated message as removed and re-added. The incremental
refresh dropped those rows and never re-fetched them because they
"already existed"; they only came back after a pull-to-refresh. It
worked only while `Email/changes` happened to list the same ids, and
the account-wide Email state that call needed was deleted whenever a
folder query returned no ids, so visiting an empty folder was enough
to break the next refresh.
- Fetch every id `Email/queryChanges` adds, including rows already in
the list, and put them back at their new index with fresh keywords.
- Keep the Email state per folder list (keyed by store mailbox id) and
run `Email/changes` from the list's own state, like the webmail's
`emailListSync`, so refreshing another folder can't move it past
changes this list hasn't seen. A list without a state is re-queried.
- An empty query result keeps the list's previous state instead of
discarding it.
- An added index past the loaded window is left to load-more instead
of being appended after a gap.
Opening Contacts re-ran the full `ContactCard/query` + `ContactCard/get`
for every account (58 KB on the audit server) although the cards are
loaded at startup and every live `ContactCard` state change already
refetches them.
The tab now only fetches when no complete fetch happened this session,
after a reset or account switch, after a filtered load, or when the
last full fetch is more than 5 minutes old, which covers changes missed
while the app was in the background or live updates were down. Pull
to refresh and the Refresh buttons still force a reload (PF6).
Typing in the Contacts search re-ran the full sort on each keystroke,
with `localeCompare` computing both display names and setting up a
collator for every comparison. The list is now sorted once per
contacts, category or sort-order change and search only filters the
sorted list; the sort itself computes each sort name once and reuses
a single `Intl.Collator` (PF10).
Port of the webmail's `sortContactsByLastName` (fb24ef5d). With the new
"Sort by last name" switch in Settings > Contacts, the contact list is
ordered and letter-grouped by "Surname, Given Middle", so family members
sit together. Cards without a structured surname fall back to the last
word of `name.full`; organizations, nicknames and bare addresses keep
their display name. The setting defaults to off, is persisted, and is
exported and imported under the webmail's key.
`name.full` feeds the vCard FN, which is mandatory; strict CardDAV
clients such as Apple Contacts drop cards without it. The app left it
out or let it go stale on several write paths:
- the contact form (and "Add to contacts", which goes through it) only
sent `full` when the optional display-name field was filled in
- editing a card put its stored `full` into that field, so renaming
the person wrote the old name back as FN
- vCard import dropped FN once it had split it into components
Port the webmail's fix (a210b22e): the form derives `full` from the
name components in display order unless a display name of its own is
set, and the edit form only shows `full` when it differs from what
would be derived, so a derived one is recomputed on every save. Import
keeps FN verbatim whether it comes before or after N, and a card with
N but no FN gets a derived `full` too.
The "Sender Favicons" setting only covered the in-app avatars. Push
notifications always fetched the sender's favicon from
icons.duckduckgo.com, so with the setting off every notification still
sent the sender's domain and the device's IP to a third party.
The webmail has the same setting, on by default, and fetches through
its own `/api/favicon` proxy, so DuckDuckGo sees the server rather than
the user. Native has no proxy, but turning the default off would
silently drop the logos existing users see and diverge from the
webmail, which is a product call. So the default stays on and the one
existing switch now covers every favicon fetch: the notification task
reads it from the persisted settings and shows initials when it is off,
or when the settings cannot be read.
Sender avatars ignored address-book photos, so a contact whose photo
shows in Contacts still got initials or a favicon in the mail list,
the viewer and the thread cards. Like the webmail's `Avatar`, the
contact photo now wins over the favicon and the initials; `disableImages`
(Junk) still shows initials only.
The lookup goes through a lowercased email -> photo index that is built
once per contacts array, and each avatar selects just its own photo
string, so a list of avatars neither rescans the address book per row
nor re-renders when an unrelated contact changes. Stalwart returns
photos as inline data URIs, which the index normalizes the same way
Contacts does, so no authenticated download is involved.
With "Sync trusted senders to address book" on, the viewer trusted every
address in every address book, so remote images and tracking pixels
loaded for any contact, including mail with a spoofed From that uses a
contact's address. The toggle also switched itself on the first time
Content & Senders was opened on a contacts-capable account, so merely
visiting settings widened trust to the whole address book.
Now only the local allow-list and, while sync is on, the dedicated
"Trusted Senders" book count, as in the webmail viewer. The setting
stays off until the user turns it on, and the viewer only fetches the
book while sync is on.
Hermes has no `Intl.PluralRules`, so `src/i18n/format.ts` fell back to
the English rule (1 is "one", everything else "other") for every
language. Russian showed "5 письма" instead of "5 писем", Polish,
Czech, Arabic and the other languages with more than two forms were
wrong in the same way, and French treated 0 as plural.
Add the CLDR 48 cardinal rules for the app's 27 locales and use them
whenever `Intl.PluralRules` is missing or rejects the locale. Engines
that have it keep using it; a test checks the built-in rules against
it for every locale.
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 bare `keep;` delivers to the default place, which on Stalwart is Junk
for a message its spam filter has already classified, so an allow-list
rule made with "Keep in inbox" changed nothing. The webmail now writes
`fileinto "INBOX";` for it (62465e1e), but saving filters on the phone
wrote `keep;` again and undid that.
The action stays `keep` in the metadata, so scripts written by either
client read back as "Keep in inbox" and save unchanged.
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.
zustand's JSON storage serialises the whole partialized slice and
writes it to AsyncStorage on every set(), including ones that only
flip `loading`. Opening one unread message rewrote the mail cache
about five times.
`createPersistStorage` now coalesces writes per store:
- a slice whose fields are the same references as the last one is
dropped without serialising; the email and contacts slices are
built through `memoizeSlice`, so a set() that only touches UI flags
hands over the same slice
- other changes wait up to 1 s and only the newest is serialised, and
JSON identical to the stored row, or to the row just hydrated from,
isn't written again
- pending writes are flushed when the app leaves the foreground and
when an account is signed out
- the account registry is still written straight away, since the
headless push task reads it from AsyncStorage
- "Refresh cached data" removes the rows through the stores, so their
storage forgets the old JSON and writes the re-fetched data back
AsyncStorage caps its Android SQLite database at 6 MB unless
`AsyncStorage_db_size_in_MB` is set. The offline cache keeps message
bodies in that database, defaults to 50 MB and can be set to 500 MB,
so it filled the database long before its own limit, after which every
AsyncStorage write failed, including the account registry, the outbox
and the mail list cache. Raise the ceiling to 1 GB. It is a maximum,
not a preallocation, and needs a native rebuild to take effect.
The whole email cache is one AsyncStorage row, and Android can't read a
row over ~2 MB. The row held every window the user had paged through,
in every folder of every account, stored the open folder twice (as
`emails` and in `mailboxSnapshots`), kept a stale copy of the active
account in `accountSnapshots`, and rows seeded from the offline body
cache carried whole message bodies. Once it outgrew the limit the next
cold start could not load it.
- the active view is persisted the way an account switch tucks it away
(`snapshotFromActive`), so the open folder is stored once, and
`merge` rebuilds `emails` from it with `viewFromSnapshot`
- `boundEmailCache` keeps at most 100 rows per folder (the largest page
size, so the next refresh can still patch the window) and 500
overall, the open folder first; a window is cut to a prefix, which
keeps its queryState valid, and a folder past the budget is dropped
- body parts are stripped from persisted rows and the active
account's stale entry is left out of `accountSnapshots`
500 typical rows serialise to about 0.3 MB.
zustand only marks a persisted store hydrated when its storage read
succeeds, and `restoreSession` waited on the account, email, calendar
and contacts stores with no timeout. A read that failed, such as an
email-cache row too large for Android's ~2 MB CursorWindow or a row
that no longer parses, left the app on the splash screen for good.
- the four stores now use `createPersistStorage`
(`src/stores/persist-storage.ts`), whose read logs the failure and
resolves as nothing stored, so the store hydrates with its defaults
and its next write replaces the bad row
- `waitForHydration` gives up after `HYDRATION_TIMEOUT_MS` (5 s) and
carries on, covering a hydration that throws in migrate/merge or a
storage call that never settles
`docs/android-release.md` walks through generating the release key
with keytool, creating the debug-to-release signing lineage with
`apksigner rotate` and which capabilities the debug key keeps, the
five repository secrets, and how to check the first rotated release
on the emulators. It spells out what rotation protects and what it
does not: devices that have not updated yet, Android 7.0-8.1, which
still verify the debug-signed v2 signature, and when the lineage can
be dropped. It also covers local release builds and the `versionCode`
formula. The README links to it instead of the bare workflow file.
`versionCode` was hard-coded to 1, so every release looked like the
same version to Android. It is now derived from `VERSION` as
`major * 10000000 + minor * 10000 + patch` (0.1.62 gives 10062), which
is above the 1 existing installs have and keeps growing, so Android
never mistakes an update for a downgrade.
The formula only increases while patch stays below 10000 and minor
below 1000. `build.gradle` refuses a `VERSION` outside that range or
outside Android's 2100000000 ceiling, and `scripts/bump-version.mjs`,
which only ever bumps patch, refuses to create such a version before
anything is committed or tagged.
Every published APK up to 0.1.62 was signed with the React Native
template's debug keystore, whose key is public. Anyone could sign an
"update" that Android installs over the app, with access to its
SecureStore credentials and mail cache. The release build type fell
back to the debug signing config whenever no release key was passed,
and the workflow carried on silently when `ANDROID_KEYSTORE_BASE64`
was empty.
- `android/app/build.gradle` reads the release key from the
`BULWARK_RELEASE_*` gradle properties or environment variables and
has no fallback. Without them a release build fails in the new
`checkReleaseSigning` task. It only runs for release variants, so
debug builds and `expo run:android` are unchanged. A local test
build can opt in with `-PallowDebugSignedRelease=true`, which is
ignored whenever `CI` is set.
- The release workflow fails in its first step when a signing secret
is missing, checks the keystore, alias and password before the
build, and refuses a debug key uploaded as the release key.
- An optional `ANDROID_SIGNING_LINEAGE_BASE64` secret re-signs the APK
with APK Signature Scheme v3 key rotation from the debug key to the
release key, from API 28, so existing installs on Android 9+ can
update and stop trusting the debug key afterwards.
- Before anything is uploaded, `apksigner verify` has to show the
release key as the Android 9+ signer, never the debug certificate.
- `*.keystore` and `*.lineage` files are gitignored.
Audit item S2 in `docs/audit-2026-09.md`.
Some senders ship the images their HTML body embeds by `cid:` as
`application/octet-stream` with no disposition and no name. The body
rendered them, but the chip filter only hid parts declared inline with
an image type, so they also showed up as nameless "Attachment" chips.
Port the webmail's rule: scan the HTML the body actually renders for
`cid:` references and hide a referenced part when it is an image or
generically typed. An explicit attachment disposition keeps its chip,
as does a referenced part with a real non-image type. A plain-text
render references nothing, so nothing extra is hidden there.
Previews keep the body's paragraph breaks, so a line-clamped row showed
a dangling "…" line and rows came out at uneven heights, and a subject
carrying CR/LF rendered over several lines in the list and the viewer.
Fold every whitespace run to a single space before display with a new
`singleLine` helper, in the mailbox and unified inbox rows, the viewer
subject and the collapsed thread card preview.
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.
The HTML and plain-text body documents had no direction, so an Arabic
or Hebrew message was laid out left-to-right: left-aligned, with its
sentence-final punctuation on the wrong side. Port the webmail's
`dir="auto"` on the body of both documents so the first strong
character picks the direction. An email that declares its own `dir`
on its `<html>` or `<body>` tag keeps it; the parser merges those
attributes into ours only while ours has none.
The shrink-to-fit reporter widens its wrapper to the content's natural
width and scales it from the top left. In a right-to-left body that
over-wide block overflowed to the left instead, so a 600px newsletter
scaled into the space left of the screen. A negative right margin now
keeps the widened wrapper flush left in either direction, so the
existing origin, pan clamp and pinch anchoring still hold.
The body stylesheet forced `max-width: 100% !important` onto every image
and table, which also beat a sender's inline max-width. An image sized
with `max-width:200px; max-height:134px; width:100%` grew to the pane
width while its max-height still clamped the height, so it rendered
squashed, and a table with `max-width:300px` spanned the pane.
Only force the cap on images and tables that declare no max-width of
their own, as the webmail does. Content wider than the pane is still
scaled down by the injected reporter, and images keep
`height: auto !important`.
The vendored catalogs were last synced on 2026-08-29 and had fallen
about a hundred keys behind, including the German update and the
translations of `email_viewer.forward_as_attachment`.
- re-vendor all 27 `locales/<lang>/common.json` from webmail e33ab899
- drop the overlay's `email_composer.send_failed`, which the webmail
catalog now carries with translations
- keep 15 keys the webmail renamed or dropped but the app still uses,
moved into `locales/rn/en.json` by `npm run i18n:harvest`
Bulk mark read/unread and star/unstar from the list selection ran the
single-message action once per message, so a large selection sent one
`Email/set` per message against Stalwart's limit of four concurrent
requests. They now go through `setKeywordForEmails`, which sends one
`keywords/$seen` or `keywords/$flagged` patch for the whole selection
(chunked by `maxObjectsInSet`). It also keeps rows that leave an open
Unread or Starred view until the view is re-opened, as the single
actions do.
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.
The mailto parser decoded `+` as a space, as in HTML form encoding, so
`mailto:alice+news@partner.example` became "alice news@…", failed
validation and opened nothing; a `+` in a subject or body was turned
into a space as well. RFC 6068 only uses percent-encoding, so a `+` now
stays a plus, like the webmail's `parseMailtoUrl`.
App compose links (`bulwarkmobile://compose?…`) went through the same
parser after `URLSearchParams.toString()`, which writes spaces as `+`.
They are now re-encoded with percent-encoding, and the recipient is read
from the `to=` parameter only, which also stops it from being added
twice.
Most apps start an email with `ACTION_SENDTO` and a `mailto:` URI. The
manifest offered the app for it, but React Native's Linking only
forwards VIEW intents, so the app opened on the inbox instead of the
composer.
MainActivity now rewrites a `SENDTO mailto:` intent into the equivalent
`VIEW mailto:` link before React Native sees it, on a cold start and
when the running app receives it. The EXTRA_EMAIL, EXTRA_CC, EXTRA_BCC,
EXTRA_SUBJECT and EXTRA_TEXT extras are folded into the link's query.
Bcc recipients of a mailto link now reach the composer too; they were
dropped for VIEW links as well.
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.
Adding a second account through the server's own OAuth opened the
authorization endpoint in the shared system browser with no `prompt`,
so the identity provider found the session of the account that was
already signed in and quietly handed back the same user.
When adding an account, the PKCE flow now sends
`prompt=select_account` like the webmail does (5771a213), or
`prompt=login` when the server's metadata lists no account picker, and
on iOS it runs the auth session ephemerally so the browser's cookies
don't carry over. The webmail hand-off gets the ephemeral session too.
Android's Custom Tabs have no private mode, so there the prompt is the
only guard.
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
The read receipt is assembled as raw RFC 5322 text, and every header
value in it comes from the message being answered. Stalwart returns
subjects already RFC 2047-decoded, so a sender could hide CRLF in an
encoded-word subject (or in the Message-ID or
Disposition-Notification-To) and have it interpolated verbatim. That
let them add headers such as Reply-To, or a whole body, to a message
the user's own account files in Sent and submits. `encodeHeaderWord`
passed any pure-ASCII value through untouched, CR/LF included.
Port the webmail fix: free-text values go through
`stripHeaderControls` (controls collapse to a space, then RFC 2047 if
non-ASCII), address-like values and message-ids through `headerAtom`
(controls and whitespace removed), and the From display name is quoted
when it uses specials.
A meeting invitation puts the join URL in the description - Teams, Meet,
Zoom and the plain iTIP fallbacks all do - and the event detail sheet
rendered it as bare <Text>. The one actionable thing in the event could
not be tapped: you had to select the URL by hand and paste it into a
browser. The mail body already linkifies (plainTextToSafeHtml, rendered
in the WebView); only the calendar was left out.
`splitTextLinks` (lib/linkify-text) splits the text into runs and marks
the http(s) URLs, using the same rule as plainTextToSafeHtml so a message
linkifies identically in the body and in the calendar. DetailRow gains an
opt-in `linkify` prop - set on the description row only, not on rows the
app composed itself - and hands each URL to `openExternalUrl` with
`confirm: true`, the same guard the "Open link" button on the video row
already uses: scheme allow-list plus the host confirmation, since this
text arrives in someone else's data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QPPAw4SK4M9Z7euVPM2cL
2026-09-04 09:49:44 +02:00
562 changed files with 75391 additions and 4442 deletions
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. Release APKs are never signed with the public debug key; see docs/android-release.md."
exit 1
fi
- uses:actions/checkout@v4
- uses:actions/setup-node@v4
@@ -30,7 +57,12 @@ jobs:
java-version:17
cache:gradle
# The action installs the legacy `tools` package by default, which the
# runner's cmdline-tools 16 no longer carries ("Failed to find package
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
- uses:android-actions/setup-android@v3
with:
packages:platform-tools
- name:Cache Android NDK / CMake
uses:actions/cache@v4
@@ -66,23 +98,38 @@ jobs:
- name:Install JS dependencies
run:npm ci
- name:Decode release keystore
id:keystore
# Checks the password and alias before the long build, and refuses a
# debug key that was uploaded as the release key by mistake.
if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
-storepass:env KEYSTORE_PASSWORD -file "$CERT"; then
echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD."
exit 1
fi
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
OWNER=$(keytool -printcert -file "$CERT")
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then
echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md."
if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then
echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer."
exit 1
fi
if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256."
exit 1
fi
if [ "$ROTATED" = "true" ]; then
echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md."
elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256."
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
- In-app sideload updates from GitHub Releases
## What's missing or rough
@@ -40,7 +40,7 @@ npx expo start
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
For release APK builds and signing see [docs/android-release.md](docs/android-release.md).
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
android:pathData="M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z"/>
<stringname="widgetprovider_inboxwidget_description">Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.</string>
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
# AsyncStorage caps its SQLite database at 6 MB unless this is set. The
# offline mail cache keeps message bodies there and can be set to 500 MB,
# and once the database is full every write fails, the account registry
# and the outbox included. This is a ceiling, not an allocation.
3. In the workflow run, "Verify the APK signature" prints the release key's
SHA-256 as the Android 9+ signer and `fac61745…3b9c`, the debug key, as the
Android 7-8.1 signer, followed by a warning about the latter. The warning is
expected for as long as the lineage is used.
4. Check it on the emulators before you announce anything:
- Install 0.1.62 (`gh release download 0.1.62 -p '*.apk'`) on
`Pixel_6_Pro_API_32` and `Pixel_7_API_34` and sign in. Then `adb install -r`
the new APK. It has to install over 0.1.62 and keep the account.
- Set `VERSION` above the new release locally (don't commit it), build with
`-PallowDebugSignedRelease=true` (see [Local release builds](#local-release-builds))
and `adb install -r` that APK over the updated app. It has to fail with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
5. Add a line `severity: security` to the release notes (web UI or
`gh release edit`). The in-app update banner then cannot be dismissed, which
matters because a device on 0.1.62 accepts a debug-signed "update" until it
has installed this release.
## What rotation does and does not protect
| Device | After it installs a rotated release |
| --- | --- |
| Android 9 and newer (API 28+) | Updates in place and keeps accounts and data. From then on it accepts only updates signed with the release key. An APK signed with the debug key is refused, and so is one that carries a lineage from the debug key to some other key. |
| Android 7.0-8.1 (API 24-27) | Updates in place, but these versions do not read v3 signatures. They check the v2 signature, which the workflow still makes with the debug key so that they can update at all. They keep accepting debug-signed "updates". |
What it does not cover:
- **Devices that have not updated yet.** Until a device installs a rotated
release it trusts the debug key as before. Whoever gets a debug-signed APK onto
it first can attach a lineage to their own key and keep the app for good. The
window closes for each device when it updates, which is why the release should
carry `severity: security`.
- **Android 7.0-8.1**, as long as the lineage is used. New installs on those
versions also start out trusting the debug key. The only fix is
[ending the migration](#ending-the-migration), after which they need a
reinstall.
- **Anything that already happened.** Rotation does not repair a device that
already runs a malicious "update".
- **The release key itself.** Rotation moves the trust to
`bulwark-release.p12`. It is only as safe as that file and its password.
The workflow passes `--rotation-min-sdk-version 28` to `apksigner sign`. By
default apksigner puts the rotated key in a v3.1 block that only Android 13+
reads and signs the v3.0 block with the old key, which would leave Android
9-12L on the debug key.
If you restricted the Firebase API key in `google-services.json` to Android apps
by certificate fingerprint in the Google Cloud console, add the release key's
SHA-1 there before the first release and keep the debug key's entry until the
migration has ended. Otherwise push registration can fail. An unrestricted key
needs nothing.
## Ending the migration
Keep `ANDROID_SIGNING_LINEAGE_BASE64` set while installs of 0.1.62 or older may
still be around: without it they cannot update at all.
Once you delete it, every APK is signed with the release key alone:
- Devices that already moved to the release key keep updating normally.
- Anything still on 0.1.62 or older, and every Android 7.0-8.1 install, refuses
the update. Those users have to uninstall, reinstall and sign in again, and
they lose local data. Afterwards their devices trust only the release key.
## Local release builds
CI signs releases, so you normally don't need the release key on your machine.
For a local release build, for profiling or checking R8, opt in to the debug key:
@@ -54,7 +54,7 @@ RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pa
- What RN does: `rewriteSessionUrls` only rewrites absolute URLs; `extractOrigin` returns null for `/jmap/` so the relative value is passed through unchanged and `secureFetch('/jmap/')` fails (`src/api/jmap-client.ts:320-340`). Same in the unified inbox `rewriteApiUrl` (`src/api/unified-inbox.ts:55-60`).
- Fix hint: when the URL has no `^https?://`, return `serverOrigin + (url.startsWith('/') ? url : '/' + url)`; keep the plain string splitting (the comment about the RN URL polyfill corrupting templates still applies).
- [x]**Redirected session fetch may lose the Authorization header** — `P3` — `partial` — fixed in 0b1c240
- [x]**Redirected session fetch may lose the Authorization header** — `P3` — `partial` — fixed in 0b1c240; the check keyed on `response.redirected`, which React Native's fetch never sets, so it only works since 859770b (audit B15)
- What WEB does: `fetchSessionResponse` detects a redirected 200 with no accounts/username (Safari and some auth proxies drop `Authorization` on redirect) and refetches `response.url` with the header (`lib/jmap/client.ts:911-925`).
- What RN does: `fetchSession` trusts the redirected response (`src/api/jmap-client.ts:363-383`); Stalwart 0.16.19 answers `/.well-known/jmap` with a 307 to `/jmap/session` (verified against stw-test19), and iOS `NSURLSession` can strip the header on redirect, which ends in `resolveAccountId` throwing "No account found in JMAP session".
- Fix hint: mirror the WEB check (`response.redirected` and empty `accounts`/`username` → refetch `response.url` with the header).
@@ -106,12 +106,12 @@ RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pa
- What RN does: `connectWithOAuth` uses `session.username || accessToken.slice(0, 8)` (`src/api/jmap-client.ts:135-136`), so the same mailbox logged in via password and via OAuth can produce two registry entries, and the drawer synthesizes `username@host`.
- Fix hint: read `Identity/get` after connect and use the primary identity email for `email` (and ideally for the registry id).
- []**Cannot remove a non-active account** — `P2` — `missing` (changelog 1.7.8 "Remove a specific account from the switcher")
- [x]**Cannot remove a non-active account** — `P2` — `missing` (changelog 1.7.8 "Remove a specific account from the switcher") — done in 6ae21d5, 490355c
- What WEB does: per-row remove button in the switcher (`components/layout/account-switcher.tsx:138-143, 322-333`) backed by `removeAccount` which tears down the client, evicts caches and clears cookies (`stores/auth-store.ts:1382-1401`).
- What RN does: only "Sign out of <active>" and "Sign out all" (`src/components/SidebarDrawer.tsx:400-437`).
- Fix hint: add a per-row remove (long-press or trailing X) that runs `teardownPushNotificationsForAccount(id)`, `jmapClient.clearAccountCredentials(id)`, `useEmailStore.removeAccount(id)`, `accountStore.removeAccount(id)`.
- []**Switcher shows stale connection state and no error indicator** — `P3` — `partial`
- [x]**Switcher shows stale connection state and no error indicator** — `P3` — `partial` — done in 6ae21d5 (the drawer shows `hasError` with its message)
- What WEB does: shows `hasError` (alert icon) with `errorMessage` and a live `isConnected` dot per account (`account-switcher.tsx:296-303`).
- What RN does: renders only the `isConnected` dot (`SidebarDrawer.tsx:355-361`); `hasError`/`errorMessage` (set in `restoreSession`) are never displayed, and since only the active account is ever connected, other rows show whatever value they had last.
- Fix hint: render `hasError`; show the dot only for the active account (or treat inactive as "cached").
@@ -126,17 +126,17 @@ RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pa
- What RN does: `authMode = props.authMode ?? 'basic'` and `quotaUsed = props.quotaUsed ?? 0` (`AccountSettings.tsx:46-47`) but `SettingsScreen` renders `<Component />` with no props (`SettingsScreen.tsx:242`), so OAuth accounts read "Basic" and the storage row never appears (RN has no `Quota/get` at all — flag for the mail/settings audit).
- Fix hint: derive from `jmapClient.usesBearerAuth` (or persist `authMode` on `AccountEntry`); fetch quota via `Quota/get` when `urn:ietf:params:jmap:quota` is advertised.
- []**Hard account cap of 5** — `P3` — `partial`
- [x]**Hard account cap of 5** — `P3` — `partial` — done in 6ae21d5 (cap raised to 10, `src/lib/account-utils.ts:4`)
- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (`lib/account-utils.ts:75-107`).
- What RN does: `MAX_ACCOUNTS = 5` (`src/lib/account-utils.ts:1`) although only the active account holds a live connection.
- Fix hint: raise the constant (or drop the cap) once the unified inbox cost per account is acceptable.
- [ ]**Shared/group account settings scope is missing** — `P3` — `missing` (changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — deferred: shared accounts are now listed read-only in AccountSettings (490355c); scoped filter/vacation stores need an accountId parameter first
- [ ]**Shared/group account settings scope is missing** — `P3` — `missing` (changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — partly: shared accounts are listed in AccountSettings (490355c), and tapping one scopes Filters and Vacation to it since a86cd1d; calendar and contacts settings are not scoped yet
- What WEB does: lists `client.getSharedAccounts()` (non-primary) on the Accounts page and enters a scoped settings mode (filters, vacation, calendars, contacts) via `managed-account-store` (`components/settings/account-settings.tsx:43-48, 112-118, 225-262`; `stores/managed-account-store.ts`).
- What RN does: `getSharedMailAccounts()` exists for mail/unified inbox (`src/api/jmap-client.ts:480-495`) but there is no shared-account listing or scoped settings.
- Fix hint: list non-personal `session.accounts` in `AccountSettings` and pass an `accountId` into the filter/vacation stores.
- [x]**Unified-inbox shared-account filter disagrees with `getSharedMailAccounts`** — `P3` — `rn-only-bug` — done in 6ae21d5
- What WEB does: a non-personal account counts as mail-capable even when its `accountCapabilities` omit mail (`lib/jmap/client.ts:4421-4445`); RN's own `getSharedMailAccounts` mirrors that (`jmap-client.ts:486-493`).
- What RN does: the unified inbox requires `accountCapabilities == null || MAIL in accountCapabilities` (`src/api/unified-inbox.ts:208-210`), so a shared account that advertises other capabilities but not mail is silently skipped there while it appears in the sidebar.
- Fix hint: reuse the `!advertisesMail && info.isPersonal → skip` rule from `getSharedMailAccounts`.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.