Compare commits

..
370 Commits
Author SHA1 Message Date
Linus Rath 53c0bc0fc2 chore: update version number to 0.1.63 2026-09-24 22:18:42 +02:00
Linus Rath a8e74c8b6d Merge branch 'main' of https://github.com/bulwarkmail/native 2026-09-24 22:18:06 +02:00
Linus Rath 949bb10ac5 docs: record the device run, the r8 and label fixes and the push crash it found 2026-09-24 22:16:46 +02:00
Linus Rath e5659ed5e8 ci: attach the r8 mapping to android releases
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`.
2026-09-24 22:14:35 +02:00
Linus Rath a764a07c65 fix: show a white splash in light mode
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.
2026-09-24 22:14:35 +02:00
Linus Rath 5ddc2df64b fix: stop holding a wake lock for 30 seconds after every push
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
2026-09-24 22:14:35 +02:00
Linus Rath fef23e4c33 fix: stop a background push from crashing the app on android 12 and later
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
2026-09-24 22:14:35 +02:00
Linus Rath 4a3bce154c feat: translate the new app strings and screen-reader labels into all 26 languages 2026-09-24 22:06:28 +02:00
Linus Rath 13a3e95a0a chore: drop unused app strings and retranslate the ones that became plurals 2026-09-24 21:45:29 +02:00
Linus Rath fd55e16492 fix: translate the last export errors, dates and participant counts
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.
2026-09-24 21:44:47 +02:00
Linus Rath 5e557266d1 fix: count the files selection and delete prompt with plurals
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.
2026-09-24 21:44:47 +02:00
Linus Rath b178ee8d07 fix: translate the tab bar and label the unified inbox and drawer buttons
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.
2026-09-24 21:44:47 +02:00
Linus Rath 1cbf763a36 fix: send calendar reminders in the app language
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.
2026-09-24 21:44:46 +02:00
Linus Rath 84fef142b9 fix: translate the calendar sheets and label their buttons
- 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.
2026-09-24 21:44:46 +02:00
Linus Rath b3a82f1241 fix: translate the week view and label the calendar toolbar
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.
2026-09-24 21:44:46 +02:00
Linus Rath 13cba64854 fix: translate the viewer's leftovers and label its icon buttons
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.
2026-09-24 21:44:46 +02:00
Linus Rath e3501f939d fix: offer undo for replies sent from the viewer
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.
2026-09-24 21:16:59 +02:00
Linus Rath 5e3cbf2419 fix: shrink the android release with r8
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.
2026-09-24 21:13:42 +02:00
Linus Rath 9c031c8761 docs: update the fix-pass notes for the labels and strings that landed since 2026-09-24 21:02:09 +02:00
Linus Rath c81dc5179f docs: tick the parity items this pass completed
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.
2026-09-24 21:02:09 +02:00
Linus Rath d72aab2519 docs: correct the stale parity checklist entries
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
2026-09-24 21:02:09 +02:00
Linus Rath d4ac1e1df4 docs: record what still needs a device check and a decision after the fix pass
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
2026-09-24 21:02:08 +02:00
Linus Rath 06ce86e31a docs: tick the audit items fixed in this pass
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.
2026-09-24 21:02:08 +02:00
Linus Rath 6fe907b4bc fix: translate the Themes and Updates settings tab names 2026-09-24 20:49:56 +02:00
Linus Rath c1e5cd6c8b fix: label the remaining settings icon buttons for screen readers
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.
2026-09-24 20:49:56 +02:00
Linus Rath 455608f9f0 fix: translate push setup errors and the open-link prompt
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.
2026-09-24 20:49:55 +02:00
Linus Rath 5801d6c97a fix: show a translated notice when the sign-in session expired
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.
2026-09-24 20:49:55 +02:00
Linus Rath 771558d8c7 fix: translate the composer's leftovers and label its header buttons
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).
2026-09-24 20:49:55 +02:00
Linus Rath 9c0c35c7ba fix: translate the mail list and label its icon buttons
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.
2026-09-24 20:49:55 +02:00
Linus Rath ccdc4dee56 fix: add the template settings strings to the catalog
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.
2026-09-24 20:49:55 +02:00
Linus Rath aab29da148 fix: translate the file sharing roles and label the files toolbar
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.
2026-09-24 20:49:55 +02:00
Linus Rath d59c571d93 fix: translate the contacts screens
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.
2026-09-24 20:49:55 +02:00
Linus Rath cb5c152cb3 fix: name the settings switches for screen readers
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.
2026-09-24 20:49:55 +02:00
Linus Rath 6e6fd95e5e fix: translate the contacts and content settings
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.
2026-09-24 20:49:55 +02:00
Linus Rath 8af163a6a3 fix: show today and the now-line in the calendar time zone
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
2026-09-24 20:40:51 +02:00
Linus Rath b7f711050e fix: translate the calendar import sheet 2026-09-24 20:34:35 +02:00
Linus Rath 9282ee6d72 fix: open the event a calendar link points at 2026-09-24 20:34:35 +02:00
Linus Rath a8e6f6e8c4 fix: keep event and task times when the calendar time zone is not the device's
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
2026-09-24 20:34:35 +02:00
Linus Rath b7a5953255 fix: ask for both neighbouring conversations in one request
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`.
2026-09-24 20:29:41 +02:00
Linus Rath 0cee422373 fix: stop reloading an invitation when the message is marked read
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.
2026-09-24 20:29:41 +02:00
Linus Rath 644443193c fix: drop the mail the viewer held in memory on sign-out
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.
2026-09-24 20:29:41 +02:00
Linus Rath d2066dad07 fix: act on the exact row picked in "All folders" and tag lists #1082
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.
2026-09-24 20:28:43 +02:00
Linus Rath dd0969d48b chore: drop translations of app strings the calendar agenda no longer uses 2026-09-24 20:27:58 +02:00
Linus Rath 96bffa51e8 refactor: check only the days a timed event spans for the all-day strip
`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.
2026-09-24 20:25:41 +02:00
Linus Rath 5434656e03 feat: bring a tapped reminder's day into view in the calendar 2026-09-24 20:25:41 +02:00
Linus Rath de3d697aa9 feat: scroll the calendar week and day views sideways #759
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.
2026-09-24 20:25:40 +02:00
Linus Rath caf61ece79 feat: scroll the calendar month view continuously #759
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.
2026-09-24 20:25:40 +02:00
Linus Rath 71522b9b92 feat: scroll the calendar agenda endlessly instead of a fixed 30 days
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.
2026-09-24 20:25:40 +02:00
Linus Rath 67935d2edc feat: translate the app-only strings into all 26 languages
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.
2026-09-24 20:25:40 +02:00
Linus Rath d7c890869b fix: reuse the startup identity read for the first message open 2026-09-24 20:20:59 +02:00
Linus Rath 4f3940f1d3 fix: catch up on a folder's changes in one request
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.
2026-09-24 20:20:59 +02:00
Linus Rath 9c2f819eac fix: load the folder list once, and right away, when signing in
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.
2026-09-24 20:20:58 +02:00
Linus Rath 9fa931c589 fix: sync at once on resume and stop pinging the server while live updates run
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.
2026-09-24 20:20:58 +02:00
Linus Rath 1196ad56c2 fix: probe the server's keyword sort order once instead of on every start
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.
2026-09-24 20:20:58 +02:00
Linus Rath 3bf2c30c99 fix: stop asking for the Stalwart principal on every start and message open
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.
2026-09-24 20:20:58 +02:00
Linus Rath 018accd865 fix: stop re-reading the mail list when the server echoes our own changes
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.
2026-09-24 20:20:58 +02:00
Linus Rath 1c7c2a3be8 fix: stop reloading every account when returning to the unified inbox
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.
2026-09-24 20:20:58 +02:00
Linus Rath 49ed48791d fix: name the webmail's Link Mobile App page in the QR sign-in hints 2026-09-24 20:16:36 +02:00
Linus Rath ef143a4dbb fix: take a conversation's size from the list row's own account
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.
2026-09-24 20:12:17 +02:00
Linus Rath 2a055ddfe2 fix: keep a star set while the mark-as-read delay runs 2026-09-24 20:12:17 +02:00
Linus Rath cd911ada79 fix: show toasts on every screen, including the Unified Inbox and contacts
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.
2026-09-24 20:12:17 +02:00
Linus Rath e0b72883ac fix: show the tapped message when the viewer is already open
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.
2026-09-24 20:12:17 +02:00
Linus Rath 54b169c6c7 fix: open a message at its full height and keep the quick reply still
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.
2026-09-24 20:12:17 +02:00
Linus Rath 6ebadf738f fix: sanitise a message body once instead of on every mount
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.
2026-09-24 20:12:17 +02:00
Linus Rath 01fc6fbba5 fix: let the pages beside an opened message wait until it has loaded
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.
2026-09-24 20:12:17 +02:00
Linus Rath 7cdebc465a fix: stop asking for the identities on every message open
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.
2026-09-24 20:12:17 +02:00
Linus Rath ad1f053f72 fix: offer archiving in the viewer for a folder named Archive without the role 2026-09-24 20:12:17 +02:00
Linus Rath 1348e7d20a fix: start loading a message on the tap, before the viewer opens
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.
2026-09-24 20:12:17 +02:00
Linus Rath ac8e3fb2e9 fix: paint the message header from the list row while the body loads
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.
2026-09-24 20:12:17 +02:00
Linus Rath 33e303e248 fix: open a message from the cache and fetch only the open cards' bodies
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.
2026-09-24 20:12:16 +02:00
Linus Rath 96c5a3f046 feat: let attendees answer a single occurrence of a recurring event
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
2026-09-24 20:10:08 +02:00
Linus Rath f397ee3e1b feat: change or delete one occurrence through its own id
"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.
2026-09-24 20:10:08 +02:00
Linus Rath 84548898c1 feat: let the server expand recurring events when it can
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
2026-09-24 20:10:08 +02:00
Linus Rath 1a651a9c25 fix: read floating task dues in the calendar time zone like events
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.
2026-09-24 20:10:08 +02:00
Linus Rath 83db70ac86 fix: open the event when a calendar reminder is tapped
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."
2026-09-24 20:10:08 +02:00
Linus Rath a4ac6cad5c fix: list the events an .ics import could not bring in
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
2026-09-24 20:10:07 +02:00
Linus Rath a7e4e84f5d fix: say when saving a task fails and keep what was typed
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.
2026-09-24 20:10:07 +02:00
Linus Rath e0a79ca5c6 test: keep the locale tests independent of which overlay keys are translated 2026-09-24 20:07:00 +02:00
Linus Rath edab181e60 fix: load list attachment chips from each search hit's own account #1082
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.
2026-09-24 20:05:38 +02:00
Linus Rath d097ae9ec3 fix: stop re-counting every tag each time the sidebar opens
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.
2026-09-24 20:05:38 +02:00
Linus Rath 8d5bced7f3 fix: list and count tagged mail from every account in the tag view #1038
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.
2026-09-24 20:05:38 +02:00
Linus Rath ee50418bac fix: search the shared accounts' folders too with "All folders" #1082
"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.
2026-09-24 20:05:38 +02:00
Linus Rath ddff2f7b66 fix: queue star, tag and unread from the viewer while offline
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.
2026-09-24 19:45:07 +02:00
Linus Rath d8db4a5597 fix: show the right image when two attachments share a name
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.
2026-09-24 19:45:07 +02:00
Linus Rath 1fb60a5c61 fix: tell the user when an automatic read receipt fails to send 2026-09-24 19:45:07 +02:00
Linus Rath 115e162b86 fix: delete large file selections in batches the server accepts
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.
2026-09-24 19:45:07 +02:00
Linus Rath a3193ebaf7 fix: create new groups in the address book being viewed 2026-09-24 19:45:07 +02:00
Linus Rath 40ace883b7 fix: keep a contact's recent-email previews on one line 2026-09-24 19:45:07 +02:00
Linus Rath 9135deed80 fix: translate the no-subject fallback in mail lists and notifications
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.
2026-09-24 19:45:06 +02:00
Linus Rath 3c7b96e11a fix: remove shared-file copies left behind by an upload cut short
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.
2026-09-24 19:45:06 +02:00
Linus Rath 2e78c9870c fix: remove the unsent copy when the server refuses a send
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.
2026-09-24 19:45:06 +02:00
Linus Rath 46e285b3c9 feat: search settings from the Settings screen
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.
2026-09-24 19:40:08 +02:00
Linus Rath 4c9efbc44d fix: stop an open settings page from catching back on other tabs 2026-09-24 19:40:08 +02:00
Linus Rath 7964e646c4 feat: add the Roundcube Elastic and Aurora Glass themes
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.
2026-09-24 19:40:08 +02:00
Linus Rath 008bff29f8 feat: load app-only strings in every language, not just English
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
2026-09-24 19:39:48 +02:00
Linus Rath af6290fd4c fix: only show the unified mailbox when it can hold more than one account's mail #843
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.
2026-09-24 19:36:41 +02:00
Linus Rath 4e6426171a feat: add a setting to clear the search when switching folders 2026-09-24 19:36:41 +02:00
Linus Rath b1fb2e9b22 feat: show attachment chips on mail list rows
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.
2026-09-24 19:36:41 +02:00
Linus Rath 25c088383f feat: show the inbox unread count on the app icon
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.
2026-09-24 19:36:41 +02:00
Linus Rath 9f75e70218 fix: only offer archiving when the archive folder can take the mail #578
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.
2026-09-24 19:36:41 +02:00
Linus Rath 30aea63a66 fix: subscribe folders created on the phone so IMAP clients show them #951 2026-09-24 19:36:40 +02:00
Linus Rath 85f23dfdf4 test: check that session restore waits for the persisted stores together
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.
2026-09-24 19:35:47 +02:00
Linus Rath 4a5d837cd7 fix: stop re-rendering hidden tabs and the mail list on every mailbox fetch
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.
2026-09-24 19:35:47 +02:00
Linus Rath f2dd3a8b4b fix: float the notification prompt instead of pushing the list down
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.
2026-09-24 19:35:47 +02:00
Linus Rath b9db3764ff fix: stop the cold start flashing white and the mail header jumping in
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.
2026-09-24 19:35:47 +02:00
Linus Rath 82ca359df7 fix: stop loading the whole app for a background push
`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.
2026-09-24 19:35:47 +02:00
Linus Rath 5bcfe1fe4d fix: load only the active language's calendar date locale
`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)
2026-09-24 19:35:47 +02:00
Linus Rath 063432bf28 fix: bundle and load only the icons the app uses
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`.
2026-09-24 19:35:47 +02:00
Linus Rath 604eb39340 fix: load only the active language's catalog at startup
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
2026-09-24 19:35:47 +02:00
Linus Rath 6c31c8d0c6 fix: stop filters saved on the phone from moving spam out of Junk
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.
2026-09-24 19:29:36 +02:00
Linus Rath 3f88868aad fix: keep folder ids, forward copies and flag order in filters saved on the phone
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.
2026-09-24 19:29:36 +02:00
Linus Rath d53bfec68a fix: keep the auto-reply running when filters are saved on the phone
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.
2026-09-24 19:29:36 +02:00
Linus Rath 4fdf39aa08 fix: go up one folder on Android back in the Files tab
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.
2026-09-24 19:28:37 +02:00
Linus Rath 409cdb139e feat: preview images, text and PDFs in the Files tab
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
2026-09-24 19:28:37 +02:00
Linus Rath 1d755fc066 fix: list event participants once with contact names and organizer status
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
2026-09-24 19:12:03 +02:00
Linus Rath a6ef0abc2a fix: report calendar deletes and imports the server refused
`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
2026-09-24 19:12:02 +02:00
Linus Rath c0582ab950 fix: send the RSVP for invitations outside the loaded calendar window
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
2026-09-24 19:12:02 +02:00
Linus Rath 15717f137a fix: keep what was typed in the event editor when calendars refresh
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.
2026-09-24 19:12:02 +02:00
Linus Rath 4b903ed179 fix: keep calendar reminders when the calendar view moves
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
2026-09-24 19:12:02 +02:00
Linus Rath 6a975b822c fix: show a task's due time in local time like events
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
2026-09-24 19:12:02 +02:00
Linus Rath 1b64c14280 fix: treat CalDAV tasks marked COMPLETED, NEEDS_ACTION or canceled as such
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
2026-09-24 19:12:02 +02:00
Linus Rath dcdda5ed9f fix: let tasks be completed again and say when the server refuses #958
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
2026-09-24 19:12:02 +02:00
Linus Rath 548b647f15 feat: add background colour and the webmail's 16 text colours to the composer
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.
2026-09-24 19:10:46 +02:00
Linus Rath fc00781b47 fix: stop each keystroke in the composer re-rendering the whole screen twice
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.
2026-09-24 19:10:45 +02:00
Linus Rath 3faac98627 fix: sign quick replies with the sending identity's signature
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.
2026-09-24 19:10:45 +02:00
Linus Rath 472c4d8f26 feat: add per-message HTML/plain-text toggle #1022
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
2026-09-24 19:10:45 +02:00
Linus Rath 0ffdcacea9 fix: start a new message in a shared folder from that mailbox's identity
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.
2026-09-24 19:10:45 +02:00
Linus Rath 2d907ffab8 feat: choose exact-address or same-domain matching for reply from received address #1000
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.
2026-09-24 19:10:45 +02:00
Linus Rath a0c2bd7f4a fix: reply and forward from the address the message was delivered to
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
2026-09-24 19:10:44 +02:00
Linus Rath 235f4ea77f fix: stop replies and forwards loading the remote images the viewer blocked
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.
2026-09-24 19:10:44 +02:00
Linus Rath 92b42e4a4f fix: keep the composer open when its draft can't be saved
"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.
2026-09-24 19:10:44 +02:00
Linus Rath 191f4f32cd fix: stop the date and time pickers offering times past the server's hold limit 2026-09-24 19:10:43 +02:00
Linus Rath 7e598c5bc9 fix: stop a failed reschedule from dropping or double-sending the message
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
2026-09-24 19:10:43 +02:00
Linus Rath ebd8dacdfd fix: stop re-rendering every mail row when the list re-renders
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
2026-09-24 19:10:43 +02:00
Linus Rath aa250759e5 fix: keep swipe bands unmounted until a mail row is swiped
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
2026-09-24 19:10:43 +02:00
Linus Rath cb9a741244 fix: reuse the list's date formatters instead of building them 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.
2026-09-24 19:10:43 +02:00
Linus Rath 695aa23563 fix: show the Bulwark shield instead of the stock envelope in the status bar 2026-09-24 19:09:36 +02:00
Linus Rath 8618abf1d7 fix: open a group mailbox notification's message in the group account #839
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.
2026-09-24 19:09:36 +02:00
Linus Rath a3d3af797c fix: keep push off after it is turned off or this device is revoked
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.
2026-09-24 19:09:35 +02:00
Linus Rath ddfe85a5b2 fix: show an error when the server refuses a push registration change
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.
2026-09-24 19:09:35 +02:00
Linus Rath ccaa24ecd3 fix: stop re-patching the push filter on every launch
`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.
2026-09-24 19:09:35 +02:00
Linus Rath d8046a8aad fix: keep push on for users with mailboxes shared to them by ACL
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.
2026-09-24 19:09:35 +02:00
Linus Rath 9e95273c14 feat: tell the user when a viewer action fails and when mail is sent
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`)
2026-09-24 19:06:55 +02:00
Linus Rath 33d26744b6 fix: archive from the viewer when the list does not hold the message
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.
2026-09-24 19:06:55 +02:00
Linus Rath 5e42c6737e fix: report spam from the viewer the way the list does #695
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.
2026-09-24 19:06:55 +02:00
Linus Rath 9c31de2e80 fix: keep the viewer on the opened message when new mail arrives
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.
2026-09-24 19:06:54 +02:00
Linus Rath 5c7301b3f1 fix: stop viewer actions from hitting another account's message with the same id
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
2026-09-24 19:06:54 +02:00
Linus Rath d987930034 fix: load each list page and search in one request
Opening a folder, searching or loading more took three sequential
round trips: `Email/query`, then `Email/get` of its ids, then a
`Thread/get` the list screen fired after rendering to size the
conversation badges.

- `queryEmailPage` sends all three in one request, chaining
  `Email/get` to the query's `/ids` and `Thread/get` to the fetched
  messages' `/list/*/threadId` (left out while threading is off). The
  page is capped at `maxObjectsInGet` so the chained get can't be
  refused, and a failed `Thread/get` keeps the page.
- The full refresh, search and load-more use it; load-more no longer
  needs its own chunked `Email/get`.
- The thread sizes now live in the email store (`threadCounts`,
  cleared on folder or account change) and the list screen reads them
  instead of fetching them itself. Rows restored from a snapshot or
  added by an incremental refresh get theirs with one `Thread/get`
  after the refresh.
- The chained `Email/get` also reports the Email state for an empty
  folder, so that list gets its own `Email/changes` baseline.
2026-09-24 19:04:37 +02:00
Linus Rath 35e0792558 fix: stop "load more" skipping messages in the Unread and Starred views
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.
2026-09-24 19:04:36 +02:00
Linus Rath 0a8686a008 fix: keep messages in the list after a background refresh
`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.
2026-09-24 19:04:36 +02:00
Linus Rath 668e01023c fix: stop the contacts tab downloading every card on each open
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).
2026-09-24 19:03:35 +02:00
Linus Rath c888e95c5a fix: create new contacts in the address book being viewed 2026-09-24 19:03:35 +02:00
Linus Rath e6be8922b7 fix: stop re-sorting the contact list on every search keystroke
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).
2026-09-24 19:03:34 +02:00
Linus Rath 182774884e feat: add a setting to sort contacts by last name #963
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.
2026-09-24 19:03:34 +02:00
Linus Rath a734d0ade1 fix: always write the contact's full name so vCards carry FN #430
`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.
2026-09-24 19:03:34 +02:00
Linus Rath 95c24a4214 fix: stop notifications fetching sender favicons when favicons are off
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.
2026-09-24 19:03:34 +02:00
Linus Rath eca3871545 feat: show the sender's contact photo as their avatar #54
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.
2026-09-24 19:03:34 +02:00
Linus Rath d89e33110a fix: load remote content only for senders in the trusted senders book
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.
2026-09-24 19:03:33 +02:00
Linus Rath ac58b36a77 test: give the translation key scans a 30 s timeout so they pass under load 2026-09-24 19:01:24 +02:00
Linus Rath e6549d31da fix: use the right plural forms on Android instead of the English ones
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.
2026-09-24 19:01:24 +02:00
Linus Rath 2511df1059 fix: translate the plain text font, message spacing and read receipt settings 2026-09-24 19:01:24 +02:00
Linus Rath a86cd1d0eb feat: manage filters and vacation of shared and group accounts
The phone only ever read and wrote Sieve scripts and the vacation
responder of the user's own account, and the "Shared with me" list in
Account settings said to use the webmail for the rest. Port the
webmail's scoped settings (4c6c1aab) for these two panes:

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

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

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

The composer's and the Scheduled screen's presets now only list times
within the limit, and the undo-send hold, from the composer and from
quick reply, is capped at it.
2026-09-24 19:00:08 +02:00
Linus Rath 48825e919a fix: stop rewriting the mail, contacts and calendar caches on every state change
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
2026-09-24 18:56:38 +02:00
Linus Rath 24849baca5 fix: let the offline mail cache grow past 6 MB on android
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.
2026-09-24 18:56:37 +02:00
Linus Rath e37621be5e fix: cap the cached mail list so android can still read it back
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.
2026-09-24 18:56:37 +02:00
Linus Rath 29169c3426 fix: stop session restore hanging when a persisted cache can't be read
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
2026-09-24 18:56:37 +02:00
Linus Rath 5237848312 docs: add the android release signing and key rotation guide
`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.
2026-09-24 18:52:15 +02:00
Linus Rath 746572c784 fix: raise the android versionCode with every release
`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.
2026-09-24 18:52:15 +02:00
Linus Rath 6f0abb4384 fix: stop signing release apks with the public debug key
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`.
2026-09-24 18:52:15 +02:00
Linus Rath 1d1d6a5bfc fix: hide body-embedded cid parts typed octet-stream from the attachment chips
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.
2026-09-24 18:50:55 +02:00
Linus Rath d45c7996d2 fix: keep list previews and subjects on one line
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.
2026-09-24 18:50:55 +02:00
Linus Rath 476780553c fix: show large message bodies in full instead of a cut-off stub #884
Full-message fetches cap body values at 512 KB and never read
`isTruncated`, so a bigger body rendered whatever the server cut off
with no notice; for a 586 KB HTML report Stalwart returned a 59
character stub and the viewer showed a heading, one line and "…".

Port the webmail's refetch: when the first HTML or text body part
comes back truncated, `getFullEmail`, `getFullEmails` and
`getThreadEmails` fetch the HTML and text body values of those messages
again in one batched `Email/get` with an 8 MB cap, keeping the values
of other parts. Truncated text attachments don't trigger it. If the
body is still truncated, or the refetch fails, the viewer now says the
message is too large to show in full.
2026-09-24 18:50:55 +02:00
Linus Rath 910c503f4a fix: render right-to-left mail right-to-left
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.
2026-09-24 18:50:55 +02:00
Linus Rath 7531118727 fix: let sender images and tables keep their own max-width #790
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`.
2026-09-24 18:50:55 +02:00
Linus Rath 55f1ec431f chore: sync the locale catalogs from the webmail
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`
2026-09-24 18:46:59 +02:00
Linus Rath cb2f03bc09 fix: mark read and star a list selection in one request
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.
2026-09-24 18:44:29 +02:00
Linus Rath 50418971f4 fix: stop mark-read, star, pin and tag from erasing other keywords
Every keyword change sent the message's whole `keywords` map, which the
server takes as a replacement. Opening a message that wasn't in the
loaded list sent `{"$seen": true}` and wiped its star and tags; starring
a listed message sent the list's stale map and reverted keywords another
client had changed since the list loaded.

Keyword changes now go out as `keywords/<name>` patch pointers (`true`
to set, `null` to clear) everywhere:
- mark read/unread, star, pin and tagging in the email store
- spam / not spam, trash-and-read, and the spam undo, which now puts
  back only the keywords the action touched
- star, tag and unread from the message viewer
- the offline outbox, whose keyword ops carry a patch and merge per
  keyword when coalesced

Ops queued by earlier builds still hold a whole map. They replay as a
patch that only sets the keywords in it (clearing the other half of
`$junk`/`$notjunk`), so an unread or unstar queued before the update is
lost but nothing gets erased. The list and the offline cache apply the
same patch locally instead of overwriting their keyword maps.
2026-09-24 18:44:28 +02:00
Linus Rath 859770b814 fix: stop signing out on launch when a redirect drops the auth header
When a proxy redirects the session request (an http to https upgrade, a
trailing slash, another port), iOS and OkHttp follow it without the
Authorization header. The server then answers 401 or, like Stalwart, an
empty session, which the client reported as invalid credentials, so a
restored account was evicted and its stored credentials deleted. The
retry that was meant to catch this keyed on `response.redirected`,
which React Native's fetch never sets.

Session discovery now also detects a redirect by comparing the final
`response.url` with the requested URL, and on a 401 or an empty session
refetches the final URL with the header. Credentials only follow within
the same host or its https upgrade, the rule `secureFetch` applies; a
redirect elsewhere fails as a discovery error, which `loadAccount`
turns into a `NetworkError`, so the stored account is kept.
2026-09-24 18:43:56 +02:00
Linus Rath c51a848dfb fix: stop reporting a send as failed when only filing it into Sent failed
`sendEmail` threw on any method error in the response. Stalwart appends
one for the implicit `onSuccessUpdateEmail` Email/set that moves the
sent message from Drafts to Sent, so a message that had already gone
out showed "Send failed" and a retry sent it a second time. A method
error that follows a created submission is now returned as a filing
warning, like the record-level filing failures already were; errors
before the submission exists still fail the send.
2026-09-24 18:43:56 +02:00
Linus Rath e67947aa93 fix: keep cc and bcc recipients when rescheduling a send
Rescheduling a held message rebuilt the replacement submission's
envelope from the Email's To only, so Cc and Bcc recipients silently
stopped getting the message, and a catch-all From was used as the
envelope sender the server refuses. The replacement now reuses the held
submission's envelope (`rcptTo` and `mailFrom`), falling back to the
message's To, Cc and Bcc when the server returns none.
2026-09-24 18:43:56 +02:00
Linus Rath 802b19916c fix: hide the schedule button when the server can't schedule sends 2026-09-24 18:43:56 +02:00
Linus Rath e28e8b4d9c fix: turn scheduled send and the undo-send delay back on for Stalwart #57
RFC 8621 puts the submission limits (`maxDelayedSend`,
`submissionExtensions`) and the mail limits
(`maxSizeAttachmentsPerEmail`) under each account's
`accountCapabilities`. Stalwart advertises an empty object for them at
session level, which is the only place the client looked, so scheduled
send was refused, the undo-send delay silently fell back to an instant
send, the Scheduled view listed nothing and the per-message attachment
cap was never enforced.

The client now reads these from the account that sends (the account's
own submission capability, else the session's primary submission
account, like the webmail), falling back to the session-level object
for servers that only advertise it there. Quick reply, which can send
from a shared account, now checks that account before holding the
message for the undo-send delay.
2026-09-24 18:43:56 +02:00
Linus Rath ee652d39df fix: open mailto links whose address contains a plus
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.
2026-09-24 18:43:43 +02:00
Linus Rath 06e89422bd fix: open the composer for mailto links sent with SENDTO
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.
2026-09-24 18:43:43 +02:00
Linus Rath cd4ce9d08b fix: upload files shared into the app with their real name and type
Sharing a photo or document from another app into Bulwark failed every
time: the upload task was handed the share's `content://` URI, but it
can only stream from a file path, so it failed with "Directory for
'/external/images/media/…' doesn't exist". The attachment was also named
after the URI's last segment (a MediaStore row id such as "1000000027"),
and SEND_MULTIPLE shares only carried a wildcard type like `image/*`.

- `uploadBlob` copies `content://` URIs into the cache, uploads the copy
  and deletes it afterwards, also when the upload fails
- `ShareIntentStore` asks the providing app for the display name, size
  and MIME type when the intent arrives, falling back to the URI segment
  plus an extension for the MIME type
- the composer gets the real name, type and size, so the size limit
  check now applies to shared files too
2026-09-24 18:43:43 +02:00
Linus Rath 8d7fbfc99c fix: clear the Files selection on Android back instead of switching to Mail 2026-09-24 18:41:23 +02:00
Linus Rath caea3d0c60 fix: offer sharing files and calendars with other users on Stalwart
`supportsSharing()` required `urn:ietf:params:jmap:principals:owner`,
which Stalwart advertises nowhere; its session only has `principals` and
`principals:availability`. The file menu therefore never showed
"Sharing & access", and `getPrincipals()` returned nothing, so the
calendar share sheet had no one to offer either.

- Gate sharing on `urn:ietf:params:jmap:principals`, like the webmail's
  `supportsPrincipals()`.
- Keep `principals:owner` out of FileNode requests unless the server
  advertises it, since an unknown capability in `using` fails the whole
  request (why the gate had been tied to it).
- Page the principal lookup by `maxObjectsInGet`, as the webmail does:
  one unbounded `Principal/query` fed every id to `Principal/get`, which
  the server refuses once the directory has more than 500 entries.
2026-09-24 18:41:23 +02:00
Linus Rath 00ad2f49fb fix: show an error when the server refuses a file rename, move or delete
`updateFileNode` and `deleteFileNodes` only looked for `notUpdated` and
`notDestroyed` in the first method response. A method-level `error`
reply (e.g. `forbidden`) has neither, so a refused rename, move or delete
reported success and the Files tab silently refreshed to the old state.

Every `FileNode/set` in `src/api/files.ts` now goes through
`requireMethodResult`, so the error reaches the existing alert in the
Files screen and the share sheet with the server's description.
2026-09-24 18:41:23 +02:00
Linus Rath 4eb8c47670 fix: list every file when the account holds more than 500 nodes #1069
The Files tab read the whole drive with `FileNode/get ids:null`, which
Stalwart silently caps at `maxObjectsInGet` (500 by default). Anything
past that, including folders created after the first 500 files and
everything inside them, never showed up, both for the own drive and for
shared accounts.

A full first page now counts as truncated, as in the webmail: the
remaining ids come from paging `FileNode/query`, are fetched in
`maxObjectsInGet`-sized `FileNode/get` batches packed into as few
requests as `maxCallsInRequest` allows, and parents that are still
unknown afterwards are fetched by id, which recovers folders on Stalwart
before 0.16.6 where the query returns files only. If the paging fails,
the first page is kept as before.
2026-09-24 18:41:23 +02:00
Linus Rath bd42206686 fix: accept a pasted JMAP session URL as the server address #971 2026-09-24 18:40:43 +02:00
Linus Rath ab845783ab fix: stop adding an OAuth account from signing in the current user again
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.
2026-09-24 18:40:43 +02:00
Linus Rath 5f3f584ca8 fix: auto-send read receipts only for the message on screen and clean up failed ones
With "Always send" the banner's auto-send ran in every page the viewer
pager keeps mounted, so opening one message also answered the receipt
requests of its unseen neighbours. The receipt is imported into Sent
before it is submitted, so a refused submission left a "Read: ..." copy
there, and the failure cleared the per-instance guard, so the next
render or remount imported another one.

- the pager marks the page on screen `active`, and the banner only
  auto-sends there
- an automatic receipt is tried once per message per session, keyed by
  account and id, so a failure or a remounted page does not send again
- `sendReadReceipt` destroys the imported copy when the submission
  fails, which also covers the manual "Send receipt" button
2026-09-24 18:36:15 +02:00
Linus Rath cb6ddd6fcf fix: strip CR/LF from read-receipt header values (GHSA-w38p-hpqv-g89c)
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.
2026-09-24 18:36:15 +02:00
Linus Rath ee04e95d4f feat: add UnifiedPush as an alternative to FCM for push notifications 2026-09-24 18:17:43 +02:00
Linus Rath 5b8c4353d8 Merge pull request #53 from lucletoffe/fix/calendar-description-links
fix(calendar): make URLs in the event description tappable
2026-09-09 22:20:23 +02:00
lucletoffeandClaude Opus 5 c2584c2bd5 fix(calendar): make URLs in the event description tappable
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
Linus Rath 6c82130267 chore: update version number to 0.1.62 2026-08-30 00:37:37 +02:00
Linus Rath f228257ec9 fix: stop the viewer crashing on any message with attachments by keeping store selectors referentially stable 2026-08-29 22:41:09 +02:00
Linus Rath a02e7f8a49 test: add an opt-in live Stalwart integration suite 2026-08-29 22:28:42 +02:00
Linus Rath ea1f53f550 fix: keep the created message id when the server answers a send with an implicit filing update 2026-08-29 22:28:41 +02:00
Linus Rath 4e035f3011 chore: update version number to 0.1.61 2026-08-29 20:25:25 +02:00
Linus Rath e04bea451a fix: check the GitHub CLI login before bumping so a failed release does not leave a bare tag 2026-08-29 20:20:54 +02:00
Linus Rath 5cb28f5d5d chore: update version number to 0.1.60 2026-08-29 20:20:15 +02:00
Linus Rath 94c9a2e7a2 chore: update version number to 0.1.59 2026-08-29 20:19:36 +02:00
Linus Rath 3f2dfe83ee chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40) 2026-08-29 20:16:33 +02:00
Linus Rath 99a20777b7 docs: tick the P1 summary and record the fix-pass status in the parity index 2026-08-29 18:56:47 +02:00
Linus Rath 96272b6e17 fix: confirm the host before opening meeting links from calendar events and invitations 2026-08-29 18:56:46 +02:00
Linus Rath 8e33808ded docs: tick the calendar parity items fixed in this pass 2026-08-29 18:55:31 +02:00
Linus Rath 2abea9742e feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show 2026-08-29 18:53:58 +02:00
Linus Rath c705227611 chore: add the harvested and hand-written RN locale keys 2026-08-29 18:49:38 +02:00
Linus Rath 5fe8e407c7 feat: duplicate, share as .ics and copy the meeting link of an event; link imported UIDs instead of skipping them (#113) 2026-08-29 18:49:08 +02:00
Linus Rath 0159d6acc8 docs: tick the outbox and unified-inbox core items 2026-08-29 18:48:51 +02:00
Linus Rath 8fd709d308 docs: record the commit hashes for the mail-list parity items 2026-08-29 18:47:21 +02:00
Linus Rath 0205aa0e1c feat: cross-account moves via blob import, shared-account offline sync, parked failed outbox ops with retry and back-off, archive replay with foldering, localized relative dates 2026-08-29 18:47:12 +02:00
Linus Rath 09c24fa805 feat: edit tasks with description, priority and due time, filter the list and overlay due tasks on the calendar 2026-08-29 18:44:36 +02:00
Linus Rath 86c3ab9f28 fix: escape the line-break regex in the quick-reply prefill 2026-08-29 18:42:26 +02:00
Linus Rath 9398b46795 docs: tick the viewer-side core items 2026-08-29 18:41:49 +02:00
Linus Rath f9ae4423e9 fix: sweep stale export files at launch and carry quick-reply text into the composer 2026-08-29 18:41:49 +02:00
Linus Rath 8e1d03c746 feat: fire calendar and task reminders as local notifications 2026-08-29 18:41:06 +02:00
Linus Rath a222e89611 refactor: merge the quick reply's email-body imports 2026-08-29 18:40:42 +02:00
Linus Rath 26786ca3e4 fix: add the reader's load-failed translation key 2026-08-29 18:40:02 +02:00
Linus Rath 6ae21d5690 feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855) 2026-08-29 18:39:45 +02:00
Linus Rath d6670c2b60 docs: tick the reader parity items 2026-08-29 18:39:29 +02:00
Linus Rath d2ed27fd3b feat: conversation view with collapsible cards, header details, auth chips, unsubscribe and read-receipt banners, attachment preview and zip, embedded-message and TNEF unwrap in the reader (#480, #830, #891) 2026-08-29 18:38:53 +02:00
Linus Rath ee3b3ffcc3 test: mock the Expo native modules the stores reach transitively 2026-08-29 18:36:08 +02:00
Linus Rath 0f80e98fbc feat: localize the calendar, add Saturday week start, ISO week numbers, month chips and 12h labels (#666) 2026-08-29 18:35:46 +02:00
Linus Rath 2b44936a17 chore: harvest new RN-only translation keys 2026-08-29 18:34:18 +02:00
Linus Rath bf92a00199 test: cover the draft route-param builder and tick the composer parity items 2026-08-29 18:34:09 +02:00
Linus Rath 00fd0696b2 docs: note the deferred date-locale item in the parity checklist 2026-08-29 18:33:27 +02:00
Linus Rath c659b5489c feat: signature, read-receipt, empty-subject, autosave and sub-address settings, identity Reply-To/Bcc/HTML signature with a default identity, template category/favourite, and reschedule/send-now/edit on the scheduled screen (#507) 2026-08-29 18:33:22 +02:00
Linus Rath 3a8264c7f7 feat: translate the account security pane 2026-08-29 18:33:16 +02:00
Linus Rath dc3ac4c7ed feat: translate the downloads pane 2026-08-29 18:30:32 +02:00
Linus Rath ae31a71e01 docs: tick settings pane, theme and sidebar-app items in the parity checklist 2026-08-29 18:30:09 +02:00
Linus Rath 0e63d45599 feat: expose a mobile sidebar-apps selector for the drawer 2026-08-29 18:30:08 +02:00
Linus Rath 22697fd111 feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes 2026-08-29 18:29:34 +02:00
Linus Rath fe45a444d3 feat: port the webmail's built-in colour themes to the Themes pane 2026-08-29 18:29:18 +02:00
Linus Rath bea3fc4c42 feat: message-list order presets with the Stalwart keyword-sort polarity probe, avatars-in-junk and folder-count toggles (#718, #498) 2026-08-29 18:28:43 +02:00
Linus Rath 5b72c4d314 feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163) 2026-08-29 18:28:38 +02:00
Linus Rath 3194b5ece1 feat: tags, pins, recipients, not-spam, delete confirm, folder-scoped search and draft editing in the mail list 2026-08-29 18:23:08 +02:00
Linus Rath f07e039ef0 feat: flip $junk/$notjunk on spam, search all folders by default, keep the search across folders and coalesce refreshes in the mail store (#850, #788, #553, #175) 2026-08-29 18:22:14 +02:00
Linus Rath 8206893860 feat: assess invitation trust, read the iTIP method from the ICS and pick the target calendar in the invitation banner 2026-08-29 18:22:00 +02:00
Linus Rath 65c73d82a1 docs: tick misc UI items in the parity checklist 2026-08-29 18:21:34 +02:00
Linus Rath 1d1ff35d1b chore: harvest new RN-only translation keys 2026-08-29 18:21:17 +02:00
Linus Rath 18e56163de test: compare locale coverage against the vendored catalog and add missing overlay keys 2026-08-29 18:20:47 +02:00
Linus Rath 3ae9bbe702 feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations 2026-08-29 18:20:01 +02:00
Linus Rath 5802caeea9 feat: open deep links, mailto: URLs and share-sheet payloads in the app 2026-08-29 18:20:01 +02:00
Linus Rath 06742ef7d5 fix: render HTML-only mail as HTML, keep stylesheets and block every external-content vector in the reader (#46, #49) 2026-08-29 18:15:22 +02:00
Linus Rath dae5700688 feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions 2026-08-29 18:14:48 +02:00
Linus Rath 42ab246cb9 feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api 2026-08-29 18:12:15 +02:00
Linus Rath 2bf2e89813 docs: tick account, security and push items in the parity checklists 2026-08-29 18:11:28 +02:00
Linus Rath ddae1e63f2 feat: manage public keys and encryption at rest from account security 2026-08-29 18:11:13 +02:00
Linus Rath 490355c65e feat: list logged-in accounts, storage quota and auth method in account settings 2026-08-29 18:11:13 +02:00
Linus Rath 1f2df5f079 fix: run the push background task on a detached JMAP session instead of re-binding the client 2026-08-29 18:11:13 +02:00
Linus Rath 2586eccaca docs: tick the Android backup item 2026-08-29 18:11:08 +02:00
Linus Rath 7f9f5af7e6 fix: keep the mail cache and outbox out of Android auto-backup 2026-08-29 18:11:07 +02:00
Linus Rath 78532211f3 docs: tick the login localization items 2026-08-29 18:10:25 +02:00
Linus Rath 240471764a feat: localize the sign-in flow and offer previously used addresses 2026-08-29 18:10:25 +02:00
Linus Rath 63bf3921b0 fix: only open safe URL schemes from contact cards 2026-08-29 18:07:15 +02:00
Linus Rath 56f8dceb8f docs: tick the auth and JMAP core parity items 2026-08-29 18:06:48 +02:00
Linus Rath 0d94d7643c fix: finish a webmail hand-off with a TOTP code instead of failing 2026-08-29 18:06:48 +02:00
Linus Rath 99eb525d22 docs: tick the contacts parity items 2026-08-29 18:05:59 +02:00
Linus Rath 58e3ef6827 fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907) 2026-08-29 18:05:56 +02:00
Linus Rath 4bebf70482 feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out 2026-08-29 18:05:16 +02:00
Linus Rath 0e514659be fix: query the server for a contact's upcoming events instead of the calendar cache 2026-08-29 18:04:48 +02:00
Linus Rath 78114b620a fix: show and remove synced trusted senders in settings and add the address-book sync toggle (#730) 2026-08-29 18:04:47 +02:00
Linus Rath bc4c57e734 feat: set a default address book from settings and guard it from deletion 2026-08-29 18:04:47 +02:00
Linus Rath 358bdb93ea feat: bucket the contact index by any letter, add select-all/export, a shared-from drawer section and category rename 2026-08-29 18:04:47 +02:00
Linus Rath 8bf48f6465 docs: tick settings-store and updater items in the parity checklist 2026-08-29 18:04:44 +02:00
Linus Rath 431facef0b feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater 2026-08-29 18:04:21 +02:00
Linus Rath 3d0d440673 refactor: drop dead mail layout, S/MIME and plugin controls from settings 2026-08-29 18:04:21 +02:00
Linus Rath 8deff66274 feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset 2026-08-29 18:04:21 +02:00
Linus Rath a071d0581c feat: refetch filters and vacation on push and fan state changes out to screens 2026-08-29 18:03:24 +02:00
Linus Rath 6f747db858 fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627) 2026-08-29 18:03:24 +02:00
Linus Rath 69bedf0b26 feat: manage public keys and encryption at rest through the Stalwart API 2026-08-29 18:03:23 +02:00
Linus Rath ce97232eb2 fix: allow cleartext server URLs only on local hosts, name the mail domain as TOTP issuer and guard external links 2026-08-29 18:03:23 +02:00
Linus Rath 516c9c931a fix: only drop exact role-folder name duplicates from the folder tree (#771) 2026-08-29 18:02:37 +02:00
Linus Rath c8be383c05 fix: page the viewer over the list a unified-inbox message was opened from 2026-08-29 18:01:53 +02:00
Linus Rath 9ae91a6ab6 docs: tick the filters, vacation and files parity items 2026-08-29 18:00:36 +02:00
Linus Rath c0e1d6b0a8 feat: files screen with search, sort, move/duplicate, multi-file upload progress, batch download, thumbnails and localization 2026-08-29 17:59:44 +02:00
Linus Rath a0ce9258cd feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563) 2026-08-29 17:59:44 +02:00
Linus Rath 1cef53af5a feat: add contacts to groups from the detail sheet, dedupe group recipients and drop the UID when duplicating 2026-08-29 17:59:11 +02:00
Linus Rath 9800ad07c2 feat: rebuild the contact form around PartialDate, org cards, group editing and cleared-field patches (#224, #307, #701) 2026-08-29 17:58:54 +02:00
Linus Rath 3fd5f10afe fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762) 2026-08-29 17:57:54 +02:00
Linus Rath edc26ce1ca fix: reconnect live updates after network blips, token refresh, account switch and app resume 2026-08-29 17:57:33 +02:00
Linus Rath 032732cea1 fix: check the account-level Stalwart capability and keep the stored password in step after a change (#47) 2026-08-29 17:57:33 +02:00
Linus Rath b52768e6fb fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900) 2026-08-29 17:57:32 +02:00
Linus Rath e70e57bf47 docs: tick push notification items in the parity checklist 2026-08-29 17:54:17 +02:00
Linus Rath f6f26f7950 feat: add push device list, re-register, onboarding prompt and an i18n key harvester 2026-08-29 17:53:59 +02:00
Linus Rath 00faceb25f fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45) 2026-08-29 17:53:53 +02:00
Linus Rath 7f956d60f7 fix: reply to Reply-To, drop own addresses on reply-all, thread with the Message-ID and keep attachments on forward (#234, #703) 2026-08-29 17:52:34 +02:00
Linus Rath 5dc40706fa fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731) 2026-08-29 17:52:26 +02:00
Linus Rath 26a34d5900 feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907) 2026-08-29 17:50:44 +02:00
Linus Rath 580e010b33 fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644) 2026-08-29 17:50:44 +02:00
Linus Rath ccbe67c1bc fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850) 2026-08-29 17:49:23 +02:00
Linus Rath 506a4b1a84 feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail 2026-08-29 17:49:23 +02:00
Linus Rath b538a0e53d feat: vacation responder sends UTC dates, edits an HTML body and is fully localized 2026-08-29 17:48:18 +02:00
Linus Rath 58a6f45c15 fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805) 2026-08-29 17:45:43 +02:00
Linus Rath feb4affea6 docs: tick i18n locale items in the parity checklist 2026-08-29 17:44:22 +02:00
Linus Rath 3ffcef2ec1 feat: actionable vacation banner, expanded IF/THEN rule view and capability-gated filter tabs 2026-08-29 17:44:14 +02:00
Linus Rath 990cd84ee3 feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n 2026-08-29 17:44:03 +02:00
Linus Rath 0b1c240802 fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907) 2026-08-29 17:42:32 +02:00
Linus Rath 2c0dbd15d1 fix: use a CSPRNG for OAuth state, TOTP secrets and UUIDs and validate handoff endpoints 2026-08-29 17:42:31 +02:00
Linus Rath 161baf1708 fix: edit or delete a single occurrence or the rest of a series instead of the whole series 2026-08-29 17:42:22 +02:00
Linus Rath 1c6aa680c5 feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail 2026-08-29 17:39:43 +02:00
Linus Rath e650cb2d0a fix: import vCard dates as RFC 9553 PartialDate and map vendor X- extensions (#224, #701) 2026-08-29 17:37:44 +02:00
Linus Rath eb1dd6c844 feat: sort the mail list oldest-first or newest-first (#5) 2026-08-29 17:31:16 +02:00
Linus Rath fb4796e188 ci: run typecheck and unit tests on push and pull request 2026-08-29 17:31:00 +02:00
Linus Rath b84d4d8e9e fix: mock netinfo in the vitest setup so the auth-store suite loads 2026-08-29 17:30:59 +02:00
Linus Rath eaa8249e76 docs: add webmail parity checklist 2026-08-29 17:30:37 +02:00
Linus Rath a63ba7cb81 Merge pull request #35 from lucletoffe/fix/reply-threading-msgid
fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
2026-08-06 16:25:28 +02:00
Linus Rath beec3322b8 Merge pull request #39 from koolninad/fix/jmap-session-ios-auth-drop
fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
2026-08-06 16:25:12 +02:00
Linus Rath 21bc02709a Merge pull request #40 from lucletoffe/fix/calendar-date-paging
fix(calendar): page a server-side date filter through CalendarEvent/query
2026-08-06 12:26:24 +02:00
lucletoffe 32b5298cdd fix(calendar): page a server-side date filter through CalendarEvent/query
queryEvents used to ignore its after/before arguments and ask Stalwart
for every event in the account with limit:1000. When an account holds
more than one page of events, the response was silently capped and the
grid rendered empty for the visible month — the missing events sat past
the truncation, never fetched. Meanwhile any recurring master whose
base start predates the window was returned exactly the same way, so
the client-side recurrence expander (already wired) never saw it.

Now the query sends after/before as a real filter ANDed with the
inCalendar restriction, and iterates position by page size until a
short page comes back (or 20 pages, as a bug backstop). Stalwart's
matcher accepts a master when any occurrence overlaps the window, so
the expander gets the raw event and produces the visible occurrences.

The refactor factors buildEventFilter() so the calendar / no-calendar
and window / no-window combinations produce the right filter shape
(single condition, AND, or absent). Tests updated to lock the new
filter shape and to cover the paging path (regression: 1000 events
that used to become 1000, now 1002).

Picked up from Jim Preston's uncommitted diff in the sister worktree
during the 2026-08-03 diagnostic session (artifact PkOWkT8C).
2026-08-06 09:20:40 +02:00
Ninad Chandorkar da1ff62725 fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
Signing in from the iOS build against a Stalwart server fails with "No
account found in JMAP session".

Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to
/jmap/session. iOS NSURLSession strips the Authorization header when it
auto-follows that redirect, so the app gets an unauthenticated session
back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers
keep the auth header on same-origin redirects, which is why the webmail
never saw this; it only bites the native app on iOS.

Request /jmap/session directly so the header stays attached, falling
back to /.well-known/jmap on 404 for servers that don't expose the
Stalwart path.

Also treat a 200 with no accounts as an auth failure. Stalwart answers
that way for missing or invalid credentials instead of sending a 401, so
a user who fat-fingers their password currently gets "No account found
in JMAP session" rather than "Invalid credentials".

Tests updated for both behaviours; full suite passes (the pre-existing
auth-store.test.ts parse failure on main is unrelated).
2026-08-05 23:34:53 +05:30
Linus Rath 077b590851 Merge pull request #38 from koolninad/fix/camera-prompt-and-drawer-first-open
fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
2026-08-05 18:39:36 +02:00
Ninad Chandorkar 71af9d105d fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
Two fixes App Review and testers hit on the iOS build of this app.

**Camera pre-prompt.** App Review rejects a custom pre-permission screen
whose button says "Allow" under guideline 5.1.1(iv) — it reads as an
imitation of the system dialog. Verbatim from the rejection: "A custom
message appears before the permission request, and to proceed users press
an 'Allow' button. Use words like 'Continue' or 'Next' on the button
instead."

The QR scanner's permission screen now says "Continue", and the copy
above it explains what the camera is for instead of asking for access.
When the OS stops allowing a re-prompt (canAskAgain is false) the button
becomes "Open Settings" and calls Linking.openSettings(), which is the
recovery path the same guideline suggests.

**Drawer first open.** The sidebar drawers park at -screenWidth and start
the slide-in from the effect that watches `visible`. That effect runs
before the Modal's native view is attached, so the very first open loses
its animation and the drawer renders almost entirely off-screen; opening
it again works because the view now exists. The open animation also runs
from the Modal's onShow, which commits the final offset once the modal is
really on screen and is a no-op on later opens. Applied to the mail,
calendar and contacts drawers, which all share the pattern.
2026-08-05 21:52:13 +05:30
Linus Rath 82b97fada4 chore: update version number to 0.1.58 2026-08-02 20:55:51 +02:00
Linus Rath 428a1bf03d chore: update version number to 0.1.57 2026-08-02 20:54:30 +02:00
Linus Rath 09350dd1f0 refactor: polish sign-in flow UI and theme-aware QR scanner 2026-08-02 20:54:21 +02:00
Linus Rath d2d13a37c9 fix: only say "Found your mail server" when discovery found it 2026-08-02 20:17:57 +02:00
Linus Rath be82bfe814 feat: show shared mailboxes (group accounts) in the sidebar #37 2026-08-02 20:17:29 +02:00
Linus Rath 5c775ad82e Merge pull request #31 from arnauda-gh/fix/swipe-action-desync
fix(ui): synchronize swipe action gesture handlers with latest props
2026-08-02 16:29:07 +02:00
Linus Rath 941e5ede12 refactor(ui): move swipe gesture decisions into a pure module
Keeps the fix — gesture handlers now read current props — but makes the
class of bug structurally impossible rather than patched case by case.

The PanResponder is created once, so its handlers closed over the props of
the render that built it. Four call sites had to be corrected individually,
and nothing stopped a fifth from being added stale. Now the decisions live
in swipe-gesture.ts as pure functions taking the config as an argument, so
there is nothing left for a handler to capture; the component keeps one
`latest` ref pointing at the current props and hands it over on each event.

Also:
- Replace the placeholder test, which asserted that assigning to a locally
  declared `{current}` object works and passed against the unfixed
  component, with 20 tests over the real gesture logic: claim/decline,
  overshoot clamping, commit thresholds, flick velocity, reveal open/close
  from an offset base, and the 'none' cases.
- Build the responder in a useMemo instead of re-running PanResponder.create
  on every render of every visible row and discarding the result.
- Fold the four repeated Animated.spring calls into springTo/settle.

Beyond the reported symptom, the same staleness broke two more cases:
entering selection mode passes 'none' for both actions, but a swipe still
fired the old action; and onAction captured a handleSwipeAction from before
the mailbox ids resolved, so on a slow connection swipe silently did nothing
without anyone touching the setting — which matches the deeper problem
Arnaud suspected on the PR.
2026-08-02 16:28:48 +02:00
Linus Rath 2577f4e64b Merge pull request #33 from arnauda-gh/fix/attachment-direct-view
fix(android): open attachments directly with default viewer app
2026-08-02 15:37:03 +02:00
Linus Rath ee21c15fec refactor(android): tidy the attachment direct-view handoff
Follow-up to the direct-view fix. No behaviour change on the happy path,
but the handoff is now easier to reason about and covered by tests.

- Use `File.contentUri` from the expo-file-system 19 API instead of the
  legacy `getContentUriAsync`, dropping the `expo-file-system/legacy`
  import. Same FileProvider, one less round trip through the bridge.
- Pull the intent launch into `openWithViewer`, which returns false when
  the handoff didn't happen, so the fallback reads as a decision rather
  than as an empty catch. It also no longer swallows the reason silently.
- Rewrite the expo-sharing comment above `shareAttachment`: it warns
  against pre-translating to a content URI, which is still true for every
  `Sharing.shareAsync` call but is exactly what the new VIEW path has to
  do. Note why `downloadAttachment` keeps the share sheet as well.
- Record why FLAG_ACTIVITY_NEW_TASK is deliberately absent: the module
  launches via startActivityForResult, which a new task would cancel.
2026-08-02 15:36:31 +02:00
Linus Rath e574fd9e11 chore: update version number to 0.1.56 2026-08-01 14:25:31 +02:00
Linus Rath dfa278f7a8 fix: sign iOS archives manually with an App Store provisioning profile 2026-08-01 13:47:49 +02:00
Linus Rath 4eeda34d95 fix: sign iOS archives with the distribution identity 2026-08-01 13:37:34 +02:00
Linus Rath 22ad291202 Merge branch 'main' of https://github.com/bulwarkmail/native 2026-08-01 13:29:18 +02:00
Linus Rath f040a48e2f feat: iOS TestFlight release pipeline 2026-08-01 13:29:15 +02:00
Linus Rath c29cbcd549 feat: step-by-step sign-in with server discovery from the email address 2026-08-01 13:28:31 +02:00
Linus Rath bf67b97176 Merge pull request #36 from lucletoffe/fix/unread-dot-anchor
fix(list): anchor the unread dot on the first line, not the row midpoint
2026-07-31 21:15:30 +02:00
lucletoffeandClaude Opus 5 93761e67ef fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` —
into In-Reply-To/References. No recipient can match that against any
Message-ID, so the conversation splits in two: the original sits alone in
its thread while the reply (and everything answering it) starts a fresh one.

Proven against a live Stalwart account: the reply carried
`In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed
in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no
match.

Fetch `messageId`/`inReplyTo`/`references` with the full email and build the
headers from them (bracketed, ancestors first, parent closing the chain).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
lucletoffeandClaude Opus 5 a2484f3508 fix(list): anchor the unread dot on the first line, not the row midpoint
The dot added in #27 is positioned with `top: '50%'`, so it centres on the
whole row. Rows are three lines tall (sender / subject / preview), so the dot
lands a full line below the checkbox and the avatar it reads as a column
with — the taller the row, the worse it looks.

Anchor it on the first line instead: row padding + half an avatar, following
the density (and the text line height when the avatar is hidden in
extra-compact). The webmail's list carries the same `top-1/2` and the same
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
Linus Rath 82997e27e4 docs: fix doubled slash in logo URLs 2026-07-25 18:23:17 +02:00
arnauda-gh b7995fafde fix(android): open attachments directly with default viewer app
Fixes #32.

On Android, Sharing.shareAsync triggers Intent.ACTION_SEND (Share sheet). Updated shareAttachment to convert the file URI to a content URI and launch android.intent.action.VIEW via expo-intent-launcher, with fallback to shareAsync if no viewer application is installed for the MIME type.
2026-07-24 12:03:28 +02:00
arnauda-gh cde461999f fix(ui): synchronize swipe action gesture handlers with latest props
Fixes #30.

PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props.
2026-07-24 11:52:07 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
Linus Rath 2427bdbd79 fix: keep VTODO-only task lists out of the Calendars drawer #28
Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
2026-07-22 21:37:27 +02:00
Linus Rath 05e2af23dd fix: add unread indicator dot to email list and unified inbox rows #27
The only unread cues were a font-weight bump and a grey-to-white shift
on the sender name, which barely render on some device fonts and are
nearly invisible in the dark theme. Mirror the webmail's dedicated
indicator (email-list-item.tsx): an 8px circle in the theme's existing
unread color, vertically centered in the row's start gutter, shown in
both the mailbox list and the unified inbox.

Verified on a Pixel 7 API 34 emulator in dark theme: unread rows are
unambiguous at a glance, read rows unchanged.
2026-07-21 20:17:41 +02:00
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
550 changed files with 169514 additions and 10891 deletions
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
+181 -12
View File
@@ -10,6 +10,13 @@ on:
required: false
default: ""
# Signing is described in docs/android-release.md.
env:
# Certificate SHA-256 of android/app/debug.keystore, the stock React Native
# template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public,
# so Android 9+ must never see it as the signer of a published build.
DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c
jobs:
build:
runs-on: ubuntu-latest
@@ -17,6 +24,26 @@ jobs:
permissions:
contents: write
steps:
# Fail in seconds instead of after a 20-minute build. There is no
# unsigned or debug-signed fallback: anything signed with the debug key
# can be "updated" by anyone.
- name: Require the release signing secrets
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=""
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
if [ -n "$missing" ]; then
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
@@ -66,23 +93,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.
- name: Decode and check the release key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
set -euo pipefail
KEYSTORE="$RUNNER_TEMP/release.keystore"
CERT="$RUNNER_TEMP/release-cert.der"
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
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."
exit 1
fi
echo "Release certificate SHA-256: $CERT_SHA256"
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
- name: Build release APK
working-directory: android
env:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
@@ -90,15 +132,135 @@ jobs:
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
- name: Rename APK with version
- name: Put apksigner on the PATH
run: |
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1)
echo "Using $BUILD_TOOLS"
echo "$BUILD_TOOLS" >> "$GITHUB_PATH"
# Optional v3 key rotation away from the debug key that signed every
# release up to 0.1.62. Gradle signed the APK with the release key only,
# which existing installs reject as a different app. With a lineage the
# APK is re-signed so that Android 9+ sees the release key plus proof
# that the debug key handed over to it, while Android 7-8.1 (v2 only)
# still see the debug key. docs/android-release.md has the details.
- name: Apply the signing key rotation
id: rotation
env:
LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }}
KEYSTORE: ${{ steps.key.outputs.store_file }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
run: |
set -euo pipefail
if [ -z "$LINEAGE_BASE64" ]; then
echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature."
echo "rotated=false" >> "$GITHUB_OUTPUT"
exit 0
fi
LINEAGE="$RUNNER_TEMP/signing.lineage"
echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE"
apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt"
DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt")
if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \
|| [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64."
exit 1
fi
# Rollback on an old signer lets anything signed with it take the app
# back after the rotation, which would undo the whole point.
PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d')
if [[ "$PAST_ROLLBACK" == *true* ]]; then
echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false."
exit 1
fi
# Oldest signer first: apksigner uses it for v2, and the rotated key
# for v3 from API 28. Without --rotation-min-sdk-version it would
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
APK=android/app/build/outputs/apk/release/app-release.apk
apksigner sign \
--v4-signing-enabled false \
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
--ks-pass pass:android --key-pass pass:android \
--next-signer \
--ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \
--ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \
--lineage "$LINEAGE" \
--rotation-min-sdk-version 28 \
--out "$RUNNER_TEMP/app-release-rotated.apk" \
"$APK"
mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK"
echo "rotated=true" >> "$GITHUB_OUTPUT"
# The last gate before anything is uploaded.
- name: Verify the APK signature
env:
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
ROTATED: ${{ steps.rotation.outputs.rotated }}
run: |
set -euo pipefail
APK=android/app/build/outputs/apk/release/app-release.apk
apksigner verify --verbose --print-certs "$APK"
signers() {
apksigner verify --print-certs "$@" "$APK" \
| sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p'
}
MODERN=$(signers --min-sdk-version 28)
LEGACY=$(signers --max-sdk-version 27)
echo "Signer on Android 9+: $MODERN"
echo "Signer on Android 7-8.1: $LEGACY"
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."
exit 1
fi
aapt2 dump badging "$APK" | grep '^package:' || true
# The R8 mapping goes out with the APK: without the one from this exact
# build, the obfuscated frames in a release stack trace cannot be
# decoded (docs/android-release.md, "Stack traces").
- name: Rename APK and R8 mapping with version
id: artifact
run: |
set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=android/app/build/outputs/apk/release/app-release.apk
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
cp "$SRC" "$DEST"
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
MAPPING=""
if [ -f "$MAPPING_SRC" ]; then
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
cp "$MAPPING_SRC" "$MAPPING"
elif grep -Eq '^android\.enableMinifyInReleaseBuilds=true\s*$' android/gradle.properties; then
echo "::error title=R8 mapping missing::R8 is on but the build wrote no $MAPPING_SRC, so stack traces from this APK could not be decoded."
exit 1
fi
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
{
echo "files<<FILES_EOF"
echo "$DEST"
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
echo "FILES_EOF"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
@@ -106,6 +268,13 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- uses: actions/upload-artifact@v4
if: steps.artifact.outputs.mapping != ''
with:
name: ${{ steps.artifact.outputs.mapping }}
path: ${{ steps.artifact.outputs.mapping }}
if-no-files-found: error
- name: Resolve target release tag
id: target_tag
env:
@@ -125,9 +294,9 @@ jobs:
echo "No release tag resolved - APK is in workflow artifacts only."
fi
- name: Attach APK to release
- name: Attach APK and R8 mapping to release
if: steps.target_tag.outputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.path }}
files: ${{ steps.artifact.outputs.files }}
+274
View File
@@ -0,0 +1,274 @@
name: Release iOS (TestFlight)
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
# any native iOS change in app.config.js (or a config plugin), never in ios/.
on:
release:
types: [published]
workflow_dispatch:
inputs:
upload_to_testflight:
description: "Upload the build to TestFlight (off = build the IPA only)"
type: boolean
required: false
default: true
concurrency:
group: release-ios-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
# marketing version, so every run needs a fresh, increasing number.
IOS_BUILD_NUMBER: ${{ github.run_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Show toolchain versions
run: |
xcodebuild -version
pod --version
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials
id: creds
env:
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Resolve workspace and scheme
id: xcode
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
echo "Using workspace $WORKSPACE, scheme $SCHEME"
# ── Unsigned path: a compile check only, so the workflow is still useful
# on forks and before the Apple secrets exist.
- name: Build without signing
if: steps.creds.outputs.signed != 'true'
run: |
xcodebuild build \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
# ── Signed path ────────────────────────────────────────────
- name: Import the distribution certificate
if: steps.creds.outputs.signed == 'true'
env:
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
# Ephemeral runner, ephemeral keychain - this password protects nothing
# beyond the life of the job.
KEYCHAIN_PASSWORD=$(uuidgen)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
rm -f "$RUNNER_TEMP/dist.p12"
# Without this, codesign triggers an interactive keychain prompt that
# nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# Put our keychain first in the search list, keeping the login one so
# Xcode can still reach Apple's intermediate certificates.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
security find-identity -v -p codesigning "$KEYCHAIN"
- name: Install the App Store Connect API key
if: steps.creds.outputs.signed == 'true'
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
# altool looks the key up by ID in this directory.
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Install the provisioning profile
if: steps.creds.outputs.signed == 'true'
id: profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
# from the profile keeps ExportOptions honest if app.config.js changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Manual signing, deliberately. Automatic signing always signs the
# archive with a *development* identity and relies on -exportArchive to
# re-sign it for distribution afterwards - which needs a development
# certificate and a registered device that a CI-only TestFlight
# pipeline has no reason to own. Naming the App Store profile up front
# skips that dance entirely.
xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
# resolved values instead of guessing twice.
- name: Dump signing settings
if: failure() && steps.creds.outputs.signed == 'true'
continue-on-error: true
run: |
echo "── identities in the keychain ──"
security find-identity -v -p codesigning || true
echo "── resolved build settings ──"
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release -showBuildSettings 2>/dev/null \
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
- name: Export the IPA
if: steps.creds.outputs.signed == 'true'
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: steps.creds.outputs.signed == 'true'
with:
name: ${{ steps.export.outputs.path }}
path: ${{ steps.export.outputs.path }}
if-no-files-found: error
- name: Upload to TestFlight
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
if: >-
steps.creds.outputs.signed == 'true' &&
(github.event_name == 'release' || inputs.upload_to_testflight)
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
IPA="${{ steps.export.outputs.path }}"
# Validate first - it catches missing Info.plist keys and entitlement
# mistakes in seconds, instead of via a rejection email 20 minutes later.
xcrun altool --validate-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
+3
View File
@@ -17,6 +17,9 @@ expo-env.d.ts
*.p12
*.key
*.mobileprovision
*.keystore
!/android/app/debug.keystore
*.lineage
# Metro / Expo dev-server logs
.metro-health-check*
+333 -40
View File
@@ -1,23 +1,42 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import {
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { startPushUpdates } from './src/api/push';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
import type { StateChange } from './src/api/types';
import { dispatchStateChange } from './src/lib/state-change-bus';
import {
startCalendarNotificationSync,
startCalendarReminderTapHandling,
} from './src/lib/calendar-notifications';
import { startLivenessMonitor } from './src/lib/connection-liveness';
import {
setPendingCalendarOpen,
type CalendarReminderTarget,
} from './src/navigation/pending-calendar-open';
import { sweepStaleExportFiles } from './src/lib/email-export';
import { useFilterStore } from './src/stores/filter-store';
import { useVacationStore } from './src/stores/vacation-store';
import {
addMessageListener,
addNotificationTapListener,
addTokenRefreshListener,
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
notificationTapJmapAccountId,
resyncPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -37,13 +56,22 @@ import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
import { mailboxAccountId } from './src/lib/mailbox-tree';
import { prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { UndoSnackbar } from './src/components/UndoSnackbar';
import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
@@ -70,13 +98,86 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
subject: payload.subject,
// A group mailbox's message lives under another JMAP account (#839).
jmapAccountId: notificationTapJmapAccountId(payload),
});
}
// A tapped calendar reminder opens its event (or task) in the Calendar tab,
// under the account it was scheduled for. The target is parked for
// CalendarScreen, which also picks it up when it mounts after a cold start.
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
const auth = useAuthStore.getState();
if (target.appAccountId && target.appAccountId !== auth.activeAccountId) {
if (!useAccountStore.getState().getAccountById(target.appAccountId)) return;
await auth.switchAccount(target.appAccountId);
if (useAuthStore.getState().activeAccountId !== target.appAccountId) return;
}
setPendingCalendarOpen(target);
// On a cold start the navigator mounts right after the auth gate flips.
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (navigationRef.isReady()) {
navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never);
}
}
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
await handleDeepLink(link, {
navigation: navigationRef,
resolveThreadId: async (emailId) => {
try {
const [email] = await getEmails([emailId]);
return email?.threadId ?? null;
} catch {
return null;
}
},
switchAccount: async (accountId) => {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
if (!useAccountStore.getState().getAccountById(accountId)) return false;
await auth.switchAccount(accountId);
return useAuthStore.getState().activeAccountId === accountId;
},
});
}
// Toasts and the undo bar show on whichever stack screen is up, so a failure
// that lands after leaving the screen it came from (a viewer action finishing
// on the Unified Inbox or a contact) is still seen, and a reply held by the
// undo-send delay can be undone from the viewer it was sent from. Every
// screen carries the hosts, and only the focused one renders them, so
// neither ever shows twice (the screen below the top one is kept live).
// The composer gets no undo bar: its Undo would hand the open composer
// another draft. A held send's bar shows on the screen below once it closes.
function FocusedHosts({ undo }: { undo: boolean }) {
if (!useIsFocused()) return null;
return (
<>
{undo && <UndoSnackbar />}
<ToastHost />
</>
);
}
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
return (
<>
{children}
<FocusedHosts undo={route.name !== 'Compose'} />
</>
);
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
@@ -89,21 +190,38 @@ function LoadingScreen({ message }: { message: string }) {
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
const c = useColors();
const mailboxes = useEmailStore((state) => state.mailboxes);
// Select the count, not the list: every mailbox fetch sets a new array, and
// re-rendering here re-renders the mail list with it.
const inboxUnreadCount = useEmailStore(
(state) => state.mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0,
);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
const t = useLocaleStore((state) => state.t);
// The webmail's navigation names; a tab the server lacks says so.
const tabLabels = {
mail: t('sidebar.mail', 'Mail'),
calendar: t('sidebar.calendar', 'Calendar'),
contacts: t('sidebar.contacts', 'Contacts'),
files: t('sidebar.files', 'Files'),
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
<AppIconBadge />
<Tab.Navigator
screenOptions={{
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
@@ -123,6 +241,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen
name="Mail"
options={{
title: tabLabels.mail,
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
@@ -143,10 +262,22 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
// The list holds the open folder's mail: name its account so the
// viewer never has to guess it from whatever folder is open.
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
const target = {
jmapAccountId: mailboxAccountId(all, currentMailboxId),
// A row of an "All folders" list or a tag view names its own
// account instead, and pages over that account's rows.
...viewerParamsForRow(email),
};
// Start on the body now: mounting the viewer takes a while.
prefetchMessage(email, target.jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
...target,
});
}}
/>
@@ -156,9 +287,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Calendar"
component={CalendarScreen}
options={{
title: tabLabels.calendar,
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
}}
listeners={{
tabPress: (e) => {
@@ -170,9 +302,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Contacts"
component={ContactsScreen}
options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
}}
listeners={{
tabPress: (e) => {
@@ -184,9 +317,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Files"
component={FilesScreen}
options={{
title: tabLabels.files,
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
}}
listeners={{
tabPress: (e) => {
@@ -197,6 +331,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen
name="Settings"
options={{
title: tabLabels.settings,
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
>
@@ -212,6 +347,7 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
const t = useLocaleStore((state) => state.t);
// Resolve the user's theme preference to a concrete light/dark style for the
// system status bar. The rest of the app's colors are still hard-coded dark
@@ -221,6 +357,13 @@ export default function App() {
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// React Navigation's default theme is light: without this its containers
// paint white behind and between screens, even in dark mode.
const background = useColors().background;
const navigationTheme = React.useMemo(() => {
const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme;
return { ...base, colors: { ...base.colors, background, card: background } };
}, [resolvedScheme, background]);
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
@@ -236,6 +379,9 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
// Calendar reminders are kept scheduled from launch, not only once the
// Calendar tab has been opened.
startCalendarNotificationSync();
return useNetworkStore.getState().init();
}, []);
@@ -273,6 +419,9 @@ export default function App() {
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
// Attachments shared out of the app linger in the cache dir; drop the
// ones older than a day so a granted content URI can't read them forever.
void sweepStaleExportFiles();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
@@ -294,12 +443,21 @@ export default function App() {
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
// Foreground FCM messages: the Kotlin service skips the headless task while
// the app is visible, so feed the relay's StateChange straight into the
// stores. SSE normally beats it, but this covers the window where the SSE
// socket is down and the poll fallback has not fired yet.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
console.log('[push] fcm message', payload.title);
const raw = payload?.data?.changed;
if (!raw) return;
try {
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
if (!changed || typeof changed !== 'object') return;
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
} catch {
// malformed payload - ignore
}
});
return unsubscribe;
}, []);
@@ -328,11 +486,67 @@ export default function App() {
};
}, [isAuthenticated]);
// Calendar reminders are local expo notifications; their taps come through
// expo-notifications, not the native tap store used for mail push above.
React.useEffect(() => {
if (!isAuthenticated) return;
return startCalendarReminderTapHandling((target) => {
void openCalendarReminder(target);
});
}, [isAuthenticated]);
// Deep links and share-sheet payloads. The cold-start URL / share is read
// once auth is restored so the target screen has credentials.
React.useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const open = (link: DeepLink | null) => {
if (!link || cancelled) return;
// Give the navigator a tick to mount after the auth gate flips.
setTimeout(() => { void openDeepLink(link); }, 50);
};
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
void getInitialShare().then((share) => {
if (!share) return;
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length) {
navigationRef.isReady() && navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
const shareSub = addShareListener((share) => {
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
return () => {
cancelled = true;
urlSub.remove();
shareSub();
};
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
// setups intact. An account the user turned push off for, or whose device
// registration was revoked, stays off (see resyncPushNotifications).
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
@@ -353,7 +567,7 @@ export default function App() {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
await setupPushNotifications({
await resyncPushNotifications({
relayBaseUrl,
accountLabel: client.username ?? undefined,
});
@@ -370,52 +584,129 @@ export default function App() {
const unsubscribe = addTokenRefreshListener(() => {
void doSetup();
});
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
// out a new endpoint, so re-register it with the relay.
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
void doSetup();
});
return () => {
cancelled = true;
unsubscribe();
unsubscribeUp();
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
// Backgrounding closes the stream (no socket + server pings while the app
// is asleep); foregrounding reconnects it and refreshes what the user is
// looking at, since events that happened in between are gone for good.
React.useEffect(() => {
if (!isAuthenticated || !client) {
if (!isAuthenticated || !client || !haveLiveSession) {
return;
}
let mounted = true;
let cleanup: (() => void) | null = null;
let handle: LiveUpdatesHandle | null = null;
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
void (async () => {
const onStateChange = async (change: StateChange) => {
// Filters / vacation edited elsewhere (webmail, another device): refetch
// so the settings panes don't save stale state over the newer script.
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
const own = primary ? change.changed?.[primary] : undefined;
const extra: Promise<unknown>[] = [];
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
dispatchStateChange(change);
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
...extra,
]);
};
const start = async () => {
try {
const stopPushUpdates = await startPushUpdates(client, {
onStateChange: async (change) => {
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
]);
},
onError: (error) => {
console.warn(error.message);
},
const next = await startLiveUpdates({
onStateChange: (change) => { void onStateChange(change); },
onError: (error) => { console.warn('[push]', error.message); },
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
isActive: () => appActive,
});
if (!mounted) {
stopPushUpdates();
next.close();
return;
}
cleanup = stopPushUpdates;
handle = next;
} catch (error) {
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
}
})();
};
const refreshAfterResume = () => {
const email = useEmailStore.getState();
void email.fetchMailboxes();
if (email.currentMailboxId) void email.refreshEmails();
void useOutboxStore.getState().flush();
};
// Foreground liveness for the per-account connection dot: the open
// stream's pings, or a `Core/echo` every 30 s while there is none (see
// startLivenessMonitor). NetInfo cannot tell an unreachable server.
const liveness = startLivenessMonitor({
ping: () => jmapClient.ping(),
streamHealthy: () => handle?.healthy ?? false,
isActive: () => appActive,
isOnline: () => useNetworkStore.getState().online,
onConnected: (connected) => {
if (!mounted || !activeAccountId) return;
const account = useAccountStore.getState().getAccountById(activeAccountId);
if (account && account.isConnected !== connected) {
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
}
},
});
void start();
const subscription = AppState.addEventListener('change', (state) => {
const nowActive = state === 'active';
if (nowActive === appActive) return;
appActive = nowActive;
if (!nowActive) {
handle?.close();
handle = null;
return;
}
// Coming back: reconnect and catch up on what was missed at once;
// one echo alongside checks the server for the connection dot.
if (!handle) void start();
else handle.reconnect();
refreshAfterResume();
void liveness.check();
});
// Reconnect when the network comes back while foregrounded.
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && appActive) {
if (handle) handle.reconnect();
else void start();
}
});
return () => {
mounted = false;
cleanup?.();
subscription.remove();
unsubscribeNetwork();
liveness.stop();
handle?.close();
handle = null;
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
// Skip the "Restoring session" flash for returning users: if we already
// have a persisted active account, render the main UI immediately with
@@ -425,7 +716,7 @@ export default function App() {
return (
<>
<StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." />
<LoadingScreen message={t('common.loading', 'Loading...')} />
</>
);
}
@@ -440,9 +731,11 @@ export default function App() {
}
return (
<NavigationContainer ref={navigationRef}>
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
<StatusBar style={statusBarStyle} />
<Stack.Navigator screenOptions={{ headerShown: false }}>
{/* On Fabric, native-stack keeps the screen right below the top live and
freezes the ones further down. */}
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
+241
View File
@@ -0,0 +1,241 @@
# Webmail parity checklist
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
behaviour, and every webmail changelog fix since the native app was started
(1.4.5 → 1.9.2), was checked against the native code.
The audit was done by reading code on both sides, not by running the app. Every
finding cites `file:line` on both sides so it can be re-verified quickly; a
sample of the 32 P1 findings was additionally spot-checked by hand.
## How to use this document
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
Tick the `- [ ]` boxes there as you fix things; this file is the index.
- Each finding has a **priority** and a **category**:
| Priority | Meaning |
|---|---|
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
| `P2` | notable feature missing or visibly wrong |
| `P3` | polish, nice-to-have, minor divergence |
| Category | Meaning |
|---|---|
| `missing` | webmail has it, native does not |
| `partial` | native has a reduced version |
| `bug` | native has it, but it is wrong |
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
| `rn-only-bug` | a bug that only exists in native (found while reading) |
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
"native #NN".
- Each area file ends with **Verified at parity** (do not redo) and
**N/A on mobile** (deliberately out of scope) lists.
- A few findings appear in two areas because they sit on a boundary (e.g.
"forward drops attachments" is in both viewer and composer). Fix once, tick
both.
## Baseline health of this repo (2026-08-29)
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
- `src/stores/__tests__/auth-store.test.ts` fails to load with
`SyntaxError: Unexpected token 'typeof'`. The chain
`auth-store → email-store → outbox-store → network-store` imports
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
import was added; `email-store.test.ts` only passes because it mocks
`../outbox-store` itself.
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
- `npx tsc --noEmit` is clean.
- Uncommitted work in the tree at audit time: ascending/descending mail sort
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
Findings that touch list ordering assume that lands.
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
themes, file storage – UI stubs only". Filters, Files and file sharing are
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
stubs (area 08).
## Status after the 2026-08-29 fix pass
Worked down the same day, area by area, by one orchestrating session plus one
agent per area, each change committed on its own (`type: short description`).
Per-area counts of ticked items are in the area files; every tick carries the
commit hash. The remaining `- [ ]` items are either deferred with a note
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
store for native #1).
## Status after the 2026-09-24 fix pass
The native audit of 2026-09-22 ([docs/audit-2026-09.md](docs/audit-2026-09.md))
spot-checked this list and found 14 ticks that were broken or overstated and 11
open items that were already done. The 2026-09-24 fix pass fixed 13 of the 14
(their ticks now also name the fixing commit; the date-locale item is open
again), dropped three obsolete items and ticked what it completed: 380 of 415
items are done, 35 open. What still needs a device check or a decision is in
the audit's "Fix pass, 2026-09-24" section.
## Areas
| # | Area | File | Items | Done | Open | P1 | P2 | P3 |
|---|---|---|---|---|---|---|---|---|
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 29 | 1 | 4 | 8 | 18 |
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 50 | 4 | 1 | 20 | 33 |
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 45 | 1 | 6 | 13 | 26 |
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 46 | 5 | 5 | 14 | 32 |
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 50 | 43 | 7 | 5 | 20 | 25 |
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 50 | 46 | 4 | 1 | 18 | 31 |
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 32 | 29 | 3 | 3 | 8 | 21 |
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 38 | 8 | 0 | 14 | 32 |
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 54 | 2 | 7 | 23 | 26 |
| | **Total** | | **415** | **380** | **35** | **32** | **138** | **244** |
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24. Done and
Open split them by tick; the P columns count the priority tags on those items
(one item carries none).
## All P1 findings (fix these first)
### Data loss / wrong send
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
### Core flow blocked / wrong content shown
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
### JMAP client, live sync, security
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
### Repo health
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
## Native issues mapped to findings
| Native issue | Where it is explained |
|---|---|
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
## Cross-cutting themes
These come up in nearly every area; fixing them centrally pays off more than
per-screen patches.
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
single gap causes the broken threading headers, missing Reply-To handling,
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
panel. (03, 04)
2. **No thread model.** The list collapses threads but the viewer is a
single-message pager over the folder page. (02, 03)
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
84 of 94 screen/component files never call `t()`; calendar, files,
vacation, scheduled, login and every settings pane except Language and
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
vacation and Files are primary-account only; shared/group accounts work
for mail folders but not elsewhere. (01, 05, 06, 07)
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
spam (#850), search defaults to all folders (#788), folder substring dedup
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
calendar/address book creation (#907), `supported-calendar-component-set`
pinning (#760), request timeouts (#702), relative session URL resolution.
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
the settings UI (layout, sound, calendar notifications, sidebar apps,
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
the toggles. (08, table in that file)
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
unread", never matches the payload to an account, no device list/revoke,
Android/FCM only. (08)
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
with none of the layers webmail added since 1.4.5: request deadlines,
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
first-touch serialisation, and — most importantly — method-error checking.
A shared `requireMethodResult` / `assertSetResult` helper used by every
`src/api/*.ts` function fixes a whole class of silent failures. (09)
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
EventSource; reconnect, token refresh, account switch and client-cert
users all leave the stream dead or stale. (09)
## Suggested order of work
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
2. The P1 list above, roughly in the order given (composer/send correctness,
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
JMAP client + SSE lifecycle).
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
checking + request deadline) — they unlock a large slice of the P2 items in
03/04/09 and make the outbox trustworthy.
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
catalogs + `t()` sweep).
5. P3 items opportunistically while touching the same files.
## Explicitly out of scope on mobile
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
back/forward and permalinks in the address bar, PWA install / service worker,
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
exchange, push relay list) the area file says what the native equivalent
would be.
## Keeping this current
Re-run the audit after each webmail release: read the new changelog "Fixes"
bullets, decide `RN affected / not affected / N/A`, and append to the matching
area file. When a finding is fixed, tick it and add the native commit hash in
the same line so the next audit can skip it.
+9 -8
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -21,15 +21,15 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- 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
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- iOS builds, but push notifications and client certificates are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
## Run locally
@@ -40,7 +40,8 @@ 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).
## License
+1 -1
View File
@@ -1 +1 @@
0.1.48
0.1.63
+117 -8
View File
@@ -7,6 +7,62 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
def appVersionName = file("${projectRoot}/VERSION").text.trim()
/**
* versionCode has to grow with every release, or Android refuses the update as
* a downgrade. It is derived from VERSION as
*
* major * 10000000 + minor * 10000 + patch (0.1.62 -> 10062)
*
* which keeps increasing as long as patch stays below 10000 and minor below
* 1000 (scripts/bump-version.mjs, which only ever bumps patch, enforces both),
* and stays under Android's 2100000000 ceiling up to major 209.
*/
def versionParts = (appVersionName =~ /^(\d+)\.(\d+)\.(\d+)$/)
if (!versionParts.matches()) {
throw new GradleException("VERSION must be MAJOR.MINOR.PATCH, got '${appVersionName}'")
}
def (versionMajor, versionMinor, versionPatch) = (1..3).collect { versionParts.group(it) as long }
if (versionMinor > 999 || versionPatch > 9999) {
throw new GradleException("VERSION ${appVersionName}: minor must stay below 1000 and patch below 10000, " +
"or versionCode stops increasing. Move to the next minor or major version instead.")
}
def appVersionCode = versionMajor * 10000000L + versionMinor * 10000L + versionPatch
if (appVersionCode < 1 || appVersionCode > 2100000000L) {
throw new GradleException("VERSION ${appVersionName} gives versionCode ${appVersionCode}, " +
"outside the 1..2100000000 range Android accepts.")
}
/**
* Release signing. The key never lives in the repo: CI passes it as
* ORG_GRADLE_PROJECT_BULWARK_RELEASE_* environment variables, and a local
* release build can set the same names with -P, in ~/.gradle/gradle.properties
* or as plain environment variables. See docs/android-release.md.
*
* There is deliberately no automatic fallback to the debug key. It is the
* public React Native template key, so an APK signed with it can be replaced
* by an "update" anyone can sign.
*/
def releaseSigningNames = [
'BULWARK_RELEASE_STORE_FILE',
'BULWARK_RELEASE_STORE_PASSWORD',
'BULWARK_RELEASE_KEY_ALIAS',
'BULWARK_RELEASE_KEY_PASSWORD',
]
def releaseSigning = releaseSigningNames.collectEntries { name ->
[(name): (findProperty(name) ?: System.getenv(name))?.toString()]
}
def missingReleaseSigning = releaseSigningNames.findAll { !releaseSigning[it]?.trim() }
def releaseSigningConfigured = missingReleaseSigning.isEmpty()
def releaseStoreFile = releaseSigningConfigured ? file(releaseSigning.BULWARK_RELEASE_STORE_FILE.trim()) : null
// Escape hatch for a local release build that never leaves the machine
// (profiling, checking R8). It is refused whenever CI is set, so a stray flag
// cannot turn a published build back into a debug-signed one.
def ciEnv = System.getenv('CI')?.trim()?.toLowerCase()
def onCi = ciEnv && !(ciEnv in ['false', '0'])
def allowDebugSignedRelease = (findProperty('allowDebugSignedRelease') ?: 'false').toString().trim().toBoolean()
def debugSignedRelease = !releaseSigningConfigured && allowDebugSignedRelease && !onCi
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
@@ -95,7 +151,7 @@ android {
applicationId 'com.anonymous.bulwarkmobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionCode appVersionCode.toInteger()
versionName appVersionName
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
@@ -107,13 +163,12 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
if (releaseSigningConfigured) {
release {
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
storeFile releaseStoreFile
storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD
keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS
keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD
}
}
}
@@ -122,7 +177,13 @@ android {
signingConfig signingConfigs.debug
}
release {
signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug
// With neither a key nor the opt-in there is no signing config at
// all; checkReleaseSigning below stops the build before packaging.
if (releaseSigningConfigured) {
signingConfig signingConfigs.release
} else if (debugSignedRelease) {
signingConfig signingConfigs.debug
}
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
@@ -142,6 +203,48 @@ android {
}
}
// Stop a release build that has no real key. This runs only when a release
// variant is actually built, so `expo run:android`, assembleDebug and IDE sync
// keep working with no key configured.
def releaseSigningHelp = [
"Release signing is not configured (missing: ${missingReleaseSigning.join(', ')}).",
"",
"Release builds are never signed with the debug key: it is the public React Native",
"template key, so an APK signed with it can be replaced by anyone. Provide the release",
"key as gradle properties (-P or ~/.gradle/gradle.properties) or environment variables:",
" BULWARK_RELEASE_STORE_FILE path to the keystore",
" BULWARK_RELEASE_STORE_PASSWORD",
" BULWARK_RELEASE_KEY_ALIAS",
" BULWARK_RELEASE_KEY_PASSWORD",
"",
onCi && allowDebugSignedRelease
? "allowDebugSignedRelease is ignored because CI is set: published builds must use the release key."
: "For a local test build that is never installed anywhere else, pass -PallowDebugSignedRelease=true.",
"See docs/android-release.md.",
].join('\n')
def checkReleaseSigning = tasks.register('checkReleaseSigning') {
group = 'verification'
description = 'Fails a release build that has no release signing key.'
doLast {
if (releaseSigningConfigured) {
if (!releaseStoreFile.isFile()) {
throw new GradleException("BULWARK_RELEASE_STORE_FILE points to ${releaseStoreFile}, which does not exist.")
}
} else if (debugSignedRelease) {
logger.warn("WARNING: this release build is signed with the PUBLIC debug key " +
"(allowDebugSignedRelease). Do not distribute it.")
} else {
throw new GradleException(releaseSigningHelp)
}
}
}
tasks.configureEach { task ->
if (task.name == 'preReleaseBuild') {
task.dependsOn(checkReleaseSigning)
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
@@ -171,6 +274,12 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
// UnifiedPush - Google-free push via a user-chosen distributor app such
// as ntfy. Coexists with FCM; the user picks the transport in settings.
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+9
View File
@@ -12,3 +12,12 @@
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
# The app's own native code (com.anonymous.bulwarkmobile). What R8 must not
# drop or rename there is already covered elsewhere: React Native's rules keep
# every NativeModule, whose @ReactMethod methods JS calls by name, and AAPT
# keeps the application, activity and services listed in AndroidManifest.xml
# (FCM, UnifiedPush and the headless JS task). Keeping the package whole on
# top of that costs a few KB and leaves its class and method names readable
# in a crash report from an obfuscated release build.
-keep class com.anonymous.bulwarkmobile.** { *; }
+31 -1
View File
@@ -13,7 +13,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -28,6 +28,30 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="mailto"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
@@ -35,6 +59,12 @@
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
</application>
</manifest>
@@ -141,7 +141,9 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = method
instanceFollowRedirects = true
// Redirects are resolved in JS (lib/client-cert.ts) so a POST
// stays a POST and credentials never follow a cross-host hop.
instanceFollowRedirects = false
if (bodyBytes != null) {
doOutput = true
setFixedLengthStreamingMode(bodyBytes.size)
@@ -66,6 +66,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
val jmapAccountId = options.takeIf { it.hasKey("jmapAccountId") }?.getString("jmapAccountId")
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
?: accountId?.let { "bulwark-mail:$it" }
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
?: accountId ?: "Bulwark Mail"
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -74,8 +79,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject, accountId,
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
}
}
@@ -86,6 +92,12 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
promise.resolve(payload?.toMap())
}
@ReactMethod
fun getInitialShare(promise: Promise) {
val payload = ShareIntentStore.consume()
promise.resolve(payload?.toMap())
}
private fun postNotification(
notificationId: String,
title: String,
@@ -96,6 +108,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
threadId: String?,
subject: String?,
accountId: String?,
jmapAccountId: String?,
groupKey: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -104,6 +118,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -113,7 +128,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
)
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(largeIcon)
.setContentTitle(title)
.setContentText(body)
@@ -122,11 +137,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pending)
if (groupKey != null) {
builder.setGroup(groupKey)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
// One summary per account group so several deliveries collapse into a
// single "N new messages" entry (Android renders the "+N more" itself
// from the children). Tapping the summary opens the app on the inbox;
// the per-message children carry the deep link.
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
val ctx = reactApplicationContext
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val children = manager.activeNotifications.filter {
it.groupKey?.endsWith(groupKey) == true &&
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
}
val count = maxOf(children.size, 1)
val inbox = NotificationCompat.InboxStyle()
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
val extras = sbn.notification.extras
val line = listOfNotNull(
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
).joinToString(" ")
if (line.isNotBlank()) inbox.addLine(line)
}
inbox.setBigContentTitle(groupTitle)
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
groupKey.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
.setStyle(inbox)
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setGroup(groupKey)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
manager.notify(groupKey, groupKey.hashCode(), summary)
}
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
val size = 192
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
@@ -3,14 +3,20 @@ package com.anonymous.bulwarkmobile
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(
BulwarkFcmModule(reactContext),
BulwarkClientCertModule(reactContext),
)
buildList {
add(BulwarkFcmModule(reactContext))
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
}
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
@@ -0,0 +1,35 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.jstasks.HeadlessJsTaskContext
/**
* React Native's HeadlessJsTaskSupport module, which AppRegistry calls when a
* headless JS task's promise settles. RN 0.81 registers it only for the old
* bridge (CoreModulesPackage); the bridgeless runtime's CoreReactPackage
* leaves it out, so in this app JS could never report a finished task and
* every BulwarkPushTask ran until its 30 s timeout, keeping the service or job
* and its wake lock alive that long. Same logic as the RN module.
*/
class BulwarkHeadlessJsTaskSupportModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "HeadlessJsTaskSupport"
@ReactMethod
fun notifyTaskFinished(taskId: Double) {
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
val id = taskId.toInt()
if (tasks.isTaskRunning(id)) tasks.finishTask(id)
}
@ReactMethod
fun notifyTaskRetry(taskId: Double, promise: Promise) {
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
val id = taskId.toInt()
promise.resolve(tasks.isTaskRunning(id) && tasks.retryTask(id))
}
}
@@ -8,8 +8,9 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@@ -31,7 +32,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
if (!isAppInForeground()) {
startHeadlessTask(data)
startHeadlessTask(applicationContext, data)
}
val params = Arguments.createMap().apply {
@@ -42,25 +43,60 @@ class BulwarkMessagingService : FirebaseMessagingService() {
BulwarkFcmModule.emit("fcm:message", params)
}
private fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
private fun startHeadlessTask(data: Map<String, String>) {
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
applicationContext.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
private const val TAG = "BulwarkPush"
private const val START_WAKE_LOCK_MS = 10_000L
// Shared with BulwarkUnifiedPushService - a push arriving over either
// transport is dispatched the same way.
fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
fun startHeadlessTask(context: Context, data: Map<String, String>) {
val intent = Intent(context, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
// Keep the device awake until the service holds React Native's
// wake lock; BulwarkPushTaskService releases this one then. Not
// HeadlessJsTaskService.acquireWakeLockNow: it is not
// thread-safe, and FCM calls this on a worker thread while the
// service takes the same static lock on the main thread, so both
// could create a lock and the overwritten one stayed held.
startWakeLock = context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bulwark:push-start")
.apply {
setReferenceCounted(false)
acquire(START_WAKE_LOCK_MS)
}
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// Android 8+ refuses to start a service from the background
// unless the app is temporarily allowlisted. A high-priority
// FCM message does that; a downgraded one or a UnifiedPush
// message may not (BackgroundServiceStartNotAllowedException
// on 12+), and uncaught this crashed the app on every such
// push. Run the same task from a job instead.
releaseStartWakeLock()
Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}")
BulwarkPushJobService.schedule(context, data)
}
}
@Volatile private var startWakeLock: PowerManager.WakeLock? = null
/** Drops the wake lock taken in [startHeadlessTask]; it also times out on its own. */
fun releaseStartWakeLock() {
startWakeLock?.release()
startWakeLock = null
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -0,0 +1,180 @@
package com.anonymous.bulwarkmobile
import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.os.PersistableBundle
import android.os.SystemClock
import android.util.Log
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
*
* A push normally starts BulwarkPushTaskService, but Android 8+ only lets a
* background app start a service while it is temporarily allowlisted, which a
* high-priority FCM message does. FCM delivers some messages at normal
* priority anyway (it downgrades apps whose high-priority pushes rarely end in
* a notification), and a UnifiedPush distributor may not raise the app at all.
* The service start then throws, so the push is handed to this job instead:
* expedited on Android 12+, where it runs right away while the app has
* expedited-job quota and as a regular job after that, and a regular job on
* Android 8-11. It needs no new dependency and ends in the same notification
* as the service path.
*/
class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
private class RunningTask(val params: JobParameters, val startedAt: Long)
private val running = ConcurrentHashMap<Int, RunningTask>()
private var taskContext: HeadlessJsTaskContext? = null
override fun onStartJob(params: JobParameters): Boolean {
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
withReadyReactContext { startTask(it, config, params) }
return true
}
// The JS task has its own 30 s timeout and finishes on its own; nothing to
// reschedule if the system stops the job first.
override fun onStopJob(params: JobParameters): Boolean {
running.values.removeIf { it.params.jobId == params.jobId }
return false
}
override fun onDestroy() {
taskContext?.removeTaskEventListener(this)
super.onDestroy()
}
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
override fun onHeadlessJsTaskFinish(taskId: Int) {
val task = running.remove(taskId) ?: return
val ms = SystemClock.elapsedRealtime() - task.startedAt
Log.i(TAG, "Push task $taskId from job ${task.params.jobId} finished after $ms ms")
jobFinished(task.params, false)
}
private fun startTask(context: ReactContext, config: HeadlessJsTaskConfig, params: JobParameters) {
val tasks = HeadlessJsTaskContext.getInstance(context)
if (taskContext !== tasks) {
taskContext?.removeTaskEventListener(this)
tasks.addTaskEventListener(this)
taskContext = tasks
}
UiThreadUtil.runOnUiThread {
try {
val taskId = tasks.startTask(config)
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
} catch (e: IllegalStateException) {
// The app came to the foreground since the push arrived and
// syncs on its own there.
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
jobFinished(params, false)
}
}
}
/**
* Calls [onReady] once with a React context whose JS instance is up,
* starting React Native if the process was woken for this job.
*
* A context exists before its instance is ready, and a task started on
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
* not available" and waits for the timeout), which happened when several
* pushes arrived while the app was starting. So the listener goes in
* first and the ready check after it; whichever sees the ready instance
* first runs the task.
*/
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
val done = AtomicBoolean(false)
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
val app = application as ReactApplication
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
host.removeReactInstanceEventListener(this)
runOnce(context)
}
}
host.addReactInstanceEventListener(listener)
val current = host.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
host.removeReactInstanceEventListener(listener)
runOnce(current)
} else {
host.start()
}
} else {
@Suppress("DEPRECATION")
val manager = app.reactNativeHost.reactInstanceManager
val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
manager.removeReactInstanceEventListener(this)
runOnce(context)
}
}
manager.addReactInstanceEventListener(listener)
val current = manager.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
manager.removeReactInstanceEventListener(listener)
runOnce(current)
} else if (!manager.hasStartedCreatingInitialContext()) {
manager.createReactContextInBackground()
}
}
}
companion object {
private const val TAG = "BulwarkPush"
// A block of job ids for push jobs, one per push that is waiting or
// running. A pending job with the same id would be replaced (and a
// running one stopped), so each push takes a free id.
private const val JOB_ID_BASE = 0x42554c00
private const val JOB_ID_COUNT = 32
/** Schedules the push task as a job. Returns false if no job could be scheduled. */
fun schedule(context: Context, data: Map<String, String>): Boolean = try {
val scheduler = context.getSystemService(JobScheduler::class.java)
val busy = scheduler.allPendingJobs.mapTo(HashSet()) { it.id }
val jobId = (JOB_ID_BASE until JOB_ID_BASE + JOB_ID_COUNT).firstOrNull { it !in busy }
if (jobId == null) {
// Every slot holds a push that has not finished yet; any of
// them fetches the same new mail.
Log.w(TAG, "Push not scheduled: $JOB_ID_COUNT push jobs already pending")
false
} else {
val extras = PersistableBundle().apply {
for ((key, value) in data) putString(key, value)
}
val job = JobInfo.Builder(jobId, ComponentName(context, BulwarkPushJobService::class.java))
.setExtras(extras)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) setExpedited(true) }
.build()
val scheduled = scheduler.schedule(job) == JobScheduler.RESULT_SUCCESS
Log.i(TAG, "Push job $jobId scheduled: $scheduled")
scheduled
}
} catch (e: Exception) {
Log.w(TAG, "Push job could not be scheduled", e)
false
}
}
}
@@ -1,18 +1,32 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import android.os.Bundle
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.HeadlessJsTaskService
class BulwarkPushTaskService : HeadlessJsTaskService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
super.onStartCommand(intent, flags, startId).also {
// startTask has taken React Native's wake lock by now.
BulwarkMessagingService.releaseStartWakeLock()
}
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
return HeadlessJsTaskConfig(
"BulwarkPushTask",
Arguments.fromBundle(extras),
30000L,
false,
)
return taskConfig(extras)
}
companion object {
// Shared with BulwarkPushJobService, which runs the same task when
// this service may not be started.
fun taskConfig(extras: Bundle): HeadlessJsTaskConfig =
HeadlessJsTaskConfig(
"BulwarkPushTask",
Arguments.fromBundle(extras),
30000L,
false,
)
}
}
@@ -0,0 +1,110 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import org.unifiedpush.android.connector.UnifiedPush
// JS-facing surface for UnifiedPush distributor management. Events from the
// distributor (endpoint changes, incoming messages) arrive through
// BulwarkUnifiedPushService, which emits them on the shared device-event
// channel.
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BulwarkUnifiedPush"
@ReactMethod
fun getDistributors(promise: Promise) {
try {
val array = Arguments.createArray()
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
promise.resolve(array)
} catch (err: Exception) {
promise.reject("up_distributors_failed", err)
}
}
@ReactMethod
fun getSavedDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun getAckDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun saveDistributor(distributor: String, promise: Promise) {
try {
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_save_distributor_failed", err)
}
}
// Asks the saved distributor for an endpoint. The endpoint itself arrives
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
// relay's VAPID public key - optional, some distributors use it to
// restrict who may push.
@ReactMethod
fun register(vapid: String?, promise: Promise) {
try {
UnifiedPush.register(
reactApplicationContext,
messageForDistributor = null,
vapid = vapid?.takeIf { it.isNotBlank() },
)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_register_failed", err)
}
}
@ReactMethod
fun unregister(promise: Promise) {
try {
UnifiedPush.unregister(reactApplicationContext)
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_unregister_failed", err)
}
}
@ReactMethod
fun getEndpoint(promise: Promise) {
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
if (stored == null) {
promise.resolve(null)
return
}
val (url, p256dh, auth) = stored
val map = Arguments.createMap().apply {
putString("url", url)
putString("p256dh", p256dh)
putString("auth", auth)
}
promise.resolve(map)
}
// NativeEventEmitter required no-ops.
@ReactMethod
fun addListener(eventName: String) {}
@ReactMethod
fun removeListeners(count: Int) {}
}
@@ -0,0 +1,100 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import com.facebook.react.bridge.Arguments
import org.json.JSONArray
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
// The relay sends the same content-blind ForwardPayload it sends over FCM -
// as encrypted Web Push when the connector provided RFC 8291 keys (the
// connector decrypts before this service sees it) - so an incoming message is
// dispatched exactly like an FCM data message: headless task when the app is
// backgrounded, `fcm:message` device event for the live JS instance.
class BulwarkUnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
saveEndpoint(this, endpoint)
val params = Arguments.createMap().apply {
putString("url", endpoint.url)
putString("p256dh", endpoint.pubKeySet?.pubKey)
putString("auth", endpoint.pubKeySet?.auth)
}
BulwarkFcmModule.emit("up:newEndpoint", params)
}
override fun onMessage(message: PushMessage, instance: String) {
BulwarkMessagingService.ensureChannel(this)
val data = parsePayload(message.content) ?: return
if (!BulwarkMessagingService.isAppInForeground()) {
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
}
// Same event name and shape as the FCM path so the foreground JS
// handler (App.tsx addMessageListener) works transport-agnostically.
val params = Arguments.createMap().apply {
val dataMap = Arguments.createMap()
for ((k, v) in data) dataMap.putString(k, v)
putMap("data", dataMap)
}
BulwarkFcmModule.emit("fcm:message", params)
}
override fun onUnregistered(instance: String) {
clearEndpoint(this)
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
val params = Arguments.createMap().apply { putString("reason", reason.name) }
BulwarkFcmModule.emit("up:registrationFailed", params)
}
companion object {
private const val PREFS = "bulwark_unifiedpush"
private const val KEY_URL = "endpointUrl"
private const val KEY_P256DH = "endpointP256dh"
private const val KEY_AUTH = "endpointAuth"
// The endpoint arrives asynchronously (possibly while no JS instance
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_URL, endpoint.url)
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
.apply()
}
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val url = prefs.getString(KEY_URL, null) ?: return null
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
}
fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
// The relay's ForwardPayload JSON, flattened to the string map the
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
// and foreground handler can stay transport-blind.
fun parsePayload(content: ByteArray): Map<String, String>? = try {
val json = JSONObject(String(content, Charsets.UTF_8))
buildMap {
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
}
} catch (_: Exception) {
null
}
}
}
@@ -17,13 +17,19 @@ class MainActivity : ReactActivity() {
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
ShareIntentStore.rewriteSendToAsView(intent)
super.onCreate(null)
NotificationTapStore.captureFromIntent(intent)
ShareIntentStore.captureFromIntent(intent, contentResolver)
}
override fun onNewIntent(intent: Intent) {
ShareIntentStore.rewriteSendToAsView(intent)
super.onNewIntent(intent)
setIntent(intent)
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
BulwarkFcmModule.emit("app:share", share.toMap())
}
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
}
@@ -40,13 +40,17 @@ object NotificationTapStore {
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
// The JMAP account a group/shared mailbox's message lives in - a JMAP
// id like the email id. Optional: older notifications don't carry it.
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
return payload
}
@@ -55,12 +59,14 @@ object NotificationTapStore {
val threadId: String,
val subject: String?,
val accountId: String?,
val jmapAccountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
}
}
@@ -68,4 +74,5 @@ object NotificationTapStore {
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
}
@@ -0,0 +1,170 @@
package com.anonymous.bulwarkmobile
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
// intent (the system share sheet). MainActivity fills this on onCreate /
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
// live "app:share" event and opens the composer. `SENDTO mailto:` intents are
// rewritten into `VIEW mailto:` links instead (see rewriteSendToAsView).
object ShareIntentStore {
@Volatile
private var pending: SharePayload? = null
fun consume(): SharePayload? {
val value = pending
pending = null
return value
}
fun captureFromIntent(intent: Intent?, resolver: ContentResolver): SharePayload? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND) {
streamExtra(intent)?.let { uris.add(it) }
} else {
streamExtras(intent)?.let { uris.addAll(it) }
}
val type = intent.type
if (text.isNullOrBlank() && uris.isEmpty()) return null
val files = uris.mapIndexed { i, uri -> describe(resolver, uri, type, i) }
val payload = SharePayload(
text = text,
subject = subject,
uris = uris.map { it.toString() },
mimeTypes = files.map { it.mimeType },
names = files.map { it.name },
sizes = files.map { it.size },
)
pending = payload
// Clear so a later lifecycle event doesn't replay this share.
intent.removeExtra(Intent.EXTRA_TEXT)
intent.removeExtra(Intent.EXTRA_SUBJECT)
intent.removeExtra(Intent.EXTRA_STREAM)
intent.action = Intent.ACTION_MAIN
return payload
}
// `SENDTO mailto:` is how most apps start an email, but React Native's
// Linking only forwards VIEW intents, so the app opened on the inbox.
// Turn it into the equivalent `VIEW mailto:` link, folding the recipient,
// subject and text extras into the URI, so it reaches JS like any other
// mailto link: Linking.getInitialURL on a cold start, the "url" event
// while running. Must run before the intent is handed to React Native.
fun rewriteSendToAsView(intent: Intent?) {
val action = intent?.action ?: return
if (action != Intent.ACTION_SENDTO) return
val data = intent.data ?: return
if (!"mailto".equals(data.scheme, ignoreCase = true)) return
val params = mutableListOf<String>()
addressExtra(intent, Intent.EXTRA_EMAIL).forEach { params.add("to=${Uri.encode(it)}") }
addressExtra(intent, Intent.EXTRA_CC).forEach { params.add("cc=${Uri.encode(it)}") }
addressExtra(intent, Intent.EXTRA_BCC).forEach { params.add("bcc=${Uri.encode(it)}") }
intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.let {
params.add("subject=${Uri.encode(it.toString().take(512))}")
}
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let {
params.add("body=${Uri.encode(it.toString().take(MAX_TEXT))}")
}
val link = data.toString()
val separator = if (link.contains('?')) "&" else "?"
intent.action = Intent.ACTION_VIEW
intent.data = if (params.isEmpty()) data else Uri.parse(link + separator + params.joinToString("&"))
}
// EXTRA_EMAIL/CC/BCC are documented as String[], but some apps put a
// single String.
private fun addressExtra(intent: Intent, key: String): List<String> =
intent.getStringArrayExtra(key)?.filterNotNull() ?: listOfNotNull(intent.getStringExtra(key))
private class SharedFile(val name: String, val mimeType: String, val size: Double)
// Ask the provider for the file's real name, type and size: a MediaStore
// URI ends in a bare row id ("1000000027") and SEND_MULTIPLE only carries
// a wildcard type such as "image/*". Size is -1 when unknown.
private fun describe(resolver: ContentResolver, uri: Uri, intentType: String?, index: Int): SharedFile {
var displayName: String? = null
var size = -1.0
try {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameColumn >= 0 && !cursor.isNull(nameColumn)) displayName = cursor.getString(nameColumn)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeColumn >= 0 && !cursor.isNull(sizeColumn)) size = cursor.getLong(sizeColumn).toDouble()
}
}
} catch (_: Exception) {
// Provider without OpenableColumns: fall back to the URI below.
}
val resolvedType = try {
resolver.getType(uri)
} catch (_: Exception) {
null
}
val mimeType = resolvedType ?: intentType?.takeUnless { it.contains('*') } ?: ""
val name = displayName?.takeIf { it.isNotBlank() } ?: fallbackName(uri, mimeType, index)
return SharedFile(name, mimeType, size)
}
// Last path segment plus an extension for the MIME type, so the
// recipient's mail client can still open the attachment.
private fun fallbackName(uri: Uri, mimeType: String, index: Int): String {
val base = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?: "shared-${index + 1}"
if (base.contains('.')) return base
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
return if (extension.isNullOrEmpty()) base else "$base.$extension"
}
@Suppress("DEPRECATION")
private fun streamExtra(intent: Intent): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
@Suppress("DEPRECATION")
private fun streamExtras(intent: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
data class SharePayload(
val text: String?,
val subject: String?,
val uris: List<String>,
val mimeTypes: List<String>,
val names: List<String>,
val sizes: List<Double>,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
if (text != null) putString("text", text)
if (subject != null) putString("subject", subject)
putArray("uris", Arguments.fromList(uris))
putArray("mimeTypes", Arguments.fromList(mimeTypes))
putArray("names", Arguments.fromList(names))
putArray("sizes", Arguments.fromList(sizes))
}
}
// 1 MiB of shared text is plenty; anything bigger is an attachment.
private const val MAX_TEXT = 1 * 1024 * 1024
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar icon for mail notifications: the Bulwark shield from
assets/logos/Bulwark Logo White.svg, white on transparent. Android draws
only the alpha channel of a small icon; the group scale keeps the shield
inside the 22dp live area of the 24dp canvas. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<group
android:pivotX="500"
android:pivotY="500"
android:scaleX="0.9"
android:scaleY="0.9">
<path
android:fillColor="#FFFFFFFF"
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" />
</group>
</vector>
@@ -1 +1,6 @@
<resources/>
<resources>
<!-- As `splash.dark.backgroundColor` in app.config.js. -->
<color name="splashscreen_background">#09090b</color>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
</resources>
+4 -1
View File
@@ -1,6 +1,9 @@
<resources>
<color name="splashscreen_background">#09090b</color>
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
<color name="splashscreen_background">#ffffff</color>
<color name="iconBackground">#09090b</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
</resources>
@@ -4,6 +4,7 @@
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#09090b</item>
<item name="android:windowBackground">@color/window_background</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
+11
View File
@@ -25,6 +25,11 @@ android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Shrink release builds with R8 (code) and the resource shrinker. Keep rules
# for classes reached by reflection go in app/proguard-rules.pro.
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
@@ -63,3 +68,9 @@ expo.useLegacyPackaging=false
# 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.
AsyncStorage_db_size_in_MB=1024
+40 -3
View File
@@ -16,23 +16,42 @@ if (!COMMIT) {
}
COMMIT = COMMIT.slice(0, 7);
// App Store Connect rejects a build whose CFBundleVersion it has already seen
// for this CFBundleShortVersionString, so this has to advance on every upload
// even when VERSION does not. CI passes the workflow run number; local builds
// fall back to 1 (never uploaded).
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
// mailto: lets Android/iOS offer the app for mail links in other apps.
scheme: ['bulwarkmobile', 'mailto'],
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'dark',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
splash: {
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: '#09090b',
backgroundColor: '#ffffff',
dark: {
image: './assets/splash-icon.png',
backgroundColor: '#09090b',
},
},
ios: {
supportsTablet: true,
bundleIdentifier: 'org.bulwarkmail.mobile',
buildNumber: IOS_BUILD_NUMBER,
config: {
// The app only speaks HTTPS/TLS and uses platform crypto, which is
// exempt. Declaring it here skips the manual export-compliance
// questionnaire that otherwise blocks every TestFlight build.
usesNonExemptEncryption: false,
},
},
android: {
adaptiveIcon: {
@@ -42,6 +61,11 @@ module.exports = {
edgeToEdgeEnabled: true,
predictiveBackGestureEnabled: false,
package: 'com.anonymous.bulwarkmobile',
// The AsyncStorage database holds cached message bodies, the outbox and
// the account registry; the platform backup would ship all of it to the
// user's Google account. Credentials live in SecureStore (excluded by
// its own rules) but the mail cache must not leave the device either.
allowBackup: false,
},
web: {
favicon: './assets/favicon.png',
@@ -55,6 +79,19 @@ module.exports = {
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
// QR scanning never records audio; leaving the mic entry in would be
// an unexplained permission in App Review.
microphonePermission: false,
},
],
[
'expo-image-picker',
{
photosPermission:
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
// Only launchImageLibraryAsync is used - no in-app capture.
cameraPermission: false,
microphonePermission: false,
},
],
],
+15
View File
@@ -0,0 +1,15 @@
const path = require('node:path');
// babel-preset-expo ships inside `expo` rather than as a direct dependency, so
// resolve it from there; this is the preset Expo uses when no config exists.
const presetExpo = require.resolve('babel-preset-expo', {
paths: [path.dirname(require.resolve('expo/package.json'))],
});
module.exports = function babelConfig(api) {
api.cache(true);
return {
presets: [presetExpo],
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
};
};
+294
View File
@@ -0,0 +1,294 @@
# Android release signing
The release APK is built by
[.github/workflows/release-android.yml](../.github/workflows/release-android.yml)
on an Ubuntu runner whenever a GitHub release is published, and attached to that
release. Installed apps pick it up through the in-app updater.
Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`,
the debug key that ships with the React Native template. Its password is
`android` and copies of it are everywhere, so anyone could sign an APK that
Android accepts as an update to Bulwark Mobile. Such an "update" runs as the
app, with its accounts, SecureStore credentials and mail cache. This is
[S2 in the 2026-09 audit](audit-2026-09.md#s2).
Release builds now need a real key:
- `android/app/build.gradle` reads the key from `BULWARK_RELEASE_*` properties.
Without them, a release build fails in `checkReleaseSigning` before anything
is packaged. Debug builds and `npx expo run:android` are not affected.
- The workflow fails in its first step when a signing secret is missing, checks
the key before the build, and refuses to upload an APK that Android 9+ would
not see as signed by the release key.
- `versionCode` now comes from `VERSION` (see [versionCode](#versioncode)).
## One-time setup
The commands are for Git Bash on Windows, run from the repository root. They
need `keytool`, which ships with every JDK, and `apksigner` from the Android SDK
build-tools. Its `.jar` runs on Java 8 or newer:
```bash
apksigner() { java -jar "$LOCALAPPDATA/Android/Sdk/build-tools/36.0.0/lib/apksigner.jar" "$@"; }
```
On macOS or Linux, call `$ANDROID_HOME/build-tools/36.0.0/apksigner` instead.
### 1. Generate the release key
```bash
read -rsp 'Keystore password: ' BULWARK_KS_PASS; echo; export BULWARK_KS_PASS
keytool -genkeypair -v -storetype PKCS12 -keystore bulwark-release.p12 \
-alias bulwark -keyalg RSA -keysize 4096 -validity 10000 \
-dname "CN=Bulwark Mail, O=Bulwark Mail" \
-storepass:env BULWARK_KS_PASS -keypass:env BULWARK_KS_PASS
```
`-validity 10000` is about 27 years. A PKCS12 keystore uses one password for
the store and the key, so `ANDROID_KEYSTORE_PASSWORD` and `ANDROID_KEY_PASSWORD`
below get the same value. `*.p12` is gitignored, but move the file out of the
checkout once the secrets are set.
**Back up `bulwark-release.p12` and its password in two places**, for example
a password manager and an offline copy. If the key is lost, no later APK can
update an installed app: every user has to uninstall, which loses their accounts
and local data, and install again. If the key leaks, you are back where S2
started.
The certificate's SHA-256 is what the workflow log prints as the release key:
```bash
keytool -exportcert -keystore bulwark-release.p12 -alias bulwark \
-storepass:env BULWARK_KS_PASS | sha256sum
```
### 2. Create the rotation lineage
Skip this and the first release signed with the new key cannot be installed
over any existing install. Android reports "App not installed as package
conflicts with an existing package", and every user has to uninstall and
reinstall.
APK Signature Scheme v3, read by Android 9 and newer, lets an APK carry a
*signing lineage*: a record signed by the old key that names the new key as its
successor. The old key here is the public debug key, so you can create that
record yourself:
```bash
apksigner rotate --out bulwark.lineage \
--old-signer --ks android/app/debug.keystore --ks-key-alias androiddebugkey \
--ks-pass pass:android --key-pass pass:android \
--set-installed-data true --set-rollback false \
--set-permission false --set-auth false --set-shared-uid false \
--new-signer --ks bulwark-release.p12 --ks-key-alias bulwark \
--ks-pass env:BULWARK_KS_PASS --key-pass env:BULWARK_KS_PASS
apksigner lineage --in bulwark.lineage --print-certs
```
The `--set-*` flags on the old signer decide what the debug key can still do on
a device that has moved to the new key:
- `--set-installed-data true` lets the new key take over an install made with
the debug key. Without it the rotation does nothing.
- `--set-rollback false` keeps an APK signed with the debug key from being
accepted again afterwards. With `true` the rotation would be pointless, and the
workflow refuses such a lineage.
- `--set-permission false` stops granting the app's signature permissions to
apps signed with the debug key, which includes every React Native debug build.
The merged manifest defines one,
`com.anonymous.bulwarkmobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`. It
guards the receivers that are registered as not exported on Android 12L and
older.
- `--set-auth false` and `--set-shared-uid false`: the app uses neither.
The lineage holds only certificates and a signature made with the debug key, so
it is not secret. It is stored as a secret only so the workflow can find it.
### 3. Repository secrets
Settings → Secrets and variables → Actions:
| Secret | Value |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | `bulwark-release.p12`, base64-encoded |
| `ANDROID_KEYSTORE_PASSWORD` | The keystore password |
| `ANDROID_KEY_ALIAS` | `bulwark` |
| `ANDROID_KEY_PASSWORD` | The same password again |
| `ANDROID_SIGNING_LINEAGE_BASE64` | `bulwark.lineage`, base64-encoded. Optional, but see step 2 |
With the GitHub CLI, from the checkout and in the same shell as step 1:
```bash
base64 -w0 bulwark-release.p12 | gh secret set ANDROID_KEYSTORE_BASE64
printf %s "$BULWARK_KS_PASS" | gh secret set ANDROID_KEYSTORE_PASSWORD
printf %s "$BULWARK_KS_PASS" | gh secret set ANDROID_KEY_PASSWORD
gh secret set ANDROID_KEY_ALIAS --body bulwark
base64 -w0 bulwark.lineage | gh secret set ANDROID_SIGNING_LINEAGE_BASE64
```
Or copy a file as base64 for the web form:
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('bulwark-release.p12')) | Set-Clipboard
```
## The first release with the new key
1. Set all five secrets.
2. Release as usual with `npm run bump`.
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:
```bash
cd android
./gradlew assembleRelease -PallowDebugSignedRelease=true
```
```powershell
$env:ORG_GRADLE_PROJECT_allowDebugSignedRelease = 'true'
npx expo run:android --variant release
```
The build prints a warning. The APK is signed with the public key, so never
give it to anyone. On a device that has moved to the release key it will not
install over the official app; uninstall that first. `allowDebugSignedRelease`
is ignored when the `CI` environment variable is set, so the workflow cannot
use it.
To sign locally with the real key, set these as `-P` flags, in
`~/.gradle/gradle.properties` or as environment variables. Never put them in
`android/gradle.properties`, which is committed.
```properties
BULWARK_RELEASE_STORE_FILE=C:/Users/you/keys/bulwark-release.p12
BULWARK_RELEASE_STORE_PASSWORD=...
BULWARK_RELEASE_KEY_ALIAS=bulwark
BULWARK_RELEASE_KEY_PASSWORD=...
```
That APK has no lineage, so it will not install over a debug-signed install
either.
With neither, a release build stops and lists what is missing. Debug builds and
`npx expo run:android` never need any of this.
## Stack traces
Release builds are shrunk and obfuscated by R8, so library frames in a crash
report look like `com.facebook.react.uimanager.U.a`. The app's own classes
(`com.anonymous.bulwarkmobile`) keep their names. The workflow attaches
`bulwark-mobile-<version>-<commit>-mapping.txt` to the release next to the APK
and keeps it as a workflow artifact. Decode a trace with the mapping of that
exact build:
```bash
retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt
```
`retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`).
A local build writes its mapping to
`android/app/build/outputs/mapping/release/mapping.txt`.
## versionCode
`versionCode` was `1` for every release up to 0.1.62. It is now derived from
`VERSION` in `android/app/build.gradle`:
```
major * 10000000 + minor * 10000 + patch 0.1.63 -> 10063
```
Android refuses an update whose `versionCode` is lower than the installed one,
so the number may only grow. The formula keeps growing while patch stays below
10000 and minor below 1000, and `npm run bump` refuses to go past either. It
stays under Android's limit of 2100000000 up to major 209. Existing installs
have `versionCode` 1, so the first release with this change updates them
normally.
Never switch to a formula that gives smaller numbers: installed apps would
refuse every update until the numbers caught up.
## Rotating again
If the release key ever has to change, extend the lineage instead of starting
a new one. Generate `bulwark-release-2.p12` as in step 1, with its password in
`NEW_KS_PASS`:
```bash
apksigner rotate --in bulwark.lineage --out bulwark-2.lineage \
--old-signer --ks bulwark-release.p12 --ks-key-alias bulwark \
--ks-pass env:BULWARK_KS_PASS --key-pass env:BULWARK_KS_PASS --set-rollback false \
--new-signer --ks bulwark-release-2.p12 --ks-key-alias bulwark \
--ks-pass env:NEW_KS_PASS --key-pass env:NEW_KS_PASS
```
Then update the secrets. The workflow accepts any lineage that starts at the
debug key and ends at the key in `ANDROID_KEYSTORE_BASE64`, and refuses one in
which an earlier key has the rollback capability. As with the first rotation,
only Android 9+ devices that install the new release follow it. If the old key
leaked, a device that someone reached first stays theirs.
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
# iOS builds and TestFlight
The iOS app is built by [.github/workflows/release-ios.yml](../.github/workflows/release-ios.yml)
on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner
minutes are free for this repository because it is public.
Unlike `android/`, which is committed and hand-maintained, **`ios/` is gitignored
and regenerated by `expo prebuild` on every run.** Never commit it, and never fix
anything by editing it - native iOS changes belong in `app.config.js` or a config
plugin, or they will be silently thrown away on the next build.
Without the secrets below the workflow still runs, but only as an unsigned
compile check; no IPA is produced.
## One-time Apple setup
You need an active Apple Developer Program membership ($99/yr). Everything here
can be done from Windows - no Mac required.
### 1. Team ID
developer.apple.com → Account → Membership details. A 10-character string like
`A1B2C3D4E5`.
### 2. Distribution certificate
Apple issues certificates against a certificate signing request. Keychain Access
is the usual way to make one, but OpenSSL works and does not need a Mac:
```bash
openssl genrsa -out dist.key 2048
openssl req -new -key dist.key -out dist.csr \
-subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"
```
Upload `dist.csr` at developer.apple.com → Certificates → **+** → **Apple
Distribution**, download the resulting `distribution.cer`, then bundle the cert
and its private key into a `.p12`:
```bash
openssl x509 -inform DER -in distribution.cer -out dist.pem
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
-passout pass:CHOOSE_A_PASSWORD
```
**On OpenSSL 3.x, add `-legacy` to that last command.** OpenSSL 3 defaults to
AES-256-CBC, which macOS `security import` refuses to read, and the workflow then
fails at the import step. OpenSSL 1.x has no `-legacy` flag and already writes
the format macOS wants - passing it there is an error. Check with `openssl
version` first; Git Bash on Windows currently ships 1.1.1.
Keep `dist.key` somewhere safe - you cannot re-download it, and losing it means
revoking the certificate and starting over.
### 3. App Store Connect API key
App Store Connect → Users and Access → Integrations → App Store Connect API →
**+**, with the **App Manager** role. Download the `.p8` (**one download only**)
and note the **Key ID** and the **Issuer ID** shown above the key list.
The key authenticates the TestFlight upload.
### 4. App ID, then app record
These live on two different Apple sites and have to be done in this order.
**developer.apple.com** → Certificates, Identifiers & Profiles → Identifiers →
**+** → App IDs → App. Explicit bundle ID `org.bulwarkmail.mobile`, matching
`ios.bundleIdentifier` in [app.config.js](../app.config.js). Leave every
capability off: push is not implemented on iOS, and sign-in uses the custom
`bulwarkmobile://` scheme rather than universal links, so no Associated Domains.
**appstoreconnect.apple.com** → Apps → **+** → New App. Platform iOS, any SKU.
The bundle ID here is a dropdown populated from the identifiers above, which is
why the App ID has to be registered first. The app name must be unique across
the entire App Store.
The TestFlight upload is rejected if the app record does not exist - and that
failure lands at the very end of a ~30 minute build.
### 5. App Store provisioning profile
developer.apple.com → Profiles → **+** → Distribution → **App Store Connect**.
Pick the App ID from step 4 and the distribution certificate from step 2, name it
`Bulwark Mobile App Store`, and download the `.mobileprovision`.
The build signs **manually** and this profile is not optional. Automatic signing
would be less to maintain, but `xcodebuild archive` under automatic signing always
signs with a *development* identity and leans on `-exportArchive` to re-sign for
distribution afterwards. That needs a development certificate and at least one
registered device - neither of which a CI-only TestFlight pipeline has any reason
to own, and Apple will not issue a development profile without a device. Naming
the App Store profile up front avoids the whole problem.
The profile expires when the certificate does (2027-08-01). Regenerate it here and
update the secret when that happens.
## Repository secrets
Settings → Secrets and variables → Actions:
| Secret | Value |
| --- | --- |
| `APPLE_TEAM_ID` | The 10-character team ID from step 1 |
| `IOS_DIST_CERT_BASE64` | `dist.p12` from step 2, base64-encoded |
| `IOS_DIST_CERT_PASSWORD` | The password you chose for `dist.p12` |
| `APP_STORE_CONNECT_KEY_ID` | Key ID from step 3 |
| `APP_STORE_CONNECT_ISSUER_ID` | Issuer ID from step 3 |
| `APP_STORE_CONNECT_PRIVATE_KEY` | The `.p8` from step 3, base64-encoded |
| `IOS_PROVISIONING_PROFILE_BASE64` | The `.mobileprovision` from step 5, base64-encoded |
To base64-encode a file:
```powershell
# PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
```
```bash
# bash
base64 -w0 dist.p12
```
## Running a build
Publishing a GitHub release triggers the iOS workflow alongside the Android one.
You can also run it by hand from the Actions tab, with an
`upload_to_testflight` toggle if you only want the IPA as a workflow artifact.
The IPA is deliberately not attached to the GitHub release: unlike the APK, it is
not installable outside TestFlight.
`CFBundleVersion` comes from the workflow run number via the `IOS_BUILD_NUMBER`
env var. App Store Connect rejects a build number it has already seen for a given
version, so never re-run a workflow expecting a re-upload to succeed - the run
number advances on its own, so just start a new run.
## After the upload
Processing takes roughly 5-30 minutes before the build shows up in TestFlight.
Internal testers (up to 100, must be on your App Store Connect team) get it
immediately. External testers (up to 10,000) need a Beta App Review, which takes
about a day the first time and is usually waved through afterwards.
## What does not work on iOS yet
- **Push notifications.** The push path is a hand-written Android module talking
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
and an equivalent native module; until then `push-notifications.ts` short-
circuits and the app polls like a device with push disabled.
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
not connect.
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
Downloading and installing a binary is forbidden by App Store Review guideline
2.5.2, so iOS updates come through TestFlight instead. The Updates settings
pane is removed from the list on iOS; the version and commit are still shown
under About & Data.
+185
View File
@@ -0,0 +1,185 @@
# Authentication, login, session & multi-account
## Summary
RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pairing, per-account keychain credentials, restore/switch/logout, Stalwart account-security API) and several WEB fixes are already mirrored (refresh coalescing, cached-token reuse, keep-session-on-network-error). The biggest gaps are: no TOTP/2FA password login at all (and the webmail handoff silently breaks for TOTP accounts), the Account Security screen is dead against every Stalwart server because it checks the wrong capability map (native issue #47), a failed "add account" or failed switch destroys the live session, expired/revoked tokens never lead back to the login screen, account display names are never refreshed (#900), relative session URLs are not resolved, and there is no per-account remove / per-device push revoke / public-key management.
## Findings
### Login flow
- [x] **TOTP / 2FA password login is impossible** — `P1` — `missing` — fixed in b52768e
- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required", `lib/jmap/client.ts:956-966` → `TOTP_REQUIRED`). With a code it calls `/api/auth/totp-token-exchange` which performs Stalwart's structured login `POST {server}/api/auth {type:'authCode', accountName, accountSecret, mfaToken, clientId, redirectUri, codeChallenge, codeChallengeMethod:'S256'}` then `POST {server}/auth/token` (authorization_code + PKCE) and continues as a bearer session (`app/api/auth/totp-token-exchange/route.ts:26-28, 90-93, 125, 150`; `stores/auth-store.ts:651-706`; changelog 1.7.5 "Support MFA login via the structured auth endpoint"). Legacy `password$totp` basic-auth fallback for pre-0.16 servers (`auth-store.ts:696-702`).
- What RN does: `JMAPClient.connect` only does HTTP Basic with the raw password (`src/api/jmap-client.ts:88-106`, `363-383`). A 402 becomes `"Session discovery failed: 402 …"`, which `describeLoginError` only maps for 403/404 (`src/lib/login-errors.ts:57`) so the user sees a generic "Sign-in failed". The 401 copy tells 2FA users to create an app password (`login-errors.ts:39-44`) — that is the only workaround today.
- Fix hint: detect 402 with a title containing `totp`/`mfa` in `fetchSession`, show a code step, then implement the two-step exchange directly on device (no CORS on native): `/api/auth` with `mfaToken` + `clientId: 'bulwark-webmail'` + PKCE, then `/auth/token`; persist the result as an OAuth bundle (`tokenEndpoint = ${server}/auth/token`, `clientId`) so `connectWithOAuth` and the existing refresh path take over.
- [x] **Webmail password handoff breaks for TOTP-protected accounts** — `P1` — `bug` (cross-repo) — fixed in 0d94d76 (RN retries the handed-off password with a code prompt; the webmail-side flow=oauth hand-off is still open)
- What WEB does: in mobile-handoff mode the login page always hands back `flow=password` with the raw password the user typed (`app/(main)/[locale]/login/page.tsx:638-642, 282`) even when the webmail itself upgraded that login to token auth via TOTP (`stores/auth-store.ts:651-706`, `upgradedToOAuth`).
- What RN does: `loginViaWebmail` feeds the password into `login()` → Basic auth (`src/stores/auth-store.ts:226-229`) → Stalwart 402 → "Sign-in failed". The user completed 2FA in the browser and still cannot sign in.
- Fix hint: WEB should hand off `flow=oauth` (access/refresh token, `token_endpoint`, `client_id`) when the password login was TOTP-upgraded (the mobile SSO branch in `app/api/auth/sso/complete/route.ts:66-97` already returns that shape); RN needs no change once WEB does that. Alternatively RN implements the previous finding and retries with a TOTP prompt when the handed-off password is rejected with 402.
- [x] **No direct OAuth/OIDC (PKCE) against the mail server — a Bulwark webmail at the JMAP origin is assumed** — `P2` — `missing` — fixed in 4bebf70
- What WEB does: discovers `/.well-known/oauth-authorization-server` or `openid-configuration` (`lib/oauth/discovery.ts:150-175`), runs client-side PKCE (`login/page.tsx:569-621`, `lib/oauth/pkce.ts`) or server-side SSO; works against Stalwart's built-in OAuth (default client id `bulwark-webmail`, `lib/oauth/token-exchange.ts:14`), Keycloak, Authentik.
- What RN does: `ConfirmStep → runHandoff(serverUrl)` opens `${serverUrl}/login?mobile_redirect_uri=…` (`src/lib/oauth.ts:49-56`, `src/screens/LoginScreen.tsx:128-135`). `serverUrl` comes from probing `/.well-known/jmap` on `domain`, `mail.domain`, `webmail.domain` (`src/lib/server-discovery.ts:70-72, 143-170`) — i.e. it finds the Stalwart host, and then assumes a Bulwark webmail lives at that same origin. When webmail is on another host (or the server only runs Stalwart), the browser opens a 404/Stalwart admin page and the only way in is the password step.
- Fix hint: either (a) do PKCE natively with `expo-web-browser`/`expo-auth-session` against the discovered `authorization_endpoint` (redirect `bulwarkmobile://auth/callback`, client id `bulwark-webmail`, scopes `openid email profile`), or (b) probe `${serverUrl}/api/config` before opening the handoff and ask for the webmail address when it is not a Bulwark instance.
- [x] **Login error classification is thinner than WEB** — `P3` — `partial` — fixed in b52768e
- What WEB does: `classifyLoginError` maps invalid credentials, network, 5xx `server_error`, `totp_required`; 429 is a `RateLimitError` with retry-after (`stores/auth-store.ts:71-96`, `lib/jmap/client.ts:849-853`).
- What RN does: `describeLoginError` (`src/lib/login-errors.ts`) has no 402 (MFA), 429 or 5xx branches; `RateLimitError('Rate limited by server')` falls to the generic "Sign-in failed" with the raw message.
- Fix hint: add branches for `402` → 2FA copy, `429`/`RateLimitError` → "try again in N s", `5xx` → "server error, try later".
- [x] **Saved-username suggestions on the login form** — `P3` — `missing` — fixed in 2404717
- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (`login/page.tsx:500-517`).
- What RN does: `EmailStep`/`PasswordStep` start empty; in add mode `ChooseStep` only offers the known server, not known usernames.
- Fix hint: prefill/offer `accounts[].username` from the registry (and remember the last typed address in AsyncStorage) in `EmailStep`.
- [x] **Login flow strings are hard-coded English** — `P3` — `partial` — fixed in 2404717
- What WEB does: every login string is localized (`login.*` keys used throughout `login/page.tsx`).
- What RN does: `LoginScreen.tsx`, all `src/screens/login/*.tsx`, `QrScanModal.tsx` and `login-errors.ts` use literal English while `SettingsScreen` already uses `useLocaleStore().t` (`src/screens/SettingsScreen.tsx:169-190`).
- Fix hint: route through `t()` with `login.*` keys (the i18n audit may cover this; listed here so it is not lost).
- [x] **QR hint copy points to a settings page that does not exist** — `P3` — `bug` — fixed in b52768e
- What WEB does: the pairing QR is generated in Settings → Security ("Link device", `components/settings/account-security-settings.tsx:894-1026`).
- What RN does: hints say "Settings → Devices → Add phone" (`src/components/QrScanModal.tsx:66`) and "Settings → Devices" (`src/screens/LoginScreen.tsx:221`).
- Fix hint: change the copy to Settings → Security → Link device.
- [x] **Client TLS certificate support is Android-only and does not cover push/SSE** — `P3` — `partial` (native issue #3) — fixed in edc26ce (live updates fall back to polling with a cert; redirects handled in JS (6f747db); iOS implementation still deferred)
- What WEB does: N/A (the browser negotiates mTLS).
- What RN does: `src/lib/client-cert.ts` bridges an Android `BulwarkClientCert` module and `secureFetch` is used by the JMAP client, blob and discovery code. Gaps: iOS has no implementation (`getNative` returns null off Android, `client-cert.ts:34-45`); the JMAP SSE stream uses `EventSource` with a plain header (`src/api/push.ts:120-137`) so live updates fail behind mTLS; the system browser used for the webmail handoff cannot present the cert; the abort signal does not reach the native path (`server-discovery.ts:103-135` works around it).
- Fix hint: document the limits in the native issue; route SSE through a native fetch-based reader or the `react-native-sse` fetch option when a cert alias is set; consider an iOS `URLSession` delegate implementation.
### Session
- [x] **Relative session URLs are not resolved** — `P2` — `bugfix-parity` (changelog 1.9.0 "Resolve relative session URLs without corrupting URI templates") — fixed in 0b1c240 (unified-inbox.ts copy is with the mail-list agent)
- What WEB does: `rewriteSessionUrl` prefixes the connected origin when `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` have no scheme, and rewrites the origin of absolute ones without touching `{accountId}`/`{blobId}` templates (`lib/jmap/client.ts:1091-1110`).
- 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; 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).
- [x] **Transient token-endpoint failure evicts the account** — `P2` — `bugfix-parity` (changelog 1.7.6 "Keep the session when the auth server is briefly unreachable", 1.7.8 "End refresh loops on sign-out and back off failed retries") — fixed in 2c0dbd1
- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (`app/api/auth/token/route.ts:143`); 5xx/network → 503 and the client keeps the account and retries with backoff (`stores/auth-store.ts:1209-1226`, `1792-1799`, `isTransientAuthError` at `:106`).
- What RN does: `refreshOAuthAccessToken` throws `HandoffError` for any non-OK status or network error (`src/lib/oauth.ts:239-241`); `forceRefreshToken` turns every failure into `false` (`src/api/jmap-client.ts:211-221`) so an expired access token + a 5xx/offline token endpoint becomes `AuthenticationError`, and `restoreSession`/`switchAccount`/`retrySession` then delete the keychain entry (`src/stores/auth-store.ts:397-405, 505-512, 569-586`).
- Fix hint: in `refreshOAuthAccessToken` throw a distinct transient error for network failures, 429 and 5xx; in `fetchSession`/`request` rethrow it as `NetworkError` instead of `AuthenticationError`; only 400/401/403 from the token endpoint are definitive.
- [x] **A revoked/expired refresh token never leads back to the login screen** — `P2` — `rn-only-bug` — fixed in b52768e
- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (`stores/auth-store.ts:1209-1214`, `markSessionExpired`, `login/page.tsx` `session_expired` banner).
- What RN does: `request()` throws `AuthenticationError('Session expired')` (`src/api/jmap-client.ts:433`) but nothing outside `auth-store`/`login-errors` handles that class (grep over `src/`); the email store just records the message. `retrySession` returns early because `get().session` is still the stale object (`src/stores/auth-store.ts:554`). The user is stuck on a dead session until a relaunch.
- Fix hint: give `JMAPClient` an `onAuthFailure` callback (set by auth-store) that runs the `AuthenticationError` branch of `retrySession` (clear creds for the active account, `isAuthenticated:false`, error "Session expired"); or clear `session` in the store on that error so `retrySession` can run.
- [x] **Logout does not revoke the OAuth refresh token** — `P3` — `partial` — fixed in 4bebf70 (pairing bundles are deliberately not revoked)
- What WEB does: `DELETE /api/auth/token` posts the refresh token to the IdP `revocation_endpoint` on logout/remove/logout-all (`app/api/auth/token/route.ts:181-254`).
- What RN does: `logout`/`logoutAll` only delete the SecureStore entry (`src/stores/auth-store.ts:286-357`, `src/api/jmap-client.ts:350-361`); the refresh token stays valid server-side.
- Fix hint: discover `revocation_endpoint` from `${serverUrl}/.well-known/oauth-authorization-server` and POST `token=<refresh>&token_type_hint=refresh_token&client_id=…` best-effort. Caveat: a QR-paired phone shares the desktop's refresh token (`app/api/auth/pair/create/route.ts:18-22`), so revoking it would also sign out the desktop — only revoke bundles that came from the browser handoff, or accept that behaviour deliberately.
- [x] **No request deadline on JMAP calls** — `P3` — `partial` (changelog 1.8.1 "Time out stalled JMAP requests so a send can't hang forever (#702)") — fixed in 0b1c240
- What WEB does: `timedFetch` aborts when no response headers arrive within 30 s (300 s for blob transfers) and does not retry timed-out non-idempotent requests (`lib/jmap/client.ts:786-816`).
- What RN does: `request()`/`fetchSession` use bare `secureFetch` with no timeout (`src/api/jmap-client.ts:363-383, 410-445`); only the native-cert path (30 s) and the discovery probe (2.5 s) have deadlines.
- Fix hint: add an `AbortController` timeout in `request()` (and blob helpers), surfacing a distinct timeout error; do not auto-retry sends.
- [x] **SSE stream is opened without a token freshness check** — `P3` — `partial` (verify with the push audit) — fixed in edc26ce
- What WEB does: SSE uses `authenticatedFetch`, so a 401 triggers the refresh path.
- What RN does: `connectEventSource` reads `jmapClient.authHeader` once (`src/api/push.ts:137`) without `ensureFreshToken` (private, `src/api/jmap-client.ts:195`), so a nearly-expired access token can be used for a long-lived stream.
- Fix hint: expose an `ensureFreshToken()`-then-header helper on the client and call it before connecting/reconnecting the stream.
### Multi-account
- [x] **A failed "add account" destroys the active session** — `P1` — `rn-only-bug` — fixed in b52768e
- What WEB does: connects the new account with a fresh `JMAPClient` and only snapshots/clears the previous account after `connect()` succeeded (`stores/auth-store.ts:709-716`, `957-962`).
- What RN does: `login()` and `completeOAuthHandoff` call `jmapClient.reset()` and reset contacts/calendar *before* connecting (`src/stores/auth-store.ts:110-114, 175-180`). On failure the store still says the previous account is active, but the singleton now holds the wrong credentials and no session; cancelling the modal (`App.tsx:470-481`) restores nothing, so every request throws "Not connected" until relaunch.
- Fix hint: connect with a throwaway `new JMAPClient()` (or keep the current creds/session in locals) and only adopt the new connection into the singleton after success; on failure `await jmapClient.loadAccount(previousActive)`.
- [x] **A failed account switch leaves the client half-switched** — `P2` — `rn-only-bug` — fixed in b52768e
- What WEB does: on restore failure the previous account's client is re-activated (`stores/auth-store.ts:1546-1565`); rate-limited/transient failures keep both accounts.
- What RN does: `loadAccount(target)` overwrites `credentials` and nulls `session`/`_accountId` on failure (`src/api/jmap-client.ts:244-273`); `switchAccount` restores only the email-store view (`src/stores/auth-store.ts:393-411`) and leaves the store's `session` set, so `retrySession` short-circuits (`:554`) and the previous account is dead.
- Fix hint: in the catch, `await jmapClient.loadAccount(previousActive)` (or restore the saved in-memory creds/session) before returning; alternatively set `session: null` so the network-recovery watcher re-establishes it.
- [x] **Account display name is never refreshed from the server (#900)** — `P2` — `missing` (changelog 1.9.0 "Refresh the account display name from the Stalwart principal on login, restore, and switch") — fixed in b52768e
- What WEB does: seeds `displayName`/`email` from the primary identity at login, then `syncAccountDisplayName` reads `x:Account/get` (principal "Full name") on login, restore and switch and updates the registry (`stores/auth-store.ts:146-176, 741-751`; `lib/stalwart/principal.ts`).
- What RN does: `addAccount({ displayName: username, email: username })` (`src/stores/auth-store.ts:123-131, 186-193`) and nothing ever updates it; the drawer header shows the raw username (`src/components/SidebarDrawer.tsx:297`). `fetchPrincipal` already exists (`src/api/account-security.ts:157-176`) but only feeds the security screen, and saving a new display name there does not touch the registry.
- Fix hint: after each successful connect run `Identity/get` (name/email) and, when `urn:stalwart:jmap` is in the account's `accountCapabilities`, `fetchPrincipal()`; `accountStore.updateAccount(id, { displayName, email })`; also update on `updateDisplayName` success.
- [x] **OAuth account id/email derived from the raw JMAP username** — `P3` — `partial` — fixed in b52768e (email refreshed from the primary identity; registry id unchanged)
- What WEB does: prefers the primary identity email over `Session.username` for OAuth/SSO because OIDC `preferred_username` may not be an address (`stores/auth-store.ts:946-951, 1096`).
- 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).
- [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)`.
- [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").
- [x] **Accounts list in Settings (reorder, set default, add) is missing** — fixed in 490355c — `P3` — `missing` (changelog 1.7.2 "List and reorder logged-in accounts from settings (#282)", 1.7.7 "Pin the default account on top and drag-to-reorder") — move up/down + set default + remove + add; account-store.reorderAccounts
- What WEB does: `AccountSettings` lists all accounts with move up/down, drag reorder, set-default, add (`components/settings/account-settings.tsx:182-220`); switcher pins the default first (`lib/account-utils.ts:117-142`).
- What RN does: `AccountSettings` shows only the active account's fields (`src/components/settings/AccountSettings.tsx`); the drawer lists accounts in registry order with set-default only (`SidebarDrawer.tsx:390-406`).
- Fix hint: add an accounts section with set-default/remove/reorder (`accountStore` needs a `reorderAccounts`).
- [x] **AccountSettings reports the wrong auth method and never shows storage** — fixed in 490355c — `P3` — `rn-only-bug` — jmapClient.usesBearerAuth + Quota/get (src/api/quota.ts)
- What WEB does: shows OAuth vs Basic from `authMode` and a quota bar (`account-settings.tsx:151-180`).
- 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.
- [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") — 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`.
### Account security settings
- [x] **Account Security screen is dead against every Stalwart server (native issue #47)** — `P1` — `rn-only-bug` — fixed in 032732c
- What WEB does: probes `client.hasAccountCapability('urn:stalwart:jmap')`, i.e. the *account-level* capability map (`stores/account-security-store.ts:276-290`, `lib/jmap/client.ts:4173-4177`, `lib/stalwart/principal.ts:33`).
- What RN does: `isStalwartSupported()` checks the *session-level* `session.capabilities` (`src/api/account-security.ts:51-54`). Verified against Stalwart 0.16.19 (stw-test19): `urn:stalwart:jmap` is present only under `accounts[<id>].accountCapabilities`, not in the top-level `capabilities`. So `AccountSecuritySettings` always renders "Account security management requires a Stalwart server" (`src/components/settings/AccountSecuritySettings.tsx:584, 622`). The same effect also shows that copy when the session is merely offline (`:583`).
- Fix hint: `const acc = session.accounts?.[jmapClient.accountId]; return !!acc?.accountCapabilities?.[STALWART_CAPABILITY] || STALWART_CAPABILITY in (session.capabilities ?? {})`; show an "offline" notice instead of the Stalwart notice when `currentSession` is null.
- [x] **Push subscriptions: no per-device list/revoke and no forced recreate (#841)** — fixed in f6f26f7 — `P2` — `missing` (changelog 1.9.0 "Per-device revoke for push subscriptions (#841)", "Recreate the push subscription on re-register (#841)")
- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes `forceRecreate` so a stale subscription is recreated rather than its expiry refreshed (`components/settings/notification-settings.tsx:95, 115-131, 169`).
- What RN does: Enable / Re-register / Disable for this device only (`src/components/settings/NotificationSettings.tsx:81, 186-198`); no device list, and whether Re-register recreates the JMAP `PushSubscription` was not verified here (push audit).
- Fix hint: call the relay device-list endpoint the web uses, render rows with revoke (`PushSubscription/set destroy` + relay unregister), and thread a `forceRecreate` flag through `setupPushNotifications`.
- [x] **Public keys (S/MIME/PGP) and encryption-at-rest configuration are missing** — fixed in ddae1e6 — `P3` — `missing` (changelog 1.8.1 "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings")
- What WEB does: `PublicKeysSection` with `x:PublicKey/query|get|set` and `x:AccountSettings/set encryptionAtRest {@type, publicKey, encryptOnAppend, allowSpamTraining}` (`components/settings/account-security-settings.tsx:573-840`; `stores/account-security-store.ts:397-455, 625-700`).
- What RN does: read-only `EncryptionSection` showing the `@type` only (`AccountSecuritySettings.tsx:539-555`; `src/api/account-security.ts:150-155`).
- Fix hint: port the four store methods to `src/api/account-security.ts` and add a key list + encryption picker.
- [x] **OAuth `state` is generated with `Math.random`** — `P3` — `rn-only-bug` — fixed in 2c0dbd1
- What WEB does: `crypto.getRandomValues` for verifier/state (`lib/oauth/pkce.ts:11-27`).
- What RN does: `randomState()` uses `Math.random` (`src/lib/oauth.ts:41-46`) although a CSPRNG helper with `getRandomValues`/`randomUUID` fallbacks already exists in `src/lib/totp.ts:16-46`. The state is the only guard against a forged `bulwarkmobile://` redirect delivering foreign credentials.
- Fix hint: export `randomBytes` from `totp.ts` (or a shared `random.ts`) and use it here.
## Verified at parity (brief list, so the fixer knows NOT to redo)
- QR login payloads: WEB emits `bulwarkmail://pair?server=<webmailBase>&code=…` (`account-security-settings.tsx:971`); RN parses it plus a `connect` variant and bare URLs (`src/lib/oauth.ts:135-162`) and redeems at `/api/auth/pair/redeem` (`:165-207`) into the same OAuth bundle the browser handoff yields.
- Webmail handoff (`mobile_redirect_uri`/`mobile_state`, fragment transport, state check, password/oauth flows) matches `login/page.tsx:115-130, 282, 638-650` and `auth/callback/page.tsx` mobile branch.
- Token refresh: coalesced per refresh token (`src/lib/oauth.ts:210-262`, WEB `refreshPromises`); cached access token reused until 60 s before expiry (#552; `jmap-client.ts:195-209`); client id always carried in the bundle so #873 does not apply; rotated refresh tokens persisted.
- Session restore: waits for AsyncStorage hydration, restores active → default account, keeps credentials and shows cached data on `NetworkError` (mirrors 1.7.6 "keep the session when the auth server is briefly unreachable"), evicts on definitive `AuthenticationError` (mirrors 1.6.0 "evict unrecoverable basic-auth accounts"), legacy single-slot migration.
- Logout: revokes this account's push subscription first, clears only that account's caches, switches to default/next account or falls back to the login screen; "Sign out all" wipes everything.
- Switch: email store is swapped before the network round-trip so the cached list shows immediately (same intent as 1.7.8 "eliminate the full-screen flash when switching accounts").
- Shared/group discovery: `getSharedMailAccounts` applies the WEB rule (non-personal OR advertises mail); unified inbox namespaces by `sourceAccountId`/`jmapAccountId`.
- Account security API (password change, display name, TOTP enrol/disable with `notUpdated` surfaced — 1.7.5 "Surface server errors on password change and TOTP toggle", app passwords, API keys with expiry/allowed IPs, principal/encryption reads, `Promise.allSettled` for forbidden principal reads) mirrors `stores/account-security-store.ts`; password/TOTP/display-name sections hidden for bearer sessions like WEB `isOAuth`.
- Server discovery by email domain (`/.well-known/jmap` probe on bare/`mail.`/`webmail.` hosts, 401 counts as a hit, known servers trusted) is RN's equivalent of WEB's admin server list / auto-pick-by-domain (#799); neither side does `_jmap._tcp` SRV.
- Login error → session-expired banner: RN's `ChooseStep` shows the store error ("Session expired") like WEB's `session_expired` banner.
- `AuthenticationError` on 401, `RateLimitError` on 429 with Retry-After parsing in `request()`.
## N/A on mobile
- "Remember me" (RN always stores credentials in the device keychain), `rememberMeEnabled`/`SESSION_SECRET` cookie logic, per-slot cookie handling (`cookieSlot`, `oauth_cookie_slot`), orphan-cookie adoption, `serverIdentifiers`/`classifySessionMatch` slot→token desync guard (RN keys credentials per registry id, no shared slot).
- Server-configured login branding/notice (`LOGIN_SHOW_TOTP`, `LOGIN_SHOW_VERSION`, logos, imprint/privacy links, company name), OAuth-only mode / auto-SSO / iframe embedded SSO (#69), server picker dropdown (#799), custom JMAP endpoint toggle, demo mode, dev-mode login, CORS error classification.
- Impersonation (`/api/auth/impersonate`, `%master` suffix stripping, impersonation reconciler), admin login.
- TOTP re-auth dialog (`totp-reauth-store`/`totp-reauth-dialog`): only used by WEB's legacy `password$totp` basic-auth path for pre-0.16 Stalwart; once RN gains TOTP via the token exchange no re-auth is needed.
- "Link device" QR generator (RN is the device being linked); TOTP enrolment QR image (same-device — RN's `otpauth://` deep link + manual secret is the right shape).
- 30 s keep-alive ping / `connectionLost` state (RN uses NetInfo + `retrySession`), per-tab SSE budget (#702), settings-sync enable/disable on login (settings audit), Stalwart passthrough auth context (`/api/auth/stalwart-context`), plugin logout hooks.
+310
View File
@@ -0,0 +1,310 @@
# Mail list, mailboxes/folders, unified views, search, tags, batch actions
## Summary
RN covers the core single-folder loop well (folder tree incl. shared/group accounts with owner-routed mutations, incremental Email/queryChanges refresh, offline seed, swipe actions with reveal mode, multi-select with archive/move/delete/tag and an undo snackbar WEB does not even have). The biggest gaps are: (1) tags are invisible in the list and there is no tag view/tag counts; (2) spam/not-spam only moves the message and never flips `$junk`/`$notjunk` (#850), and there is no not-spam in the list at all; (3) search is always folder-scoped (WEB defaults to all folders, #788) with no history/suggestions; (4) the unified inbox is a one-shot, inbox-only, cross-account-only screen with no actions, paging, counts, Unread/Starred/All-mail views or per-role views; (5) folder management is create/rename/delete only (no subfolders, move, reorder, empty folder, mark folder read); (6) several WEB bugfixes are still live in RN: #771 substring dedup hides folders, permanent delete has no confirmation, Sent/Drafts show sender instead of recipient, "Never" mark-as-read marks read instantly, pins are written as `$important` instead of `$pinned`. One P1 RN-only bug: opening a message from the unified inbox that is not in the active folder page renders another message's body in the pager.
## Findings
### Folder tree / mailboxes
- [x] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` — fixed in 516c9c9
- What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0).
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus.
- What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill).
- Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places.
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`).
- What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`).
- Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account.
- What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`.
- Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
- [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` — partial in 6ae21d5: "Move under" picker + parent re-fetch done; deferred: sortOrder reorder UI (no drag-and-drop on mobile yet)
- What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`.
- What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`).
- Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`.
- [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` — partial in 6ae21d5: role picker + #288 role icons done; deferred: per-folder custom icons and the colorful-icon toggle (cosmetic)
- What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`).
- What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment.
- Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type).
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`).
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`).
- What RN does: nothing — the only way is select-all-on-page + delete, page by page.
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`).
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
- What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart.
- Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
- [x] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` — fixed in f07e039
- What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780).
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`).
- What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account").
- What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`).
- Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`.
### Unified mailbox / cross-account views
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in 6ae21d5; the drawer only shows the Unified section when it can hold more than one account's mail since af6290f (#843)
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`).
- What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A).
- Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`).
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists.
- What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row.
- Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge.
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`).
- What RN does: none.
- Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later.
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`).
- What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`).
- Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
- [x] **Unified search** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8).
- What RN does: no search box on `UnifiedInboxScreen`.
- Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views".
- What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open.
- Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides. (`setEmailKeywords` is gone since 5041897; keyword changes now go through `patchKeywordsForEmails` in `src/api/email.ts` or the store's `setKeywordForEmails`.)
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`).
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
- [x] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` — fixed in c8be383; the viewer's actions still hit the open folder's account and followed the live list until 5c7301b and 9c31de2 (audit B3, B5)
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`).
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in 6ae21d5
- What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot.
- Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account.
### List rendering and behaviour
- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`).
- What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`).
- Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added).
- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`).
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in 6ae21d5; the view and the badges cover shared accounts since 8d5bced (#1038), and the counts are no longer re-fetched on every drawer open since d097ae9
- What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`).
- What RN does: none (no reference to `$answered` anywhere in `src/`).
- Fix hint: add the two icons next to the star/paperclip in `EmailRow`.
- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in 3194b5e
- What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`).
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`).
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
- [x] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` — fixed in 3194b5e
- What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads".
- What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere).
- Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account.
- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner.
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
- [x] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` — list side fixed in 3194b5e (single, batch, swipe via `src/lib/delete-confirm.ts`); viewer side done in d2ed27f (`src/screens/EmailThreadScreen.tsx`)
- What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`).
- What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`).
- Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`.
- [x] **"Never" mark-as-read (`markAsReadDelay === -1`) marks read instantly** — `P2` — `rn-only-bug` — fixed in d2ed27f
- What WEB does: `-1` → never, `0` → instant, else timer (`components/mail/mail-app.tsx:1511-1528`).
- What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant".
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
- [x] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` — fixed in f07e039
- What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`).
- What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows).
- Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`.
- [x] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` — fixed in f07e039
- What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`).
- Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window".
- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7).
- What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing.
- Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam".
- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in 3194b5e; the viewer's Spam and Not spam stayed a plain move until 5e42c67 (#695)
- What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`).
- What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk".
- Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op.
- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8).
- What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`).
- Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`.
- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in 3194b5e
- What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`).
- What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message.
- Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`).
- [x] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`).
- What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`).
- Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
- [x] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0).
- What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it.
- Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index.
- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`).
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
- [ ] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` — partly: 0205aa0 localized the relative strings; there is still no `dateLocale` setting (see area 08, "Language list not localized")
- What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized.
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`).
- Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4; contact photos win over favicons and initials since eca3871 (#54)
- What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`).
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup.
- Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
- [x] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`).
- What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message.
- Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough.
- [x] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` — fixed in 0205aa0
- What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`).
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
- What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles.
- Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`).
### Search
- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in 3194b5e; "All folders" only searched the open folder's account until ee50418 (#1082)
- What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries.
- What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field.
- Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`.
- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`).
- What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`).
- Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`.
- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`).
- What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`).
- Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`.
### Tags / keywords
- [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` — partial in 3194b5e: unknown `$label:` ids listed/removable in TagSheet; deferred: visibility, nesting, reorder, rename migration (device-local tag model, see native issue #1)
- What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`).
- What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1).
- Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority.
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes).
- What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`).
- Fix hint: remove the button or guard with `Alert.alert` confirm.
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`).
- What RN does: none; after a reinstall all tags must be re-typed with the exact id.
- Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`.
- What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other.
- Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
### Offline cache (RN only, sanity check)
- [x] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct).
- Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists).
## Verified at parity (do not redo)
- Folder tree: nesting, per-account grouping of shared/group accounts with unread roll-up, expanded state persisted, role priority sort (`src/lib/mailbox-tree.ts`, `SidebarDrawer.tsx`); create/rename/delete own folders with server error surfaced (`FolderSettings.tsx`); delete-with-emails confirm.
- Shared/group folders: queries and every mutation (read/star/pin/tag/move/archive/delete/undo/import) routed to the owner account with the unprefixed id (`refFor`, `src/stores/email-store.ts:64-99`); thread screen fetches by owner (`EmailThreadScreen.tsx:90-96`); state-change handling per account (`email-store.ts:981-1015`).
- Folder tree preserved when a refresh fails (#780 equivalent, `email-store.ts:604-612`), shared-account failure isolated (`:595-601`); Mailbox/changes incremental sync with drain.
- Missing Archive/Trash/Junk → error alert (`EmailListScreen.tsx:363-387`), never silently destroys when Trash is missing (#195 equivalent).
- Delete semantics: trash / trash-and-read (#323) / permanent / junk auto-permanent, single and batch (`email-store.ts:1268-1335`, `1434-1527`); undo snackbar for archive/delete/move (single + batch) — RN is ahead of WEB here.
- Archive single / by year / by month with folder creation in one request, batch archive, "Reorganize existing archive" (`src/api/email.ts:579-692`, `ReadingSettings.tsx:105-159`).
- Swipe actions: configurable per direction, instant and reveal modes, pure gesture module with tests, stale-props fix (`SwipeableRow.tsx`, `swipe-gesture.ts`); more actions than WEB (pin, move). RTL: ar, he and fa ship since 990cd84, but swipe directions are not mirrored yet (area 08, RTL finding).
- Multi-select: long-press, select-all-visible/indeterminate, batch star/read/tag/move/archive/delete (`EmailListScreen.tsx:320-500`).
- Pagination via `onEndReached`, pull-to-refresh, incremental `Email/queryChanges` + `Email/changes` refresh with per-account state tokens, base-view restore after search (#10), sort change invalidates snapshots (#5), offline seed and fallback.
- Search: full-text with wildcard suffix (`src/lib/search-utils.ts` = WEB `toWildcardQuery`), from/to/subject/date/attachment/unread/starred tri-state filters, chips row, search inside a shared folder routed to its owner.
- Keyword writes send RFC 6901-escaped `keywords/<name>` patch pointers since 5041897 (`src/api/patch-pointer.ts`; the whole-map writes erased other keywords, audit B4), with `null` instead of `false` — not affected by 5c484af1 / be97c8bf; tag ids normalized exactly like WEB `normalizeKeywordLevel` (`KeywordSettings.tsx:118-123`); batch tag toggle via `TagSheet` with "all have it" semantics.
- Unread dot (#27), date formats smart/relative/full with 12/24h, preview toggle, density-aware rows, sender favicon avatars with failure cache, `.eml`/`.zip` import into the open folder, Scheduled quick view, "Include group inboxes" setting and shared badge/account dot in the unified list.
- Mark-as-read delay (0/3s/5s) in the thread screen; both clients still display `receivedAt` in the list (WEB `lib/email-date.ts` is not wired into the list yet).
## N/A on mobile
- Category tabs / message-list tabs (`stores/message-list-tabs-store.ts`) — plugin-registered; RN has no plugin runtime.
- Hover quick actions, keyboard navigation/shortcuts, right-click context menus, drag-and-drop of mail onto folders/tags and folder-tab drag (Pro) — desktop interactions; RN has swipe + selection instead.
- Admin policy gates for unified/cross views (`usePolicyStore` feature flags) — RN has no admin policy channel; only the user-side toggles apply.
- Return-to-list-after-action setting — the RN thread screen always returns after delete/archive/spam.
- "Refresh" toolbar button and F5/Ctrl+R interception — pull-to-refresh covers it.
- Favicon unread badge (its native counterpart, the app-icon badge, landed in 25c0883, iOS only), sidebar collapse/resize, Pro multi-pane shell, a folder subscription UI (neither client shows `isSubscribed`; both subscribe the folders they create, native since 30aea63, #951).
- Plugin search hooks / external search results, `onEmailsFetched` transforms.
- Source-folder chip on rows is N/A *until* All-mail/cross views exist in RN.
+266
View File
@@ -0,0 +1,266 @@
# Email viewer / thread view / message rendering / attachments
## Summary
RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pinch zoom, external-content gate with trusted senders, cid inlining, attachment open/share/save with templated filenames, view source, .eml export, calendar-invitation banner, shared-folder routing for body/attachments/source) but it is a *single-message* viewer, not a thread view, and it never fetches `headers`/`messageId`/`inReplyTo`/`references`, so everything the WEB viewer derives from headers (SPF/DKIM/DMARC, List-Unsubscribe, read receipts, Reply-To, identity badge, correct `In-Reply-To`) is absent or wrong. The biggest gaps: (1) older messages of a thread are unreachable and the pager shows the wrong message when opened from the unified inbox / contact activity; (2) replies write the JMAP id into `In-Reply-To`, ignore `Reply-To`, do not strip the user's own address on reply-all, and forwards drop every attachment; (3) HTML-only messages (same part in `textBody` and `htmlBody`) are rendered and quoted as raw HTML source, and `<style>` blocks are stripped wholesale, which is the most likely root of native issues #46 ("email preview shows css") and #49 ("Unreadable text in dark-mode emails"); (4) external-content blocking only recognises `<img src=http…>`, `background=` and inline `url()`, so srcset/`<source>`/poster/whitespace-obfuscated pixels still load.
## Findings
### Thread view / navigation
- [x] **Thread messages other than the newest are unreachable — fixed in d2ed27f** — `P1` — `missing`
- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (`components/email/thread-conversation-view.tsx:98-115`, `171-200`, store `fetchThreadEmails` via `Thread/get` + `Email/get` in `stores/email-store.ts:3574-3640`, `lib/jmap/client.ts:2763-2800`).
- What RN does: the list collapses same-thread rows to the newest message with a count badge (`src/screens/EmailListScreen.tsx:224-238`, default `disableThreading: false` at `src/stores/settings-store.ts:266`), the row opens `EmailThreadScreen` with a single `emailId` (`App.tsx:145-151`), and the screen only ever shows that one message (`src/screens/EmailThreadScreen.tsx:113-120`, `685-840`). `getThread` exists in `src/api/email.ts:448` but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping".
- Fix hint: on open, call `getThread(threadId, ownerAccountId)` + `getFullEmails(ids)` and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusing `EmailBodyView` per card; keep the horizontal pager for thread-to-thread navigation.
- [x] **Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in c8be383** — `P1` — `rn-only-bug` — the body was right after c8be383, but delete, archive, move and spam hit the open folder's account, and new mail moved the page, until 5c7301b and 9c31de2 (audit B3, B5)
- What WEB does: the viewer renders `selectedEmail` directly; prev/next are derived from the current list (`components/email/email-viewer.tsx:5220-5279`).
- What RN does: the pager is a `FlatList` over `useEmailStore(s => s.emails)` and the initial index is `Math.max(0, emails.findIndex(id))` (`src/screens/EmailThreadScreen.tsx:128-133`, `535-572`). `UnifiedInboxScreen` keeps its own local list and never writes the store (`src/screens/UnifiedInboxScreen.tsx:35-50`, `61-84`), `ContactActivity` navigates with ids from a contact search (`src/components/contacts/ContactActivity.tsx:170-177`). In both cases the opened id is absent from `emails`, so the visible pane is `emails[0]` (a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act on `activeEmailId`.
- Fix hint: when `route.params.emailId` is not in `emails`, render a single-item pager (`data=[{id: emailId}]`) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
- [x] **Mark-as-read "Never" (-1) marks the message read instantly — fixed in d2ed27f** — `P2` — `rn-only-bug`
- What WEB does: `-1` means never auto-mark, `0` instant, `>0` delayed (`components/email/email-viewer.tsx:1218-1238`).
- What RN does: `ReadingSettings` offers `Never = -1` (`src/components/settings/ReadingSettings.tsx:167-173`) but the viewer does `if (markAsReadDelay > 0) {timer} else { markRead() }` (`src/screens/EmailThreadScreen.tsx:237-245`), so `-1` behaves like instant.
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
- [x] **Skeleton / loading parity — verified, no action** — `P3` — `verified` (see "Verified at parity"); no action.
### Body rendering & sanitisation
- [x] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in 06742ef** — `P1` — `bug`
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in `textBody` and `htmlBody`; WEB checks `hasDistinctTextBody` (different partIds) before preferring the text alternative, and routes by the part's `type` (`components/email/email-viewer.tsx:1727-1747`, `components/email/thread-conversation-view.tsx:332-346`; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML").
- What RN does: `extractTextBody` returns the html part's value for HTML-only mail, then `rawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null` (`src/components/EmailBodyView.tsx:43-57`, `504-511`). For any HTML-only message whose markup fails `MEANINGFUL_HTML_RE` (`src/lib/email-html.ts:353-362`, e.g. a `<div>`/`<p>` body without `style=`), `rawHtml` is null and the raw source — tags, `<head>`, CSS — is shown as plain text. The same value feeds the reply quote: `plainTextBody()` (`src/screens/EmailThreadScreen.tsx:59-65`, `377`) so replies to HTML-only mail quote escaped HTML source.
- Fix hint: compare `textBody[0].partId !== htmlBody[0].partId` (and `part.type === 'text/html'`) before treating `text` as an alternative; in `navigateCompose` pass `htmlBody` (and let `buildInitialHtml` use it) or convert with `htmlToPlainText` (`src/lib/compose-html.ts:108`) when the only body is HTML.
- [x] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in 06742ef** — `P2` — `bugfix-parity`
- What WEB does: iframe sanitiser keeps `<style>` (`lib/email-sanitization.ts:60-78`), and when external content is blocked it neutralises `url()`/`@import`/`@font-face` inside the sheet (`stripExternalStyleSheetCss`, `lib/email-sanitization.ts:474-509`, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (`components/email/email-viewer.tsx:2265-2267`).
- What RN does: `FORBID_TAGS` includes `'style'` (and `svg`) so every stylesheet is removed before render (`src/lib/email-html.ts:16-28`, `50-68`). Class-based colours vanish: a newsletter with white text via `.hero { color:#fff }` on a `bgcolor` cell renders white-on-white (light) or, after the invert filter, black-on-black. Also `hasNativeDarkMode` is evaluated on the *stripped* HTML inside `wrapEmailHtml` (`src/lib/email-html.ts:226-229`) but on the raw HTML in the component (`src/components/EmailBodyView.tsx:612-613`), so for emails with `@media (prefers-color-scheme: dark)` the CSS inversion is applied but the DOM re-invert pass is skipped.
- Fix hint: keep `<style>` (the WebView is an isolated document; CSP already has `style-src 'unsafe-inline'`), port `stripExternalStyleSheetCss` + `decodeCssEscapes` for the blocked case, and evaluate `hasNativeDarkMode` once on the same input.
- [x] **Dark-mode inversion CSS is an older revision of WEB's — fixed in d2ed27f** — `P2` — `bugfix-parity`
- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7 `height:100%` wrappers): background-*image* containers are re-inverted unconditionally and their media get `filter:none`; only bgcolor/`background:` containers use the `:not(:has(media))` guard; nested guard is limited to colour containers; `[style*="height:100%"]` is neutralised; Word/Outlook HTML gets `line-height:1.15` + `MsoNormal` margins and a fallback gutter; `messageSpacing` auto/edge gutter (`components/email/email-viewer.tsx:2200-2253`, `2287-2305`, `2438-2461`).
- What RN does: `DARK_INVERSION` applies the `:not(:has(...))` guard to background-image containers too and includes them in the nested `filter:none` rule (`src/lib/email-html.ts:167-185`), the DOM pass only re-inverts containers without media children (`src/components/EmailBodyView.tsx:72-88`); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon, `email-viewer.tsx:3199-3212`, changelog 1.6.0), no `messageSpacing`.
- Fix hint: copy the current `darkModeCSS` block and the DOM re-invert loop verbatim into `email-html.ts` / `DARK_REINVERT_SCRIPT`; add a "View in light/dark mode" entry to the More sheet that overrides `renderAsDark` for the current message.
- [x] **External-content detection misses most tracking vectors (privacy leak) — fixed in 06742ef** — `P2` — `bugfix-parity`
- What WEB does: `isExternalResourceUrl` strips C0/space so `"\nhttps://x"` and `h\ttps://` count; blocks `<img srcset>`, `<picture><source>`, `<video poster>`, media `src`, `background=`, inline `url()` incl. CSS escapes, `<style>` url()/@import, and forbids `<link>` in blocking mode; the banner is driven by "something was actually blocked" (`lib/email-sanitization.ts:399-406`, `511-615`, `108-150`; changelog 1.7.8).
- What RN does: `hasRemoteContent` only matches `<img src="http…">`, `background="http…"` and `style="...url(http…)"` (`src/lib/email-html.ts:364-369`); when it returns false `shouldBlock` is false and the CSP becomes `img-src data: cid: https: http:` (`src/lib/email-html.ts:235`), so a pixel expressed as `srcset=`, `<source>`, `poster=`, `src=" https://…"` (leading whitespace/newline), or a CSS-escaped `\68ttps://` loads unconditionally under policy `ask`/`block`. `blockRemoteImageSrcs` (`316-347`) is likewise src/srcset/background/style only, and has no `data-blocked-*` restore.
- Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP `img-src data: cid:` + `media-src`/`font-src` none whenever policy is `block`/`ask`-not-allowed and sender untrusted, like WEB's `externalBlocked`), port `isExternalResourceUrl` normalisation, add `source/video/audio/poster/link` handling, and show the banner when the strict CSP is in effect and the HTML contains any `http(s)://` resource reference.
- [x] **Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in 06742ef** — `P3` — `bugfix-parity`
- What WEB does: swaps `src` for a transparent 1x1 GIF, empties `alt`, `display:none`, and collapses empty `<td>/<div>` wrappers (`lib/email-sanitization.ts:319-320`, `537-546`, `678-704`); hides images that fail to load (`components/email/email-viewer.tsx:2393-2411`, changelog 1.7.7).
- What RN does: sets `src=""` and `alt="[remote image blocked]"` (`src/lib/email-html.ts:319-323`), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding.
- Fix hint: use the same transparent-GIF + `display:none` swap and port `collapseBlockedImageContainers` as a regex/DOM pass in the injected script; add an `img` `error` listener that hides the element.
- [x] **mailto: links inside the body open the OS mail handler instead of the app composer — fixed in 06742ef** — `P2` — `missing`
- What WEB does: `mailto:` opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leaves `mailto:` to the app's protocol handling, `components/email/email-viewer.tsx:2420-2436`).
- What RN does: `onShouldStartLoadWithRequest` hands `mailto:` to `Linking.openURL` (`src/components/EmailBodyView.tsx:715-724`), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark.
- Fix hint: parse `mailto:` (address, `subject`, `body`, `cc`) and `navigation.navigate('Compose', { prefillTo, ... })`; the `prefillTo` param already exists (`src/screens/ComposeScreen.tsx:292-298`).
- [x] **`data:` URL navigations replace the email body — fixed in 06742ef** — `P3` — `rn-only-bug`
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:717-719`), and `safeUri` keeps `href="data:image/…"` (`src/lib/email-html.ts:109-113`); tapping such a link navigates the WebView to the image with no way back. Only the initial `about:blank` load should be allowed.
- Fix hint: allow `data:` only when `request.navigationType === 'other' && !request.isTopFrame`-style initial loads, otherwise return false (optionally open images externally).
- [x] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB — fixed in 06742ef** — `P3` — `partial`
- What WEB does: allows only raster `data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)` on media tags, re-checks `srcset` candidates individually, forbids SVG data URIs and `<svg>` (`lib/email-sanitization.ts:24`, `330-388`), and the iframe has no scripting at all.
- What RN does: `safeUri` allows any `data:image/*` including `image/svg+xml` (`src/lib/email-html.ts:109-113`), `srcset` values are never scheme-checked except when blocking (`326-329`), and the page CSP must allow `'unsafe-inline'` scripts for the injected bridge (`236-245`), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings).
- Fix hint: restrict `data:` to the raster list, scheme-check every `srcset` candidate, and consider parsing with a real HTML parser (e.g. `htmlparser2`/`parse5` on the JS side) instead of regexes; document the residual risk.
- [x] **Quoted reply text is not collapsed (#480) — fixed in 06742ef** — `P3` — `missing`
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed `<blockquote>`, `>`-lines in plain text) behind a "•••" toggle (`lib/quote-collapse.ts:143-197`, `219-259`; changelog 1.7.8).
- What RN does: nothing; long threads show every quoted original inline.
- Fix hint: port `setupQuoteCollapse` into the injected WebView script (runs on the parsed DOM, uses inline styles only) and `collapsePlainTextQuotes` into `wrapPlainTextEmail`.
- [x] **Plain-text bodies are always monospace (#830) — fixed in 06742ef** — `P3` — `bugfix-parity`
- What WEB does: renders text/plain in the app font by default with a `plainTextFont: 'sans' | 'mono'` setting (`stores/settings-store.ts:329`, `565`; `components/email/email-viewer.tsx:5050-5060`; changelog 1.9.0).
- What RN does: `wrapPlainTextEmail` hard-codes `ui-monospace, Menlo, …` (`src/lib/email-html.ts:290`); no setting (`src/stores/settings-store.ts` has no `plainTextFont`).
- Fix hint: add the setting (Reading settings) and switch the font-family in `wrapPlainTextEmail`.
- [x] **Plain-text linkifier only catches http(s) — verified, no action** — `P3` — `verified` (same as WEB `plainTextToSafeHtml`); no action.
- [x] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in 06742ef** — `P3` — `partial`
- What WEB does: fetches every cid part as an authenticated `blob:` URL regardless of size/type; the browser sniffs `application/octet-stream` image bytes (`components/email/email-viewer.tsx:1509-1575`; changelog 1.8.1 #543).
- What RN does: skips parts over `MAX_INLINE_IMAGE_BYTES` (`src/components/EmailBodyView.tsx:15`, `556-559`) and builds `data:${att.type || 'application/octet-stream'};base64,…` (`573`), which Chromium/WebKit may refuse to decode as an image for `application/octet-stream`.
- Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g. `File.base64()` from expo-file-system after `downloadInto`) instead of dropping them.
### Header / meta
- [x] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched — fixed in ccbe67c** — `P2` — `missing` (root cause for the next five items)
- What WEB does: `Email/get` requests `messageId, inReplyTo, references, headers, bodyStructure` and parses `Authentication-Results`, `X-Spam-*`, `X-Spam-LLM` into `email.authenticationResults/spamScore/spamLLM` (`lib/jmap/client.ts:1775-1870`).
- What RN does: `EMAIL_FULL_PROPERTIES` stops at `bodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt` (`src/api/email.ts:11-15`); the `Email` type has no `headers`/`messageId` (`src/api/types.ts:65-87`).
- Fix hint: add the four properties to `EMAIL_FULL_PROPERTIES`, extend the type, and port `parseAuthenticationResults`/`parseSpamScore`/`extractListHeaders` (`lib/email-headers.ts`) to `src/lib/email-headers.ts`.
- [x] **No "Show details" panel: CC/BCC/Reply-To/sent vs received/size/MIME/Message-ID/thread id — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (`components/email/email-viewer.tsx:3892-3944`, `4187-4542`).
- What RN does: one line `to <names>` (`src/screens/EmailThreadScreen.tsx:806-809`); CC/BCC/Reply-To are fetched but never shown; date/size only (`811-817`).
- Fix hint: add CC/BCC rows and a collapsible details block under the sender row; reuse `formatSize`.
- [x] **SPF/DKIM/DMARC/spam-score chips and spoof-aware identity badge — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (`components/email/email-viewer.tsx:4353-4438`, `lib/email-headers.ts:13-115`); "via <identity>" / sub-address tag badge hidden when spoofed (`components/email/email-identity-badge.tsx:33-68`, changelog 1.7.3).
- What RN does: none.
- Fix hint: after the headers finding, render chips in the details block; port `isAuthenticationSpoofed` for the badge.
- [x] **List-Unsubscribe (RFC 2369) banner — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: parses `List-Unsubscribe` (http preferred, else mailto), confirm dialog, sends the mailto unsubscribe through the account itself, remembers dismissals per Message-ID (`components/email/unsubscribe-banner.tsx:64-106`, `components/email/email-viewer.tsx:753-773`, `2672-2680`; changelog 1.7.7).
- What RN does: none.
- Fix hint: port `parseUnsubscribeUrls`/`isValidUnsubscribeUrl` from `lib/validation.ts`; for mailto call `sendEmail` with the parsed fields; open http via `expo-web-browser`.
- [x] **Read receipts (MDN, RFC 8098): request banner, send, "always/ask/never", `$mdnsent` — fixed in d2ed27f** — `P2` — `missing` — the receipt headers were not sanitized (GHSA-w38p) and "Always send" answered the unseen neighbour pages until cb6ddd6 and 5f3f584 (audit S1, B7)
- What WEB does: detects `Disposition-Notification-To`, offers Send/Ignore (or auto-sends in "always" mode) only in received folders, builds the multipart/report with `lib/mdn.ts`, uploads → `Email/import` into Sent → `EmailSubmission/set` with explicit envelope, then sets `$mdnsent` (`components/email/email-viewer.tsx:2686-2784`, `lib/jmap/client.ts:4080-4130`, `components/email/read-receipt-banner.tsx`).
- What RN does: none; no `readReceiptResponse` setting.
- Fix hint: port `lib/mdn.ts` verbatim (pure string builder), reuse `uploadBytes` (`src/api/blob.ts:67`) + `importEmailBlob` (`src/api/email.ts:423`) + an `EmailSubmission/set` helper; add the setting.
- [x] **Reply-To shown / used — fixed in 7f956d6** — folded into the reply-recipients finding below.
- [x] **Sender / recipient tap actions (popover, view/add contact, copy address) — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: every address is a `RecipientPopover` (contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (`components/email/recipient-popover.tsx`, `components/email/email-viewer.tsx:1140-1170`, `3841-3866`).
- What RN does: names are static `Text` (`src/screens/EmailThreadScreen.tsx:794-810`).
- Fix hint: wrap sender/recipients in `Pressable` opening a small action sheet (copy, compose to, open `ContactDetail`/`ContactForm` prefilled).
- [x] **Displayed date uses `receivedAt` and device locale, ignoring the app's time-format setting — fixed in d2ed27f** — `P3` — `bugfix-parity`
- What WEB does: `emailDisplayDate` prefers `sentAt` (falls back when missing or >24 h in the future, #891) and formats with the user's `timeFormat`/locale (`lib/email-date.ts:33-45`, `components/email/email-viewer.tsx:3811`).
- What RN does: `formatHeaderDate(email.receivedAt)` with `toLocaleDateString(undefined, …)` (`src/screens/EmailThreadScreen.tsx:39-50`, `812-816`), so imported/migrated mail shows the import date and 12/24 h ignores the setting.
- Fix hint: port `emailDisplayDate` (already in `src/lib/date-format.ts`?—if not, 15 lines) and use `useSettingsStore(s => s.timeFormat)` + locale from `useLocaleStore`.
- [x] **Tags, `$important` badge and answered/forwarded state in the header — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: tag badges (removable) under the subject, "Important" pill (`components/email/email-viewer.tsx:3789-3806`); `$answered`/`$forwarded` set after send (`components/mail/mail-app.tsx:1663-1667`).
- What RN does: Tag sheet exists but no badges are rendered in the pane; `$answered`/`$forwarded` are never set after a reply/forward (no hit for `$answered` in `src/`).
- Fix hint: render `keywordDefs` matches from `email.keywords` under the subject; after a successful send in `ComposeScreen` call `setEmailKeywords(originalId, {...keywords, $answered|$forwarded: true}, accountId)`. (`setEmailKeywords` is gone since 5041897: set `keywords/$answered` or `keywords/$forwarded` through `patchKeywordsForEmails` or the store's `setKeywordForEmails`.)
### Calendar invitation banner
- [x] **Invitation parsing is not routed to the owning account (#847, #867) — fixed in 8206893** — `P2` — `bugfix-parity`
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (`components/email/calendar-invitation-banner.tsx:373-388`, `412-432`; changelog 1.9.0).
- What RN does: `parseCalendarBlob(attachment.blobId)` uses `jmapClient.accountId` (`src/components/email/CalendarInvitationBanner.tsx:54`, `src/api/calendar.ts:355-360`) even though `EmailPane` knows `jmapAccountId`; Stalwart answers `CalendarEvent/parse` with an error for a blob in another account, so invitations in group/shared mailboxes show nothing (state `error` → banner hidden).
- Fix hint: pass `jmapAccountId` from `EmailPane` into the banner and through to `parseCalendarBlob(blobId, accountId)`.
- [x] **iMIP method detection ignores the raw ICS `METHOD` and Content-Type params — fixed in 8206893** — `P2` — `partial`
- What WEB does: reads `method=` from the part/attachment/header Content-Type, then fetches the raw ICS and reads `METHOD:` (`lib/calendar-invitation.ts:319-339`, `components/email/calendar-invitation-banner.tsx:419-447`), falling back to participant heuristics; `CANCEL`, `REPLY`, `COUNTER`, `REFRESH`, `DECLINECOUNTER`, `PUBLISH`, `ADD` each get their own title/info/actions.
- What RN does: `extractMethodFromRawIcs` exists (`src/lib/calendar-invitation.ts:103-106`) but is never called; only `inferInvitationMethod` heuristics are used (`CalendarInvitationBanner.tsx:62`), so a cancellation whose event status is not `cancelled`, or a REPLY from a single attendee, is shown as a plain invitation with RSVP buttons.
- Fix hint: fetch the blob text (`getDownloadUrl` + `secureFetch`, as `fetchRawEmail` does) in parallel with parse and prefer `extractMethodFromRawIcs`.
- [ ] **Banner lacks WEB's trust assessment, actor summary, existing-event / "already in calendar" state, calendar picker, "View in calendar", collapse, sequence badge, counter-proposal review** — `P3` — `partial` — deferred: trust assessment, iTIP method and calendar picker landed in 8206893 (calendar agent); the existing-event state followed in c0582ab, which looks the event up on the server by `uid` (also so Accept sends the RSVP, audit B22); "View in calendar", collapse, sequence badge and counter-proposal review remain open in the calendar area
- What WEB does: `getInvitationTrustAssessment` (sender vs organizer + auth results) warning, actor line ("X accepted"), `queryCalendarEvents({uid})` to show "already in calendar" and current RSVP, picker when >1 calendar, "View in calendar" navigation, collapsible card, `sequence` "updated" pill, apply counter proposal for organizers (`components/email/calendar-invitation-banner.tsx:392-474`, `502-553`, `657-672`, `721-752`, `802-1140`).
- What RN does: title/date/location/video/organizer rows, Yes/Maybe/No, Add to first writable calendar (`src/components/email/CalendarInvitationBanner.tsx:83-207`); no dedupe check before offering "Add", no picker, no trust warning, no navigation to the event.
- Fix hint: port `getInvitationTrustAssessment` (needs the headers finding for auth), look up `useCalendarStore.events` by `uid` to switch to "already in calendar"/current response, add a calendar picker sheet when `calendars.length > 1`, and a "View in calendar" button that navigates to `CalendarScreen` with the start date.
- [x] **The `.ics` part stays in the attachment list while the banner is shown — fixed in d2ed27f** — `P3` — `bugfix-parity`
- What WEB does: hides calendar MIME parts when the banner renders (`components/email/email-viewer.tsx:1591-1600`; changelog 1.4.14).
- What RN does: `renderAttachments` filters only inline images (`src/screens/EmailThreadScreen.tsx:713-722`).
- Fix hint: filter `isCalendarType(att.type) || name.endsWith('.ics')` when `calendarInvitationParsingEnabled && findCalendarAttachment(email)`.
### Attachments
- [x] **Forwarding drops every attachment — fixed in 7f956d6** — `P1` — `bug`
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (`components/email/email-composer.tsx:598-616`, #543).
- What RN does: `navigateCompose('forward')` passes only from/to/cc/subject/plain body (`src/screens/EmailThreadScreen.tsx:366-382`); the `Compose.replyTo` param has no `attachments` field (`src/navigation/types.ts:7-19`) and `ComposeScreen` never seeds `attachments` from the original (`src/screens/ComposeScreen.tsx:354`). A forwarded invoice arrives without the PDF.
- Fix hint: add `attachments?: Attachment[]` to the route param, and in `ComposeScreen` initialise `attachments` with `{blobId,name,type,size}` entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload via `getDownloadUrl(..., ownerAccountId)` + `uploadBytes`, or block forward there).
- [x] **message/rfc822 attachments: no inline unwrap, no `.eml` preview — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the `.eml` chip; `.eml` attachments preview like an email (`components/email/email-viewer.tsx:1433-1507`, `1625-1636`; changelog 1.7.3, 1.8.1).
- What RN does: the message renders "(empty message)" and the `.eml` chip can only be shared.
- Fix hint: add `postal-mime` (pure JS, works in RN with a `TextDecoder` polyfill) and mirror the unwrap effect; render the parsed HTML through `EmailBodyView`.
- [x] **TNEF `winmail.dat` is not decoded — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (`lib/tnef.ts`, `components/email/email-viewer.tsx:1344-1431`).
- What RN does: shows `winmail.dat` as an opaque attachment; the real files inside are unreachable.
- Fix hint: `lib/tnef.ts` is pure `Uint8Array` code; copy it and feed `jmapClient.fetchBlobArrayBuffer`.
- [x] **Attachment chip list: no MIME-based fallback names, MDN/DSN report parts and non-inline cid images mishandled — fixed in d2ed27f** — `P3` — `partial` — body-embedded `cid:` parts typed octet-stream are hidden from the chips since 1d1d6a5
- What WEB does: unnamed parts get `Document.pdf`/`Email.eml`/`Attachment.<sub>` (`components/email/email-viewer.tsx:191-225`); `message/disposition-notification` and `message/delivery-status` parts are hidden (`1604-1606`); inline hiding requires `disposition === 'inline'` (`1601-1603`).
- What RN does: unnamed → `'attachment'` (`src/screens/EmailThreadScreen.tsx:745`); report parts are listed; any `cid` image is hidden regardless of disposition (`719-721`), so an image attached with a Content-ID but `disposition: attachment` (common from some clients) disappears from the list even when the body does not reference it.
- Fix hint: port `getAttachmentDisplayName`, add the two report-type filters, and check `disposition === 'inline'` (or that the body actually references the cid via `extractCidRefs`).
- [x] **No image thumbnails on chips, no "Download all" zip, no per-chip Preview vs Download choice — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (`components/email/email-viewer.tsx:2024-2102`, `2104-2161`, `3949-4085`; changelog 1.6.0, 1.7.5).
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
- [x] **Post-export action (archive/trash after "Export .eml") missing — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: `postExportAction` setting runs archive/trash after export (`components/email/email-viewer.tsx:2559-2571`).
- What RN does: export only (`src/screens/EmailThreadScreen.tsx:632-640`).
- Fix hint: add the setting and call `onArchive`/`onDelete` after `shareEmailEml` resolves.
### Actions from the viewer
- [x] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity) — fixed in 7f956d6** — `P1` — `bug`
- What WEB does: passes the original's RFC `messageId` and `references`, stripped of brackets, as JMAP `inReplyTo`/`references` arrays (`lib/jmap/client.ts:3124-3194`; changelog 1.5.4 #234).
- What RN does: `navigateCompose` sets `inReplyTo: email.id` (`src/screens/EmailThreadScreen.tsx:379`), and `sendEmail` writes it verbatim into `header:In-Reply-To:asText` / `header:References:asText` (`src/api/email.ts:823-826`). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply and `References` is never accumulated. `messageId`/`references` are not even fetched (see headers finding).
- Fix hint: fetch `messageId`/`references`, pass `inReplyTo: email.messageId?.[0]` and `references: [...(email.references ?? []), email.messageId[0]]`, and send them as JMAP `inReplyTo`/`references` arrays (or `<…>`-wrapped `header:…:asMessageIds`).
- [x] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in 7f956d6** — `P1` — `bugfix-parity`
- What WEB does: `buildReplyRecipients` — reply goes to `Reply-To` if present else `From`; reply-all adds original To/CC minus the user's own identities (exact and `+tag`-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (`lib/reply-recipients.ts:74-111`, `components/mail/mail-app.tsx:920`, `2940-2944`).
- What RN does: `initialTo` = From (+ all original To on reply-all, including the user's own address), `initialCc` = all CC; `email.replyTo` is fetched but never passed (`src/screens/ComposeScreen.tsx:299-319`, `src/screens/EmailThreadScreen.tsx:366-382`). Replying to a newsletter with `Reply-To: support@…` mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself.
- Fix hint: copy `lib/reply-recipients.ts` (pure), pass `replyToAddresses: email.replyTo` in the route param and the identities' emails as `ownEmails`.
- [x] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in 7f956d6** — `P2` — `bugfix-parity`
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with `Name <email>` (`components/email/email-composer.tsx:462-500`, `lib/quote-header.ts:54-91`; changelog 1.7.1, 1.7.3, 1.8.1).
- What RN does: `buildInitialHtml` supports `htmlBody` (`src/lib/compose-html.ts:79-83`) but the viewer only passes `body: plainTextBody(email)` (`src/screens/EmailThreadScreen.tsx:377`), and the header uses display name only (`compose-html.ts:31-35`) rather than WEB's `Name <email>` (#482).
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`, cid refs rewritten to fetched data URIs or re-attached with the same `cid`) and switch `senderName` to the `Name <email>` form.
- [x] **Forward as attachment (.eml) missing — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: More menu "Forward as attachment" builds a `message/rfc822` attachment referencing the original blobId with a `{date}-{subject}.eml` name (`lib/forward-as-attachment.ts:38-58`, `components/mail/mail-app.tsx:2022-2060`; changelog 1.8.1).
- What RN does: none.
- Fix hint: add a More-sheet entry that opens `Compose` with `attachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}]` (depends on the forward-attachments fix).
- [x] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246) — fixed in 5b72c4d** — `P3` — `partial`
- What WEB does: `resolveReplyFrom` — exact, then `+tag`-stripped, then same-domain catch-all with a From override (`lib/reply-identity.ts:197-256`).
- What RN does: `identities.find(i => i.email === candidate.email)` (`src/screens/ComposeScreen.tsx:431-437`).
- Fix hint: port `resolveReplyFrom`; the override needs a From override in `sendEmail`.
- [x] **Quick reply box under the message — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (`components/email/email-viewer.tsx:5066-5154`).
- What RN does: none.
- Fix hint: optional; a bottom input that calls `sendEmail` with `buildReplyRecipients` output and the plain-text quote.
- [x] **View source: no copy-to-clipboard; whole raw message loaded into a `<Text>` — fixed in d2ed27f** — `P3` — `partial`
- What WEB does: synthetic source from the JMAP object with a Copy button (`components/email/email-viewer.tsx:5159-5208`, `lib/email-source.ts`). RN's raw RFC 822 view is actually more faithful.
- What RN does: fetches the full blob as text and renders it in one selectable `Text` (`src/screens/EmailSourceScreen.tsx:24-35`, `68-70`); a 30 MB message will stall the JS thread.
- Fix hint: add `Clipboard.setStringAsync`; cap display at e.g. 1 MB with a "Share full source" fallback.
- [x] **Add sender to contacts / edit contact from the viewer — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: contact sidebar with "Add to contacts"/"Edit" (`components/email/email-viewer.tsx:552-568`, `5336-5368`; changelog 1.7.4).
- What RN does: none (see popover finding).
- Fix hint: sheet entry navigating to `ContactForm` with prefilled name/email.
### Trusted senders / settings
- [x] **"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in 06742ef** — `P3` — `rn-only-bug`
- What WEB does: writes to the book only when `trustedSendersAddressBook` is on, else to the local list (`components/email/email-viewer.tsx:4646-4655`, `components/trusted-senders-modal.tsx:136-140`).
- What RN does: always does both (`src/components/EmailBodyView.tsx:644-653`), and `addToTrustedSendersBook` creates the book on first add (`src/stores/contacts-store.ts:308-321`), so a user who left the setting off (default `false`, `src/stores/settings-store.ts:239`) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (`src/components/settings/ContentSendersSettings.tsx:19-21`, `123-144`), so book-trusted senders are invisible/unremovable there.
- Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show `trustedSenderEmails` in the settings modal with remove support.
- [x] **S/MIME settings screen is a dead stub — fixed in 3d0d440** — `P3` — `rn-only-bug`
- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (`components/email/email-viewer.tsx:923-937`).
- What RN does: `SmimeSettings` renders empty mock arrays and buttons with no handlers (`src/components/settings/SmimeSettings.tsx:32-33`, `118-121`, `163-166`); there is no verification/decryption in the viewer.
- Fix hint: hide the screen (or show a "not available in the mobile app" note) until a crypto path exists; treat viewer-side S/MIME as N/A.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Body isolation: sandboxed WebView with `default-src 'none'` CSP, `originWhitelist` about:blank, links opened externally, no cookies/storage/file access (`src/components/EmailBodyView.tsx:675-724`, `src/lib/email-html.ts:236-256`) — matches WEB's srcDoc iframe + CSP approach (1.6.7).
- `hasMeaningfulHtmlBody` text-alternative preference (`src/lib/email-html.ts:353-362`) — same regex as WEB `lib/signature-utils.ts` (only the same-partId guard is missing, see P1 finding).
- Dark mode: invert+hue-rotate with media re-invert, native-dark-mode detection, emoji re-invert pass, "Always view emails in light mode" setting (`src/lib/email-html.ts:167-185`, `197-199`, `src/components/EmailBodyView.tsx:64-157`, `499-502`) — only the CSS revision differs (see finding).
- Plain-text path: escaped + linkified, native dark colours without inversion (`src/lib/email-html.ts:120-126`, `263-311`).
- External content policy ask/block/allow, per-message "Load external content", "Trust sender" persisting locally and to the address book, trusted list from the address book and contacts (`src/components/EmailBodyView.tsx:480-535`, `643-672`).
- cid inline images fetched with auth and injected as data URIs, transparent placeholder otherwise (`src/components/EmailBodyView.tsx:546-584`, `src/lib/email-html.ts:213-218`).
- Shrink-to-fit of fixed-width emails + in-page pinch zoom/pan with pager/scroll locking (`src/components/EmailBodyView.tsx:159-460`) — RN-only, better than WEB's horizontal scroll (#409).
- Mark read on open with delay setting (apart from the `-1` bug), optimistic star/unread/tag toggles, delete/archive/spam/not-spam/move scoped to the message's account, `no trash folder` alert (`src/screens/EmailThreadScreen.tsx:221-364`). The account scoping was wrong for messages opened from the Unified Inbox, a notification or a deep link until 5c7301b (audit B3); star, tag and unread are queued offline since ddff2f7.
- Prev/next navigation with prefetched neighbour panes and swipe (`src/screens/EmailThreadScreen.tsx:122-181`, `535-572`) — RN-only pager.
- Skeleton while loading (`src/screens/EmailThreadScreen.tsx:845-886`).
- Attachments: chips with size, show-all toggle, `hideInlineImageAttachments` setting, `attachmentPosition` setting, `mailAttachmentAction` preview/download, templated filenames + transforms identical to WEB (`src/lib/download-filename.ts` diff vs `lib/download-filename.ts` is whitespace/quotes only, minus `DEFAULT_BUNDLE_TEMPLATE`/`BUNDLE_TOKENS`), Downloads settings screen with live preview (`src/components/settings/DownloadsSettings.tsx`).
- Attachment/blob/source/.eml fetches routed by owner account for shared/group messages (`src/screens/EmailThreadScreen.tsx:87-96`, `198-215`, `src/api/blob.ts:119-135`, `src/lib/email-export.ts`) — #847/#867 parity except the calendar banner.
- Client-cert-aware downloads via `secureFetch`, streaming download via `File.downloadFileAsync` (`src/lib/email-export.ts:85-113`).
- View source (raw RFC 822, shareable) and Export .eml with the email filename template (`src/screens/EmailSourceScreen.tsx`, `src/lib/email-export.ts:173-206`).
- Calendar invitation: detection by MIME/extension, parse via `CalendarEvent/parse`, RSVP via import-then-`rsvpEvent` with `replyTo`/`organizerCalendarAddress`, cancelled notice, "no writable calendar" warning, `calendarInvitationParsingEnabled` setting (`src/components/email/CalendarInvitationBanner.tsx`, `src/lib/calendar-invitation.ts`).
- Tag sheet from the viewer with keyword definitions (`src/screens/EmailThreadScreen.tsx:1033-1100`).
## N/A on mobile
- Print (WEB `handlePrint`, `components/email/email-viewer.tsx:2621-2670`) — could be done with `expo-print` later, but not a parity requirement.
- Copy permalink to a message (#733) — RN opens incoming message links since 5802cae (`src/navigation/linking.ts`) but has no "copy link" action.
- Fullscreen reading toggle, Pro split panes, toolbar overflow measurement, plugin slots/hooks (`email-banner`, `email-detail-sidebar`, `onRenderEmailBody`, `onBeforeExternalLink`, `ui.rerenderEmail`), keyboard shortcuts, drag attachments out to the file system (#267), drag emails out as `.eml`, import `.eml` from the viewer menu (RN imports from the list screen instead).
- Iframe-specific fixes: "Render the email body on DOM parse instead of iframe load" (#635), iframe flash/flicker fixes (1.5.4, 1.6.7), `blob:` in CSP `object-src`/`frame-src` for PDF previews (#253), pdf.js bytes vs `blob:` fetch (#871), RSC 307 loop (#919), iOS Safari viewport zoom on input focus (#838).
- Demo-mode welcome pane, tour, contact sidebar panel layout (desktop only; the actions themselves are listed above).
- S/MIME plugin UI (WEB core has none since 1.7.6); listed above only because RN ships a misleading stub.
+313
View File
@@ -0,0 +1,313 @@
# Composer, drafts, sending, identities, signatures, templates, scheduled send
## Summary
RN has a working "compose / reply / forward / send now / send later" happy path (WebView rich editor with send-time readback, attachments + inline images, contact suggestions, identity sheet, HOLDFOR scheduling with a cancel list) but is far from parity with the 3,900-line web composer. The biggest gaps are structural: RN has **no drafts at all** (no autosave, no save-on-close, no opening/editing an existing draft), **no signatures** (identities carry `textSignature`/`htmlSignature` but the composer never uses them), **no Bcc**, **no template insertion in the composer**, **no Reply-To handling**, and **broken threading headers** (`In-Reply-To` is set to the JMAP email id instead of the RFC Message-ID). The send path also files the message into Sent *before* submission, so a failed `EmailSubmission/set` leaves a never-sent copy in Sent. Several web bugfixes (reply-all self-dedupe / #703, #570 attachment reminder on quoted text, #702 request timeout, unclosed angle brackets, subject-prefix dedupe) are not present in RN.
Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the native repo`.
## Findings
### Drafts
- [x] **No draft autosave / save-on-close — closing a compose loses everything** — fixed in 5b72c4d — `P1` — `missing` — offline or without a Drafts folder the close dialog still dropped the text, and the iOS swipe skipped the dialog, until 92b42e4 (audit B16)
- What WEB does: debounced 2 s autosave to the server Drafts folder with `$draft`+`$seen` keywords (`components/email/email-composer.tsx:1648-1815` saveDraftOnce, `:1842-1883` timer; `lib/jmap/client.ts:2998-3060` createDraft), serialized against send (#303, `:1821-1837`), `beforeunload` best-effort save (`:1082-1091`), "Save / Discard / Cancel" close dialog (`:2347-2426`, `handleSaveDraftAndClose`, `handleDiscardAndClose`), toast when the save is refused so text is not dropped (`:2384-2401`, #702).
- What RN does: the only close path is `onClose` in `src/screens/ComposeScreen.tsx:518-541`, an Alert with Cancel / **Discard** — no "Save draft" option and no server draft is ever created. The hardware back button / iOS swipe-back is not intercepted at all (no `beforeRemove` listener anywhere in `ComposeScreen.tsx` or `src/navigation/*`), so the dirty guard is bypassed entirely by the OS back gesture.
- Fix hint: add a `createDraft(...)`-style call in `src/api/email.ts` (Email/set create into the `drafts`-role mailbox with `$draft`/`$seen`, destroy previous draft id only after the new create succeeds), call it from a debounced effect + from the close Alert ("Save draft" button), and register `navigation.addListener('beforeRemove', …)` to route back gestures through the same guard.
- [x] **Cannot open / edit an existing draft** — fixed in 5b72c4d — `P1` — `missing`
- What WEB does: `handleEditDraft` in `components/mail/mail-app.tsx:1812` re-opens a draft with recipients, subject, HTML body, identity restored from `From` (`lib/reply-identity.ts:140-167` findDraftIdentityId), server attachments carried as blobId refs (`email-composer.tsx:577-597`, #849) and the signature embedded (`:520-535`, #848); sending destroys the old draft only after success (`lib/jmap/client.ts:3342-3360`) and clears the viewer when the sent draft was displayed (`mail-app.tsx:1645-1648`).
- What RN does: `RootStackParamList.Compose` (`src/navigation/types.ts:7-22`) has no `draftId`/initial-body params; tapping a message in the Drafts folder opens the read-only `EmailThreadScreen` (no `role === 'drafts'` handling anywhere in `src/screens/EmailThreadScreen.tsx` or `src/screens/EmailListScreen.tsx`).
- Fix hint: add a `draft` route param (id, to/cc/bcc, subject, htmlBody, attachments, from), an "Edit draft" action in the thread screen when the mailbox role is `drafts` or the email has `$draft`, and pass `draftId` into `sendEmail` so the old draft is destroyed after a successful submission. Depends on the previous item.
- [x] **Draft keeps In-Reply-To/References and reply-mode state** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: drafts store the reply context in `ComposerDraftData` (`email-composer.tsx:109-132`) and threading headers are computed at send from the parent (`lib/email-threading.ts:30-51`).
- What RN does: N/A today because drafts do not exist; when implementing drafts make sure `inReplyTo`/`references` (see the threading finding below) survive the round trip.
- Fix hint: persist `header:In-Reply-To`/`header:References` on the draft Email/set and read them back via `inReplyTo`/`references` properties when reopening.
### Sending / EmailSubmission
- [x] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — fixed in ccbe67c — `P1` — `bug`
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
- [x] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — fixed in 7f956d6 — `P1` — `rn-only-bug`
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
- What RN does: `src/screens/EmailThreadScreen.tsx:379` passes `inReplyTo: email.id` (the JMAP id, e.g. `a1b2c3`), never `references`; `src/api/email.ts:823-826` writes that value verbatim into `header:In-Reply-To:asText` and `header:References:asText`. `EMAIL_FULL_PROPERTIES` (`src/api/email.ts:11-15`) does not even request `messageId`/`references`, and the RN `Email` type (`src/api/types.ts:65-87`) has no such fields. Every RN reply therefore breaks threading in recipients' clients and emits a syntactically invalid `In-Reply-To`.
- Fix hint: add `'messageId', 'inReplyTo', 'references'` to `EMAIL_FULL_PROPERTIES` and the `Email` type, port `computeReplyThreadingHeaders`, and send `inReplyTo`/`references` as JMAP array properties (bare msg-ids) instead of raw `header:*:asText` strings. Only replies should continue the chain; forwards should not (WEB `email-composer.tsx:2142-2145`).
- [x] **No request timeout — a stalled send hangs the composer forever** — fixed in ccbe67c — `P2` — `bugfix-parity` (changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)")
- What WEB does: `JMAPClient.REQUEST_TIMEOUT_MS = 30_000` (`lib/jmap/client.ts:629`, `:811`, `:830-843`) raising `RequestTimeoutError`; the composer shows a dedicated "may already have gone out, check Sent" message instead of "send failed" so users do not re-send duplicates (`email-composer.tsx:2315-2323`).
- What RN does: `jmapClient.request` (`src/api/jmap-client.ts:405-445`) uses `secureFetch` with no `AbortSignal`/deadline; `performSend` (`ComposeScreen.tsx:906-982`) leaves `sending=true` until the promise settles, so the Send button spins indefinitely on a stalled connection.
- Fix hint: wrap `secureFetch` in an `AbortController` + `setTimeout` (30 s) in `jmapClient.request` (or a `timeoutMs` option used by `sendEmail`), throw a distinguishable `RequestTimeoutError`, and show a "may already be sent - check Sent" alert in `performSend`'s catch.
- [x] **Undo-send delay has no undo toast / "send now"** — fixed in 5b72c4d — `P2` — `partial`
- What WEB does: after a delayed send `pendingUndoSend` is set (`stores/email-store.ts:1700-1712`) and a toast offers Undo / Send now (changelog 1.7.0 "'Send now' action on the send-delay toast"; `email-store.ts:4381` cancelUndoSend).
- What RN does: `onSend` (`ComposeScreen.tsx:825-828`) applies `sendDelaySeconds` as HOLDFOR and `performSend` deliberately stays silent for the undo window (`:961-963` comment: "invisible unless the user cancels from the Scheduled view"). There is an `UndoSnackbar` component (`src/components/UndoSnackbar.tsx`) but it is not used for send. The user has to know to open the Scheduled screen within the 5-30 s window.
- Fix hint: after `sendEmail` returns `{ scheduled: true, emailSubmissionId }` show `UndoSnackbar` for `sendDelaySeconds` with Undo → `cancelScheduledSend(id)` and "Send now" → EmailSubmission/set update of the envelope without HOLDFOR (or cancel + resend), mirroring `rescheduleEmailSubmission` in `lib/jmap/client.ts:7985`.
- [x] **Double-submit guard relies on async state** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog 1.6.x "Guard Send against double-submit")
- What WEB does: synchronous `isSendingRef` re-entry guard (`email-composer.tsx:1980-1986`, `:2007`, `:2078-2079`).
- What RN does: `performSend` checks `canSend` which includes `!sending` (`ComposeScreen.tsx:508-516`, `:883`), but `setSending(true)` only happens after two awaits (`getHtml` at `:892`, attachment reminder at `:905`), so two quick taps both pass the guard and submit twice.
- Fix hint: add a `sendingRef = useRef(false)` set synchronously at the top of `performSend`, reset in `finally`.
- [x] **Reply / forward does not mark the original `$answered` / `$forwarded`** — fixed in 7f956d6 — `P2` — `missing`
- What WEB does: after a successful reply/forward the original gets `$answered` or `$forwarded` (`components/mail/mail-app.tsx:1663-1673`), routed to the owning account.
- What RN does: nothing — `grep -F '$answered'` over `src/` returns no hits; `performSend` (`ComposeScreen.tsx:941-974`) has no access to the original email id (`replyTo` params in `src/navigation/types.ts:10-19` carry no `emailId`).
- Fix hint: add `originalEmailId`/`jmapAccountId` to the `replyTo` route param and call the existing keyword primitive (`setEmailKeywords` via `applyOrQueue` in `src/stores/outbox-store.ts`) after the send resolves. (`setEmailKeywords` is gone since 5041897; the keyword primitive is now `patchKeywordsForEmails`, and the outbox queues `keywords/<name>` patches.)
- [x] **Identity `replyTo` / `bcc` are never applied to outgoing mail** — fixed in 7f956d6 — `P2` — `missing`
- What WEB does: `sendEmail` copies the identity's `replyTo` onto the message (`lib/jmap/client.ts:3167-3172`, `:3184`); identity form lets users edit Reply-To and Bcc (`components/identity/identity-form.tsx:39-41`, `:66-70`, `:132-133`).
- What RN does: RN `Identity` type has `replyTo`/`bcc` (`src/api/types.ts:136-145`) but `sendEmail` (`src/api/email.ts:788-796`) never sets `replyTo`, and no composer Reply-To field exists; `IdentitySettings.tsx:209-215` edits only name/email/textSignature.
- Fix hint: in `sendEmail` set `replyTo: identity.replyTo` and merge `identity.bcc` into `bcc`; pass the full `Identity` (not only `primaryIdentity.id`) from `performSend`.
- [x] **Read-receipt (MDN) request missing** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: composer toggle (`email-composer.tsx:3053-3066`), `requestReadReceiptDefault` setting (`stores/settings-store.ts:351`), header `Disposition-Notification-To` set on create (`lib/jmap/client.ts:3201-3204`).
- What RN does: no toggle, no setting, header never set.
- Fix hint: add a toolbar toggle + `requestReadReceipt` option to `sendEmail` that writes `header:Disposition-Notification-To:asText`.
- [x] **Client-side Message-ID not generated** — fixed in ccbe67c — `P3` — `partial`
- What WEB does: `generateMessageId(fromEmail)` with the sender's domain, guarded for insecure origins (`lib/jmap/client.ts:544-575`, `:3192`; changelog 1.9.0 fix).
- What RN does: `sendEmail` sets no `messageId`, relying on the server; RN's `src/lib/uuid.ts:6-20` already has a guarded UUID generator (Hermes fallback) so the crash class of the web fix does not apply.
- Fix hint: optional — set `messageId: [`${generateUUID()}@${domain}`]` in `sendEmail` so the id is known before the response (useful once drafts/threading exist).
- [ ] **Sending from shared/group accounts unsupported** — `P3` — `missing` (partly N/A) — deferred: RN is single-active-account; envelope/identity routing for shared accounts not implemented
- What WEB does: routes the submission to the identity's owning account (#461, `email-composer.tsx:2258-2274`, `lib/jmap/client.ts:3131-3134` targetAccountId) and preselects the shared folder's identity (`lib/reply-identity.ts:114-128`).
- What RN does: `ComposeScreen.tsx:267-272` explicitly always uses the user's own Sent ("composing on behalf of a shared account isn't supported"), `getIdentities` (`src/api/identity.ts:5-12`) only queries the primary account. Replying from a shared-folder message sends as the reaching login.
- Fix hint: low priority; if implemented, query `Identity/get` for the shared `accountId`, submit with that `accountId`, and file into that account's Sent.
- [x] **Dead duplicate send implementation** — fixed in 42ab246 — `P3` — `rn-only-bug`
- What RN does: `src/api/submission.ts:5-64` is an older `sendEmail` (no HOLDFOR, no result) that nothing imports (`grep "api/submission"` → no hits); `ComposeScreen.tsx:38` imports `sendEmail` from `src/api/email.ts`.
- Fix hint: delete `src/api/submission.ts` (and its test if any) to avoid someone wiring the stale path.
### Recipients
- [x] **No Bcc field** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: Bcc chip input toggled next to To (`email-composer.tsx:2768-2792`, `:2825-2851`), included in send/draft/dirty-check.
- What RN does: only To and Cc (`ComposeScreen.tsx:346-349` state, `:1058-1149` UI); `sendEmail` accepts `bcc` (`src/api/email.ts:772`) but the screen never passes it.
- Fix hint: clone the Cc block (`ccRecipients`/`ccInput`/`ccVisible`) as Bcc, include it in `alreadySelected`, `commitTyped`, `isDirty`, and the `sendEmail` call.
- [x] **Reply-To header ignored when replying** — fixed in 7f956d6 — `P2` — `bugfix-parity` (changelog 1.9.0 "Honour an external Reply-To even on a self-sent message"; 1.7.x #703)
- What WEB does: `buildReplyRecipients` (`lib/reply-recipients.ts:74-111`) replies to `replyToAddresses` when present (RFC 5322), falls back to From, and handles self-sent messages (`isSelfSent`, `:52-54`) by addressing the original To/Cc instead of yourself.
- What RN does: `initialTo` (`ComposeScreen.tsx:291-312`) always uses `replyTo.from`; `EmailThreadScreen.tsx:370-381` does not pass `email.replyTo` although it is fetched (`EMAIL_FULL_PROPERTIES` includes `replyTo`). Replying to a mailing-list/ticket mail with Reply-To goes to the wrong address; replying to your own sent message mails yourself.
- Fix hint: pass `replyToAddresses: email.replyTo` in the route param and port `buildReplyRecipients` (pure TS, no DOM) into `src/lib/`.
- [x] **Reply-all does not drop the user's own identities / duplicates** — fixed in 7f956d6 — `P2` — `bugfix-parity`
- What WEB does: reply-all filters every own identity address (exact and `+tag`-stripped) out of To/Cc (`lib/reply-recipients.ts:39-45`, `:104-110`) and `expandRecipients` dedupes case-insensitively across the whole list (`lib/email-composer-utils.ts:425-437`).
- What RN does: `initialTo` (`ComposeScreen.tsx:304-310`) adds every original `to` except the From; `initialCc` (`:314-319`) copies `cc` unfiltered — the user's own address lands in To/Cc of every reply-all, and the same address can appear in both To and Cc (`alreadySelected` at `:371-374` only guards suggestions).
- Fix hint: filter against the identities list (already loaded at `:406-419`, but note it loads asynchronously — compute recipients after identities arrive or filter at send time) and dedupe across To/Cc/Bcc in `commitTyped`.
- [x] **Cc addresses are not validated; `Name <addr` recovery and quoted names missing** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog 1.9.0 "Split recipient lists whose angle brackets never close", 1.7.x #672)
- What WEB does: `splitRecipients` is quote/angle-aware and recovers from an unclosed `<` (`lib/email-composer-utils.ts:275-321`), `splitPastedRecipients` keeps display names, unwraps `<addr>` tokens and returns leftovers to the input (`:470-521`), `splitMailbox` tolerates a missing `>` (`lib/rfc5322-mailbox.ts:49-58`), `isValidEmail` is RFC 5322 with injection guards (`lib/validation.ts:4-28`).
- What RN does: `parseRecipients` (`ComposeScreen.tsx:69-84`) splits on `[,;\n]` regardless of quotes, requires a closed `>` (else the whole string becomes the "email"), and `hasValidRecipients` (`:504`) only checks **To** against a loose `EMAIL_RE` (`:66`) — an invalid Cc chip is sent to the server and fails there. `"Doe, John" <j@x>` becomes two chips.
- Fix hint: port `splitRecipients`/`parseRecipient`/`splitPastedRecipients` and `isValidEmail` (all DOM-free) into `src/lib/recipients.ts`; validate Cc/Bcc in `canSend` and mark invalid chips red.
- [x] **Contact groups not offered as recipients; no server (Sent-folder) search** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: groups appear in autocomplete as a single expandable chip (`email-composer.tsx:1214-1245`, changelog 1.7.0), plus an on-demand "search the server" row backed by Sent (`:1189-1212`, changelog 1.6.x).
- What RN does: suggestions come only from `individuals` (`ComposeScreen.tsx:367-392`, groups filtered out); groups are reachable only via `GroupDetailScreen.tsx:83` prefill.
- Fix hint: include groups in `suggestions` and expand members on pick (use `isGroup`/members from `contacts-store`); server search is optional.
- [x] **Recipient chips show no avatar / no "Name <addr>" tooltip; no chip editing** — fixed in 5b72c4d — `P3` — `partial` (N/A for drag-reorder)
- What WEB does: avatars in suggestions, chip context menu (copy, add to contacts, move To/Cc/Bcc) (`email-composer.tsx:3860-3895`).
- What RN does: `RecipientChip` (`ComposeScreen.tsx:111-124`) shows name-or-email with an X only.
- Fix hint: long-press menu with "Move to Cc/Bcc", "Copy address", "Add to contacts".
### Identity / From
- [x] **Sub-addressing (`user+tag@`) missing** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: `SubAddressHelper` next to From with tag suggestions (`components/identity/sub-address-helper.tsx`, `lib/sub-addressing.ts`), configurable delimiter (`stores/settings-store.ts:347`), identity matching strips `+tag` (`lib/reply-identity.ts:18-31`).
- What RN does: none; identity matching in `ComposeScreen.tsx:424-441` is exact-address only.
- Fix hint: port `lib/sub-addressing.ts` (pure) and add a small "+tag" affordance in the From row.
- [x] **From override / catch-all alias on an owned domain missing** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x #246)
- What WEB does: `resolveReplyFrom` (`lib/reply-identity.ts:197-256`) picks an identity or, when the message arrived at an unknown address on a domain the user owns, pre-fills a From override sent through the identity's envelope (`email-composer.tsx:2598-2708` UI, `:2113-2121` envelope).
- What RN does: From is always an identity (`ComposeScreen.tsx:908`).
- Fix hint: port `resolveReplyFrom`; allow an override name/email pair and set `submissionCreate.envelope.mailFrom` to the identity (RN already builds envelopes for HOLDFOR at `src/api/email.ts:828-844`).
- [x] **Identity preselection does not consider Bcc or `+tag` and never uses the primary-identity setting** — fixed in c659b54 — `P3` — `partial`
- What WEB does: exact → base (`+tag` stripped) match over to/cc/bcc (`lib/reply-identity.ts:38-68`), self-sent keeps the sending identity (`email-composer.tsx:812-822`), synced `preferredPrimaryId` default identity (#507, `stores/identity-store.ts:18,77`; `hooks/use-identity-sync.ts`).
- What RN does: `ComposeScreen.tsx:424-441` matches exact addresses in to/cc only (no bcc, no base match), default = identity matching the active account email else `identities[0]`; no "default identity" setting exists in `IdentitySettings.tsx`.
- Fix hint: port `findReplyIdentityId`; add a "Use as default" action in `IdentitySettings` persisted in `settings-store`.
- [x] **Identity display name not sanitized on create/update** — fixed in ccbe67c — `P3` — `bugfix-parity` (changelog 1.5.x "Sanitize identity display name to prevent invalid From headers")
- What WEB does: `sanitizeIdentityDisplayName` before every Email/set (`lib/jmap/client.ts:3034`, `:3181`) and `sanitizeDisplayName` (`lib/rfc5322-mailbox.ts:27-30`).
- What RN does: `IdentitySettings.tsx:253-278` sends the raw name; `performSend` uses `primaryIdentity.name` verbatim (`ComposeScreen.tsx:908`). A name like `Jane <jane@x>` or one containing a newline produces a malformed From.
- Fix hint: port `sanitizeDisplayName` and apply it in `saveDraft` (IdentitySettings) and in `sendEmail`'s `from`.
- [x] **Identity editor lacks HTML signature, Reply-To, Bcc fields and size caps** — fixed in c659b54 — `P3` — `partial` — note: the HTML signature is edited as raw HTML in a monospace field (no rich preview); links/images are sanitized on save
- What WEB does: `components/identity/identity-form.tsx` edits name, Reply-To list, Bcc list, text + HTML signature with a 2047-byte cap hint (`:12`, `:39-41`, `:66-70`, `:126-133`), sanitizes HTML.
- What RN does: `IdentitySettings.tsx:209-215` DraftIdentity has name/email/textSignature only.
- Fix hint: add multiline `htmlSignature` (or keep text-only but at least Reply-To/Bcc comma lists validated with the ported `isValidEmail`).
- [ ] **Identity sync (visibility/interval refresh) missing** — `P3` — `partial` — deferred: composer refetches on every mount; acceptable per finding
- What WEB does: `hooks/use-identity-sync.ts` refreshes identities on tab focus and every 30 min.
- What RN does: `ComposeScreen.tsx:406-419` refetches on every composer mount (adequate), but `settings-store.identities` (`src/stores/settings-store.ts:424-431`) only refreshes when the Identities settings pane mounts. Acceptable; listed for completeness.
- Fix hint: optional `AppState` foreground refresh.
### Signatures
- [x] **Signatures are never inserted or sent** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: compose embeds the signature as an editable block (`email-composer.tsx:254-275` buildEmbeddedSignatureHtml, `:437-443`), replies place it above or below the quote per `signaturePosition` (`:345-353`, `stores/settings-store.ts:349-350`), `-- ` separator toggle, plain-text fallback (`lib/signature-utils.ts:18-51`), alias without signature falls back to the primary identity's (`email-composer.tsx:688-693`), identity switch swaps the block (`:702-775`), double-click unlocks the atom for editing (`components/email/signature-block.ts:112-120`, `:240`), drafts keep it (#848).
- What RN does: `Identity.textSignature`/`htmlSignature` exist (`src/api/types.ts:142-143`) and are editable in settings, but `buildInitialHtml` (`src/lib/compose-html.ts:64-106`) and `performSend` (`ComposeScreen.tsx:906-960`) never reference them. Users who set a signature in RN or on the web get none on mobile.
- Fix hint: in `buildInitialHtml` append `<p>-- </p><div data-signature>…</div>` (sanitized `htmlSignature`, else escaped `textSignature`) after the empty paragraph for compose and above/below the quote for replies according to a new `signaturePosition` setting; re-splice on identity change via `editorRef.setHtml`. The WebView editor is a plain contenteditable, so no "unlock" mechanism is needed (mobile equivalent of 3d1bdf09 is simply "always editable").
- [x] **Signature settings (position, separator) absent** — fixed in c659b54 — `P3` — `missing`
- What WEB does: `components/settings/composing-settings.tsx:84-96` (position above/below quote, `-- ` separator).
- What RN does: `ComposingSettings.tsx:13-20` exposes auto-select identity, send delay, attachment reminder only; `settings-store.ts:131-137` has no signature keys.
- Fix hint: add `signaturePosition` / `signatureSeparatorEnabled` to `settings-store` and the Composing pane (do this together with the signature finding).
### Editor / body
- [x] **Reply / forward quotes plain text only — HTML layout and inline images are lost** — fixed in 5b72c4d — `P2` — `missing` (changelog #163, #543, "Editable, layout-preserving quote island")
- What WEB does: `getQuoteBodies` picks the right part by MIME (`lib/email-composer-utils.ts:36-55`, #649), quotes the sanitized HTML as an editable island (`email-composer.tsx:482-496`), rewrites `cid:` images and hydrates them as data URLs so they render and are re-attached on send (`:873-957`, `:1938-1978`).
- What RN does: `EmailThreadScreen.tsx:59-65` `plainTextBody` returns the text part or `preview`, and `navigateCompose` passes only `body` (`:377`); `buildInitialHtml` supports `htmlBody` (`compose-html.ts:76-80`) but is never given one. Replies to HTML-only mail quote whatever `textBody[0]` holds (for HTML-only messages RFC 8621 exposes the HTML source there — raw tags in the quote, the #649 bug) and inline images vanish.
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`/`email-html.ts`) from the thread screen, port `getQuoteBodies`' MIME routing, and for `cid:` images fetch the blob (`getDownloadUrl` in `src/api/blob.ts:119`) as base64, insert with `data-cid`, and add the part as `disposition: 'inline'` on send (RN's `rewriteInlineImages` at `compose-html.ts:143-161` already handles `data-cid`).
- [x] **Forward drops the original attachments** — fixed in 7f956d6 — `P1` — `missing`
- What WEB does: forward seeds the composer with the original's attachments as blobId refs (`email-composer.tsx:598-617`), skipping cid-embedded images, and sends them along (`:2200-2213`); "Forward as attachment" also exists (`lib/forward-as-attachment.ts`).
- What RN does: `navigateCompose` (`EmailThreadScreen.tsx:366-382`) passes no attachments and `Compose` params have no attachment field; `attachments` state starts empty (`ComposeScreen.tsx:354`). A forwarded invoice arrives without the invoice, silently.
- Fix hint: add `attachments: email.attachments` (blobId/name/type/size, filter `disposition==='inline'` images referenced by the body) to the route param and seed `AttachmentEntry`s with `blobId` set and `uploading:false`; `OutgoingAttachment` already accepts blobIds from another message in the same account.
- [x] **Quote header is English-only and shows the sender name without address** — fixed in 5b72c4d — `P3` — `partial` (changelog #482, i18n quote header)
- What WEB does: localized `quote_header.*` labels and `On {date}, {from} wrote:` (`lib/quote-header.ts:54-91`), sender rendered as `Name <email>`.
- What RN does: `compose-html.ts:84-105` hardcodes "Forwarded message", "From/Date/Subject", "On …, X wrote:" and `senderName` (`:30-33`) returns name **or** email, never both. Locale files exist under `locales/<lang>/`.
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
- [x] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
- What RN does: `ComposeScreen.tsx:321-328` only tests `^re:`/`^fwd?:`; "AW: foo" becomes "Re: AW: foo", "Fwd: foo" replied becomes "Re: Fwd: foo".
- Fix hint: copy `lib/subject-prefix.ts` verbatim (pure TS) and use the locale's prefix strings.
- [x] **Attachment reminder scans the quoted original** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #570)
- What WEB does: `extractUserAuthoredText` strips blockquotes/quoted island and everything after the forwarded separator before keyword matching (`lib/email-composer-utils.ts:175-210`, used at `email-composer.tsx:2060-2066`).
- What RN does: `passesAttachmentReminder` (`ComposeScreen.tsx:764-791`) runs `htmlToPlainText` over the **whole** editor HTML including the `<blockquote>`/forward block built by `buildInitialHtml`, so replying to any mail that mentions "attached" fires the warning.
- Fix hint: strip `<blockquote>…</blockquote>` and everything from the forwarded separator before matching (regex is fine, no DOM needed).
- [x] **Empty subject blocks Send instead of confirming** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #684)
- What WEB does: confirm dialog with "Don't ask again" (`email-composer.tsx:2046-2051`, `:3228-3264`, setting `emptySubjectWarningEnabled`).
- What RN does: `canSend` requires `subject.trim().length > 0` (`ComposeScreen.tsx:513`) — the Send button is simply disabled with no explanation.
- Fix hint: drop the subject condition from `canSend` and add an Alert ("Send without subject?") gated by a new `emptySubjectWarningEnabled` setting.
- [x] **Toolbar coverage: no text color, tables, font, horizontal rule, text-direction** — fixed in 5b72c4d — `P3` — `partial`
- What WEB does: Tiptap with Underline, Link, TextAlign, TextStyle+Color, ResizableImage, Table (`components/email/rich-text-editor.tsx:191-225`), auto text direction (changelog 1.7.0), tables (#236).
- What RN does: bold/italic/underline/strike, H1/H2, lists, blockquote, align, link, image, clear, undo/redo (`ComposeScreen.tsx:1190-1240`; commands in `src/components/RichTextEditor.tsx:8-24`). No `dir="auto"` on the editor (`src/lib/editor-html.ts:244`).
- Fix hint: `execCommand('foreColor')` + a small palette, `insertHTML` for a basic table; set `dir="auto"` on `#editor`.
- [x] **Plain-text mode still renders the rich editor and derives text from HTML (links lose their href)** — fixed in c659b54 — `P3` — `partial` — note: the toggle now also lives under Composing; the duplicate in ReadingSettings (area 08 file, uncommitted edits there) was left in place
- What WEB does: plain-text mode swaps to a `<textarea>` (`email-composer.tsx:2875-2891`) and `htmlToPlainText` renders links as `text <href>` (`lib/html-to-text.ts:134-146`); the setting lives under Composing (#422).
- What RN does: `plainTextMode` only suppresses the HTML part at send (`ComposeScreen.tsx:951`); the user still sees formatting buttons; `htmlToPlainText` (`compose-html.ts:113-135`) strips `<a>` tags entirely so pasted links disappear from the text/plain alternative (which is the **only** part in plain-text mode). The toggle sits in `ReadingSettings.tsx:262` instead of Composing.
- Fix hint: hide the format bar and use a `TextInput multiline` when `plainTextMode`; in `htmlToPlainText` replace `<a href="X">T</a>` with `T <X>` before stripping tags; move the toggle to `ComposingSettings`.
- [x] **Inline image insert: no camera, no paste, no multi-select; not offered for file attachments either** — fixed in 5b72c4d — `P3` — `partial`
- What WEB does: drag/drop and paste of images embed as data URLs + cid (`email-composer.tsx:1519-1551`, changelog #163).
- What RN does: `insertInlineImage` (`ComposeScreen.tsx:693-732`) library only, single image; `pickFileAttachment` (`:646-673`) picks one file at a time; no `launchCameraAsync`.
- Fix hint: add a "Camera" option to the attach Alert (`ImagePicker.launchCameraAsync`) and allow multiple in `pickFileAsync` where supported.
- [x] **Send-time editor readback (RN #9/#11) — verified robust, one edge** — fixed in 5b72c4d — `P3` — `rn-only-bug`
- What RN does: `getHtml` rejects after 2 s when the page script is dead (`src/components/RichTextEditor.tsx:143-152`) and `performSend` aborts loudly (`ComposeScreen.tsx:890-902`); `editor-html.ts` is escape-free and unit-tested (`src/lib/__tests__/editor-html.test.ts`). The Android release fix (c5e5092) has since been superseded by the inline `source={{ html }}` + `originWhitelist={['about:blank']}` approach (`RichTextEditor.tsx:198-230`); `android/app/src/main/assets/editor.html` from that commit is no longer referenced (`grep editor.html` → only the TS module). Edge: `removeAttachment` (`ComposeScreen.tsx:553-571`) rewrites `bodyHtml` from the last `change` message rather than a live readback, so if the bridge lags it can `setHtml` a stale body and drop the last keystrokes.
- Fix hint: use `await editorRef.current.getHtml()` before stripping the `<img data-cid>`; delete the orphaned `editor.html` asset.
### Attachments / upload
- [x] **No byte progress, cancel does not abort the transfer, no size-limit check** — fixed in 5b72c4d — `P2` — `partial` (changelog 1.9.0 "Real byte progress…cancel aborts", 1652a0ec "respect server limits")
- What WEB does: XHR upload with `onProgress` + `AbortSignal` (`lib/jmap/client.ts:3954-3990`), chip progress bar (`email-composer.tsx:2951-2965`), `abortController.abort()` on remove (`:1605-1609`), `getMaxSizeUpload()` from the core capability (`lib/jmap/client.ts:4180-4181`; used for files at `components/files/files-app.tsx:311-336`).
- What RN does: `uploadBlob` (`src/api/blob.ts:5-62`) reads the whole file into memory (`new File(uri).bytes()`) and `fetch`es it with no progress or signal; `AttachmentChip` shows a spinner + "Uploading..." (`ComposeScreen.tsx:174-192`); `removeAttachment` (`:553-571`) removes the chip while the upload keeps running; no `maxSizeUpload`/`maxSizeAttachmentsPerEmail` check — a 60 MB video from the picker fails only after the full upload with a raw `Upload failed: 413`.
- Fix hint: check `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` (and the mail capability's `maxSizeAttachmentsPerEmail` for the running total) before starting; use `expo-file-system` `createUploadTask`/`uploadAsync` with progress callback and `cancelAsync()`, keep the handle on `AttachmentEntry`.
- [x] **Attachment chip has no preview / open** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: click a chip to preview via `FilePreviewModal` (`email-composer.tsx:1615-1646`).
- What RN does: chip is display-only (`ComposeScreen.tsx:164-200`).
- Fix hint: on press open the local `uri` with `expo-sharing`/`Linking`.
### Scheduled send
- [x] **Scheduled screen: cancel only — no reschedule, no "send now", no edit** — fixed in c659b54 — `P2` — `partial` — reschedule dropped Cc and Bcc until e67947a, and a refused half of it could drop or double-send the message until 7e598c5 (audit B19)
- What WEB does: scheduled view with send-now button (changelog 1.6.x), reschedule (`stores/email-store.ts:4353` rescheduleScheduledEmail, `lib/jmap/client.ts:7985`), cancel-for-edit that reopens the composer (`email-store.ts:4326`), listing routed to the owning account (#874).
- What RN does: `src/screens/ScheduledScreen.tsx:51-78` offers only "Cancel send"; `src/api/email.ts:902-988` has `listScheduledEmails`/`cancelScheduledSend` only.
- Fix hint: add `rescheduleScheduledSend(submissionId, emailId, identityId, holdFor)` = `EmailSubmission/set { update: {id: {undoStatus:'canceled'}}, create: {replacement: {emailId, identityId, envelope(HOLDFOR)}} }` and an edit path once drafts exist (cancel → open composer prefilled from the Email).
- [x] **Scheduled screen strings are hard-coded English** — fixed in c659b54 — `P3` — `rn-only-bug`
- What RN does: `ScheduledScreen.tsx:52-56`, `:69`, `:85` (`toLocaleString()` without locale/12h setting), `:118` "Scheduled", `:20`/`:88` "(no recipient)"/"(no subject)" — no `t()` calls at all, whereas `ComposeScreen` is localized.
- Fix hint: wrap in `useLocaleStore().t` and format with the shared date helper (`src/lib/date-format.ts`).
- [x] **Scheduled preset times ignore the user's 12/24h format** — fixed in c659b54 — `P3` — `rn-only-bug`
- What RN does: `ComposeScreen.tsx:966-970` and `:1321-1326` use `toLocaleString(undefined, …)` / `toLocaleString()` rather than the `timeFormat` setting the quote header already respects (`compose-html.ts:37-51`).
- Fix hint: reuse `formatQuoteDate`-style formatting with `timeFormat`.
### Templates
- [x] **No way to insert a template in the RN composer** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: template picker + "save as template" buttons in the composer (`email-composer.tsx:3030-3049`), `t` shortcut, insertion at the caret keeping typed text and the signature (#539/#540/#621, `:1278-1373`), placeholder auto-fill (`lib/template-utils.ts:46-62`, `components/templates/placeholder-fill-modal.tsx`), default recipients / identity per template (`lib/template-types.ts:8-13`).
- What RN does: templates can be created/edited/imported/exported in `src/components/settings/TemplateSettings.tsx` and stored in `src/stores/templates-store.ts`, but `ComposeScreen.tsx` never imports `useTemplatesStore` — the feature is unreachable from the composer.
- Fix hint: add a toolbar button that opens a bottom sheet listing `templates`; on pick set subject if empty and `editorRef.insertHtml(escaped body)` (the `insertHtml` bridge exists in `editor-html.ts:368-373`); substitute `{{date}}`, `{{day_of_week}}`, `{{sender_name}}` via a port of `getAutoFilledPlaceholders` and prompt for the rest.
- [x] **Template model lacks isHTML, category UI, favourites, default recipients, identity; export format differs** — fixed in c659b54 — `P3` — `partial`
- What WEB does: `lib/template-types.ts:1-17`, export envelope `{version:1,type:'webmail-templates',…}` (`lib/template-utils.ts:148-156`), import validation (`:286-351`), favourites/recent (`stores/template-store.ts`).
- What RN does: `templates-store.ts:307-316` has name/subject/body/category/isFavorite only; `exportAll` (`:395-397`) emits `{version:1,templates}` **without** `type:'webmail-templates'`, so the web `importTemplates` rejects an RN export with `invalid_type` (`lib/template-utils.ts:302-304`); RN import (`:399-434`) accepts web exports. Category/favourite are not editable in `TemplateSettings.tsx:68-81`.
- Fix hint: add `type: 'webmail-templates'` and `exportedAt` to `exportAll`; carry `isHTML`/`defaultRecipients`/`identityId` through import so they survive a round trip.
- [ ] **Templates not in cross-device settings sync (#825)** — `P3` — `missing` (blocked) — deferred: blocked on native settings sync (issue #1)
- What WEB does: templates ride in the per-account settings blob with tombstone merge (`stores/template-store.ts:208-218`, `lib/template-utils.ts:163-284`).
- What RN does: no settings sync exists at all (native issue #1 "Common settings / global settings" is open), so this is blocked on that feature.
- Fix hint: when settings sync lands, reuse `mergeSyncedTemplates` (pure TS).
### mailto / share intents
- [x] **No `mailto:` handling / share-to-app intent** — `P2` — `missing` — composer side (prefill*, prefillAttachments upload) done in 5b72c4d; scheme, intent filters and links to Compose done in 5802cae (area 08); shared files, `SENDTO` and `+` addresses work since cd4ce9d, 06e8942, ee652d3 (audit B9–B11)
- What WEB does: PWA `protocol_handlers` for mailto (`app/manifest.ts:118`), `parseMailtoUrl` (`lib/validation.ts:133-167`), in-app composer open through the unsaved-draft guard (`components/mail/mail-app.tsx:1110`, changelog 1.9.0).
- What RN does: `app.config.js:29` registers only the custom `bulwarkmobile` scheme; no `intentFilters` (Android `mailto`/`SEND`) or iOS `LSApplicationQueriesSchemes`/`CFBundleURLTypes` for `mailto`; no `Linking.getInitialURL`/`addEventListener('url')` handling that routes to `Compose` (grep over `App.tsx`/`src/navigation` shows none). Tapping a mailto link in another app cannot open Bulwark; sharing a file/text to the app is impossible.
- Fix hint: add `scheme: ['bulwarkmobile', 'mailto']` + Android `intentFilters` for `mailto` and `android.intent.action.SEND`, parse with a port of `parseMailtoUrl`, and navigate to `Compose` with `prefillTo`/subject/body (add those params); for SEND intents seed `attachments` from the shared `content://` URI (upload path already supports it, `src/api/blob.ts:17-22`).
### Security / crypto
- [ ] **S/MIME sign/encrypt on send is a stub** — `P3` — `missing` — deferred: no raw-MIME send path in RN; certificates are stored only
- What WEB does: crypto plugins hook `onComposeSend` (`email-composer.tsx:2180-2216`) and the store has `sendRawEmail` (`stores/email-store.ts:1724`).
- What RN does: `src/components/settings/SmimeSettings.tsx` lists imported certificates ("Your Certificates", "Recipient Certificates") but `ComposeScreen.tsx` has no sign/encrypt control (`grep -i smime|sign|encrypt` → none) and `sendEmail` has no raw-MIME path.
- Fix hint: out of scope for parity right now; document as a known gap in the settings pane ("certificates are stored but not yet used for sending").
### Misc
- [x] **Auto-add reply recipients to trusted senders** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x)
- What WEB does: after a reply, To/Cc are added to the trusted-senders list/address book (`email-composer.tsx:2283-2294`).
- What RN does: `settings-store.ts:105-106` has `trustedSenders`, but `ComposeScreen.tsx` never touches it (`grep -i trusted` → none).
- Fix hint: after a successful reply push `finalTo`/`finalCc` emails into `trustedSenders` (dedupe).
- [ ] **Offline send is not queued** — `P3` — `missing` (RN-only opportunity) — deferred: needs a new outbox op kind carrying the whole Email/set + submission payload; drafts now autosave to the server instead
- What RN does: `outbox-store.ts:40-43` only knows `keywords|mailboxes|destroy` ops; `performSend` fails with a network error when offline (`ComposeScreen.tsx:975-979`) and the composed text is lost unless the user keeps the screen open. WEB has no offline outbox either, so this is not a parity item, but once drafts exist the natural mobile behaviour is "save draft locally, submit when online".
- Fix hint: add a `send` op kind carrying the Email/set create + submission payload, flushed by `outbox-store.flush`.
- [x] **Permission / picker error strings are not localized** — fixed in 5b72c4d — `P3` — `rn-only-bug`
- What RN does: `ComposeScreen.tsx:620-623`, `:696-699`, `:719` hard-code "Photo library permission is required…", "Could not load image"; `identityError` fallback text at `:1052` ("identity unavailable", "Loading...").
- Fix hint: route through `t()` with keys in `locales/<lang>/common.json`.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Scheduled send via `EmailSubmission` envelope `HOLDFOR` with explicit `rcptTo` (bare addresses, names stripped) and `maxDelayedSend`/FUTURERELEASE capability checks: `src/api/email.ts:828-844`, `src/api/jmap-client.ts:513-531`, `ComposeScreen.tsx:796-821` — matches `lib/jmap/client.ts:592-612`, `:3245-3258`. The capability check read only the session-level object, which Stalwart leaves empty, so scheduling and the undo delay were off on Stalwart until e28e8b4 (#57, audit B8); holds are capped at Stalwart's 7-day limit since 4e7ae2f.
- Undo-send delay setting (0/5/10/20/30 s) applied only when the server supports delayed send: `ComposeScreen.tsx:825-828`, `ComposingSettings.tsx:42-48`.
- Send-time readback of the live editor DOM with timeout + loud abort (RN #9/#11): `RichTextEditor.tsx:143-152`, `ComposeScreen.tsx:890-902`; escape-free page script with a parse test.
- `Name <addr>` recipients split into JMAP `name`/`email` (#672): `ComposeScreen.tsx:69-84`, `:944-947`.
- Duplicate recipients within one field rejected on blur/submit: `ComposeScreen.tsx:466-499`; suggestion list hides already-added addresses `:371-392`.
- Quote header values HTML-escaped (#482): `src/lib/compose-html.ts:88-100`.
- Inline images: data-URL placeholder in the editor, `cid:` rewrite + `disposition:'inline'` parts on send, orphaned cids not attached: `compose-html.ts:143-161`, `ComposeScreen.tsx:910-927`; removing the chip strips the `<img>`.
- Attachment reminder with word-boundary keyword match and configurable keyword list: `ComposeScreen.tsx:764-791`, `ComposingSettings.tsx:95-136`.
- Send blocked while uploads are in flight or errored (web waits instead; equivalent outcome): `ComposeScreen.tsx:502-516`.
- Body/attachment presence required to send; dirty-check close guard on the header X: `ComposeScreen.tsx:506-541`.
- Auto-select reply identity from original To/Cc + setting: `ComposeScreen.tsx:431-437`, `settings-store.ts:131`; preselect identity matching the active account (a971d5a1 / "pre-select primary sender identity"): `:426-429`.
- Identity picker bottom sheet (`src/components/IdentitySheet.tsx`), identity CRUD in settings with email immutable on edit (`IdentitySettings.tsx:263-277`).
- Guarded UUID generator (a92c852c equivalent): `src/lib/uuid.ts:6-20`.
- Pasted `<script>`/`<style>` stripped before send: `ComposeScreen.tsx:913` via `stripDangerousTags`; link insertion rejects non-http/mailto/tel schemes: `editor-html.ts:380-384`.
- Plain-text-only send (text/plain part only) when `plainTextMode`: `ComposeScreen.tsx:951`, `src/api/email.ts:798-806`.
- Blob upload tolerant of both Stalwart upload-response shapes: `src/api/blob.ts:38-61`.
- Scheduled list filters `undoStatus==='pending'` and future `sendAt`, cancel via `undoStatus:'canceled'` keeping the Sent copy: `src/api/email.ts:902-988`.
- Contact prefill from contact/group detail: `ContactDetailScreen.tsx:159`, `GroupDetailScreen.tsx:83`.
## N/A on mobile
- Ctrl/Cmd+Enter send, Ctrl+Shift+Enter schedule, `t` template shortcut, Escape close (`email-composer.tsx:2428-2468`).
- Drag-and-drop of files onto the composer and drag-to-reorder / drag-between-fields recipient chips (#593), chip drag preview.
- Pro multi-account From dropdown namespacing (44cdd083), per-pane composer tabs, tab-title logic (`lib/subject-prefix.ts:129-144` buildComposeTabTitle), "Keep the fresh-compose tab title clear".
- Composer sidebar / toolbar plugin slots and all `emailHooks.*` plugin transforms (no plugin runtime in RN).
- `beforeunload` server autosave (browser lifecycle) — replaced by the `beforeRemove` recommendation above.
- Browser `registerProtocolHandler`/PWA manifest mailto registration (replaced by OS intent filters above).
- Signature "double-click to unlock" atom (Tiptap-specific); the RN contenteditable is always editable.
- Quoted-HTML shadow-island typing detection (#654) — Tiptap/shadow-DOM specific.
- Send through another logged-in account's client for DKIM (#461) — RN is single-active-account; identities always belong to the submitting account.
- Route scheduled sends / identity on reschedule to a shared account (#874, a15af722) — RN does not compose on behalf of shared accounts, but the Scheduled screen lists, cancels, reschedules and edits sends held in shared accounts since 72f48db.
+302
View File
@@ -0,0 +1,302 @@
# Calendar & tasks
## Summary
RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-viewer colours, client-side recurrence expansion that is a faithful port of WEB's, birthday calendar, tasks-only calendar hiding, push-driven refresh) but the write path is far behind WEB and has several data-corrupting bugs: the recurrence scope dialog is a stub (every choice edits/deletes the whole series, and editing an occurrence moves the whole series), all-day events grow by one day on every create/edit, events are created without a `timeZone`, and invitations are never actually sent because RN builds participants with the retired `sendTo` and never sets `organizerCalendarAddress`. There is no calendar management UI (create/rename/colour/delete/clear/share), no rights-based editability, no reminders/alerts firing, no trust assessment on invitations, no custom time zone, no Jalali, and the whole calendar UI is unlocalized even though RN has an i18n layer. Roughly half of the WEB changelog fixes since 1.4.5 apply to RN and are not there.
## Findings
### Recurrence
- [x] **Recurrence scope dialog is a stub: "This event" / "This and following" behave as "All events"** — `P1` — `missing` — fixed in 161baf1
- What WEB does: `handleScopeSelect` implements all three scopes: "this" writes a `recurrenceOverrides/<recurrenceId>` patch (or the synthetic id on Stalwart >= 0.16.20), "this_and_future" truncates the master with `until` and creates a new series, "all" patches the master; delete does the mirror (exclude / truncate / destroy) and refetches the range (ref `components/calendar/calendar-app.tsx:842-998`, `lib/recurrence-overrides.ts:21-32`, changelog 1.9.0 "Edit a single recurring occurrence via a one-shot override patch").
- What RN does: `handleScopeSelect` ignores the chosen scope with a TODO ("treat all scopes as 'all'") and either opens the edit modal on the occurrence or deletes `action.event.id`, which the store resolves to the master via `originalId` (ref `src/screens/CalendarScreen.tsx:347-363`, `src/stores/calendar-store.ts:306-329`). Choosing "This event" on delete destroys the entire series.
- Fix hint: port WEB's `handleScopeSelect`/`truncateRecurrenceAtEvent`/`findMasterEvent` into CalendarScreen; add `buildRecurrenceOverridePatch` (WEB `lib/recurrence-overrides.ts`; removed again in f397ee3, where "This event only" goes to `updateEvent`/`deleteEvent` with the occurrence's id) and `RECURRENCE_OVERRIDE_IMMUTABLE_KEYS`; for "this" write `{ [`recurrenceOverrides/${recurrenceId}`]: override }` on `originalId`; for delete-this write `{ excluded: true }`; refresh after.
- [x] **Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts / earlier occurrences vanish)** — `P1` — `bug` — fixed in 161baf1
- What WEB does: for scope "all" the updates go to the master but `start` from an occurrence is never applied to the master unless the user changed it; scope "this" goes into an override (ref `components/calendar/calendar-app.tsx:930-943`).
- What RN does: `EventModal` seeds `start` from the occurrence (`getEventStartDate(event)`, `src/components/calendar/EventModal.tsx:197`) and `handleSave` always sends `start` + `duration` (`EventModal.tsx:262-281`); `updateEvent` resolves the occurrence to `originalId` and PATCHes the master (`src/stores/calendar-store.ts:306-318`). Any occurrence edit moves the whole series to that occurrence's date.
- Fix hint: part of the scope work above; when scope is "all", drop `start` from the patch unless the user actually changed the date/time relative to the occurrence, or compute the delta and apply it to the master's `start`.
- [x] **Store's optimistic merge after an occurrence edit only updates one occurrence** — `P2` — `bug` — fixed in 161baf1
- What WEB does: after any occurrence or master mutation with expanded occurrences in view it refetches the visible range (`stores/calendar-store.ts:186-206, 749-751`).
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
- [x] **Synthetic occurrence ids / server-side expansion (Stalwart >= 0.16.20) not used** — `P3` — `missing` — done in 8454889 (server expansion when the probe finds synthetic ids, device-side expansion otherwise), f397ee3 (one occurrence changed or deleted through its own id) and 96c5a3f (RSVP to one occurrence)
- What WEB does: probes `CalendarEvent/set` with `h333333` once, then queries with `expandRecurrences: true`, hydrates occurrences from base events and writes occurrence patches through the synthetic id with a fallback to base-event overrides (ref `lib/jmap/client.ts:5648-5680, 5747-5751, 5828-5868`, `lib/recurrence-instances.ts:35, 73, 208-232`, `stores/calendar-store.ts:82-184`).
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
- [x] **`this_and_future` / huge-span RRULE (#735) — RN not affected** — `P3` — `bugfix-parity` — verified, nothing to fix (caps already in place; "this and following" truncates with `until` and drops `count`)
- What WEB does: memory note on #735 (Stalwart u32 overflow for spans > 136 years). WEB's editor caps `until` to a picked date and `count` to 999.
- What RN does: same caps (`src/lib/recurrence.ts:227-229`, `RecurrenceEditor.tsx:139-144, 244-249`), no alarms with absolute triggers far in the future. Not affected; noted so it isn't re-investigated.
### Events: create / edit
- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
- [x] **Changing the calendar in the edit modal is ignored (cannot move an event between calendars)** — `P2` — `rn-only-bug` — fixed in 161baf1 + 3fd5f10 (cross-account moves are not offered)
- What WEB does: `calendarIds: { [calendarId]: true }` is part of the saved patch and the store remaps store ids to server ids (ref `components/calendar/event-modal.tsx:503`, `stores/calendar-store.ts:706-715`).
- What RN does: `EventModal.handleSave` passes `calendarId` as the second argument, but `CalendarScreen.handleSave` only uses it on create (`src/screens/CalendarScreen.tsx:365-374`); the picker in edit mode is a no-op.
- Fix hint: on update include `calendarIds: { [cal.originalId || calendarId]: true }` in the patch; for shared calendars the owning account differs, so either block cross-account moves or delete + recreate.
- [x] **Edit-modal calendar picker offers the Birthdays calendar, iCal-subscription calendars and read-only shared calendars (#762)** — `P2` — `bugfix-parity` — fixed in 3fd5f10
- What WEB does: `canCreateEventsIn` excludes subscription calendars and calendars without `mayWriteAll`/`mayWriteOwn` (ref `lib/calendar-editability.ts:20-24`, changelog 1.9.0 #762).
- What RN does: `EventModal` receives `eventCalendars`, which still contains the virtual birthday calendar (`src/screens/CalendarScreen.tsx:213-216, 227-231, 544-552`) and all shared calendars regardless of rights; picking Birthdays yields a server error, picking a subscription calendar lets the next feed sync delete the event (`src/stores/calendar-subscriptions-store.ts:58-74`).
- Fix hint: filter with a RN `canCreateEventsIn(cal, isSubscriptionCalendar)` (subscriptions from `useCalendarSubscriptionsStore`), exclude `BIRTHDAY_CALENDAR_ID`.
- [x] **No rights-first editability (edit/delete offered on read-only and RSVP-only events)** — `P2` — `missing` — fixed in 3fd5f10
- What WEB does: `getEventEditability` gates on calendar `myRights` first (`mayWriteAll`, `mayWriteOwn` + owner, `mayRSVP` + participant), subscription calendars are read-only, and the popover shows edit/delete vs. RSVP-only accordingly (ref `lib/calendar-editability.ts:47-76`, `components/calendar/event-detail-popover.tsx:161-171, 540-575`, changelog 1.9.0 "Rights-first event editability, including alias organizers").
- What RN does: `EventDetailSheet` always renders Edit/Delete (`src/components/calendar/EventDetailSheet.tsx:328-351`); the only read-only guard is the birthday calendar (`src/screens/CalendarScreen.tsx:313-316`). The server rejects the write, but the user sees a generic error.
- Fix hint: port `calendar-editability.ts`; pass `calendarsById`, the user's addresses (identities + aliases) and `isSubscriptionCalendar` into the sheet; hide Edit/Delete for `read-only`, show only RSVP for `rsvp-only`.
- [x] **`myRights.mayWrite` (non-existent JMAP property) used for writability everywhere** — `P2` — `rn-only-bug` — fixed in 3fd5f10
- What WEB does: uses `mayWriteAll`/`mayWriteOwn`/`mayDelete` from RFC-style `CalendarRights` (ref `lib/calendar-editability.ts:20-24`, `components/calendar/calendar-sidebar-panel.tsx:328-334`).
- What RN does: `CalendarRights.mayWrite` is documented as a "legacy short flag" (`src/api/types.ts:~507`) and is what `CalendarSidebarDrawer` (`:92-98`), `TasksSheet` (`:84-87`), `ICalImportSheet` (`:51-54`) and `CalendarInvitationBanner` (`:96`) test. Stalwart never sends it, so every calendar counts as writable and the "Subscribed" section can never populate.
- Fix hint: replace with `!r || r.mayWriteAll || r.mayWriteOwn` and drop `mayWrite` from the type.
- [x] **No "send invitations" toggle in the editor** — `P3` — `missing` — fixed in 2abea97
- What WEB does: `sendInvitations` checkbox (default on) decides `sendSchedulingMessages` (ref `components/calendar/event-modal.tsx:409, 594, 1333-1338`).
- What RN does: always sends when participants exist (`src/stores/calendar-store.ts:22-24, 293-297`).
- Fix hint: add a switch in the Participants section and thread it through `onSave`.
- [x] **Reminder picker is presets-only** — `P3` — `partial` — fixed in 2abea97 (custom amount + unit row; absolute / end-relative / non-display alerts survive a save)
- What WEB does: alert rows with a number + unit (minutes/hours/days/weeks/at time), multiple rows, preserving exotic alerts (ref `components/calendar/event-modal.tsx:101-135, 358-386`, changelog 1.6.x #170).
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
### Invitations / scheduling
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
- Fix hint: drop the `..` check.
- [x] **Invitation banner: no trust assessment, no METHOD from Content-Type / raw ICS, no inline body-part detection** — `P2` — `partial` — fixed in 8206893 (auth results parsed from `email.headers`)
- What WEB does: `getInvitationTrustAssessment` (DMARC/DKIM/SPF + sender/organizer mismatch -> trusted/caution/warning banner), `getInvitationMethod` reads `method=` from the attachment/body Content-Type, then `extractMethodFromRawIcs`, then infers; `findCalendarAttachment` also walks `textBody`/`htmlBody` sub-parts (ref `lib/calendar-invitation.ts:341-446`, changelog 1.6.x "RSVP with trust assessment").
- What RN does: `inferInvitationMethod(parsed)` only (`src/components/email/CalendarInvitationBanner.tsx:62`; `extractMethodFromRawIcs` exists at `src/lib/calendar-invitation.ts:103-106` but is unused); `findCalendarAttachment` checks `email.attachments` only (`:139-148`); no trust UI. A spoofed invitation from an unauthenticated sender looks identical to a real one, and a REPLY/CANCEL whose ICS carries no participants is treated as `unknown` and offered "Add to calendar".
- Fix hint: port `getInvitationTrustAssessment` (RN `Email` has `authenticationResults`?; if not, add to the fetched properties) and show a coloured banner + reason; read `attachment.type` params and fall back to downloading the blob text for `METHOD:`.
- [x] **Invitation banner imports into the first writable calendar, not the default calendar, and parses against the primary account** — `P3` — `partial` — fixed in 3fd5f10 + 8206893
- What WEB does: imports into the default calendar of the account that owns the email; shared-folder emails are parsed against the folder owner (#867) and multi-account lookups are routed by source account (#847) (changelog 1.9.0).
- What RN does: `calendars.find(cal => !cal.myRights || cal.myRights.mayWrite !== false)` (`src/components/email/CalendarInvitationBanner.tsx:96`) and `parseCalendarBlob(attachment.blobId)` on `jmapClient.accountId` (`:59`, `src/api/calendar.ts:355-372`). RN shows only the active account's mailboxes, so #847/#867 are largely N/A; the "first writable" pick is wrong when a subscription calendar sorts first.
- Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`.
- [x] **Organizer not identified in the participant list; no "invited by", status counts or cancelled/tentative rendering** — `P3` — `partial` — fixed in 5dc4070 + 0f80e98 (organizer label, cancelled strike-through in sheet/card/month chips, tentative badge on cards; week-view blocks are untouched)
- What WEB does: `getParticipantList` marks the organizer (also via `organizerCalendarAddress`, #731), the popover shows "(organizer)", strikes through `status === 'cancelled'` and badges `tentative` (ref `lib/calendar-participants.ts:126-157`, `components/calendar/event-detail-popover.tsx:299-312, 445-449`, changelog 1.7.2 #572).
- What RN does: participants listed with status dots only (`src/components/calendar/EventDetailSheet.tsx:298-325`); no cancelled/tentative styling in sheet, `EventCard` or views.
- Fix hint: port `getParticipantList`/`getStatusCounts`; add `line-through` when `event.status === 'cancelled'` in `EventCard`, `WeekView`, `EventDetailSheet`.
### Calendars
- [x] **No calendar management UI: create, rename, colour, description, delete, clear events, copy CalDAV URL** — `P2` — `missing` — fixed in dae5700 (no CalDAV URL copy: RN has no DAV base URL)
- What WEB does: sidebar context menu + settings section with create (with kind picker), edit name/colour/description, clear all events (unlink-aware), delete, share, copy URL (ref `components/calendar/calendar-sidebar-panel.tsx:328-395`, `components/settings/calendar-management-settings.tsx`, `stores/calendar-store.ts:1028-1206`).
- What RN does: `createCalendar` exists in the store but is only reachable through the subscription sheet (`src/stores/calendar-store.ts:378-382`, `src/stores/calendar-subscriptions-store.ts:82-84`); there is no `updateCalendar`/`removeCalendar`/`clearCalendarEvents` API or UI (`src/api/calendar.ts` has `deleteCalendar` at 417 but nothing calls it for own calendars). Long-press only offers set-default and shared-calendar recolour (`src/components/calendar/CalendarSidebarDrawer.tsx:208-211`).
- Fix hint: add `Calendar/set` update + destroy (`onDestroyRemoveEvents`) to `src/api/calendar.ts`, expose in the store, and add "New calendar" + long-press "Rename / Colour / Delete / Clear" in the drawer. Deleting must be blocked for `isDefault` and shared calendars like WEB.
- [x] **Own-calendar colour cannot be changed (only shared calendars are recolourable)** — `P3` — `partial` — fixed in dae5700
- What WEB does: colour picker writes `Calendar/set { color }` for own calendars and a local override for shared ones (ref `calendar-sidebar-panel.tsx:330, 366`, `lib/shared-calendar-colors.ts:33-40`).
- What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`).
- Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`.
- [x] **Shared-calendar colour key differs from WEB (not portable, but internally consistent)** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: key = `${localAccountId}|${accountId}|${originalId}` (ref `lib/shared-calendar-colors.ts:33-40`).
- What RN does: key = `${accountId}|${id}` where `id` is already the namespaced `${accountId}:${raw}` (`src/lib/calendar-utils.ts:474-478`). Works, but the account id is duplicated in the key and would break if the namespacing format changes.
- Fix hint: use `cal.originalId ?? cal.id` like WEB.
- [ ] **Calendar creation does not pin `supported-calendar-component-set` (#760)** — `P3` — `bugfix-parity` — deferred: the drawer now creates calendars over `Calendar/set` (dae5700); MKCALENDAR against Stalwart's `/dav/` endpoint needs a CalDAV client + calendar-home discovery that RN does not have
- What WEB does: MKCALENDAR through the WebDAV proxy with `VEVENT`/`VTODO`/both, then finishes over JMAP; falls back to `Calendar/set` (ref `lib/jmap/client.ts:5366-5400, 5436-5485`, `components/calendar/calendar-kind-picker.tsx:16-18`, changelog 1.9.0 #760).
- What RN does: plain `Calendar/set` (`src/api/calendar.ts:374-396`). Only matters once RN gains a create-calendar UI; RN has no WebDAV proxy, but it can talk to Stalwart's CalDAV endpoint directly (the same host serves `/dav/`).
- Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id.
- [x] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` — fixed in 58e3ef6 (in-flight dedupe of fetchCalendars/fetchTasks; ensureRange awaits it)
- What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907).
- What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected.
- Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch.
- [x] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird").
- What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`).
- Fix hint: port `isTaskLikeObject` and use it in both filters.
- [x] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access").
- What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`).
- Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect.
- [x] **Calendar sharing (JMAP `shareWith`) not available for calendars** — `P2` — `missing` — fixed in dae5700
- What WEB does: `shareCalendar` via `Calendar/set shareWith/<principal>` with a principal picker and share indicators (ref `stores/calendar-store.ts:1078-1100`, `lib/jmap/client.ts:4921`, changelog 1.6.x "JMAP sharing for calendars and address books", #244, #257).
- What RN does: sharing exists only for Files (`src/api/files.ts:229-241`, `src/components/files/ShareSheet.tsx`); nothing for calendars (also likely nothing for address books — that belongs to the contacts audit).
- Fix hint: reuse `ShareSheet` + the principal picker with a `Calendar/set` `shareWith/{principalId}` patch; request `shareWith`/`myRights` in `Calendar/get` properties.
### Views / navigation / locale
- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa), `formatReminder` (2abea97) and ICalImportSheet (b7f7110) localized too; deferred: ICalSubscriptionSheet and RecurrenceEditor still hard-code English
- What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal").
- What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`).
- Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`.
- [ ] **First day of week limited to Monday/Sunday; no Saturday, no Jalali (#490)** — `P3` — `missing` — Saturday (0|1|6) done in 0f80e98; deferred: Jalali month grid needs a port of `lib/jalali-utils.ts` + `useCalendarLocale`
- What WEB does: `firstDayOfWeek` 0/1/6 with Saturday default for Persian and a full Jalali month grid / headers (ref `hooks/use-calendar-locale.ts:35-120`, `lib/jalali-utils.ts:161-169`, changelog 1.8.0 #490).
- What RN does: `FirstDayOfWeek = 0 | 1` (`src/stores/settings-store.ts:76`), `MonthView`/`WeekView` typed `0 | 1` (`MonthView.tsx:34`, `WeekView.tsx:40`), no Jalali.
- Fix hint: widen the type to `0|1|6`, add Saturday to `CalendarSettings`; Jalali is a larger port of `jalali-utils.ts` + a `useCalendarLocale` equivalent (only if the fa locale is added to RN).
- [x] **Week numbers are not ISO when the week starts on Monday; no week numbers in week view** — `P3` — `rn-only-bug` — fixed in 0f80e98 (month view; the week view header has no room for it)
- What WEB does: `getISOWeek` when `weekStartsOn === 1`, else `getWeek(..., { weekStartsOn: 0 })` (ref `components/calendar/mini-calendar.tsx:74-79`).
- What RN does: `getWeek(row[0], { weekStartsOn })` (`src/components/calendar/MonthView.tsx:137`) — without `firstWeekContainsDate: 4` this is not ISO numbering and can be off by one around New Year. WeekView has no week number.
- Fix hint: use `getISOWeek` for Monday start; optionally show the number in the WeekView header row.
- [x] **"Show time in month view" setting is ignored (#666)** — `P3` — `bugfix-parity` — fixed in 0f80e98
- What WEB does: on mobile, `showChips = !isMobile || showTimeInMonthView` renders event chips with times instead of dots (ref `components/calendar/calendar-month-view.tsx:49-52`, `event-card.tsx:80, 178`, changelog 1.8.0 #666).
- What RN does: setting exists (`src/stores/settings-store.ts:169`, `CalendarSettings.tsx:435-443`) but `MonthView` always draws up to 3 dots (`src/components/calendar/MonthView.tsx:83-86`). RN affected.
- Fix hint: pass the setting into `MonthView` and render a compact chip (title + start time) per event when on.
- [x] **Hover-preview setting exposed on mobile** — `P3` — `rn-only-bug` — fixed in 0f80e98 (store key kept for settings-sync compatibility)
- What WEB does: hover preview is a desktop pointer feature.
- What RN does: `CalendarSettings` shows "Hover preview" with delay options and nothing consumes `calendarHoverPreview` (`src/components/settings/CalendarSettings.tsx:87-102`; no other reference in `src/`).
- Fix hint: remove the setting row (keep the store key for settings-sync compatibility or drop it).
- [x] **No day view (falls back to agenda); tapping a month day does not open the day** — `P3` — `partial` — fixed in 0f80e98 ("Day" hidden from the mobile default-view options; a synced "day" shows as Agenda); a real day view is back since de3d697, with the week and month views scrolling freely (de3d697, caf61ec, #759) and an endless agenda (71522b9)
- What WEB does: day view with time grid; on mobile, tapping a date in month view switches to day view and offers "back to month" (ref `components/calendar/calendar-app.tsx:461-470`, `calendar-day-view.tsx`).
- What RN does: `calendarDefaultView === 'day'` maps to agenda (`src/screens/CalendarScreen.tsx:131-137`); month view shows a list below the grid instead. Acceptable UX, but the settings option "Day" is misleading.
- Fix hint: either reuse `WeekView` with a single-day `weekDays` array as a day grid, or hide "Day" from the RN default-view options.
- [x] **Week view: event block time label ignores 12h setting; timed grid has no long-press-at-minute precision** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: `formatSnapTime(minutes, timeFormat)` (ref `lib/calendar-utils.ts:278`).
- What RN does: `minutesToTimeLabel` always `HH:mm` (`src/components/calendar/WeekView.tsx:315-319`) while the gutter honours 12h (`:229-231`). Long-press creates at the whole hour (`:123-128`), fine for mobile.
- Fix hint: use `timePattern(timeFormat)` with `format()`.
- [x] **EventCard shows the description under a location (MapPin) icon and never shows the location** — `P3` — `rn-only-bug` — fixed in 0f80e98
- What WEB does: card shows time, location and calendar (ref `components/calendar/event-card.tsx`).
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab
- What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar/<view>/<date>?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files").
- What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); `handleDeepLink` hands an event to the Calendar tab through `pending-calendar-open`, the same path a tapped reminder takes, but ignores the date.
- Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet.
### Data loading / sync
- [x] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` — fixed in 58e3ef6
- What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`).
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
- [x] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial` — fixed in 5fe8e40
- What WEB does: whitelists the JSCalendar properties it sends (drops server-computed `utcStart`/`utcEnd`/`isOrigin`/`created`/`updated`, rewrites participants to `calendarAddress`, normalises all-day duration/timeZone) and links UIDs that exist in another calendar by adding the target calendar to `calendarIds` (ref `stores/calendar-store.ts:822-958`, changelog 1.5.x #113).
- What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures").
- Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch.
- [x] **iCal subscriptions: no per-account scoping, no edit, no auto-refresh/interval, no rollback, `webcals://`, basic-auth URLs** — `P2` — `partial` — fixed in dae5700
- What WEB does: subscriptions carry `accountId` and are skipped for other accounts (`stores/calendar-store.ts:1360-1363, 1468-1472`), editable name/colour/url/interval (`:1294-1329`), refreshed every 5 min per interval (`components/calendar/calendar-app.tsx:320-324`), rolled back when the first fetch fails (`:1274-1289`), `webcals?://` normalised (`:1240`), basic auth in the URL supported server-side (#275), stale events unlinked rather than deleted when also in another calendar (`:1401-1427`).
- What RN does: `calendar-subscriptions-store` persists `{ id, name, url, color, calendarId }` globally (`src/stores/calendar-subscriptions-store.ts:16-24, 162-166`) — after switching accounts the list still shows and `syncSubscription` imports into a calendar id of the other account (or a colliding raw id); `syncAll` is never called (`:155-160`); no edit UI (`ICalSubscriptionSheet.tsx`); a failed first sync leaves the empty calendar + sub with `lastError` (`:82-116`); only `webcal://` is rewritten (`:38-40`); `fetch('https://user:pass@host')` throws in RN's WHATWG fetch so #275 URLs fail; stale events are deleted outright (`:58-74`).
- Fix hint: add `accountId: jmapClient.accountId` to each sub and filter by it; call `syncAll` on calendar mount + `AppState` foreground with a per-sub interval; add an edit sheet; on first-sync failure delete the calendar; regex `/^webcals?:\/\//i`; parse credentials out of the URL into an `Authorization` header.
- [x] **Subscription feeds are fetched on-device (security note, not a gap)** — `P3` — `partial` — fixed in dae5700 (10 MB cap)
- What WEB does: `/api/fetch-ical` server-side with an SSRF guard, DNS pinning and redirect validation (GHSA-24w9, changelog 1.9.2).
- What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`).
- Fix hint: optional size cap.
- [x] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` — fixed in 58e3ef6
- What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view.
- What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`).
- Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`.
- [ ] **Push: FCM subscription excludes `CalendarEvent`/`Calendar` types** — `P3` — `partial` — deferred: local reminders (8e1d03c) are resynced at launch, on `Calendar`/`CalendarEvent` state changes and on return to the foreground (4b903ed), but nothing refreshes them in the background; adding the calendar types to the push subscription lives in `src/lib/push-notifications.ts` (push area) and would need a background refresh + reschedule hook
- What WEB does: push subscription covers calendar types so the sidebar refreshes on external changes (FEATURES.md "JMAP push keeps everything in sync").
- What RN does: foreground EventSource/polling dispatches to `useCalendarStore.handleStateChange` (`App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`) — at parity while the app is open; the background push subscription carries only `['EmailDelivery']` since 00faceb (`src/lib/push-notifications.ts`), so calendar changes never wake the app.
- Fix hint: leave as is unless background calendar refresh becomes needed.
### Alerts / notifications
- [x] **Reminders never fire on mobile (no alert scheduler, no local notifications)** — `P2` — `missing` — fixed in 8e1d03c (expo-notifications local notifications scheduled from the store for the next 7 days, calendar default alerts honoured, cancelled events / completed tasks / acknowledged alerts skipped); moving the calendar view cancelled them until 4b903ed (audit B17), and a tapped reminder opens its event since 83db70a
- What WEB does: `useCalendarAlerts` polls every 60 s, proactively fetches the next 24 h, computes fire times from `alerts` (offset relative to start/end, absolute triggers, calendar default alerts via `useDefaultAlerts`), dedupes through a persisted acknowledged store, plays a sound and toasts; task due alerts too; cancelled events muted (#572) (ref `lib/calendar-alerts.ts:39-201`, `hooks/use-calendar-alerts.ts:21-158`, `stores/calendar-notification-store.ts`).
- What RN does: `src/lib/calendar-alerts.ts` only converts offsets <-> reminder presets (1-103); the `calendarNotificationsEnabled`/`calendarNotificationSound` settings are shown in `NotificationSettings.tsx:52-53, 235-240` but nothing reads them; `package.json` has no `expo-notifications`. Users who set "15 minutes before" get nothing.
- Fix hint: add `expo-notifications`, port `computeFireTime`/`getPendingAlerts`, and schedule local notifications for the next N days on every `fetchEvents` (cancel + reschedule by event id); honour `useDefaultAlerts` via `Calendar.defaultAlertsWithTime/WithoutTime` (add those to `Calendar/get` properties).
### Tasks
- [x] **Task sheet supports only title + due date; no edit, description, priority, due time, progress states, alerts, filters, "show on calendar"** — `P2` — `partial` — fixed in 09c24fa (edit, description, priority, due date + time, calendar move, all/pending/completed/overdue filters, overdue-first sort, `needs-action` <-> `completed` with `progressUpdated`, due tasks overlaid on the grid; task alerts still come only from the server data); completing failed on Stalwart, which rejects `progressUpdated`, until dcdda5e (audit B12, #958)
- What WEB does: task modal with description, due date + optional time, priority (none/high/medium/low -> 1-9), progress, calendar, alert; list filters all/pending/completed/overdue, overdue-first sort, `showTasksOnCalendar` overlays due tasks on the grid; completion toggles `needs-action` <-> `completed` with `progressUpdated` (ref `components/calendar/task-modal.tsx:55-140`, `task-list-view.tsx:80-110`, `stores/task-store.ts:366, 442-452`).
- What RN does: `TasksSheet` creates `{ title, progress, due }` only, no tap-to-edit, delete + toggle only (`src/components/calendar/TasksSheet.tsx:117-135, 210-235`); un-completing sets `in-process` (`src/stores/calendar-store.ts:418-426`); `showTasksOnCalendar` (`settings-store.ts:174`) is unused; tasks are never drawn in month/week/agenda.
- Fix hint: add a task edit sheet mirroring `task-modal.tsx`, filter chips, and render due tasks as all-day chips when `showTasksOnCalendar`.
### Settings
- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; the editors and views work in calendar-zone wall time since a8e6f6e and 8af163a, and floating task dues since 1a651a9; deferred: birthday-calendar colour setting
- What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`).
- What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`).
- Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Client-side recurrence expansion: `src/lib/recurrence-expansion.ts` is a faithful port of `lib/recurrence-expansion.ts` (byX filtering, bySetPosition, RDATE overrides, fast-forward, per-occurrence `utcStart`/`utcEnd` #116); only the server-instance guard differs.
- Stalwart singular `recurrenceRule`/`excludedRecurrenceRule` normalisation on read and write (#13, JSCalendar 2.0) — `src/api/calendar.ts:64-126`.
- Singular `inCalendar` OR-filter, `timeZone` on query/get, batched `CalendarEvent/get` (#141) — `src/api/calendar.ts:30-38, 210-231`.
- Shared calendars via other session accounts, namespaced ids (`${accountId}:${id}`), `originalId`, event remapping, per-account query routing, best-effort failure isolation — `src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:35-58, 170-204`.
- Per-viewer colours for shared calendars with auto-assigned unused palette colour (#345) — `src/screens/CalendarScreen.tsx:181-211`, `src/lib/calendar-utils.ts:465-511`.
- Tasks-only calendars hidden from event UI and pickers (#28 / 9c084d9d) — `CalendarScreen.tsx:227-242`.
- Set default calendar via `onSuccessSetIsDefault` — `src/api/calendar.ts:403-413`.
- Malformed `utcStart`/`start` fallback (#316) — `src/lib/calendar-utils.ts:35-56`; undefined alert trigger guard (#143) — `src/lib/calendar-alerts.ts:57-60`.
- Multi-day all-day bars aligned across the week, timed full-day promotion, cluster-based overlap layout — `src/lib/calendar-utils.ts:240-420`, `WeekView.tsx:69-101`.
- Start move keeps duration (`EventModal.tsx:244-248`), Save disabled until title, long-press slot/day creates at the tapped time (7e9aeaf0 equivalent).
- Server-sent iMIP via `sendSchedulingMessages` only (no client-side duplicate emails); import uses no scheduling (#411) — `src/api/calendar.ts:233-297`.
- RSVP via RFC 6901-escaped `participants/<id>/participationStatus` — `src/api/calendar.ts:331-352`.
- Birthday calendar generation incl. Feb 29 clamp and age — identical to WEB (`diff` clean apart from the extra WEB null fields).
- iCal import with preview, select all/none, 5 MB cap, UID dedupe — `ICalImportSheet.tsx`.
- Push StateChange refresh for `Calendar`/`CalendarEvent` on primary + shared accounts — `App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`.
- Calendar tab hidden when the account lacks the JMAP calendars capability — `App.tsx:95, 160`, `src/lib/capabilities.ts:25`.
- Week start setting, week numbers in month view, 12h/24h time format threaded through views.
## N/A on mobile
- Mouse drag to move/resize, click-drag create, 15-minute snapping, top-edge resize, hover preview (`hooks/use-time-grid-interactions.ts`) — long-press create is the mobile equivalent.
- Keyboard shortcuts (t/m/w/d/a/k/n, arrows), right-click context menus (long-press could carry the same actions, see "Duplicate/export" finding).
- Pro multi-account aggregation (`hooks/use-pro-multi-account-calendars.ts`, `localAccountId::` namespacing, `reconcileSelectedIds`) — RN is single-active-account.
- `/api/fetch-ical` server-side SSRF guard / DNS pinning (GHSA-24w9) — no server component in RN.
- `webcal:` / `mailto:` protocol-handler registration and import-or-subscribe prompt for detected webcal links.
- Agenda plugin sidecar, plugin event-action slot (Jitsi), Pro tab deep-link URL writing.
- Refresh-gesture interception (F5/Ctrl-R) — RN has pull-to-refresh.
- Quick natural-language event input: the webmail has no parser for it either (dropped from the findings by the 2026-09 audit).
+318
View File
@@ -0,0 +1,318 @@
# Contacts & address books
## Summary
RN covers the visible surface reasonably well (list with alphabetical index, detail screen with every RFC 9553 field block, a full edit form, groups with member management, address-book CRUD, vCard import/export, trusted-senders book, contact activity). The gaps are mostly below the UI: the RN JMAP layer (`src/api/contacts.ts`) ignores `notCreated/notUpdated/notDestroyed`, never assigns a UID (#644), does not re-fetch the created card (so a freshly saved contact shows as a blank row), does not paginate `ContactCard/query`, and has no first-touch gate (#907). The RN vCard parser is a stale fork of the web one: it predates #224 (dates are imported as raw strings, which Stalwart drops), lacks every X- vendor extension, Apple grouped labels, and org-only cards (#701). Composer autocomplete is contacts-only (no groups, no recent recipients, no directory), there is no "add sender to contacts" entry point from the viewer, no way to create a group, no shared/group-account address books, and the trusted-senders settings screen cannot show or remove entries that live in the synced book.
## Findings
### JMAP / store layer
- [x] **Newly created contact appears as a blank row until refresh** — `P2` — `bug` — fixed in 580e010
- What WEB does: `client.createContact` reads the server-assigned id from `created` and then re-fetches the full card with `ContactCard/get` before returning it (`lib/jmap/client.ts:5128-5133`), so the store appends a complete card.
- What RN does: `createContact` returns `res.methodResponses[0][1].created['new-contact']` verbatim (`src/api/contacts.ts:73`) which per JMAP only carries server-set properties (id, created, updated…); the store appends that stub (`src/stores/contacts-store.ts:170-174`). `ContactFormScreen.handleSave` just `goBack()`s (`src/screens/ContactFormScreen.tsx:631-633`) so the list shows an "Unnamed" row with no email until a push/pull refresh; `doDuplicate` even navigates to that stub (`src/screens/ContactDetailScreen.tsx:231-232`).
- Fix hint: in `api/contacts.ts` merge `{ ...contact, addressBookIds, ...created }` or issue a follow-up `ContactCard/get` for the created id (mirror WEB) before returning.
- [x] **`ContactCard/set` errors are swallowed or crash with a TypeError** — `P2` — `bug` — fixed in 580e010
- What WEB does: every set call checks `notCreated` / `notUpdated` / `notDestroyed` and throws the server `description` (`lib/jmap/client.ts:5122-5126, 5155-5160, 5180-5184`); the method-level `error` response is also handled.
- What RN does: `createContact` indexes `.created['new-contact']` without checking `created`/`notCreated` (`src/api/contacts.ts:73`) so a rejected card surfaces as "Cannot read property 'new-contact' of undefined"; `updateContact` (`:76-85`) and `deleteContacts` (`:87-93`) never look at `notUpdated`/`notDestroyed`, so the store applies the optimistic change (`src/stores/contacts-store.ts:176-195`) although the server refused it (e.g. read-only shared book, invalid PartialDate).
- Fix hint: route all three through `methodResult()` (already defined at `src/api/contacts.ts:7-15`) and throw on `notCreated/notUpdated/notDestroyed[id].description`; `bulkDelete` should only drop the ids that were actually destroyed.
- [x] **No UID assigned on contact creation (#644)** — `P2` — `bugfix-parity` — fixed in 580e010
- What WEB does: sends `uid: contactData.uid || 'urn:uuid:<uuid>'` on every create because Stalwart stores the card without one otherwise (`lib/jmap/client.ts:5113-5116`, changelog 1.6.x "Assign a UID to contact cards on creation").
- What RN does: `createContact` forwards the card as-is (`src/api/contacts.ts:65-70`); form, import, duplicate and trusted-sender creation all go through it. Group membership then falls back to the raw id (`src/screens/GroupDetailScreen.tsx:90`), and exported vCards/CardDAV clients get UID-less cards.
- Fix hint: add `uid: contact.uid || \`urn:uuid:${generateUUID()}\`` in `api/contacts.ts createContact` (RN already has `src/lib/uuid.ts`).
- [x] **Contact list silently truncated at the query limit (no pagination)** — `P2` — `bug` — fixed in 580e010
- What WEB does: `fetchPaginatedContacts` walks `ContactCard/query` with `position`/`limit = maxObjectsInGet` until `total` is reached, then batches `ContactCard/get` (`lib/jmap/client.ts:~4985-5022`).
- What RN does: a single `ContactCard/query` with `limit: 1000` (`src/api/contacts.ts:26-40`); the server may clamp the limit lower and anything beyond is never fetched. `getContacts` batches the get correctly (`:42-57`).
- Fix hint: loop on `position` until `ids.length < limit || total reached` in `queryContacts`, mirroring WEB.
- [x] **No first-touch gate: concurrent AddressBook/get + ContactCard/query on cold start (#907)** — `P2` — `bugfix-parity` — fixed in 26a34d5
- Store-side: `fetchAddressBooks`/`fetchContacts` are single-flight and contacts wait for an in-flight AddressBook/get; the request-level `FirstTouchGate` in `src/api/jmap-client.ts` was added by the client agent.
- What WEB does: `FirstTouchGate` serialises the first request per `(collection, account)` so Stalwart's lazy default-collection creation cannot race across cluster nodes (`lib/jmap/first-touch-gate.ts:1-101`, wired at `lib/jmap/client.ts:678,1129`; changelog 1.9.x "Gate first-touch calendar and contacts requests to stop duplicate default calendars (#907)").
- What RN does: `ContactsScreen` fires `fetchAddressBooks()` and `fetchContacts()` in the same effect (`src/screens/ContactsScreen.tsx:120-123`) and `restoreSession` fans out `fetchContacts` + `fetchCalendars` (`src/stores/auth-store.ts:91-93`) with no gate in `src/api/jmap-client.ts` (grep for gate/firstTouch: none). Against a multi-node Stalwart this can mint a duplicate default address book / calendar.
- Fix hint: port `first-touch-gate.ts` into `src/api/` and wrap `jmapClient.request` (key on `AddressBook/`, `ContactCard/`, `Calendar/`, `CalendarEvent/` + accountId); reset it on logout/account switch.
- [x] **Contacts account id: RN uses the mail primary account, not `primaryAccounts[contacts]`** — `P3` — `partial` — fixed in 580e010
- What WEB does: `getContactsAccountId()` prefers `primaryAccounts["urn:ietf:params:jmap:contacts"]` (`lib/jmap/client.ts:4677-4680`).
- What RN does: every call uses `jmapClient.accountId`, resolved from the mail/core primary (`src/api/contacts.ts:18,32,44,63`; `src/api/jmap-client.ts:388-399`).
- Fix hint: add `getContactsAccountId()` to the RN client and use it in `api/contacts.ts`.
- [x] **Shared / group-account address books not listed or writable** — `P2` — `missing` — fixed in 26a34d5
- Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via `originalId`/`accountId`.
- What WEB does: `getAllAddressBooks`/`getAllContacts` iterate every contacts-capable or non-personal account, namespace ids as `accountId:bookId`, tag `isShared/accountName` (`lib/jmap/client.ts:4718-4733, 4756-4793, 5036-5070`); sidebar renders "Shared from X" sections (`components/contacts/contacts-sidebar.tsx:160-176, 562-603`); create/update/move de-namespace ids and pick the right account (`stores/contact-store.ts:473-527, 529-561, 926-978`); changelog 1.8.x "Require an explicit shared-account fallback for contacts and calendars".
- What RN does: single-account only (`src/api/contacts.ts` throughout); `AddressBook` type has no `accountId/isShared` (`src/api/types.ts:369-377`).
- Fix hint: iterate `session.accounts` where `accountCapabilities[contacts]` or `!isPersonal` (as WEB `getContactCapableAccountIds`), namespace ids, and render a "Shared from" section in `ContactsSidebarDrawer`; route writes to the owning account. The `myRights.mayWrite` checks already present in `AddressBookPickerSheet:96` and `ContactsSettings:204,209` will then become meaningful.
- [x] **Deleting a contact does not clean group member references locally** — `P3` — `partial` — fixed in 26a34d5
- Drawer member counts resolve through `selectGroupMembers` in 358bdb9.
- What WEB does: `cleanGroupMembers` strips the deleted card's id/uid/`urn:uuid:` variants from every group's `members` in state on delete/bulk delete (`stores/contact-store.ts:339-366, 571-578, 911-918`).
- What RN does: `deleteContact`/`bulkDelete` just filter the list (`src/stores/contacts-store.ts:185-195`); group counts in the drawer use raw `Object.keys(g.members).length` (`src/components/contacts/ContactsSidebarDrawer.tsx:150`) so they keep counting deleted members until the next full refresh.
- Fix hint: port `cleanGroupMembers`; resolve drawer counts through `selectGroupMembers` like WEB `memberCountByGroup` (`contacts-sidebar.tsx:250-270`).
- [x] **Deleting an address book leaves its contacts in the cache** — `P3` — `partial` — fixed in 26a34d5
- What WEB does: `removeAddressBook` drops contacts filed in that book from state (`stores/contact-store.ts:1053-1056`).
- What RN does: strips the book id but keeps the cards (`src/stores/contacts-store.ts:260-276`), so they show under "All"/"Uncategorized" until a refresh; the confirm text ("will be removed from this book") also implies they survive (`src/components/settings/ContactsSettings.tsx:282`).
- Fix hint: filter out cards whose only book was the deleted one, or call `fetchContacts()` after the destroy.
- [x] **Persisting every contact card (incl. base64 photos) to AsyncStorage** — `P3` — `rn-only-bug` — fixed in 26a34d5
- `media` is stripped from the persisted snapshot; picked photos are downscaled to 512px JPEG with `expo-image-manipulator` in 9800ad0.
- What RN does: `partialize` persists `contacts` and `addressBooks` (`src/stores/contacts-store.ts:355-366`); cards carry inline `data:` photos (`src/screens/ContactFormScreen.tsx:690-691` stores the picked image as base64 with no downscale). Android AsyncStorage's default 6 MB cap will make the write fail for a few hundred photo contacts and the cache silently stops updating.
- Fix hint: strip `media` from the persisted snapshot (re-hydrate photos from the server) and/or downscale photos on pick (WEB caps at 512px JPEG q0.85, `components/contacts/contact-form.tsx:96-126`).
### Address books
- [x] **Set default address book missing** — `P3` — `missing` — fixed in bc4c57e
- `setDefaultAddressBook` (onSuccessSetIsDefault) and the `isDefault`-free update patch landed in 580e010; new contacts/imports prefer the default book via `getDefaultAddressBookId`.
- What WEB does: context-menu "Set as default" uses `AddressBook/set onSuccessSetIsDefault` (`components/contacts/contacts-sidebar.tsx:612-663`, `lib/jmap/client.ts:4851-4869`, `stores/contact-store.ts:1017-1044`), and the create form preselects the default book (`components/contacts/contact-form.tsx:362-367`).
- What RN does: no UI; `updateAddressBook` even forwards `isDefault` as a patch property (`src/api/contacts.ts:117,122`) which WEB notes is read-only and fails the whole update (`lib/jmap/client.ts:4819-4821`). New contacts/imports default to `addressBooks[0]` (`src/screens/ContactFormScreen.tsx:581,591-593`; `src/screens/ContactsScreen.tsx:201-204`; `src/components/settings/ContactsSettings.tsx:54`).
- Fix hint: add `setDefaultAddressBook` (onSuccessSetIsDefault) to `api/contacts.ts` + a row action in `ContactsSettings`; drop `isDefault` from the update patch; prefer `books.find(b => b.isDefault)` as the default target.
- [x] **Default address book can be offered for deletion** — `P3` — `partial` — fixed in bc4c57e
- What WEB does: hides delete for `isDefault`/shared books (`components/contacts/contacts-sidebar.tsx:615`).
- What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error.
- Fix hint: add `!book.isDefault` to the guard.
- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing`
- deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists).
- What WEB does: `shareAddressBook` via `shareWith/<principal>` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`).
- What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257).
- Fix hint: low priority on mobile; if added, reuse whatever calendar-sharing sheet the calendar agent proposes and request `properties` explicitly.
### Contact card / form
- [x] **Clearing a whole field group on edit never reaches the server** — `P2` — `rn-only-bug` — fixed in 9800ad0
- What RN does: `formToPatch` only includes keys that are non-empty (`src/screens/ContactFormScreen.tsx:378-395`), and `updateContact` sends the patch as a JMAP `update` (`src/api/contacts.ts:82`). Removing the last phone/address/note/keyword/anniversary/online link of an existing contact therefore omits the key, which JMAP treats as "unchanged"; the store merge (`contacts-store.ts:178-182`) also keeps the old value, so the UI shows the removed items again.
- Fix hint: in edit mode emit `null` for every collection the form owns that ended up empty (emails, phones, addresses, organizations, titles, anniversaries, onlineServices, personalInfo, notes, keywords, nicknames, speakToAs, calendarUri…) — the same approach WEB uses for `media: null` (`components/contacts/contact-form.tsx:555-558`).
- [x] **Saving the form drops non-photo media (logo, sound)** — `P3` — `rn-only-bug` — fixed in 9800ad0
- What WEB does: copies every non-photo `media` entry through and keeps the original photo key (`components/contacts/contact-form.tsx:544-553`).
- What RN does: rebuilds `media` with only `media.photo` (`src/screens/ContactFormScreen.tsx:359-366,395`), so an imported LOGO/SOUND is deleted on the first edit.
- Fix hint: carry `existing.media` entries with `kind !== 'photo'` into the patch.
- [x] **Anniversary input accepts free text that the server rejects; year-only dates get a fake Jan 1** — `P2` — `rn-only-bug` — fixed in 9800ad0
- `stringToPartialDate` / lossless `partialDateToString` live in `src/lib/contact-utils.ts` (580e010).
- What WEB does: `<input type="date">` plus `stringToPartialDate` always yields a structured PartialDate (`components/contacts/contact-form.tsx:192-206, 525-528`); changelog 1.9.x #224 stresses that Stalwart drops non-PartialDate anniversaries.
- What RN does: `stringToAnniversaryDate` returns the raw string for anything other than `YYYY-MM-DD` / `--MM-DD` (`src/screens/ContactFormScreen.tsx:116-128`), so "1990-05", "May 5" or a `YYYY-MM-DDT…` value is sent as a string → Stalwart rejects the set (and, via the previous finding, the failure is invisible). `partialDateToString` pads a year-only or year-month PartialDate with `01` (`:110`), so merely opening and saving a contact with `{year:1990}` rewrites it to 1990-01-01 — this feeds the birthday calendar with a wrong day.
- Fix hint: reject/normalise input to PartialDate (support `YYYY`, `YYYY-MM`, `--MM-DD`, `---DD` like WEB `parseVcardDate`, `lib/vcard.ts:360-394`), and make `partialDateToString` lossless (`1990`, `1990-05`).
- [x] **Organization-kind cards (#701)** — `P3` — `partial` — fixed in 9800ad0
- Detail subtitle no longer repeats the org name (1cef53a).
- What WEB does: person/organization toggle, org name validates as the identity, `name.full` filled from org, `kind: 'org'` written, detail hides the duplicated org line (`components/contacts/contact-form.tsx:269-277, 443-449, 491-495, 560-563, 696-735`; `components/contacts/contact-detail.tsx:178-180`).
- What RN does: no toggle/kind; validation requires given/surname/full or an email (`src/screens/ContactFormScreen.tsx:607-612`), so an org card needs its name typed into "Display name" and an org-only card without email cannot be created; detail subtitle repeats the org under the heading when they are equal (`src/screens/ContactDetailScreen.tsx:136`).
- Fix hint: add a Person/Organization pill in the Identity section; when org: skip name components, set `name.full = org`, `kind: 'org'`, accept org name as identity; hide the equal subtitle.
- [x] **Photo data URIs from Stalwart not normalised (#307)** — `P2` — `bugfix-parity` — fixed in 580e010
- What WEB does: `normalizeContactPhotoUri` rewrites `data:base64,…` / `data:;base64,…` to `data:image/jpeg;base64,…` in `getContactPhotoUri` and in the form (`stores/contact-store.ts:157-180`; `contact-form.tsx:376`).
- What RN does: `getContactPhotoUri` returns `media.uri` raw (`src/lib/contact-utils.ts:71-77`), used by `ContactListRow:38`, `ContactDetailScreen:114` and the form (`ContactFormScreen.tsx:233-237`); RN `Image` cannot decode a media-type-less data URI so such photos render blank, and re-saving writes the malformed URI back.
- Fix hint: port `normalizeContactPhotoUri` into `contact-utils.ts` and apply in `getContactPhotoUri` + `contactToForm`.
- [x] **Picked photos are stored full-size** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: downscales to 512px JPEG before embedding (`components/contacts/contact-form.tsx:96-126`) and rejects files >10 MB.
- What RN does: `ImagePicker` with `quality: 0.8`, no dimension cap, base64 straight into the card (`src/screens/ContactFormScreen.tsx:680-695`).
- Fix hint: use `expo-image-manipulator` to resize to ≤512px before reading base64.
- [x] **Duplicate contact keeps the source UID** — `P2` — `rn-only-bug` — fixed in 1cef53a
- What WEB does: strips `id`, `uid`, `created`, `updated` before creating the copy (`components/contacts/contacts-app.tsx:448-451`).
- What RN does: `doDuplicate` only strips `id/addressBookIds/created/updated` (`src/screens/ContactDetailScreen.tsx:216-222`), so the copy is created with the same `uid`; group membership by uid then matches both cards and CardDAV/vCard consumers see two cards with one UID.
- Fix hint: also drop `uid` (and let `createContact` mint a fresh one once the #644 fix lands).
- [x] **Gender vocabulary differs from WEB and from the vCard exporter** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: `masculine | feminine | other | none | unknown` (vCard SEX mapping, `components/contacts/contact-form.tsx:1062-1069`), which `grammaticalGenderToVcardSex` understands (`lib/vcard.ts:34-48`).
- What RN does: `masculine | feminine | common | neuter | animate | inanimate` (`src/screens/ContactFormScreen.tsx:437-445`); values other than masculine/feminine export as an empty GENDER and display differently across clients.
- Fix hint: align option lists (or accept both on display) so the same card renders the same value in both apps.
- [x] **Online service `label` and multiple nicknames not editable** — `P3` — `partial` — fixed in 9800ad0
- Detail shows every nickname (1cef53a).
- What WEB does: online-service entries carry `label` (`contact-form.tsx:24-28, 517-523`); detail shows all nicknames (`contact-detail.tsx:194-196`).
- What RN does: `OnlineDraft` has only `uri/service` (`src/screens/ContactFormScreen.tsx:42, 332-338`), and the form/detail read only the first nickname (`:168-170, 269-270`; `src/lib/contact-utils.ts:171-174`). Saving a card with several nicknames collapses them to one.
- Fix hint: keep unknown nicknames when rebuilding `nicknames`, add a label field to the online row.
- [x] **Display name includes the middle name (differs from WEB sorting/grouping)** — `P3` — `partial` — fixed in 580e010
- What WEB does: `given + surname` only, then `name.full`, nickname, org, email (`stores/contact-store.ts:125-150`).
- What RN does: `given + middle + surname` (`src/lib/contact-utils.ts:13-14`). Harmless on its own but sort order, dedupe keys and `#672`-style name sanitising diverge between clients.
- Fix hint: drop `middle` from the join or make it a display-only concern.
### List, search, sort
- [x] **Alphabetical index buckets every non-ASCII initial under "#"** — `P3` — `bug` — fixed in 358bdb9
- What WEB does: `\p{L}` test + `Intl.Collator(locale, sensitivity base)` so Ä/É/Ł get their own sections (`components/contacts/contact-list.tsx:242-262`).
- What RN does: `/[A-Z]/.test(letter)` (`src/screens/ContactsScreen.tsx:53-54`), so "Éric" and "Ölçer" land in "#".
- Fix hint: use `/\p{L}/u` and `localeCompare` (Hermes supports Unicode property escapes).
- [x] **"Uncategorized" means "no address book" in RN but "no category" in WEB, and its count is hard-coded 0** — `P3` — `bug` — fixed in 358bdb9
- `selectUncategorized` (no keywords) in 26a34d5; the drawer row moved under Tags as "No category" with a live count.
- What WEB does: "No category" = contacts without keywords, with a live count (`components/contacts/contacts-sidebar.tsx:246-248, 526-541`; `contacts-app.tsx:283-285`).
- What RN does: filter = contacts with no `addressBookIds` (`src/stores/contacts-store.ts:439-440`; `src/screens/ContactsScreen.tsx:146-149`), drawer row passes `count={0}` (`src/components/contacts/ContactsSidebarDrawer.tsx:128-136`) so the count never shows; on Stalwart every card has a book so the view is always empty.
- Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count.
- [ ] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing`
- deferred: optional; free-text search only.
- What WEB does: filter drawer with tri-state chips (`components/contacts/contact-list.tsx:18-76, 154-228, 350-456`; changelog 1.5.x "Revamp contact detail view with filters").
- What RN does: free-text search only (`src/lib/contact-utils.ts:216-241`).
- Fix hint: optional; a filter sheet reusing the WEB predicate block would do.
- [x] **No select-all / bulk export in multi-select** — `P3` — `partial` — fixed in 358bdb9
- What WEB does: select-all toggle, range select, bulk export, bulk add-to-group (`components/contacts/contact-list.tsx:283-305, 458-502`).
- What RN does: long-press multi-select with delete / move / tag only (`src/screens/ContactsScreen.tsx:164-226, 282-313`).
- Fix hint: add "Select all visible" and "Export selected" (reuse `contactsToVCard` + share flow from `ContactsSettings:56-78`).
- [x] **Rename category (keyword) missing** — `P3` — `missing` — fixed in 358bdb9
- `renameKeyword` in the store (26a34d5); long-press a tag in the drawer to rename.
- What WEB does: keyword context menu → rename, rewrites `keywords` on every affected card (`components/contacts/contacts-sidebar.tsx:680-698`; `stores/contact-store.ts:1087-1116`).
- What RN does: tags can be assigned/removed per contact and in bulk (`TagAssignSheet`, `contacts-store.ts:213-235`) but not renamed.
- Fix hint: long-press on a Tags row in `ContactsSidebarDrawer` → prompt → loop `updateContact` like WEB `renameKeyword`.
- [x] **Add-to-group from the contact itself missing** — `P3` — `partial` — fixed in 1cef53a
- What WEB does: detail "More" menu and list context menu offer "Add to group" (creating a group if none) (`components/contacts/contacts-app.tsx:438-446`; `contact-detail.tsx:150-152`).
- What RN does: membership is only editable from `GroupDetailScreen` (`src/screens/GroupDetailScreen.tsx:86-98`); the detail "More" sheet has Duplicate/Move/Export/Delete only (`src/screens/ContactDetailScreen.tsx:247-270`).
- Fix hint: add a "Add to group" item that opens a group picker (list of `selectGroups`) and calls the same `members` patch as `GroupDetailScreen.addMembers`.
### Groups
- [x] **No way to create a contact group** — `P2` — `missing` — fixed in 9800ad0
- "New group" row in the drawer and long-press on the header "+" (358bdb9); `createGroup`/`memberIds` route param.
- What WEB does: "+" menu → "Create group" with name + member picker (`components/contacts/contacts-sidebar.tsx:299-305`; `contact-group-form.tsx`; `stores/contact-store.ts:734-760`).
- What RN does: `ContactForm` supports `asGroup` (`src/screens/ContactFormScreen.tsx:554,627`) but the only caller passes an existing `contactId` for editing (`src/screens/GroupDetailScreen.tsx:138`; grep `asGroup` in `src/`: no other call site). The Contacts header "+" always opens a plain new-contact form (`src/screens/ContactsScreen.tsx:336-343`).
- Fix hint: add "New group" to the header/drawer that navigates to `ContactForm { asGroup: true }` with a members picker (`ContactPickerSheet`), or a dedicated small screen that creates `{ kind:'group', name:{components:[{kind:'given', value}]}, members }`.
- [x] **Group edit reuses the full person form (name split into first/last, photo, emails…)** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: dedicated group form with name + searchable member checklist (`components/contacts/contact-group-form.tsx:80-196`).
- What RN does: `GroupDetailScreen` → `ContactForm asGroup` shows Prefix/First/Middle/Last/Nickname, Email, Phone… (`src/screens/ContactFormScreen.tsx:747-813`); a group named "Sales Team" appears in "First name".
- Fix hint: when `asGroup`, render a single "Group name" field bound to `given` and hide the person-only sections.
- [x] **Group compose: RN expands to individual recipients; no To/Cc/Bcc choice** — `P3` — `partial` — fixed in 1cef53a
- Dedupe via `getGroupRecipients` (26a34d5); a single group chip is up to the composer agent.
- What WEB does: hands the composer one expandable group chip (RFC 5322 group syntax) with a To/Cc/Bcc submenu (`components/contacts/contacts-app.tsx:561-587`; `contact-group-detail.tsx:69-87`; changelog 1.7.x "Contact groups as single expandable recipient chips").
- What RN does: "Email all" pushes one recipient per member into `prefillTo` (`src/screens/GroupDetailScreen.tsx:72-84`). Functionally fine; dedupe by email is missing (two members sharing an address are both added).
- Fix hint: dedupe case-insensitively; a group chip is optional unless the composer agent adds group chips.
### Composer autocomplete
- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing`
- store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); the composer uses `getAutocomplete` and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (`searchRecipients` has no caller), directory principals, and the calendar's `ParticipantInput`, which still offers contacts only.
- What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
- What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`).
- Fix hint: add `recentRecipients` (scan Sent `to/cc`, 300 msgs) to the contacts store, include groups as a pickable entry that expands to member emails on pick, and reuse `sanitizeDisplayName/splitMailbox`-style cleanup for names that contain a mailbox (#672).
### Viewer integration
- [x] **"Add sender to contacts" from the message view missing** — `P2` — `missing` — done in d2ed27f (the viewer's address sheet, `src/components/email/AddressActionSheet.tsx`, offers "Add to contacts" and "Open contact")
- contact-side done: `ContactForm { prefill }` seeds email + given/surname (9800ad0), `findContactByEmail` in the store (26a34d5); the sender sheet in `EmailThreadScreen` belongs to the viewer agent.
- What WEB does: contact sidebar/popover on sender click with "Add to contacts" that creates a card with given/surname split from the display name (`components/email/email-viewer.tsx:5350-5366, 558-564`); also the mobile recipient popover (#306) and `/contacts/new?addEmail=` deep link (`components/contacts/contacts-app.tsx:182-198`).
- What RN does: no contact lookup or "add" action in `EmailThreadScreen` (grep contact/ContactForm: only `SenderAvatar` at `:794`). Only `ContactForm`'s `route.params` support `contactId/addressBookId/asGroup` (`src/navigation/types.ts:24`) — no email/name prefill.
- Fix hint: add `prefill?: { email; name }` to the `ContactForm` route, and a sender-tap sheet in `EmailThreadScreen` with "Add to contacts" / "Open contact" (lookup by email in `useContactsStore`).
- [x] **Sender-name tap → contact popup missing** — `P3` — `missing` — done in d2ed27f (tapping a sender or recipient opens `AddressActionSheet`, which shows the matching contact and "Open contact")
- deferred: viewer agent (same sheet as above; `findContactByEmail` + `ContactDetail { contactId }` are ready).
- What WEB does: clicking the sender name opens the contact popup with details/actions (changelog 1.4.x "Show contact popup when clicking the sender name", `email-viewer.tsx:392-455`).
- What RN does: none (see previous item).
- Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`.
- [ ] **Contact deep links (`/contacts/<id>[/edit]`, `/contacts/new`)** — `P3` — `missing`
- partly: `/contacts/<id>` opens the contact since 5802cae; `/contacts/new` and `/contacts/<id>/edit` only open the Contacts tab.
- What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`).
- What RN does: `src/navigation/linking.ts` (5802cae) maps `/contacts/<id>` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`.
### vCard import / export (`lib/vcard.ts` vs `src/lib/vcard.ts`)
- [x] **Imported dates are raw strings, not RFC 9553 PartialDate (#224) — birthdays vanish on Stalwart** — `P1` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `parseVcardDate` converts BDAY/ANNIVERSARY/DEATHDATE/X-ABDATE (basic + extended forms, reduced accuracy, `VALUE=text` skipped) into `{year, month, day}` and drops undecodable dates (`lib/vcard.ts:360-394`, `addAnniversary` in `buildContact` ~`:480-490`; changelog 1.9.x "Import vCard dates as RFC 9553 PartialDate and map common X- extensions (#224)").
- What RN does: `card.anniversaries.a0 = { kind: 'birth', date: val }` with the raw string (`src/lib/vcard.ts` BDAY case ~`:687`, ANNIVERSARY ~`:701`, DEATHDATE ~`:709`), so every imported birthday is rejected by Stalwart (the whole `ContactCard/set` create fails with invalidProperties, and because of the error-handling finding above the failure is reported only as a generic "import failed for one contact" warning) — i.e. importing a vCard with a BDAY loses the entire contact, not just the date.
- Fix hint: copy `parseVcardDate` + `addAnniversary` from WEB into RN `vcard.ts`; add a test with `BDAY:19850412`, `BDAY:--0412`, `BDAY;VALUE=text:circa 1800`.
- [x] **Vendor X- extensions not imported (X-ABDATE, X-ABRELATEDNAMES, X-SPOUSE/MANAGER/ASSISTANT, X-MAIDENNAME, X-GENDER, X-ANDROID-CUSTOM, X-AIM/X-SKYPE/… IM handles)** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `X_ONLINE_SERVICES` map → onlineServices, `RELATION_LABELS`/`ANDROID_RELATION_TYPES` → relatedTo, `ANDROID_EVENT_KINDS` → anniversaries, X-MAIDENNAME → `surname2`, X-GENDER → grammaticalGender (`lib/vcard.ts:250-323, ~1009-1100, ~1113-1120`); export writes `X-MAIDENNAME` and `X-ABDATE` for `other` anniversaries (`~1173-1178, ~1280-1283`).
- What RN does: `default: break` (`src/lib/vcard.ts:~858`); none of the maps exist (function list: no `X_ONLINE_SERVICES`, `RELATION_LABELS`, `ANDROID_*`). Export skips `surname2` and `other` anniversaries (`~:1025-1045`).
- Fix hint: port the maps and the extension cases verbatim; both files otherwise share structure so a near-mechanical diff apply works.
- [x] **Apple grouped labels (`item1.TEL` + `item1.X-ABLABEL`) are lost** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `splitGroupPrefix` keeps the group, `collapseProperties` folds a sibling `X-ABLABEL` into an `X-ABLABEL=` param, `decodeAppleLabel` strips `_$!<…>!$_` (`lib/vcard.ts:116-140, 413-434`); labels then land on emails/phones/addresses/online services.
- What RN does: `stripGroupPrefix` discards the group (`src/lib/vcard.ts:120-131`) and properties are keyed by `keyPart` only (`:334-339`), so iOS/macOS exports lose every custom label and the `X-ABLABEL` lines are silently dropped (`:~854`).
- Fix hint: port `splitGroupPrefix` + `collapseProperties` + `RawProperty` list handling in `parseVCard`.
- [x] **Org-only vCards are dropped on import and exported without FN (#701)** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `hasOrg` counts as identity in `buildContact` (`lib/vcard.ts:~1123-1127`); `generateSingleVCard` falls back to the org name for the mandatory FN (`~:1158-1162`).
- What RN does: `if (!hasName && !hasEmail && card.kind !== 'group') return null` (`src/lib/vcard.ts:~893-895`) so a company card with only ORG+TEL is skipped; export emits no `FN`/`N` when there is no personal name (`~:932-936`), producing an invalid vCard (FN is required by RFC 6350) that the RN importer itself would then reject.
- Fix hint: apply the two WEB lines.
- [x] **Only one NICKNAME survives import** — `P3` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `card.nicknames[\`n${idx}\`]` (`lib/vcard.ts` NICKNAME case).
- What RN does: `card.nicknames.n0 = { name: val }` overwrites (`src/lib/vcard.ts` NICKNAME case ~`:548`).
- Fix hint: index like WEB.
- [x] **Import target defaults to the first book and cannot be changed from Settings** — `P3` — `partial` — fixed in bc4c57e
- What WEB does: `client.createContact` without `addressBookIds` files into the `isDefault` book (`lib/jmap/client.ts:5095-5101`).
- What RN does: `ContactsSettings` passes `books[0]` with no `onChangeTarget` (`src/components/settings/ContactsSettings.tsx:54, 269-275`); `ContactsScreen` allows changing the target but also starts at `addressBooks[0]` unless a book category is active (`src/screens/ContactsScreen.tsx:201-204, 476, 480-486`).
- Fix hint: prefer `isDefault`; pass `onChangeTarget` in settings too.
- [x] **Import keeps the source UID (fine) but nothing mints one when absent** — covered by the #644 finding above; also `importContacts` swallows per-card failures with only a `console.warn` (`src/stores/contacts-store.ts:206-208`) so "Imported 0 contacts" is the only feedback. `P3` — add a failure count to the result sheet. — fixed in 26a34d5
- The result sheet now shows how many cards failed to import.
### Trusted senders
- [x] **Trusted-senders settings show only the local list; synced book entries are invisible and cannot be removed** — `P2` — `partial` — fixed in 78114b6
- What WEB does: modal shows the address-book entries when the feature is on, adds/removes through `addToTrustedSendersBook`/`removeFromTrustedSendersBook`, and settings show the synced count (`components/trusted-senders-modal.tsx:22-48`; `components/settings/content-senders-settings.tsx:25-40, 77-82`).
- What RN does: `ContentSendersSettings` reads/writes only `settings-store.trustedSenders` (`src/components/settings/ContentSendersSettings.tsx:19-21, 100-144`); `removeFromTrustedSendersBook` exists in the store (`src/stores/contacts-store.ts:323-333`) but has no caller. A sender trusted from the viewer is written to both places (`src/components/EmailBodyView.tsx:644-651`), so deleting it in settings only removes the local copy and the book keeps trusting it (`EmailBodyView.tsx:513-516`).
- Fix hint: in the modal, list `trustedSenders ∪ trustedSenderEmails`, mark book entries, and call `removeFromTrustedSendersBook` (+ local remove) on delete; show the union count.
- [x] **`trustedSendersAddressBook` setting is dead (`false`, no setter, no toggle) — book is consulted unconditionally** — `P3` — `partial` — fixed in 78114b6
- Setting is `boolean | null` with a "Sync trusted senders to address book" toggle. Until d89e331 it switched itself on for a contacts-capable account, and the viewer then trusted every contact in every book; it now stays off until the user turns it on, and only the Trusted Senders book counts (the webmail still auto-enables it).
- What WEB does: `null` until the account proves contacts support, then auto-`true` (`stores/auth-store.ts:380-385`, `stores/settings-store.ts:361,593`); toggle in settings (`content-senders-settings.tsx:77-82`); the book is only loaded/used when enabled (`mail-app.tsx:199-203`, `email-viewer.tsx:1705`).
- What RN does: default `false`, no setter (`src/stores/settings-store.ts:106,239`); `EmailBodyView` loads the book regardless and ORs `trustedSenderEmails` into the trust check (`:480-485, 513-516`); the `addressBookEmails` branch (treats *every* contact as trusted) is gated on the dead flag (`:486-498`) so it never runs.
- Fix hint: either drop the flag and the dead `addressBookEmails` branch, or expose a "Sync trusted senders to address book" toggle that gates the passive load and the `addToTrustedSendersBook` write.
- [x] **Race: trusting a sender while the passive book load is in flight silently fails** — `P2` — `rn-only-bug` — fixed in 26a34d5
- What WEB does: single-flight promise shared by all callers (`stores/contact-store.ts:184-185, 1156-1199`), so `addToTrustedSendersBook` awaits the load and then has a `bookId`.
- What RN does: `loadTrustedSendersBook` returns immediately when `trustedSendersLoading` is true (`src/stores/contacts-store.ts:279`); `addToTrustedSendersBook` then reads `bookId === null` and throws (`:312-317`), which the viewer swallows (`EmailBodyView.tsx:650`). The passive load is kicked off by the same screen a moment earlier (`:483-485`), so the very first "Trust sender" after app start is likely to hit this.
- Fix hint: keep the in-flight promise in a module variable and return it; if the passive load found no book and `createIfMissing` is true, create it after the await.
- [x] **Duplicate "Trusted Senders" books are not consolidated and the pick is non-deterministic (#730)** — `P3` — `bugfix-parity` — fixed in 26a34d5
- Deterministic pick (sorted by id); consolidation not ported.
- What WEB does: sorts matches by id, uses the first, merges the rest and deletes them (`stores/contact-store.ts:1169-1187, 208-245`); `getAddressBooks({throwOnError})` prevents minting on a failed fetch.
- What RN does: `books.find(b => b.name === …)` (`src/stores/contacts-store.ts:283`) — order-dependent; if the web already created a duplicate pair, RN may trust a different subset than web. RN only creates on explicit add (`:284-290`) and `fetchAddressBooks` throws on error, so RN itself does not mint duplicates.
- Fix hint: sort by id before picking; optional consolidation.
- [x] **Trusted-sender card stores no display name; "Name <email>" input not parsed** — `P3` — `partial` — fixed in 26a34d5
- What WEB does: parses `Name <email>` and stores `name.full` (`stores/contact-store.ts:1201-1225`).
- What RN does: lower-cases the whole string and stores `emails` only (`src/stores/contacts-store.ts:308-321`); a `Name <email>` string (e.g. typed in settings) becomes an invalid address.
- Fix hint: reuse the WEB regex.
- [x] **Auto-trust reply recipients missing** — `P3` — `missing` — done in 5b72c4d (area 04)
- deferred: composer agent; `addToTrustedSendersBook` now accepts `Name <email>` and never races the passive load (26a34d5).
- What WEB does: on reply/replyAll every To/Cc recipient is added to the trusted book (`components/email/email-composer.tsx:2284-2292`; changelog 1.4.x "Auto-add recipients to trusted senders when replying").
- What RN does: `ComposeScreen` has no trusted-sender code (grep `trusted`: none).
- Fix hint: after a successful send in reply mode call `addToTrustedSendersBook` (+ `addTrustedSender`) for each outgoing To/Cc.
### Activity / misc
- [x] **Contact activity: upcoming events come only from the already-loaded calendar cache** — `P3` — `partial` — fixed in 0e51465
- What WEB does: queries the server for the next 365 days (`components/contacts/contact-activity.tsx:133-169`).
- What RN does: filters `useCalendarStore().events` (`src/components/contacts/ContactActivity.tsx:90-91, 127-142`), i.e. only the range the calendar tab happened to load; on a fresh start it is empty.
- Fix hint: call the RN calendar API for `[now, now+365d]` with a participant filter, falling back to the cache.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Address book list/create/rename/delete with `myRights` checks (`src/components/settings/ContactsSettings.tsx`, `AddressBookPickerSheet`); inline "New address book…" in the move sheet (WEB #415).
- Move contacts between books (single from detail, bulk from list) via `addressBookIds` patch (`contacts-store.ts:237-241`) — RN equivalent of WEB drag-and-drop.
- Group by first letter with sticky headers + settings toggle (`ContactsScreen.tsx:158-162`, `ContactsSettings.tsx:132-137`); sort by display name, or by last name since 1827748 (#963); the sort no longer re-runs on every search keystroke since e6be892.
- Search over name/emails/phones/orgs (RN also matches keywords).
- Detail screen renders every WEB field block: emails/phones with contexts+features+labels, addresses (components, flat, timezone), org/units/titles/roles, anniversaries with age, gender/pronouns, languages, personalInfo, online services, calendar URIs, crypto keys, categories, relatedTo, notes, address book names, created/updated; quick actions call/SMS/mail/share; copy via long-press.
- Contact form: prefix/given/middle/surname/suffix, nickname, display name (`name.full`), multiple emails/phones with context + phone feature pills, addresses as RFC 9553 components, multiple orgs (org/department/title/role), anniversaries with native date picker, online services, personalInfo w/ level, gender+pronouns, calendar URIs, categories (chips + suggestions from existing keywords), multiple notes, photo pick/remove, address book pick, dirty-state discard confirm, email regex validation, `media: {}` when photo removed (equivalent of WEB `media: null`).
- `name.full` display fallback (#179), `getContactPrimaryEmail` with `pref`. `name.full` (the vCard FN) is written on every form, "add sender" and import save since a734d0a (#430).
- Group detail: member list resolved via id/uid/`urn:uuid:` (`contacts-store.ts:401-415`), add members via `ContactPickerSheet`, remove member, delete group, email all.
- Tags (keywords) = WEB categories: drawer section with counts, filter by tag, bulk assign via `TagAssignSheet`, per-contact edit in form.
- vCard import sheet: file picker, 5 MB cap, parse, duplicate detection by email (`detectDuplicates` identical), select all/none, target book, result count; export all (settings) and single (detail) with share sheet; multi-vCard files; QUOTED-PRINTABLE + charset decode (RN has a Hermes-safe `decodeBytes`), RFC 6868 params, KIND/MEMBER, PHOTO/LOGO/SOUND, RFC 6474/6715/8605/9554 properties.
- Contact activity: recent emails via `from/to` OR filter (limit 5), opens the thread.
- Trusted senders: same book name constant, passive load, add on "Trust sender" in the viewer, `isTrustedAddressBookSender` check.
- Contacts tab hidden when the session lacks `urn:ietf:params:jmap:contacts` (`src/lib/capabilities.ts:29-31`, `App.tsx:170-179`) — WEB 1.8.x "Hide Contacts and Calendars when the account lacks the JMAP capability".
- Push `StateChange` for `AddressBook`/`ContactCard` triggers refetch (`contacts-store.ts:152-168`, `App.tsx:390-396`); contacts cache persisted for instant render; selected category persisted.
- Contact store reset on logout/account switch (`src/stores/auth-store.ts:58,76,113,179,379`) — the RN counterpart of the 583e20d9 namespacing fix (single-account store, so no id-form flip).
## N/A on mobile
- Pro multi-account aggregation (`hooks/use-pro-multi-account-contacts.ts`, `fetchAllAccountsContacts`, `::` id namespacing) — Pro shell only; RN keeps one account active and resets the store on switch.
- Local-storage (non-JMAP) contact fallback (`supportsSync === false`, `local-<uuid>` ids) — RN hides the tab when contacts are unsupported.
- Drag-and-drop to address books / categories, right-click context menus, Ctrl/Shift range selection, resizable panes, keyboard shortcuts — replaced by long-press multi-select + sheets.
- Print contact (`components/contacts/contact-print.ts`).
- Plugin contact APIs and the `contact-cryptokeys` plugin slot.
- `mailto:`/`tel:` handoff differences (RN uses `Linking`).
- Native issue #34 (device contacts / calendar sync adapters) is a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.
+181
View File
@@ -0,0 +1,181 @@
# Filters (Sieve), Vacation responder, Files (FileNode)
## Summary
Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests as of ~1.7.2, so external/Nextcloud/opaque handling, `# Rule:` dedupe, brace-safe parsing and the store logic are at parity. It is missing the 1.7.3 "extended rules" change (multi-value conditions + attachment field + `mime` require); because both clients share one active script, a rule authored on WEB with those features either makes RN fall back to "opaque" mode or, worse, gets silently rewritten as `header :contains "undefined" ...` the next time RN saves anything. RN's FilterRuleModal also never re-seeds its state (always-mounted Modal), so editing a second rule shows and saves the first rule's fields. Vacation: RN has the basic JMAP VacationResponse flow but no HTML body, sends non-UTC `fromDate/toDate`, and has hard-coded English. Files: README's "UI stubs only" is stale - RN has real FileNode browsing (folder = blobId==null), create folder, single-file upload, rename, delete, download/share, RFC 9670 sharing with a principal picker and "shared with me" - but reads the non-existent `updated` property (dates always blank, sort-by-modified is a no-op), does not percent-decode WebDAV-created names, does not reset on account switch, and lacks preview, move/copy/paste/duplicate, favorites/recent, search, upload progress/limits and multi-file upload.
## Findings
### Filters (Sieve)
- [x] **RN generator corrupts WEB-authored attachment rules on any save** — fixed in 1c6aa68 — `P1` — `bug` (missing feature with data-loss side effect)
- What WEB does: `attachment` field with `has_any`/`has_type` comparators emits `header :mime :anychild ...` and adds `mime` to `require` (`lib/sieve/generator.ts:48-67`, `:132`; types `lib/jmap/sieve-types.ts:16-30`; changelog 1.7.3 "Extended filter rules — attachment field and multi-value conditions").
- What RN does: `src/lib/sieve/types.ts:21-26` has no `attachment` field. A single-value attachment rule from WEB passes RN's metadata validation (`src/lib/sieve/parser.ts:40-44` only requires strings) and is listed as an editable Bulwark rule. RN's generator has no attachment branch: `src/lib/sieve/generator.ts:36-62` falls through to `HEADER_MAP['attachment']` → `undefined`, emitting `header :contains "undefined" ""` and never adding `mime` to requires. Every RN save regenerates the whole script (`src/stores/filter-store.ts:102-118`: toggle, reorder, add, delete), so one toggle on the phone silently breaks the user's attachment rules server-side. WEB then re-reads that broken condition as a generic header rule.
- Fix hint: port the WEB generator/parser/types diff (see next item) - `git diff` between `lib/sieve/generator.ts` and `src/lib/sieve/generator.ts` is ~60 lines. Until then, at minimum make `generateCondition` throw / keep `rawBlock` for unknown fields instead of emitting `"undefined"`.
- [x] **Multi-value conditions (string[] values) unsupported → WEB scripts become opaque on RN** — fixed in 1c6aa68 — `P1` — `missing`
- What WEB does: `FilterCondition.value: string | string[]` (`lib/jmap/sieve-types.ts:40-49`); generator emits Sieve string-lists via `toValueList`/`formatStringArg` (`lib/sieve/generator.ts:15-32`, `57-90`); parser accepts list literals with `parseValueTail`/`classifyMatches` (`lib/sieve/parser.ts:344-400`, `455-484`) and validates array values (`parser.ts:37-44`); modal splits comma input on blur/save (`components/filters/filter-rule-modal.tsx:59-78`, `131-147`, `353-381`); summaries join with the locale's "or" (`components/settings/filter-settings.tsx:46-51`, `114-125`).
- What RN does: `src/lib/sieve/types.ts:38` `value: string`; `src/lib/sieve/parser.ts:40-44` rejects array values so `parseScript` returns `OPAQUE` (`parser.ts:681`). Result: after a WEB user types "a, b" in one condition, RN shows the "edited outside the visual builder" banner with only raw editing and "Reset to visual builder" (which regenerates an empty script → all rules lost, `src/stores/filter-store.ts:188` + `saveFilters`). If a list value somehow reached the RN modal, `cond.value.trim()` at `src/components/filters/FilterRuleModal.tsx:159` would throw and `<Input value={condition.value}>` (`:271`) would receive an array.
- Fix hint: apply the WEB diff to `types.ts`, `parser.ts` (`isValidCondition`, `parseValueTail`, `classifyMatches`, body regex), `generator.ts` (`toValueList`, `formatStringArg`, size scalar), then add comma-split/join helpers to `FilterRuleModal`, array-aware `summarizeRule`, and locale keys `value_placeholder_multi`, `attachment_type_placeholder`, `condition_fields.attachment`, `comparators.has_any/has_type` (present in WEB `locales/en/common.json`, absent in RN `locales/en/common.json` settings.filters).
- [x] **FilterRuleModal never re-initialises: editing rule B shows/saves rule A's fields** — fixed in 1c6aa68 — `P1` — `rn-only-bug`
- What WEB does: `FilterRuleModal` is conditionally mounted (`components/settings/filter-settings.tsx:697-707`), so `useState(rule?.name)` etc. run fresh per open.
- What RN does: `src/components/settings/FilterSettings.tsx:359-365` always renders `<FilterRuleModal visible={showRuleModal} rule={editingRule} .../>`; the modal seeds `name/matchType/conditions/actions/stopProcessing` only in `useState` initialisers (`src/components/filters/FilterRuleModal.tsx:74-82`) and has no reset effect. Opening "Add Rule" after editing a rule shows the previous rule's content; opening rule B after rule A shows A's fields but saves them under B's `id` (`:170`), overwriting B. Compare `SieveEditorSheet.tsx:33-39`, which does re-seed on `visible`.
- Fix hint: either mount the modal conditionally (`{showRuleModal && <FilterRuleModal .../>}`) or add a `useEffect([visible, rule])` that resets all local state like `SieveEditorSheet` does.
- [x] **Vacation-active banner is not actionable** — fixed in 3ffcef2 — `P3` — `partial`
- What WEB does: banner is a button that switches the settings tab to Vacation (`components/settings/filter-settings.tsx:483-507`, `vacation_configure` label).
- What RN does: plain `View` with title/description only (`src/components/settings/FilterSettings.tsx:231-241`); no "Configure →" affordance.
- Fix hint: accept an `onOpenVacation` prop from `SettingsScreen` and wrap the banner in a `Pressable`.
- [x] **Expanded ("IF / THEN") visual rule summary missing** — fixed in 3ffcef2 — `P3` — `partial`
- What WEB does: `VisualRuleSummary` renders condition/action chips with IF/THEN labels and the match-type hint when `expandedFilterView` is on (`components/settings/filter-settings.tsx:89-156`, `612-616`); changelog 1.4.6 "Add expanded visual view for filter rules".
- What RN does: the `filtersExpandedView` toggle exists (`src/components/settings/FilterSettings.tsx:350-355`) but only lifts the `numberOfLines` clamp on the same one-line summary (`:268`, `:292`); `if`/`then`/`match_all_conditions` locale keys are unused.
- Fix hint: add a `VisualRuleSummary` component rendering rows of chips (all conditions, all actions) when `expandedView` is true.
- [x] **Per-account (shared/group) filters not supported** — `P3` — `missing` (depends on whether RN gets managed shared-account settings at all) — done in a86cd1d (tap a shared account in Account settings to manage its filters; the rule editor offers that account's folders)
- What WEB does: `filter-store` tracks `availableAccounts`/`selectedAccountId` and every Sieve call takes an `accountId` (`stores/filter-store.ts:62-134`); `FilterSettings` scopes to `managedAccountId` and fetches that account's mailboxes for the move target list (`components/settings/filter-settings.tsx:188-232`, `248-255`); client `getSieveAccounts()` (`lib/jmap/client.ts:4465-4469`).
- What RN does: `src/api/sieve.ts:20-23` always uses the primary Sieve account; store has no account selection (`src/stores/filter-store.ts:32`). No `managedAccount` concept exists in RN (`grep -ri managedAccount src` → none).
- Fix hint: only if the Accounts area adds shared-account management: thread an optional `accountId` through `api/sieve.ts` and the store's `fetchFilters/saveFilters`.
- [x] **Sieve test files have mojibake in test names** — fixed in 1c6aa68 — `P3` — `rn-only-bug`
- What WEB does: `lib/sieve/__tests__/*.test.ts` use "→" in `it(...)` names.
- What RN does: `src/lib/sieve/__tests__/{parser,generator,external-rules}.test.ts` start with a UTF-8 BOM and contain "→" (double-encoded arrow) at e.g. `external-rules.test.ts:257`, `:343`, `generator.test.ts:369`, `parser.test.ts:104`. Cosmetic, but it shows the files were copied through a wrong encoding; re-copy from WEB when porting the 1.7.3 changes and add tests for list values / `:mime`.
- Fix hint: rewrite the three files as UTF-8 without BOM from the WEB copies.
### Vacation responder
- [x] **fromDate/toDate sent without timezone designator (not a JMAP UTCDate)** — fixed in b538a0e — `P2` — `rn-only-bug` — not verified live against stw-test; RFC 8621 UTCDate semantics implemented
- What WEB does: `datetime-local` → `new Date(local).toISOString()` (`"...T10:00:00.000Z"`) (`components/settings/vacation-settings.tsx:196`, `:207`) and displays via `utcToLocalDatetime` (`:16-20`).
- What RN does: `toJmapDate` produces `"YYYY-MM-DDTHH:MM:SS"` with no `Z`/offset (`src/components/settings/VacationSettings.tsx:10-19`) and `fromJmapDate` just slices the server's UTC string, showing UTC as if local (`:21-24`). RFC 8621 `VacationResponse.fromDate` is a `UTCDate`; Stalwart will either reject the set (`invalidProperties`) or interpret the wall time as UTC, shifting the schedule by the user's offset. Not verified live - verify against stw-test (`reference_stalwart_stw_test_container.md`) before fixing.
- Fix hint: parse the typed local time with `new Date(y, m-1, d, hh, mm).toISOString()` and format for display with the device timezone; better, use a native date-time picker.
- [x] **HTML vacation body missing** — fixed in b538a0e — `P2` — `missing`
- What WEB does: "Formatted message (HTML)" toggle + `RichTextEditor`, sanitised on save with a plain-text fallback derived from the HTML, HTML preview (`components/settings/vacation-settings.tsx:47-48`, `81-83`, `109-127`, `242-258`, `279-293`); store carries `htmlBody` (`stores/vacation-store.ts:10`); changelog 1.7.8 "Vacation: HTML body support".
- What RN does: store/API already carry `htmlBody` (`src/api/vacation.ts:11`, `src/stores/vacation-store.ts:15`) but the screen never reads or writes it (`src/components/settings/VacationSettings.tsx:63-71` saves only `isEnabled/fromDate/toDate/subject/textBody`). Saving from RN leaves any WEB-set `htmlBody` untouched (partial update), so no data loss - just no way to see/edit/clear it.
- Fix hint: reuse the composer's editor (RN rich editor) behind an "HTML" toggle; when off, send `htmlBody: null`; derive `textBody` from HTML when empty.
- [x] **No "unsaved changes" gating; Save always enabled** — fixed in b538a0e — `P3` — `partial`
- What WEB does: `hasChanges` compares local vs. store and disables Save when nothing changed or the end is before the start (`components/settings/vacation-settings.tsx:94-102`, `317`).
- What RN does: `canSave` only checks warnings (`src/components/settings/VacationSettings.tsx:61`).
- Fix hint: add a `hasChanges` memo mirroring WEB.
- [x] **"Start date in the past" warning missing** — fixed in b538a0e — `P3` — `partial`
- What WEB does: `warnings.start_in_past` (`components/settings/vacation-settings.tsx:75-79`).
- What RN does: only end-before-start, format, empty-body (`src/components/settings/VacationSettings.tsx:56-59`).
- Fix hint: add the check; RN `locales/en/common.json` already has `settings.vacation.warnings`.
- [x] **Vacation screen is hard-coded English despite locale keys existing** — fixed in b538a0e — `P3` — `rn-only-bug`
- What WEB does: all strings via `settings.vacation.*`.
- What RN does: `VacationSettings.tsx` never calls `useLocaleStore`; "Vacation Responder", "Date Range", "Saved", warnings etc. are literals (`:57-59`, `:72-74`, `:88`, `:101-158`). RN `locales/en/common.json` already contains `settings.vacation.{title,description,status,date_range,message,preview,save,saving,warnings}`.
- Fix hint: wire `t('settings.vacation.…')` like `FilterSettings.tsx` does.
- [x] **Vacation fetch/save on a shared account** — `P3` — `missing` (same dependency as the filters item) — done in a86cd1d
- What WEB does: `fetchVacationResponse(client, managedAccountId)` (`components/settings/vacation-settings.tsx:52-56`, `121-128`).
- What RN does: always `jmapClient.accountId` (`src/api/vacation.ts:27`, `:43`).
### Files (JMAP FileNode)
- [x] **Modification date never shown / sort-by-modified is a no-op (reads `updated`, server property is `modified`)** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.8.1 "Files: Show the modification date instead of the creation date (#700)", commit 348e032d)
- What WEB does: requests and reads `modified` (`lib/jmap/client.ts:6478-6486` `FILE_NODE_PROPERTIES`; `lib/jmap/types.ts:891-895` explains that asking for the wrong name silently yields `undefined`; `stores/file-store.ts:177` `lastModified: node.modified || node.created`).
- What RN does: `src/api/files.ts:18-21` requests `'updated'`; `src/api/types.ts:548` declares `updated?: string`; `FilesScreen.tsx:222-225` sorts on `a.updated` and `:527-529` renders `item.updated` → always undefined, so the list shows no date and "Sort: Modified" does nothing. `FilesSettings` preview promises a Modified column.
- Fix hint: rename to `modified` in `FILE_NODE_PROPERTIES`, the `FileNode` type, `FilesScreen` sort/render; fall back to `created` like WEB.
- [x] **Percent-encoded names from WebDAV-created nodes shown raw** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.9.0 "Decode percent-encoded FileNode names from WebDAV-created nodes (#869)", commit 5109aa05)
- What WEB does: `decodeFileNodeName` (`lib/jmap/filenode-name.ts:17-28`) applied at the client boundary in `getFileNodes`/`listAllFileNodes`/`listAllFileNodesAcrossAccounts` (`lib/jmap/client.ts:23-25`, `:6518`, `:6567`, `:6594`); conservative (needs a valid `%XX`, refuses decoded `/` or NUL).
- What RN does: `getAllFileNodes`/`getAllFileNodesAcrossAccounts` (`src/api/files.ts:61-126`) return `node.name` verbatim; "Spares%20Catalog" shows as such.
- Fix hint: port `filenode-name.ts` to `src/lib/` and map nodes through it in both list functions (keep the raw name only if a rename must round-trip the original - WEB doesn't).
- [x] **Files drive not reset on account switch** — fixed in c0e1d6b — `P2` — `bugfix-parity` (changelog 1.9.0 "Reset the account-scoped Files drive on every account switch", commit 5c06ae8b)
- What WEB does: `initedAccountRef` tracks the account; on change it `clearClient()`s and re-bootstraps (`components/files/files-app.tsx:110-114`, `160-179`).
- What RN does: `FilesScreen` keeps `allNodes`/`path` in component state and loads only on mount (`src/screens/FilesScreen.tsx:113-114`, `171-173`); `switchAccount` resets contacts/calendar stores but nothing files-related (`src/stores/auth-store.ts:360-380`). Switching accounts while the Files tab is mounted keeps showing the previous account's tree (and any create/upload goes to the new account under a stale `parentId`) until pull-to-refresh.
- Fix hint: subscribe to `useAuthStore(s => s.activeAccountId)` in `FilesScreen` and reset `path`/`selection` + call `loadFiles()` on change, or move the node cache into a store that `switchAccount` resets.
- [x] **Files capability gated on session capabilities, not the account's** — fixed in a0ce925 — `P3` — `bugfix-parity` (changelog 1.7.5 "Hide Files when the account lacks the filenode capability (#563)")
- What WEB does: `supportsFiles(accountId)` checks `account.accountCapabilities` (or non-personal) and `probeFileNodeSupport` refuses to probe when the server advertises filenode but the account doesn't (`lib/jmap/client.ts:6418-6458`).
- What RN does: `useHasFiles()` checks `session.capabilities` (`src/lib/capabilities.ts:33-35`); `App.tsx:184-193` disables the tab from that. An account whose `jmap-file-node-*` permissions were revoked still gets an enabled Files tab that errors on load.
- Fix hint: check `session.accounts[filesAccountId].accountCapabilities[CAPABILITIES.FILES]` (or `!isPersonal`) in `useHasFiles`.
- [x] **No upload size limit from server config** — fixed in c0e1d6b — `P3` — `bugfix-parity` (changelog 1.4.10 "Files: Use dynamic server-configured maximum upload sizes")
- What WEB does: filters oversized files against `client.getMaxSizeUpload()` and toasts `file_too_large` (`components/files/files-app.tsx:311-329`; `lib/jmap/client.ts:4179-4182`).
- What RN does: `startUpload` passes the picked asset straight to `uploadFileNode` (`src/screens/FilesScreen.tsx:345-381`); `grep -ri maxSizeUpload src` → nothing. The server returns an opaque 4xx instead.
- Fix hint: read `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` and compare with `asset.size` before uploading.
- [x] **Upload buffers whole file in memory, no progress, no cancel** — fixed in a0ce925 — `P2` — `partial` (changelog 1.4.12 "Stream WebDAV PUT uploads (#162)", 1.7.2 "Report real upload progress (#333)")
- What WEB does: `uploadBlob(file, { signal, onProgress })` with XHR progress and an `AbortController`; progress bar with percent and Cancel (`stores/file-store.ts:591-658`; `components/files/file-browser.tsx:1178-1212`).
- What RN does: `uploadBlob` reads the entire file with `new File(uri).bytes()` and posts an `ArrayBuffer` (`src/api/blob.ts:22-31`); `FilesScreen` shows only a spinner in the header button (`:460-464`). Large videos/PDFs from the document picker can exhaust memory; the user cannot cancel.
- Fix hint: use `expo-file-system` `uploadAsync`/`FileSystem.createUploadTask` (supports progress callbacks and cancellation, streams from disk) for the JMAP upload URL, then `FileNode/set`.
- [x] **Single-file upload only; no duplicate-name handling** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: multi-select input (`file-browser.tsx:1135-1141`), per-file progress `current/totalFiles`, `getUniqueName` appends " (1)" when a name already exists (`stores/file-store.ts:211-219`, `622-628`).
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
- [x] **Move (to folder / to parent), cut/copy/paste, duplicate, new text file, undo missing** — fixed in c0e1d6b — `P2` — `missing` — Move to… (folder picker) and Duplicate done; deferred: cut/copy/paste, new text file, undo (lower priority on mobile)
- What WEB does: `moveToFolder`, `moveToParent`, `cutResources`/`copyResources`/`pasteResources` (copy via `copyFileNode` = new node reusing the blobId), `duplicateResource`, `createTextFile`, `undoLastAction` with toast Undo (`stores/file-store.ts:825-933`, `1006-1015`; `lib/jmap/client.ts:6787-6830` `copyFileNode`; context menu `file-browser.tsx:1674-1801`; drag-drop onto folders and ".." rows).
- What RN does: actions sheet offers Preview/Share, Save to device, Sharing & access, Rename, Delete only (`src/screens/FilesScreen.tsx:804-858`); `src/api/files.ts` has `updateFileNode(id, { parentId })` (`:149-160`) so move is one call away, but no copy helper.
- Fix hint: add "Move to…" (folder picker built from `allNodes` folders, then `updateFileNode(id, { parentId })`) and "Duplicate" (`FileNode/set create` with original `blobId/type/size`, mirroring WEB `copyFileNode`). Cut/copy/paste and undo are lower priority on mobile.
- [x] **Favorites, recent files, search/filter, details pane missing** — fixed in c0e1d6b — `P3` — `missing` — search field and a type/size/modified line in the actions sheet done; deferred: favorites/recent (optional)
- What WEB does: favorites and recent (pruned against server nodes on refresh, changelog 1.4.12 #146) persisted in localStorage (`stores/file-store.ts:243-248`, `518-527`, `988-1004`); in-list search (`file-browser.tsx:494-525`, `1106-1132`); details sidebar with type/size/modified/path (`:1929-1969`).
- What RN does: none of these (`FilesScreen.tsx` has no search state; settings has no favorites store).
- Fix hint: a header search field filtering `visibleFiles` is cheap; details can be a row in the actions sheet; favorites/recent optional.
- [x] **Multi-select batch download; batch delete only** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: multi-select toolbar offers Download (N) and Delete (N) (`file-browser.tsx:979-999`).
- What RN does: selection header offers Delete only (`src/screens/FilesScreen.tsx:405-427`).
- [x] **Sort direction/key only changeable in Settings; no in-screen column sort** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: clickable Name/Size/Modified headers toggle sort (`file-browser.tsx:814-828`, `1529-1546`).
- What RN does: sort comes from `filesDefaultSortKey/Dir` settings only (`FilesScreen.tsx:134-135`, `210-231`).
- Fix hint: a sort chip in the header cycling name/size/modified + direction; fix the `modified` property first.
- [x] **Settings exposed but ignored: folder layout "Sidebar" and "Show thumbnails"** — fixed in c0e1d6b — `P3` — `rn-only-bug` — thumbnails implemented (authenticated Image), folder-layout radio removed (sidebar is N/A on a phone)
- What WEB does: `folderLayout: 'sidebar'` renders `FolderTreeSidebar` and hides folders from the list (`file-browser.tsx:497-500`, `1223-1269`); `showThumbnails` renders image thumbnails (`:272-297`, `:1494-1496`).
- What RN does: `FilesSettings.tsx:161-171`, `219-224` offer both toggles and preview them, but `FilesScreen` never reads `filesFolderLayout` or `filesShowThumbnails` (only `showIcons/coloredIcons/showHiddenFiles/sort/view`, `:131-136`).
- Fix hint: either implement thumbnails (`<Image source={{ uri: getFileNodeDownloadUrl(node), headers: { Authorization } }}>` for image extensions) and drop the folder-layout radio (sidebar is N/A on a phone), or hide both settings.
- [ ] **Legacy flat-name migration not run on RN** — `P3` — `missing` (changelog 1.7.3 #379; WEB `stores/file-store.ts:286-479`) — deferred: WEB runs the one-time migration; not ported to mobile
- What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`).
- What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`.
- [ ] **Deep link `/files/<folder>` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored
- What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`).
- What RN does: `src/navigation/linking.ts` (5802cae) turns any `/files/...` link into `{ kind: 'files' }` and opens the tab; `Files: undefined` in `src/navigation/types.ts:35`.
- Fix hint: parse the rest of the path in `linking.ts`, pass it to `Files` as a `path` param and walk `allNodes` by names.
- [x] **Hard-coded English throughout Files screen and ShareSheet** — fixed in c0e1d6b — `P3` — `rn-only-bug`
- What WEB does: everything via `files.*` locale keys (`locales/en/common.json` `files` has ~120 keys incl. `share`, `shared`, `shared_with_me`, `shared_by`).
- What RN does: `FilesScreen.tsx` ("New folder", "Delete this item?", "Shared by", "Upload unavailable"…, e.g. `:148`, `:310`, `:521`, `:621`, `:722-727`, `:825-849`) and `ShareSheet.tsx` (`:41-45`, `:160-166`, `:175`, `:219-242`) use literals, even though RN `locales/en/common.json` already has a `files` block (`:1513`) with most of these keys.
- Fix hint: wire `useLocaleStore(s => s.t)` with the existing keys; add `share/shared/shared_with_me/shared_by` from WEB.
- [ ] **Stability warning banner not shown** — `P3` — `partial` — deferred: product call, not shown on mobile
- What WEB does: persistent yellow "stability_warning" above the browser (`files-app.tsx:617-620`; changelog 1.4.10 "Update file feature disabled messages and add stability warnings").
- What RN does: nothing. Optional; product call.
- [x] **Storage quota not surfaced in Files** — `P3` — `missing` (partly N/A) — done in 490355c: the quota shows in Account settings; Files shows none (like WEB's mobile layout)
- What WEB does: passes `quota` from the email store into the navigation rail beside Files (`files-app.tsx:55`, `:569`); changelog 1.7.5 "Storage quota not shown with Stalwart (#577)".
- What RN does: `FilesScreen` shows no quota. Whether RN shows quota anywhere is outside this area; if it exists in Settings/Account, this is N/A.
- [x] **README still says "file storage - UI stubs only"** — fixed in c0e1d6b — `P3` — `rn-only-bug` (docs)
- `repos/react-native/README.md:30` lists filters, S/MIME, plugins, themes and file storage as stubs; filters, vacation and files are real implementations (`src/api/files.ts`, `src/screens/FilesScreen.tsx`, `src/components/files/ShareSheet.tsx`, `src/api/__tests__/files.test.ts`). Update the README when the items above land.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Sieve parser/generator/types are a faithful port up to 1.7.2: `diff -w --strip-trailing-cr lib/sieve/parser.ts repos/react-native/src/lib/sieve/parser.ts` shows only the multi-value/attachment hunks (and `debug.warn` → `console.warn`). Included and identical: `@metadata` JSON round-trip, external-rule parsing with origin labels (Roundcube/Nextcloud/etc., changelog 1.4.14 #201), Nextcloud marker regions preserved verbatim, `# Rule:` dedupe of Bulwark blocks that failed to re-parse (1.7.0 "literal braces" fix, `parser.ts` `findBodyOpenBrace` + `filteredExternal`), vacation-only script detection, `externalRequires` merge, `INBOX` canonical path (1.7.1 #313: `FilterRuleModal.tsx:57`), all 10 action types, `computeRequires` (fileinto/copy/imap4flags/reject/body/vacation), `stop` folding into `stopProcessing`. Since then the webmail writes "Keep" as `fileinto "INBOX"` (62465e1e, #1027) and added the vacation `include`, `:mailboxid` targets, `redirect :copy` and the spam guard; ported in f63d739, d53bfec, 3f88868 and 6c31c8d.
- Filter store semantics identical: skip server-managed `vacation` script (1.4.10), keep activation via `onSuccessActivateScript` on create/update (1.4.10), external/opaque rules read-only, bulwark rules kept contiguous before external ones, `addRule/updateRule/deleteRule/reorderRules/toggleRule`, rollback on failed save, opaque banner with "Open raw editor" / two-step "Reset to visual builder", `SieveScript/validate` via uploaded blob with error-method handling, `createSieveScript('filters')`.
- Filter rule editor: name/match type/conditions (from/to/cc/subject/header+name/size/body)/comparators/actions (move & copy folder picker from own non-shared mailboxes, forward, reject text, add_label from keywords store, mark read/star/discard/keep/stop)/stop-processing; empty-name/condition/action validation; comparator reconciliation on field change; default first mailbox for move/copy. Reorder via up/down chevrons instead of drag (fine on mobile). Delete uses a native confirm. Mailboxes are loaded at login (`src/stores/auth-store.ts:84`), so WEB fix #485 (load mailboxes when Filters opened directly) is not needed.
- Raw Sieve editor: warning banner, Validate with server errors, two-step confirm save, re-seeds on open, atomic `setOpaqueScript`.
- Vacation: `VacationResponse/get|set` on singleton with `core+mail+vacationresponse` using, default object when the list is empty, capability gating (`isVacationSupported`), enable toggle + status pill, subject, plain-text body, preview, end-before-start warning, empty-body warning, error surfacing.
- Files API: folder detection `blobId == null` (`src/api/files.ts:12-14`), `FileNode/get ids:null`, which Stalwart caps at `maxObjectsInGet` (500), continued since 4eb8c47 by paging `FileNode/query` (which lists folders too since Stalwart 0.16.6) and batched gets (#1069), `shareWith/myRights` requested explicitly, `principals:owner` in `using` only when advertised (sharing is gated on `principals` since caea3d0), real-hierarchy folder create without blob/type/size, cascade delete with `onDestroyRemoveChildren`, MIME-type >30 chars → `application/octet-stream` (1.4.12), cross-account "shared with me" aggregation with `accountId:nodeId` namespacing and owner-routed download URLs, shared subtrees browsable and writes hidden inside them, `Share2`/`Users` badges, ShareSheet with the same read/readWrite/manager presets as WEB `FILE_PRESETS`, custom-rights detection, principal search excluding self, revoke, refresh after change; unit tests in `src/api/__tests__/files.test.ts`.
- Files UI: list/grid toggle persisted to settings, folder breadcrumb, pull-to-refresh, hidden-file filter, colored/plain icons, long-press multi-select (own nodes only), rename/new-folder prompts rejecting `/`, delete confirmation naming folder cascade, path stack pruned when a folder disappears, Files tab disabled when the capability is missing.
## N/A on mobile
- Drag-and-drop upload / whole-folder upload (`uploadFolder`, `getDroppedFilesAndFolders`), drag-out of files, marquee selection, keyboard shortcuts (Ctrl+A/C/X/V, F2, Delete, Backspace), right-click context menus, resizable folder-tree sidebar, breadcrumb right-click dropdown, `?preview=` window title updates, `window.open` blob preview safety list (`isMimeTypeSafeForInlinePreview`).
- WebDAV proxy client (`lib/webdav/client.ts`, `stores/webdav-store.ts`) - WEB itself only uses it for calendar collections now; the Files app is JMAP-only, so RN correctly has no WebDAV code.
- Pro-shell cross-account Files picker / `accountFolders` / `__account_root__` breadcrumb sentinel, `filesEnabled` admin policy gate (RN has no policy store), plugin hooks `onSieveScriptGenerate`/`onFiltersSave`.
- Drag-to-reorder filter rules (RN uses chevrons), line-number gutter and Tab-inserts-spaces in the Sieve editor (RN shows "N lines" below it).
- "Attach from Files" in the composer and "Save attachment to Files" in the viewer: neither client implements these (WEB composer only takes local files, `components/email/email-composer.tsx:1391-1555`; no `createFileNode` call outside the Files store), so nothing to port.
+347
View File
@@ -0,0 +1,347 @@
# Settings & settings sync, push notifications, i18n, themes, updates, plugins, misc UI
All paths below are relative to WEB root `the webmail repo` unless prefixed `RN:` (= `repos/react-native/`). Line numbers are from the tree as of 2026-08-29.
## Summary
Settings parity is roughly "half the keys, a third of the behaviour": RN persists 77 keys (`RN: src/stores/settings-store.ts:102-230`) against WEB's ~110 (`stores/settings-store.ts:540-735`), but 16 of the RN keys are stored and never read anywhere outside the settings UI (stub toggles), several defaults silently diverge from WEB, and there is no cross-device sync at all (native issue #1) because WEB sync is a webmail-server feature (`/api/settings`, cookie-bound, AES-GCM with `SESSION_SECRET`) that a direct-to-JMAP native client cannot reach. Push is the biggest functional gap: RN subscribes to `Email`/`Mailbox` state changes (WEB only `EmailDelivery`), has no `emailPush` spam filter, ignores the message ids the relay forwards and instead guesses "newest unread in Inbox" (which produces a wrong notification when the newest unread is read elsewhere), never matches the relay payload to an account (scans for a field the relay does not send), has no device list / revoke / force-recreate, and is Android/FCM-only (no iOS, no UnifiedPush; native issues #44/#45/#48). i18n in RN is a flat-lookup shim: 15 of WEB's 27 locales, no interpolation, no plurals, no RTL, vendored catalogs six weeks stale, and 84 of 94 screen/component files never call `t()` at all (every settings pane except Language and Filters is hard-coded English). Themes/plugins/S-MIME/sidebar apps are visual stubs whose controls persist state nobody reads; the update path is a working Android sideloader with no severity concept and a dismissable banner (WEB's security/deprecated notices are non-dismissable).
## Settings inventory
Legend: RN? = key exists in RN store; Honored = read by RN app code outside `stores/settings-store.ts` and `components/settings/*` (verified by grep, 2026-08-29). "Owner" names the agent that covers the behaviour when it is not this area.
| WEB key (default) | RN key (default) | RN? | Honored | Notes |
|---|---|---|---|---|
| fontSize (medium) | fontSize (medium) | yes | partial | Only `useTypography()` callers scale: EmailListScreen and its attachment chips (`RN: src/theme/dynamic.ts:47`); the other files import static `typography` from tokens |
| density (regular) | density (regular) | yes | yes | EmailListScreen via `useDensity()` |
| animationsEnabled (true) | animationsEnabled (true) | yes | partial | Only via `useAnimDuration` in 3 files; 18 files call `Animated.timing` with fixed durations (UndoSnackbar, SwipeableRow, every sheet) |
| messageListOrder / messageListOrderScope (#718) | mailSortAscending (false) | partial | yes | Single boolean; native issue #5. Owner: mail-list agent |
| dateFormat (smart) | dateFormat (smart) | yes | yes | `RN: src/lib/date-format.ts` |
| dateLocale (auto) | – | no | – | Regional numeric order (1.7.x "User-selectable regional date format") missing |
| timeFormat (24h) | timeFormat (24h) + calendarTimeFormat (24h) | yes | yes | RN has two independent time-format settings; WEB has one |
| firstDayOfWeek (1) | calendarFirstDayOfWeek (1) | yes | yes | RN type is `0 \| 1`, WEB also allows 6 (Saturday) |
| timeZone (auto, #755) | – | no | – | Missing |
| markAsReadDelay (0) | markAsReadDelay (0) | yes | yes | |
| deleteAction (trash) | deleteAction (trash) | yes | yes | email-store |
| permanentlyDeleteJunk (false) | same | yes | yes | |
| returnToListAfterAction (true) | – | no | – | Owner: mail viewer agent |
| showPreview (true) | showPreview (true) | yes | yes | |
| mailLayout (split) | mailLayout (split) | yes | **no** | ReadingSettings offers Split/Focus, nothing reads it. N/A on phone; remove the control or hide it |
| emailsPerPage (50) | emailsPerPage (25) | yes | yes | Default differs |
| externalContentPolicy (ask) | same | yes | yes | EmailBodyView |
| messageSpacing (auto), plainTextFont (sans) | – | no | – | Owner: viewer agent |
| mailAttachmentAction / attachmentPosition | same | yes | yes | |
| emailAlwaysLightMode (false) | same | yes | yes | EmailBodyView:471 |
| archiveMode (single) | same | yes | yes | |
| hoverActions* | – | no | – | N/A on touch |
| swipeRightAction (archive) / swipeLeftAction (delete) | swipeRightAction (read) / swipeLeftAction (archive) + swipeMode | yes | yes | Defaults differ (WEB right=archive,left=delete; RN right=read,left=archive). RN adds `pin`, `move`, `reveal` mode; WEB adds `spam`. Owner: mail-list agent |
| autoSaveDraftInterval, sendConfirmation, defaultReplyMode, rtlEditingSupport, subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, requestReadReceiptDefault, readReceiptResponse, emptySubjectWarningEnabled | – | no | – | Owner: compose agent |
| autoSelectReplyIdentity (false) | same (**true**) | yes | yes | Default differs |
| plainTextMode (false) | same | yes | yes | |
| sendDelaySeconds (0) | same (0, any number) | yes | yes | RN does not validate to 0/10/30/60 like WEB (`stores/settings-store.ts:901`) |
| attachmentReminderEnabled / attachmentReminderKeywords (multilingual list, :670-697) | same (4 English words, :255) | yes | yes | RN default list is English-only |
| sessionTimeout (0) | – | no | – | Owner: auth agent |
| trustedSenders ([]) | same | yes | yes | RN `addTrustedSender` does not strip `Name <addr>` (WEB :1008-1016) |
| trustedSendersAddressBook (null → auto true) | same (**false**) | yes | partial | Read by EmailBodyView:470 but there is no toggle in ContentSendersSettings and no auto-enable, so it is permanently off |
| expandedFilterView | filtersExpandedView | yes | yes | Only read inside FilterSettings itself (fine) |
| showTimeInMonthView (false) | calendarShowTimeInMonth (**true**) | yes | **no** | Stored, never read. Owner: calendar agent |
| showWeekNumbers | calendarShowWeekNumbers | yes | yes | |
| calendarHoverPreview | same | yes | **no** | N/A on touch; hide the control |
| enableCalendarTasks / showTasksOnCalendar | same | yes | yes / **no** | showTasksOnCalendar stored, never read |
| showBirthdayCalendar (false) | same (**true**) | yes | yes | Default differs |
| birthdayCalendarColor | – | no | – | Owner: calendar agent |
| sharedCalendarColors | same | yes | yes | |
| groupContactsByLetter | same | yes | yes | |
| emailNotificationsEnabled (true) | same | yes | yes | App.tsx:336, push-background-task.ts:32 |
| emailNotificationSound (true) | same | yes | **no** | Stored, never read; Android channel sound is whatever the OS default is |
| notificationSoundChoice (default/cheerful/involved/swift/relax) | same (default/chime/ping/pop/none) | yes | **no** | Different option set, never read, preview button has no `onPress` (`RN: NotificationSettings.tsx:205-210`) |
| pushRelayUrl ('' = admin default) | (AsyncStorage `push:relayBaseUrl:v1`, free text) | separate | yes | See push findings |
| calendarNotificationsEnabled / calendarNotificationSound | same | yes | **no** | Stored, never read (calendar-alerts lib does not consult them). Owner: calendar agent for the alarm side |
| calendarInvitationParsingEnabled | same | yes | yes | CalendarInvitationBanner |
| protocolOpenMode | – | no | – | N/A (web protocol handlers) |
| toolbarPosition | – | no | – | N/A |
| showToolbarLabels | same | yes | yes | EmailThreadScreen |
| hideAccountSwitcher, showRailAccountList, proInterface | – | no | – | N/A |
| enableUnifiedMailbox, unifiedCrossAccount, enableCross*View, allMailFolderIds | – | no | – | RN has a UnifiedInbox screen without these switches. Owner: mail-list agent |
| includeGroupInUnified (true) | same (**false**) | yes | yes | Default differs |
| preferredIdentityIds (per account, #507) | – | no | – | Owner: identity agent |
| disableThreading | same | yes | yes | |
| senderFavicons | same | yes | yes | |
| showAvatarsInJunk, faviconUnreadBadge, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount | – | no | – | faviconUnreadBadge N/A; others Owner: mail-list/sidebar agent |
| folderIcons, emailKeywords, nestedTags | – (keywords-store) | separate | – | Owner: mail agent |
| hideInlineImageAttachments / attachmentImagePreviewsEnabled | yes / no | | | |
| sidebarApps, keepAppsLoaded | same | yes | **no** | Never rendered anywhere (see Plugins section) |
| onboardingCompleted, tourCompleted, showOnboardingOnNewDevices | – | no | – | N/A (no tour) |
| emailDownloadTemplate, attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics | emailExportTemplate, attachmentExportTemplate, exportSpaceReplacement, exportLowercase, exportStripDiacritics | yes | yes | `RN: src/lib/email-export.ts` |
| bundleDownloadTemplate, filenameCollapseSeparators, postExportAction | – | no | – | Owner: mail viewer/export agent |
| debugMode, debugCategories | (React local state only) | no | **no** | `RN: AboutDataSettings.tsx:61-63` — toggles live in `useState`, lost on unmount, nothing reads them |
| settingsSyncDisabled | (local state) | no | **no** | `RN: AboutDataSettings.tsx:65,246-248` — a "Settings sync" switch that does nothing |
| – | theme (system) | RN-only | yes | WEB keeps this in theme-store |
| – | activeThemeId (null) | RN-only | **no** | Never read |
| – | smimeDefaultEncrypt / smimeRememberUnlocked / smimeAutoImport | RN-only | **no** | Stubs |
| – | pluginEnabled ({}) | RN-only | **no** | Stub |
| – | files* (8 keys) | RN-only | 6 of 8 | filesFolderLayout, filesShowThumbnails never read. Owner: files agent |
| – | bottomQuickActions, swipeMode, offlineCache* | RN-only | yes | |
RN toggles that are stored but never read (fix: either wire them or remove the control): `mailLayout`, `calendarShowTimeInMonth`, `calendarHoverPreview`, `showTasksOnCalendar`, `emailNotificationSound`, `notificationSoundChoice`, `calendarNotificationsEnabled`, `calendarNotificationSound`, `sidebarApps`, `keepAppsLoaded`, `activeThemeId`, `smimeDefaultEncrypt`, `smimeRememberUnlocked`, `smimeAutoImport`, `pluginEnabled`, `filesFolderLayout`, `filesShowThumbnails`.
## Findings
### Settings store and settings sync
- [ ] **No cross-device settings sync; RN cannot join the WEB sync store as-is (native #1)** — `P2` — `missing` — deferred (needs a server-side store); key-mapping table shipped as SETTINGS_KEY_MAP + DEVICE_LOCAL_KEYS in src/stores/settings-store.ts (8deff66) and used by export/import; the dead 'Settings sync' switch is gone
- What WEB does: every settings change (plus theme, locale, templates #825) is debounced 2 s and POSTed to the webmail server's `/api/settings` (`stores/settings-store.ts:1340-1359`, `:72-96`). The server verifies the caller against its own session cookies for any account slot (`app/api/settings/route.ts:71-94`), strips admin-locked keys (`:142-167`) and writes `sha256(username:serverUrl).enc` under `SETTINGS_DATA_DIR`, AES-256-GCM with key `sha256(SESSION_SECRET)` (`lib/settings-sync.ts:12-32,34-53`). On login it GETs the blob and imports it with per-account map merging (`stores/settings-store.ts:1125-1165`, `:942-951`). `proInterface` is device-local (`:147`).
- What RN does: persists a flat JSON under AsyncStorage key `webmail:settings:v1` (`RN: src/stores/settings-store.ts:91,393-397`) and nothing else. The AboutData "Settings sync" toggle is `useState` (`RN: AboutDataSettings.tsx:65,246-248`).
- Fix hint: RN talks to Stalwart directly and has no webmail session cookie, so it cannot call `/api/settings` (403 identity mismatch, and the deployment's webmail URL is unknown to it). Two realistic designs: (a) store the same JSON blob on the JMAP server as a Blob (`Blob/upload` + a well-known keyword/mailbox message, or a FileNode in the user's Files root such as `/.bulwark/settings.json`) and have WEB read/write the same object so both clients converge — needs a shared key-name mapping (WEB names above) and a `updatedAt` for last-writer-wins; or (b) let the login flow optionally capture the webmail origin (the QR sign-in already comes from webmail) and proxy sync through it with a bearer token. Either way, ship a key-mapping table first (RN `mailSortAscending` ↔ WEB `messageListOrder`, `calendarFirstDayOfWeek` ↔ `firstDayOfWeek`, `emailExportTemplate` ↔ `emailDownloadTemplate`, etc.) and exclude device-local keys (`swipeMode`, `bottomQuickActions`, `offlineCache*`, `theme`?).
- [x] **Default values silently diverge from WEB** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: `emailsPerPage` 50, `autoSelectReplyIdentity` false, `showBirthdayCalendar` false, `showTimeInMonthView` false, `includeGroupInUnified` true, `trustedSendersAddressBook` null→auto-true, swipe right=archive/left=delete, multilingual attachment-reminder keyword list (`stores/settings-store.ts:540-735`).
- What RN does: 25, true, true, true, false, false, right=read/left=archive, 4 English keywords (`RN: src/stores/settings-store.ts:232-325`).
- Fix hint: align defaults where the behaviour is the same on both clients (attachment keywords, includeGroupInUnified, autoSelectReplyIdentity, showBirthdayCalendar) before any sync exists, otherwise the first sync flips them for existing users.
- [x] **`trustedSendersAddressBook` can never be turned on in RN** — `P3` — `partial` — done in 78114b6 (toggle in Content & Senders); since d89e331 it stays off until the user turns it on, where the webmail turns it on by itself
- What WEB does: `null` resolves to `true` on first connect when the server has contacts; the Content & Senders tab has a toggle (`components/settings/content-senders-settings.tsx`, `updateSetting('trustedSendersAddressBook'`).
- What RN does: default `false` (`RN: src/stores/settings-store.ts:239`), read by `EmailBodyView.tsx:470,489` but `ContentSendersSettings.tsx` has no control for it.
- Fix hint: add the toggle; treat undefined as auto-on when `useHasContacts()`.
- [x] **`addTrustedSender` does not normalise `Name <addr>`** — fixed in 8deff66 — `P3` — `bug`
- What WEB does: strips display-name angle form before storing/comparing (`stores/settings-store.ts:1008-1035`).
- What RN does: lowercases and trims only (`RN: src/stores/settings-store.ts:471-489`); callers that pass a formatted address store a value that will never match.
- Fix hint: port the angle-bracket regex into `add/remove/isSenderTrusted`.
- [x] **`sendDelaySeconds` and other enum keys not sanitised on hydrate** — fixed in 8deff66 — `P3` — `bug`
- What WEB does: rejects/resets invalid `sendDelaySeconds`, `messageListOrder`, `messageListOrderScope`, `subAddressDelimiter` on import and in `migrate` (`stores/settings-store.ts:898-930,1207-1209`).
- What RN does: `mergeWithDefaults` only checks `typeof` (`RN: src/stores/settings-store.ts:399-415`); a corrupt `swipeLeftAction: "foo"` or `density: "x"` flows into the UI.
- Fix hint: add per-key validators (allowed-value sets) to `mergeWithDefaults`; `normalizeBottomQuickActions` is the pattern.
- [x] **Reset-to-defaults only resets three keys; no export/import** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: `resetToDefaults` restores every key; About & Data offers Export/Import JSON (feature-gated) and "Refresh cached data" (`components/settings/about-data-settings.tsx:77-129,171-220`, `lib/clear-cached-data.ts`).
- What RN does: `handleReset` resets `externalContentPolicy`, `senderFavicons` and trusted senders only (`RN: AboutDataSettings.tsx:104-110`); no export/import; "Clear cache" only clears the offline body cache.
- Fix hint: add `resetToDefaults` to the store (`set(DEFAULT_PERSISTED)` + persist), an export via `expo-sharing` and import via `expo-document-picker` using WEB's export shape so files round-trip between clients; add a "refresh cached data" that clears `email-snapshot`/contacts/calendar caches without logging out.
- [x] **Debug mode / categories are inert local state; no debug/logger abstraction** — fixed in 8deff66 — `P3` — `missing` — src/lib/debug.ts; dead Debug tab removed in 3d0d440
- What WEB does: `debugMode`/`debugCategories` persisted and honoured by `lib/debug.ts:8-13`; `lib/logger.ts` server side (`lib/error-reporting.ts`, cited here before, has since been deleted from the webmail). Debug tab gated by admin (`settings-app.tsx:766`).
- What RN does: `AboutDataSettings.tsx:61-66,226-244` keeps toggles in `useState` with categories (`sync`, `render`) that exist nowhere; the "Debug" tab is `implemented: false` (`SettingsScreen.tsx:111`); code uses raw `console.warn`.
- Fix hint: persist `debugMode`/`debugCategories`, add `src/lib/debug.ts` mirroring WEB's category API, route the `[push]`, `[settings-store]`, `[updates-store]` warnings through it, and drop the dead Debug tab or implement a log viewer.
- [x] **Settings panes are hard-coded English (no i18n)** — fixed in 22697fd — `P2` — `partial` — all panes I own use t(); RN-only keys harvested into locales/rn/en.json (npm run i18n:harvest); Calendar/Contacts/ContentSenders/Filter/Vacation/Files/Folder panes belong to other agents
- What WEB does: every settings component uses `useTranslations` with keys in `locales/*/common.json`.
- What RN does: only `LanguageSettings.tsx` and `FilterSettings.tsx` call `t()`; the other 24 settings components plus `settings-section.tsx` have literal English strings (grep count 0 for `t(` in each). `SettingsScreen.tsx:184-187` translates group/tab labels only, and the "Experimental"/"Not implemented"/"Unavailable" badges (`:297,270-273`) are literal.
- Fix hint: the vendored catalog already contains `settings.*` keys for almost every label (WEB keys); wrap each label/description in `t('settings.<tab>.<key>', 'fallback')`.
- [x] **Settings tabs shown regardless of server capability / admin policy** — fixed in 3ffcef2 — `P3` — `partial` — Sieve/Vacation gating via useHasSieve/useHasVacation (filters agent's commit)
- What WEB does: hides Vacation/Filters when Sieve is absent, Templates/Keywords/Sidebar apps/Plugins/Themes/Debug behind admin feature flags, Security behind Stalwart features (`components/settings/settings-app.tsx:724-766`); settings can be `locked`/hidden per policy (`appearance-settings.tsx:89-119`).
- What RN does: hides only calendar/contacts/files by capability and updates by platform (`RN: SettingsScreen.tsx:142-179`); Vacation and Filters render even without Sieve; `SettingItem` has a `locked` prop (`settings-section.tsx:68-86`) that nothing sets. Admin policy is N/A (no webmail server), but Sieve gating is not.
- Fix hint: gate `vacation`/`filters` on `CAPABILITIES.SIEVE` in the session like WEB's `supportsSieve`/`supportsVacation`.
### Push notifications
- [x] **RN subscribes to `Email` and `Mailbox` state changes, not just `EmailDelivery`** — fixed in 00faceb — `P2` — `bugfix-parity` — the `emailPush` map it added was refused for ACL-shared mailboxes, which could leave push off for good, and was re-patched on every launch, until d8046a8 and ccaa24e (audit B18)
- What WEB does: `PUSH_TYPES = ['EmailDelivery']` because `Email` fires on every mutation and produced spurious notifications (`lib/web-push.ts:39-44`; changelog 1.5.x "Scope new-mail notifications to genuine inbox deliveries").
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`RN: src/lib/push-notifications.ts:104`). Every read/flag/move/draft on any client wakes the device through FCM and runs the headless task.
- Fix hint: change to `['EmailDelivery']` and bump the subscription (the refresh path only patches `expires`, `:411-429`; add a `types` mismatch check like WEB `:369-383` so existing subscriptions get corrected).
- [x] **"Newest unread in Inbox" heuristic notifies the wrong message** — fixed in 00faceb — `P2` — `rn-only-bug`
- What WEB does: the service worker uses the `emailIds` the relay forwards (EmailPush) or, on StateChange, asks `/api/push/preview?accountId=&emailId=` (`app/api/push/preview/route.ts:88-119`).
- What RN does: `processAccountForPush` queries `notKeyword:$seen` in Inbox, limit 1, and notifies it unless it equals `lastNotified` (`RN: src/lib/push-background-task.ts:151-164`). Because RN also subscribes to `Email` changes, reading the newest unread message on another device triggers a push, the query now returns the *next older* unread message, which was never `lastNotified`, so the user gets a notification for old mail. Two messages arriving in one push yield one notification; mail Sieve-filed outside Inbox never notifies; the relay's `emailIds` (`repos/relay/src/payload.ts`, `fcm.ts` data.emailIds) are ignored.
- Fix hint: parse `data.emailIds` (JSON string) and `data.kind`; when present, `Email/get` those ids directly and post one notification per id (or a grouped one); fall back to the Inbox query only for legacy `jmap-state-change` payloads, and there compare against a stored set of seen ids rather than a single `lastNotified`.
- [x] **Per-account matching of the FCM payload never matches** — fixed in 00faceb — `P2` — `rn-only-bug` — local id → JMAP id map in push:jmapAccountIds:v1 (+AccountEntry.jmapAccountId), accountLabel fallback
- What WEB does: SW reads `accountId` from the payload's `changed` map (`app/api/push/preview/route.ts:90-99`).
- What RN does: `identifyAccountFromFcmData` scans payload values for a locally stored `deviceClientId` (`RN: src/lib/push-background-task.ts:67-87`), but the relay sends `kind, accountLabel, accountId, emailIds, changed` (`repos/relay/src/fcm.ts` message.data) - never the subscription id. Result: every push processes every logged-in account serially inside a 30 s headless budget (`BulwarkPushTaskService.kt` timeout), loading each account's JMAP session.
- Fix hint: the relay's `accountId` is the JMAP primary account id; persist each account's JMAP account id in `AccountEntry` (`RN: src/stores/account-store.ts:7-19` has none) or in the push registry (`push:accountIds:v1` → map local id → jmap id) at setup time, and match on that. `accountLabel` (= username) is a weaker second key.
- [x] **No `emailPush` delivery filter (spam still wakes the device)** — fixed in 00faceb — `P2` — `bugfix-parity` — the map was refused for ACL-shared mailboxes and never read back until d8046a8 and ccaa24e (audit B18)
- What WEB does: when the session advertises `urn:ietf:params:jmap:emailpush` (Stalwart ≥ 0.16.16) the subscription carries a per-account filter `notKeyword:$junk AND inMailboxOtherThan:[junk ids]` (`lib/web-push.ts:53-108,491-497`, commit 63fa2d2d) and re-syncs it in the background on every page load (`:668-688`, `components/push-notification-prompt.tsx:119-126`, 1.9.2).
- What RN does: `createPushSubscription` has no `emailPush` parameter (`RN: src/api/push.ts:23-57`); the Inbox-only query hides junk from the notification but the wake-up and JMAP round-trip still happen for every spam delivery.
- Fix hint: port `buildEmailPushConfig` + `serverSupportsEmailPush` (session capabilities are in `jmapClient.currentSession.capabilities`), add `emailPush` to `createPushSubscription`/`updatePushSubscription`, and extend `refreshSubscriptionExpires` to patch `types`/`emailPush` when they differ.
- [x] **No device list, per-device revoke, or force-recreate (#841)** — fixed in f6f26f7 — `P2` — `missing`
- What WEB does: Notifications tab lists every `PushSubscription` on the account with relay liveness and "this device" marker, lets the user revoke any of them, and "Re-register" destroys and recreates the subscription so revoked shared-mailbox access stops fanning out (`lib/web-push.ts:565-639`, `:143,457-461`; `components/settings/notification-settings.tsx:169-193,251-303`; changelog 1.8.x).
- What RN does: "Re-register" calls `setupPushNotifications` which reuses the stored subscription and only refreshes `expires` (`RN: src/lib/push-notifications.ts:345-352`); no list/revoke UI.
- Fix hint: add `forceRecreate` to `PushSetupParams`, and a `listPushDevices`/`revokePushDevice` pair (JMAP `PushSubscription/get` + relay `/api/push/active/:id` + `DELETE /api/push/register/:id`) rendered under the relay card.
- [ ] **Relay URL is a free-text field instead of an admin-curated list; `DEFAULT_RELAY_BASE_URL` not overridable** — `P3` — `partial` — https now required (isValidRelayUrl, 00faceb); deferred: per-account relay storage / discovery
- What WEB does: user picks from `resolvePushRelayOptions(policy)` (default + admin list + legacy), never types a URL; admin can lock it (`lib/push-relays.ts:39-80`, `notification-settings.tsx:226-241`).
- What RN does: `TextInput` with any `https?://` accepted (`RN: NotificationSettings.tsx:174-184`), stored device-wide in AsyncStorage (`push-notifications.ts:160-175`) so two accounts on different servers share one relay.
- Fix hint: keep free text (there is no policy source in RN) but store the relay per account alongside `push:accountIds:v1`, and let `.well-known`/login-time discovery optionally seed it. Not blocking.
- [x] **Re-register error path is opaque (native #45)** — fixed in 00faceb — `P2` — `bug` — PushSetupError{phase}, relay body in message, guarded getToken, per-account teardown keeps the FCM token; no device repro yet
- What WEB does: surfaces `WebPushUnsupportedError` separately and reports precise relay/JMAP failures; reaps only relay-confirmed-dead leftovers (`lib/web-push.ts:466-489`).
- What RN does: `setupPushNotificationsInner` calls `native.getToken()` directly (not the guarded `getFcmToken`) so a Firebase rejection (`fcm_token_failed`, typical on de-Googled devices or right after `deleteToken()` during Disable) bubbles up raw; `registerWithRelay` throws `Relay register failed: <status>` without the body (`RN: src/lib/push-notifications.ts:312-313,215-217`); `pollVerificationCode` times out after 75 s with the same message for every cause (`:268`). The UI shows `error.message` verbatim (`NotificationSettings.tsx:95-99`). Disable then immediately Enable also races: `teardownPushNotificationsForAccount` deletes the FCM token when it was the last account (`:477-481`) and the next `getToken()` may be rejected until Firebase re-registers.
- Fix hint: read the relay response body into the error, distinguish "no Google Play services" (map to the UnifiedPush finding), skip `deleteToken()` unless the user disables push for all accounts, and log each phase (`token`, `relay`, `jmap create`, `verify`) so the settings pane can show which step failed. Needs a repro on the reporter's device to confirm the exact failure.
- [ ] **iOS push missing entirely** — `P2` — `missing` — deferred: needs an APNs transport in the relay + iOS token module; the settings pane now states Android-only
- What WEB does: Web Push works on iOS 16.4+ once installed to the home screen (`lib/web-push.ts:157-164,394-398`).
- What RN does: `getNative()` returns `null` off Android and setup throws "Push notifications require Android" (`RN: src/lib/push-notifications.ts:125-128,303-304`); the relay has only FCM and Web Push transports (`repos/relay/README.md` endpoints).
- Fix hint: needs an APNs transport in the relay (`POST /api/push/register/apns` storing a device token, HTTP/2 to `api.push.apple.com` with a `.p8` key) plus an iOS native module (or `expo-notifications`) for the token and a Notification Service Extension to fetch sender/subject over JMAP. Content-blind payload stays the same.
- [x] **No non-FCM transport (UnifiedPush, native #44/#48)** — `P3` — `missing` — done in ee04e95 (Android: UnifiedPush connector through a distributor app such as ntfy; the relay falls back to unencrypted delivery for distributors without Web Push keys); not in a release yet
- What WEB does: N/A (browser push).
- What RN does: FCM only (`BulwarkMessagingService.kt`, `push-notifications.ts:120-128`).
- Fix hint: UnifiedPush distributors expose an RFC 8030 endpoint, which is exactly what the relay's `/api/push/register/web` already accepts (`repos/relay/src/server.ts` handleRegisterWeb); VAPID/`keys` are optional in UnifiedPush so the relay must accept a record without `p256dh`/`auth` and send unencrypted (or only the id). Client side: `unifiedpush-react-native`/`org.unifiedpush.android.connector` receiving the message and calling the same headless task.
- [x] **No "+N more messages" grouping** — fixed in 00faceb — `P3` — `missing`
- What WEB does: one notification per account with the newest message as headline and a "+N more" line, tag `bulwark-mail:<accountId>`, click opens inbox when grouped (`public/sw.js:180-216`; changelog 1.8.x).
- What RN does: one notification per message id `mail:<emailId>` with no `setGroup`/summary (`RN: BulwarkFcmModule.kt postNotification`).
- Fix hint: set `setGroup("bulwark-mail:"+accountId)` on each notification and post a summary notification with `setGroupSummary(true)` and an `InboxStyle` listing; Android does the "+N" rendering.
- [x] **Foreground FCM message is only logged** — fixed in 00faceb — `P3` — `partial`
- What WEB does: SW shows the notification regardless; in-app state refresh comes from the separate SSE channel.
- What RN does: `addMessageListener` handler just `console.log`s (`RN: App.tsx:300-305`); since the Kotlin service skips the headless task in the foreground (`BulwarkMessagingService.kt:26-31`) no notification is posted and no store refresh happens if SSE happens to be down (SSE fallback is 5 s polling, so the gap is small).
- Fix hint: on `fcm:message`, call `useEmailStore.getState().handleStateChange(JSON.parse(payload.data.changed))` (relay sends it) and optionally post an in-app banner.
- [x] **No push onboarding prompt** — fixed in f6f26f7 — `P3` — `missing`
- What WEB does: after the PWA install prompt, a per-account dismissable "enable notifications" card appears 1 s after login unless already enabled (`components/push-notification-prompt.tsx:128-177`; changelog 1.8.0 "Background notification onboarding").
- What RN does: push only starts if `getStoredRelayBaseUrl()` is non-null (`RN: App.tsx:353-354`), i.e. the user must find Settings → Notifications and press Enable; nothing invites them.
- Fix hint: on first authenticated launch, prompt once (with "don't ask again" persisted per account) and call `setupPushNotifications({ relayBaseUrl: DEFAULT_RELAY_BASE_URL })`.
- [x] **Notification sound settings are dead** — fixed in f6f26f7 — `P3` — `partial` — controls removed, channel hint shown; store keys dropped in the settings-store pass
- What WEB does: `notificationSoundChoice` picks one of 5 sounds with preview; email/calendar sound toggles gate playback (`lib/notification-sound.ts`, `notification-settings.tsx:305-331`).
- What RN does: `NotificationSettings.tsx:203-250` renders a sound `Select` (values `chime/ping/pop/none` that exist nowhere), a preview button without `onPress`, and email/calendar sound toggles that no code reads; the Android channel `bulwark_mail` uses the default sound and cannot change it after creation (`BulwarkMessagingService.kt:62-78`).
- Fix hint: on Android sounds belong to channels: create one channel per sound choice (or open the system channel settings via `Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS`), and remove the toggles that cannot be honoured.
### i18n and locales
- [x] **12 of WEB's 27 locales missing in RN** — fixed in 990cd84 — `P2` — `missing`
- What WEB does: `ar ca cs da de en es fa fr he hu it ja ko lv mn nb nl pl pt ro ru sk tr uk zh zh-TW` (`i18n/routing.ts:15`).
- What RN does: `SUPPORTED_LOCALES` has 15 (`RN: src/i18n/index.ts:21-37`); `scripts/sync-locales.mjs` copies every WEB folder (`:32-42`) but `locales/` only contains the 15 and the index imports them statically. Missing: ar, ca, da, fa, he, hu, mn, nb, ro, sk, tr, zh-TW.
- Fix hint: run the sync script, add the imports/labels, and handle `zh-TW` (region subtag) in `detectDeviceLocale` which only compares `languageCode` (`:45-52`).
- [x] **Vendored catalogs are stale and drift from WEB** — fixed in 990cd84 — `P2` — `partial` — RN-only keys live in locales/rn/<lang>.json (merged at runtime); sync-locales.mjs --check reports drift
- What WEB does: `locales/en/common.json` has 2959 leaf keys (2026-08-29).
- What RN does: `RN: locales/en/common.json` last synced 2026-07-22 has 2336 keys; 640 WEB keys are absent; RN en carries 17 keys WEB lacks (hand-added `email_composer.*`) which the 14 other RN locales do not have (de/fr/ja each miss the same 16 keys), and 19 keys RN code calls (`email_composer.schedule_*`, `email_list.no_*_folder`) exist in no catalog, so the inline fallback always shows in every language.
- Fix hint: make `sync-locales.mjs` merge instead of overwrite (keep RN-only keys under a separate `locales/rn/<lang>.json` overlay), add a key-parity test like WEB's translation coverage test, and run it in CI.
- [x] **`translate()` has no interpolation, no plurals, no rich text** — fixed in 990cd84 — `P2` — `partial` — hand-rolled plurals replaced in OfflineBanner/AboutData/Layout/Identity/Templates (8deff66, 22697fd); rich text (tags) not needed by any RN string
- What WEB does: next-intl ICU messages (`{count, plural, ...}`, `{name}` args).
- What RN does: plain nested lookup returning the raw string (`RN: src/i18n/index.ts:54-71`); any WEB string with `{placeholder}` renders literally, and plurals are hand-rolled English (`OfflineBanner.tsx:22`, `AboutDataSettings.tsx:96,176,190`, `LayoutSettings.tsx:148-149`).
- Fix hint: add `t(key, fallback, params)` with `{name}` substitution and a minimal ICU plural (`intl-messageformat` is small and works on Hermes), then replace the hand-rolled plurals.
- [ ] **No RTL support (ar/he/fa)** — `P2` — `missing` — done in 990cd84: forceRTL/allowRTL on override + restart hint, isLayoutRTL() helper in src/i18n; deferred: SwipeableRow side swap (mail-list agent's file - call isLayoutRTL() there)
- What WEB does: `getLocaleDirection` sets `dir=rtl`, logical CSS, JS popovers flip, RTL-aware swipe (`i18n/direction.ts`; changelog 1.7.0-1.7.3 RTL entries).
- What RN does: ar, he and fa ship since 990cd84, which also calls `I18nManager.forceRTL`/`allowRTL` when the language override changes (after a restart); mail bodies get `dir="auto"` since 910c503 (audit U4). `SwipeableRow` still maps left/right physically.
- Fix hint: when adding ar/he/fa, call `I18nManager.forceRTL(dir==='rtl')` + `allowRTL` on override change (requires reload), audit `paddingLeft/Right` → `paddingStart/End`, and swap swipe actions when `I18nManager.isRTL`.
- [ ] **Language list not localized and no "auto" date locale** — `P3` — `partial` — deferred: language labels are native names (webmail parity); timeZone landed as calendarTimeZone (calendar agent); dateLocale needs a locale override threaded through src/lib/date-format.ts call sites in other areas
- What WEB does: language picker sorted, flags, `dateLocale` (auto/iso/en-GB/en-US), `firstDayOfWeek`, `timeZone` on the Language tab (`components/settings/language-settings.tsx` `updateSetting('dateLocale'|'firstDayOfWeek'|'timeZone'`).
- What RN does: `LanguageSettings.tsx` offers language, `dateFormat`, `timeFormat` only; day-of-week lives under Calendar; no time zone.
- Fix hint: add `dateLocale` and `timeZone` keys and thread them through `src/lib/date-format.ts` (`Intl.DateTimeFormat(locale, { timeZone })`).
### Themes and appearance
- [ ] **Font size setting is honoured by the mail list only** — `P3` — `partial` — deferred: Text.defaultProps is gone on the new architecture; needs the static typography import replaced by useTypography() per screen
- What WEB does: `--font-size-base` on `:root` scales everything (`stores/settings-store.ts:1264-1274`).
- What RN does: `useTypography()` (`RN: src/theme/dynamic.ts:47-68`) is used by EmailListScreen and its attachment chips only; the other screens spread the static `typography` from `tokens.ts`, so Small/Large changes almost nothing (the thread reader, compose, settings, calendar, contacts all stay fixed).
- Fix hint: either make `tokens.typography` a hook-backed getter or, cheaper, set `Text.defaultProps`/`maxFontSizeMultiplier` and scale via `allowFontScaling` + a root `PixelRatio` factor.
- [x] **`animationsEnabled` ignored by most animations; no reduce-motion respect** — fixed in 3ae9bbe — `P3` — `partial` — useShouldAnimate ORs in AccessibilityInfo.isReduceMotionEnabled; the 18 fixed-duration Animated.timing calls in other areas' files still need useAnimDuration
- What WEB does: `--transition-duration: 0s` when off (`stores/settings-store.ts:1322-1331`) and `@media (prefers-reduced-motion: reduce)` (`app/globals.css:681`).
- What RN does: 18 files call `Animated.timing`/`LayoutAnimation` with literal durations (e.g. `UndoSnackbar.tsx:32-42`, every sheet, `SwipeableRow.tsx`); `useAnimDuration` used in 3. `AccessibilityInfo.isReduceMotionEnabled` is never consulted.
- Fix hint: route durations through `useAnimDuration` and OR in `AccessibilityInfo.isReduceMotionEnabled()` inside `useShouldAnimate`.
- [x] **Density preview and other hard-coded dark colours break on the light theme** — fixed in 22697fd — `P3` — `rn-only-bug` — Appearance/Themes/SidebarApps/settings-section/OfflineBanner/SettingsScreen badge use tokens; Plugins pane replaced
- What WEB does: preview uses tokens (`components/settings/appearance-settings.tsx:21-64`).
- What RN does: `AppearanceSettings.tsx:63` paints read subjects `rgba(250,250,250,0.8)` and `:193` preview text `rgba(161,161,170,0.7)` regardless of theme, so the preview is near-invisible on light. Similar literals: `PluginsSettings.tsx:27-30`, `ThemesSettings.tsx:108`, `SidebarAppsSettings.tsx:86,238,296`, `OfflineBanner.tsx:43` (`#a16207`), `SettingsScreen.tsx:382`.
- Fix hint: replace with `c.textSecondary`/`c.mutedForeground`/`c.warningBg` tokens.
- [x] **`userInterfaceStyle: 'dark'` likely defeats the "System" theme on iOS** — fixed in 5802cae — `P3` — `bug` (verify) — userInterfaceStyle automatic + light/dark splash
- What WEB does: `system` follows `prefers-color-scheme` (`stores/theme-store.ts:53-56`).
- What RN does: `RN: app.config.js:33` sets `userInterfaceStyle: 'dark'`, which prebuild writes as `UIUserInterfaceStyle = Dark` in Info.plist, so `useColorScheme()` (`src/theme/colors.ts:16`, `App.tsx:220`) always returns `dark` and "System" behaves like "Dark". On Android without `expo-system-ui` (not in `package.json`) the key is ignored with a prebuild warning, so Android is probably fine. Splash background is also dark-only (`:38`).
- Fix hint: set `userInterfaceStyle: 'automatic'` and give the splash a light variant (`splash.dark`).
- [x] **Themes tab is a stub with fake themes; `activeThemeId` never applied** — fixed in fe45a44 — `P3` — `missing` (record as N/A for now) — Qui/Nord/Catppuccin/Solarized token sets generated from lib/builtin-themes.ts (src/theme/builtin-themes.ts); useColors() applies activeThemeId; upload button removed; Roundcube Elastic and Aurora Glass added in 7964e64, "Flat fields" not ported
- What WEB does: 6 built-in themes (`lib/builtin-themes.ts:879-936`), zip upload, marketplace, admin forced/default theme, compiled CSS tokens, PWA theme-color meta (`stores/theme-store.ts`).
- What RN does: `ThemesSettings.tsx:18-22` lists `Default/Qui/Sepia` (Qui exists in WEB; Sepia does not), stores `activeThemeId` that nothing reads, "Upload .zip" has no handler (`:81-83`); the tab is marked Experimental.
- Fix hint: minimal viable subset = map WEB's built-in theme token sets (`builtin-themes.ts` light/dark `colors`) onto `ThemePalette` and let `useColors()` pick `activeThemeId`; remove the upload button. Otherwise hide the tab.
- [ ] **Status bar / navigation bar theming partial** — `P3` — `partial` — deferred: expo-navigation-bar / expo-system-ui are not installed; add one and call setBackgroundColorAsync(c.background) on theme change
- What WEB does: theme-color meta follows active theme (#671).
- What RN does: `StatusBar style` is derived (`App.tsx:219-223`) but the Android navigation bar (edge-to-edge is on, `app.config.js:56`) is never themed, and the bottom tab bar/`SafeAreaView` colours come from `useColors` so they are fine.
- Fix hint: `expo-navigation-bar`/`SystemUI.setBackgroundColorAsync(c.background)` on theme change.
### Updates
- [x] **No severity concept; security/deprecated updates are dismissable** — fixed in 431face — `P3` — `partial` — severity:/advisory: lines in the release body
- What WEB does: server-side check against `version.telemetry.bulwarkmail.org` with `severity` (normal/security/deprecated), red non-dismissable banner for security/deprecated, stale cached status discarded after upgrade (`lib/version-check/sender.ts:86-96`, `stores/update-store.ts:86-122`, commit 39dcc6b2).
- What RN does: GitHub Releases `latest` only (`RN: src/api/updates.ts:57-91`); banner dismissed per tag (`UpdateBanner.tsx:21`, `updates-store.ts:136-146`); no notion of a mandatory update; no advisory link.
- Fix hint: have the version server add a `native` channel (or read a `severity:` line from the release body) and make the banner non-dismissable when `security`/`deprecated`.
- [x] **Update check hits the GitHub API unauthenticated and blocks on companion downloads** — fixed in 431face — `P3` — `rn-only-bug`
- What RN does: `fetchLatestRelease` calls `api.github.com` (60 req/h/IP) and then downloads the `.apk.sha256` asset on every check (`RN: src/api/updates.ts:44-55,74-81`); on rate-limit the store shows `GitHub API 403` under "Last checked" with no backoff other than the 6 h interval.
- Fix hint: treat 403/429 as "skip until next interval" without surfacing an error, fetch the checksum only when the user taps Install.
- [x] **Release notes rendered raw** — fixed in 431face — `P3` — `partial`
- What RN does: `cachedLatest.body` shown as plain text, 20 lines (`UpdatesSettings.tsx:187-194`); markdown headings/links appear literally.
- Fix hint: strip markdown or use a tiny renderer; link to `htmlUrl`.
### Plugins, extensions, S/MIME, sidebar apps (mostly N/A / out of scope for now)
- [x] **Plugins tab is a non-functional stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now) — tab hidden; pane is an explainer
- What WEB does: sandboxed iframe plugin runtime, marketplace, signing, admin approval, ~40 hooks/API methods (changelog 1.5.x-1.9.x Plugins entries).
- What RN does: `PluginsSettings.tsx:41` starts with `plugins = []`, "Upload .zip" has no handler (`:176-178`), `pluginEnabled` map persists toggles for nothing.
- Fix hint: out of scope; hide the tab (or keep it as an "install from webmail" explainer). Minimal viable subset would be theme-only plugins (token sets) once the Themes finding lands.
- [x] **S/MIME tab is a stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now)
- What WEB does: S/MIME moved out of core into a privileged crypto plugin (changelog 1.6.0 "Breaking"); core exposes key/cert management on the Security tab.
- What RN does: `SmimeSettings.tsx:32-33` uses empty `MOCK_KEYS`/`MOCK_CERTS`; Import buttons have no handlers (`:118-120,163-165`); three toggles persist unused prefs; the tab is labelled implemented and lives under Privacy.
- Fix hint: mark `implemented: false` in `SettingsScreen.tsx:97` until a native crypto path exists.
- [ ] **Sidebar apps: settings exist, apps never rendered; no admin defaults (#931)** — `P3` — `partial` — deferred: useMobileSidebarApps()/openSidebarApp() in src/lib/sidebar-apps.ts (0e63d45) ready for SidebarDrawer (mail-list agent's file); admin defaults N/A
- What WEB does: apps render in the navigation rail (`components/layout/navigation-rail.tsx:391` filters `showOnMobile`), inline iframe or new tab, `keepAppsLoaded`, drag reorder, plus operator-pinned apps merged from policy (`lib/sidebar-apps.ts:103-112`, commit 4fe5701b).
- What RN does: `SidebarAppsSettings.tsx` adds/edits/removes entries into `sidebarApps` (`settings-store.ts:502-523`) but `SidebarDrawer.tsx` never reads them (grep: no usage outside settings); reorder handle is decorative (`:72`).
- Fix hint: minimal viable subset = list `sidebarApps.filter(showOnMobile)` at the bottom of `SidebarDrawer`, open with `expo-web-browser` (`openMode:'tab'`) or a `react-native-webview` screen (`inline`); admin defaults are N/A without a policy source (could be read from a webmail `/api/config` if the webmail origin becomes known).
### Misc UI
- [x] **Deep-link scheme registered but nothing handles it** — fixed in 5802cae — `P2` — `missing` — bulwarkmobile://, webmail https permalinks, mailto: (scheme + SENDTO filter) and ACTION_SEND/SEND_MULTIPLE share targets → Compose; iOS universal links still deferred; shared files never uploaded, `SENDTO` opened the inbox and `+` addresses opened nothing until cd4ce9d, 06e8942 and ee652d3 (audit B9–B11)
- What WEB does: permalinks for mail/calendar/contacts/files/settings with build/parse helpers (`lib/deep-links.ts:196-235,272-311,322-370,379-414`), `mailto:` opens the built-in composer, protocol handler registration, `webcal:`.
- What RN does: `scheme: 'bulwarkmobile'` (`RN: app.config.js:29`) and a `VIEW`/`BROWSABLE` intent filter (`AndroidManifest.xml:25-30`) exist, but `NavigationContainer` has no `linking` prop (`App.tsx:443`) and there is no `Linking.getInitialURL`/`addEventListener('url')` anywhere, so tapping a `bulwarkmobile://` URL merely launches the app. No `https` App Links for the webmail permalink format, no `mailto:` intent filter (tapping a mailto link in another app never offers Bulwark), no `SEND`/`SEND_MULTIPLE` share target.
- Fix hint: add a `linking` config mapping `bulwarkmobile://mail/message/:emailId`, `/calendar/event/:id`, `/contacts/:id`, `/settings/:tab` to the existing routes (`src/navigation/types.ts`), reuse WEB's path grammar so a webmail permalink can be rewritten; add `<data android:scheme="mailto"/>` and `ACTION_SEND` (`text/*`, `image/*`) filters routed to `Compose` with `prefillTo`/attachments. iOS: `CFBundleURLTypes` via `scheme` is already emitted; add `associatedDomains` for universal links later.
- [x] **No general toast system; only the email undo snackbar** — fixed in 3ae9bbe — `P3` — `partial` — src/stores/toast-store.ts + ToastHost mounted in App.tsx; Alert.alert call sites not migrated
- What WEB does: typed toasts with title/message, primary+secondary actions, progress bar, pause-on-hover, 10 s for errors (`stores/toast-store.ts`, `components/ui/toast.tsx`), used for every post-action acknowledgement (move toast shows full folder path, send-delay toast with "Send now").
- What RN does: `UndoSnackbar.tsx` is bound to `useEmailStore.pendingUndo` only; 23 files use `Alert.alert` (modal, blocking) for confirmations and errors; success acknowledgements mostly absent.
- Fix hint: generalise `UndoSnackbar` into a `toast-store` (queue, type, action) and mount one host in `App.tsx`; migrate error `Alert.alert`s that need no decision.
- [x] **No prompt dialog; Dialog is confirm-only and untranslated** — fixed in 3ae9bbe — `P3` — `partial`
- What WEB does: `components/ui/confirm-dialog.tsx` and `prompt-dialog.tsx` (text input), `useConfirmDialog` hook.
- What RN does: `Dialog.tsx:8-17` takes title/message/confirm/cancel with English defaults `'Confirm'`/`'Cancel'` (`:33-34`); text prompts are ad-hoc `TextInput`s.
- Fix hint: add an `input` prop and default the labels via `t('common.confirm')`/`t('common.cancel')`.
- [x] **Offline banner not localized; no "reconnect" retry surface** — fixed in 990cd84 — `P3` — `partial`
- What WEB does: `online` event triggers refetch (`components/mail/mail-app.tsx:269-278`, `lib/jmap/client.ts:7398`); strings come from catalogs.
- What RN does: `OfflineBanner.tsx:22-29` hard-codes "You are offline" with an English plural; network-store logic itself is sound (`network-store.ts:16-22` treats `isInternetReachable === null` as online) and App.tsx retries the session on reconnect (`:245-252`).
- Fix hint: `t('common.offline', ...)` and reuse plural helper once interpolation exists.
- [x] **Accessibility labels almost absent** — `P3` — `partial` — done in my files (RadioGroup/Select roles, Dialog header, Settings back button, ToastHost, UpdateBanner, AccountSettings, security key rows); the mail list, composer, Files, contacts, sign-in and settings switches followed in 9c0c35c, 771558d, aab29da, d59c571, 5801d6c, cb5c152 and c1e5cd6, and the viewer, calendar, tab bar, unified inbox and drawer in 13cba64, b3a82f1, 84fef14 and b178ee8 (audit U8)
- What WEB does: aria-labels on icon buttons, `role="checkbox"`, screen-reader improvements (changelog 1.8.0 Navigation).
- What RN does: `accessibilityLabel`/`Role` appear in 8 of 94 `.tsx` files (FilesScreen 4, EmailListScreen 3, login 4, EmailThreadScreen 2, ToggleSwitch 1). Icon-only `Pressable`s in settings (`SettingsScreen.tsx:217-222` back button, `SidebarAppsSettings.tsx:91-96`, `SmimeSettings.tsx:91-106`, `UpdateBanner.tsx:40`), the tab bar badge and the `RadioGroup`/`Select` in `settings-section.tsx` have none.
- Fix hint: give every icon-only Pressable an `accessibilityLabel` + `accessibilityRole="button"`, `ToggleSwitch` `accessibilityRole="switch"` + `accessibilityState`, `RadioGroup` options `radio`.
- [x] **No haptic feedback** — fixed in 3ae9bbe — `P3` — `missing` (mobile-only nicety) — expo-haptics + src/lib/haptics.ts haptic(kind); SwipeableRow/undo wiring is the mail-list agent's (call haptic('light') at threshold, haptic('success') on undo)
- What RN does: `VIBRATE` permission exists only for the notification channel; no `expo-haptics` in `package.json`; swipe-to-action, undo and destructive confirms give no tactile feedback.
- Fix hint: `expo-haptics` `impactAsync(Light)` on swipe threshold cross and `notificationAsync(Success/Warning)` on undo/destructive.
- [x] **Version badge / "update available" tag missing from About** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: About card shows version, commit and an `update: x.y.z` / `security` pill (`components/settings/about-data-settings.tsx:20-49`).
- What RN does: `AboutDataSettings.tsx:114-134` shows version+commit; update info lives only in the (Android-only) Updates tab; on iOS nothing indicates a newer build exists.
- Fix hint: reuse `useUpdatesStore.hasUpdate()` for a pill; on iOS link to TestFlight/App Store instead of Install.
## Verified at parity (do not redo)
- Per-account push registry keys, legacy key migration, in-flight coalescing, relay register/verify/active/unregister endpoint usage, 90-day expiry with 7-day refresh, relay-confirmed-dead reaping: `RN: src/lib/push-notifications.ts:16-95,271-298,342-383,411-439` match `lib/web-push.ts:16-25,334-389,448-489` and `repos/relay/README.md` endpoints.
- Notification tap routing switches account before navigating (`RN: App.tsx:58-78`), cold-start tap via `NotificationTapStore`.
- Headless task respects `emailNotificationsEnabled` and restores the active account afterwards (`push-background-task.ts:32-41,136-144`).
- Relay treats `EmailPush` bodies and forwards only ids (`repos/relay/src/payload.ts`); RN register payload shape (`subscriptionId`, `fcmToken`, `accountLabel`) matches `handleRegister`.
- Update cache staleness after upgrade (#913 analogue): RN compares `cachedLatest.tag` to the running version at read time (`updates-store.ts:150-156`), so a cached "newer" release cannot survive an upgrade. `version-compare.ts` semantics match WEB `lib/version-compare.ts`.
- APK download: size check, optional SHA-256 from companion asset or body, `REQUEST_INSTALL_PACKAGES`, installer intent (`install-update.ts`); Updates tab hidden off Android (`platform-capabilities.ts`, `SettingsScreen.tsx:145-147`); pending-APK polling every 30 s.
- Theme light/dark/system palette hook (`src/theme/colors.ts`), density/font hooks exist (`dynamic.ts`), status bar style follows theme (`App.tsx:219-223`).
- Language picker with system default, `dateFormat` preview, `timeFormat` (`LanguageSettings.tsx`); locale override persisted (`locale-store.ts`); `t()` falls back en → fallback → key.
- Confirm dialog styling mirrors WEB confirm-dialog (`Dialog.tsx:19-27`); undo snackbar timer is `createdAt`-based so re-renders do not reset it.
- Offline detection via NetInfo with reachability fallback; outbox flush on reconnect (`App.tsx:289-295`).
- Hardware back closes a settings pane (`SettingsScreen.tsx:192-199`); settings groups/tabs mirror WEB's six groups.
## N/A on mobile
- Pro interface, toolbar position, hide account switcher / rail account list, hover actions & corners, favicon unread badge, keyboard shortcuts (`hooks/use-keyboard-shortcuts.ts`), guided tour / onboarding-on-new-devices, PWA install prompt & Apple touch icons, protocol handler page (`mailto:`/`webcal:` registration is done via intent filters instead — see deep-link finding), `protocolOpenMode`, admin policy lock/hide of settings, plugin sandbox/marketplace/signing, theme zip upload, telemetry (server-side), server-side version-check scheduler, `settingsSyncEnabled` config flag, `NEXT_PUBLIC_LOCALE_PREFIX` routing, calendar hover preview, mail layout (split/focus/horizontal), resizable columns (`stores/ui-store.ts`), `SETTINGS_DATA_DIR`/`SESSION_SECRET` encryption at rest (native has no server; use OS keystore semantics instead), spam-siege easter egg.
+319
View File
@@ -0,0 +1,319 @@
# JMAP client core, live sync, offline/outbox, network & error handling, security, S/MIME & encryption-at-rest
## Summary
RN's JMAP client (`src/api/jmap-client.ts`, 615 lines) is a thin transport: session discovery, Basic/Bearer auth with proactive+reactive OAuth refresh, 401/429 mapping, capability lookups, blob URL templating. It lacks essentially every robustness layer WEB grew between 1.4.5 and 1.9.2: request deadlines (#702), `maxConcurrentRequests` back-off (#780), transient-network retry, rate-limit pause/resume, `maxObjectsInSet`/`maxSizeUpload` awareness, relative-session-URL resolution, keep-alive ping with connection-lost signalling, and first-touch serialisation (#907). Most RN API functions index `methodResponses[0][1]` without checking for `error` / `notUpdated`, which combines badly with the outbox (a rejected mutation is treated as done). Live sync is the weakest area: there is no `AppState` handling at all, `react-native-sse` never re-polls after a network error (status 0), the SSE stream captures the Authorization header once (stale after OAuth refresh), the push effect does not re-run on account switch (stream stays bound to the previous account), and a client-cert user gets no SSE and no polling fallback. Security posture is decent (SecureStore, hardened WebView, no secret logging) but has gaps: `Linking.openURL` with unvalidated schemes from contact/calendar data, `Math.random` for OAuth `state` and TOTP secret, http:// server URLs accepted, AsyncStorage mail cache in Android auto-backup, password change leaving stored credentials stale (locks the account out). S/MIME is a mock UI; encryption-at-rest is read-only; List-Unsubscribe is absent.
## Findings
### JMAP client core (request pipeline)
- [x] **No request deadline: a stalled socket hangs send/save forever (#702)** — `P1` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `timedFetch` puts a 30 s deadline on response headers (300 s for blob transfers), throws `RequestTimeoutError`, never retries a timed-out non-idempotent request (`lib/jmap/client.ts:629-634`, `786-816`, `836-846`; changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)").
- What RN does: `request()`, `fetchSession()`, `fetchBlobArrayBuffer()` call `secureFetch` with no `AbortController`/timeout (`src/api/jmap-client.ts:405-447`, `363-386`, `568-584`). RN `fetch` has no timeout of its own; iOS suspends sockets in background and hands back dead pooled connections on resume, so the composer's Send stays disabled indefinitely. Only the client-cert native path has a 30 s timeout (`src/lib/client-cert.ts:250`).
- Fix hint: wrap every fetch in `jmap-client.ts` with an `AbortController` + `setTimeout` (30 s API, 300 s blob), throw a `RequestTimeoutError` subclass, and make `isTransientNetworkError` classify it as transient only for idempotent ops (never re-send `EmailSubmission/set`).
- [x] **`maxConcurrentRequests` refusal (400 `jmap:error:limit`) is a hard failure (#780)** — `P2` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `isConcurrentRequestRefusal` + 3 jittered retries (200/400/800 ms) before failing (`lib/jmap/client.ts:462-473`, `1136-1146`; changelog "Keep the folder tree when a refresh burst hits maxConcurrentRequests (#780)").
- What RN does: any non-OK status throws `JMAP request failed: 400` (`src/api/jmap-client.ts:442-444`). A single StateChange fans out to `handleStateChange` in email, contacts and calendar stores concurrently (`App.tsx:391-396`), each of which issues 1-3 requests, so Stalwart's default ceiling of 4 is hit in ordinary use; the folder tree is preserved (`src/stores/email-store.ts:604-612`) but the refresh is dropped.
- Fix hint: port `isConcurrentRequestRefusal` and the retry loop into `JMAPClient.request()`; read the body text before deciding.
- [x] **No transient-network retry** — `P2` — `missing` — fixed in 0b1c240
- What WEB does: retries once after 1 s on a fetch `TypeError` unless already reconnecting or timed out (`lib/jmap/client.ts:836-846`).
- What RN does: `request()` propagates the raw `TypeError("Network request failed")` (`src/api/jmap-client.ts:427`); `isTransientNetworkError` recognises it (`src/lib/network-error.ts:48-63`) but only the outbox uses that.
- Fix hint: single retry for GET/idempotent bodies in `request()`; keep non-idempotent (`EmailSubmission/set`) un-retried.
- [x] **429 handling: no rate-limit state, no pause of live updates, HTTP-date `Retry-After` becomes NaN** — `P2` — `partial` — fixed in 0b1c240 (rate-limit window + `onRateLimit`; SSE/polling pause via the window check in `request()`)
- What WEB does: `setRateLimited` blocks all requests until the window ends, tears down and re-arms push, notifies UI via `onRateLimit` and a throttled toast event; `parseRetryAfter` handles seconds and HTTP-date, caps at 5 min, defaults 60 s (`lib/jmap/client.ts:820-826`, `848-852`, `7421-7486`; `stores/auth-store.ts:208-226`).
- What RN does: throws `RateLimitError(ms)` (`src/api/jmap-client.ts:436-440`) where `parseInt` of an HTTP-date yields `NaN*1000`; nobody catches it (grep: only tests). SSE reconnect (5 s), polling (5 s), outbox flush and stores keep hammering the server during the window.
- Fix hint: add `rateLimitedUntil` to the client, short-circuit `request()` while limited, close SSE/polling and re-arm after the window, expose to `OfflineBanner`.
- [x] **HTTP error body and cause not surfaced; JSON parse unguarded** — `P3` — `partial` — fixed in 0b1c240
- What WEB does: error message includes status + first 200 chars of body, JSON parse failure mapped to a clear error (`lib/jmap/client.ts:1147-1156`; changelog "Surface the underlying network error cause in passthrough failures").
- What RN does: `JMAP request failed: ${status}` and `response.json()` unguarded (`src/api/jmap-client.ts:442-446`); `loadAccount` wraps into `NetworkError` but `request()` throws bare `TypeError`.
- Fix hint: read text, include in message, wrap `JSON.parse`; throw `NetworkError` from `request()` too.
- [x] **Method-level `error` / `notUpdated` / `notDestroyed` unchecked in most RN API functions** — `P1` — `rn-only-bug` — fixed in ccbe67c (mail/identity/push; contacts, calendar, files done by their agents)
- What WEB does: mutations inspect `notUpdated`/`notDestroyed` (`lib/jmap/client.ts:2282-2286` moveEmail; `3290-3345` sendEmail), reads check the method name before indexing (`1207-1208`).
- What RN does: `res.methodResponses[0][1].list` / `.ids` indexed blindly in `getMailboxesWithState` (`src/api/email.ts:61-70`), `getEmails` (`322-327`), `getEmailsWithState` (`338-343`), `getThread` (`428-434`), `queryEmails` (`244-260`), `getIdentities` (`src/api/identity.ts:5-12`); `Email/set` mutations never look at the response: `setEmailKeywords` (`email.ts:436-445`), `moveEmail`/`moveEmails` (`447-480`), `deleteEmails` (`483-495`), `setKeywordsForEmails` (`527-539`), `restoreEmailMailboxes`/`setEmailMailboxes` (`541-575`), `destroyEmails` (`581-586`), `updateIdentity`/`deleteIdentity` (`identity.ts:27-45`). An `['error', {type:'forbidden'}]` response makes the read path throw a `TypeError` (classified transient by `network-error.ts:51`, see outbox finding) and makes the write path report success: the outbox removes the entry (`src/stores/outbox-store.ts:199-200`), the optimistic list/cache edit is never reverted.
- Fix hint: add a shared `requireMethodResult(res, callId)` (like `resultFor` in `src/api/account-security.ts:59-67`) and an `assertSetResult(body, ids)` that throws on `notUpdated`/`notDestroyed`; use them in every `src/api/*.ts` function.
- [x] **`Email/get` and `Email/set` not split to `maxObjectsInGet`/`maxObjectsInSet`; no `getMaxObjectsInSet`** — `P2` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: `batched(ids, getMaxObjectsInGet())` for gets (`lib/jmap/client.ts:713-727`), `getMaxObjectsInSet()` for `batchMarkAsRead`, `batchDeleteEmails`, `batchMoveEmails`, `batchUpdateKeywords` (`1885-1894`, `2109-2136`, `1953-1961`, `4194-4197`; `lib/jmap/request-limits.ts`; changelog "Split requests to stay inside the server's advertised limits").
- What RN does: `getEmailsWithState` (`src/api/email.ts:338-343`) is called from the incremental refresh with an unbounded `addedIds + updatedIds` list (`src/stores/email-store.ts:848-857`), `getMailboxesByIds` after `Mailbox/changes` is unbounded (`email.ts:114-127`, store `565-567`), `listScheduledEmails` fetches up to 200 ids in one get (`email.ts:922-928`); `setKeywordsForEmails`, `moveEmails`, `restoreEmailMailboxes`, `destroyEmails`, `archiveEmails` put every selected id into one `Email/set` (no `maxObjectsInSet` accessor exists in `jmap-client.ts`). Only `fetchEmailsChunked` (`email-store.ts:371-381`), `getEvents`/`getContacts` (`src/api/calendar.ts:214`, `contacts.ts:45`) and `offline-sync.ts:92` chunk. Over-limit requests fail whole, so a long offline gap (many updated ids) breaks the incremental refresh until the snapshot is dropped.
- Fix hint: add `getMaxObjectsInSet()` + a `batched()` helper to `jmap-client.ts`; apply in the listed functions.
- [x] **`maxSizeUpload` / `maxSizeRequest` ignored** — `P3` — `missing` — fixed in 0b1c240 (accessors; enforced by the files/composer agents)
- What WEB does: `getMaxSizeUpload()` (`lib/jmap/client.ts:4179-4182`) gates attachment/file uploads with a clear message (changelog 1.6.x "Use dynamic server-configured maximum upload sizes"; "maxSizeRequest").
- What RN does: no reference to `maxSizeUpload` anywhere (`grep -rn maxSizeUpload src` empty); `uploadBlob` (`src/api/blob.ts:5-62`) surfaces an opaque `Upload failed: 413`.
- Fix hint: expose `getMaxSizeUpload()` and check before `File(uri).bytes()` in `blob.ts`/ComposeScreen.
- [x] **Relative session URLs are not resolved (37f830da)** — `P2` — `bugfix-parity` — fixed in 0b1c240 (unified-inbox.ts with the mail-list agent)
- What WEB does: `rewriteSessionUrl` rebases both absolute and relative (`/jmap/`) `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` onto the login origin without touching `{accountId}` templates (`lib/jmap/client.ts:1091-1114`; changelog 1.9.x "Resolve relative session URLs without corrupting URI templates").
- What RN does: `rewriteSessionUrls` only rewrites when `extractOrigin(url)` matches `^https?://` (`src/api/jmap-client.ts:320-340`); a relative `apiUrl` is returned unchanged and `fetch('/jmap/')` then fails with "Network request failed". Same gap in `src/api/unified-inbox.ts:55-60`. (RN correctly avoids `new URL()` so templates are not corrupted.)
- Fix hint: in `rewrite()`, when `extractOrigin` is null and the string starts with `/`, return `serverOrigin + url`.
- [x] **Capability check uses the broad `principals` URN, not `principals:owner` (44360024)** — `P2` — `bugfix-parity` — fixed in a0ce925; since Stalwart advertises `principals:owner` nowhere, that also hid sharing, so since caea3d0 the sharing UI is gated on `principals` and `principals:owner` only goes into `using` when advertised
- What WEB does: declares `urn:ietf:params:jmap:principals:owner` in `using` only when that exact capability is advertised in session or account capabilities (`lib/jmap/client.ts:4689-4698`, `6472`; changelog "Check the specific capability a request declares (principals:owner), not a broader one").
- What RN does: `supportsSharing()` checks `CAPABILITIES.PRINCIPALS` (`src/api/files.ts:23-25`) and then pushes `PRINCIPALS_OWNER` into `using` (`files.ts:40-43`). A server advertising `principals` but not `principals:owner` rejects every FileNode request with `unknownCapability`.
- Fix hint: check `hasCapability(CAPABILITIES.PRINCIPALS_OWNER) || accountCapabilities[...]` before adding it.
- [x] **PatchObject pointer keys not JSON-Pointer escaped (5c484af1)** — `P3` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: `keywordPointer()`/`pointerToken()` escape `~` and `/` (`lib/jmap/patch-pointer.ts:39-55`); mailbox moves use a whole-map replacement (`lib/jmap/client.ts:66-74`).
- What RN does: `moveEmail`/`moveEmails` build `mailboxIds/${id}` pointers unescaped (`src/api/email.ts:456-457`, `473-474`). Stalwart ids never contain `/`, so exposure is limited to non-Stalwart servers; keywords are always sent as a whole map (`email.ts:436-445`), so tag names with `/` are safe. (Since 5041897 keywords are sent as `keywords/<name>` pointers too, escaped by `keywordPointer` in `src/api/patch-pointer.ts`.)
- Fix hint: add `pointerToken()` or switch `moveEmail(s)` to the full `mailboxIds` replacement RN already uses in `restoreEmailMailboxes`.
- [x] **Send: message created directly in Sent instead of Drafts + `onSuccessUpdateEmail` (#188)** — `P2` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: creates in Drafts with `$draft`, submits, and moves to Sent via `onSuccessUpdateEmail` (full `mailboxIds` replacement, `keywords/$draft: null`) so the SMTP send happens before the message lands in Sent; needed for servers that encrypt on append (`lib/jmap/client.ts:3186-3190`, `3231-3238`, `3272-3287`; changelog "File the post-send message with a full mailboxIds replacement").
- What RN does: `emailCreate.mailboxIds = { [sentMailboxId]: true }`, `keywords: { $seen: true }`, no `onSuccessUpdateEmail` (`src/api/email.ts:786-793`, `836-846`). With Stalwart encryption-at-rest + `encryptOnAppend` the copy is encrypted before `EmailSubmission/set` reads it; and if the submission fails, a never-sent copy sits in Sent.
- Fix hint: mirror WEB: create in Drafts (needs the drafts mailbox id), `onSuccessUpdateEmail: { '#draft': { mailboxIds: {sent: true}, 'keywords/$draft': null } }`, and report `notUpdated` as a filing warning.
- [x] **Message-ID / In-Reply-To handling diverges from WEB** — `P3` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: generates `messageId` client-side from the sender domain, strips angle brackets from `inReplyTo`/`references` and sends them as JMAP arrays (`lib/jmap/client.ts:546-570`, `3177-3180`, `3186-3188`; changelog "Generate the Message-ID client-side using the sender's domain").
- What RN does: relies on the server for Message-ID; uses raw `header:In-Reply-To:asText` / `header:References:asText` strings (`src/api/email.ts:823-826`).
- Fix hint: port `generateMessageId` + `stripMessageIdBrackets`, use `inReplyTo`/`references` arrays.
- [x] **Dead duplicate `src/api/submission.ts` with zero error checking** — `P3` — `rn-only-bug` — fixed in 42ab246
- What RN does: `submission.ts:140-199` is an older `sendEmail` that ignores `notCreated`/errors; no importers (`grep api/submission` empty). Risk is someone wiring it back in.
- Fix hint: delete the file.
- [x] **No keep-alive ping / connection-lost signalling / session refresh** — `P2` — `missing` — fixed in edc26ce
- What WEB does: `Core/echo` every 30 s with exponential skip back-off, `reconnect()` on failure, `onConnectionChange` drives the "connection lost" banner and per-account `isConnected` (`lib/jmap/client.ts:1010-1075`; `stores/auth-store.ts:199-206`); basic-auth 401 re-fetches the session and re-prompts TOTP (`client.ts:862-897`).
- What RN does: no ping (`grep Core/echo src` empty); connectivity comes only from NetInfo (`src/stores/network-store.ts`), so a reachable-but-down server never surfaces, and the session document (capabilities, shared accounts, `eventSourceUrl`) is never re-fetched until restart. A basic-auth 401 throws `AuthenticationError` straight away (`src/api/jmap-client.ts:432-434`).
- Fix hint: add a 30 s `Core/echo` while foregrounded (see AppState finding) feeding `network-store.online`/`account-store.isConnected`; on 401 for basic auth re-fetch the session once before failing.
- [x] **Password change leaves the stored credential stale, then evicts the account** — `P1` — `rn-only-bug` — fixed in 032732c
- What WEB does: (same class of exposure via cookie, but the server-side passthrough re-reads; out of scope). `stores/account-security-store.ts:503-526`.
- What RN does: `changePassword` succeeds (`src/api/account-security.ts:185-197`, `src/components/settings/AccountSecuritySettings.tsx:137-141`) but `JMAPClient.credentials.password` and the SecureStore entry keep the old password. The next request gets 401 -> `AuthenticationError`; on the next launch `restoreSession` clears credentials and removes the account (`src/stores/auth-store.ts:516-523`), and `retrySession` does the same (`571-587`). Push background task also starts failing.
- Fix hint: add `jmapClient.updatePassword(newPassword)` that rewrites `credentials` and persists via `setStoredCredentials`; call it after a successful `changePassword` (and after TOTP enable/disable if the password format changes).
- [x] **First-touch serialisation for Calendar/Contacts (#907)** — `P3` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `FirstTouchGate` holds later Calendar*/AddressBook*/ContactCard* requests per account until the first one settles, so clustered Stalwart doesn't create two default calendars (`lib/jmap/first-touch-gate.ts`, `lib/jmap/client.ts:1121-1128`).
- What RN does: `refetchFeatureStores` fires `fetchContacts`, `fetchCalendars`, `refresh` concurrently on every login/restore/switch (`src/stores/auth-store.ts:81-99`).
- Fix hint: port `first-touch-gate.ts` and wrap `request()`; reset it in `reset()`/`loadAccount`.
- [x] **Mailbox fetch not retried on first login (lazy provisioning, #217)** — `P3` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: retries the initial mailbox fetch when a freshly created account returns no folders (changelog 1.6.x "Retry mailbox fetch on first login to handle lazy provisioning (#217)").
- What RN does: `fetchMailboxes` does one `Mailbox/get`; an empty list on a brand-new Stalwart account stays empty until pull-to-refresh (`src/stores/email-store.ts:584-588`).
- Fix hint: if the list is empty and there is no `inbox` role, retry after ~1 s up to 3 times.
- [x] **Session redirect with dropped Authorization not handled** — `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: detects `response.redirected` + empty accounts/username and refetches the final URL with the header (`lib/jmap/client.ts:911-925`).
- What RN does: `fetchSession` accepts whatever comes back; `resolveAccountId` then throws "No account found in JMAP session" (`src/api/jmap-client.ts:363-401`). iOS strips Authorization on cross-origin redirects.
- Fix hint: same detection; retry against `response.url`.
### Live updates (SSE / polling / state changes)
- [x] **No `AppState` handling: SSE never paused in background nor recovered on foreground; no refresh on resume** — `P1` — `missing` — fixed in edc26ce
- What WEB does: `visibilitychange` -> immediate `checkForStateChanges()` + `recycleStaleSSE()`; `online` -> reconnect SSE or poll; polls pause while hidden; 90 s ping-timeout monitor (`lib/jmap/client.ts:7332-7400`, `6992-6996`; changelog #702/#781 notes).
- What RN does: `grep -rn AppState src App.tsx` is empty. The EventSource is opened once (`App.tsx:380-418`, `src/api/push.ts:120-157`). `react-native-sse` re-polls only when the XHR reaches DONE with a status (`node_modules/react-native-sse/src/EventSource.js:118-131`); on `onerror`/status 0 (socket dropped, iOS suspension, Wi-Fi to cellular) nothing schedules a reconnect (`137-149`). Result: after the first network blip, push is dead for the rest of the process lifetime with no visible sign; the app also keeps the socket (and 30 s server pings) alive while backgrounded.
- Fix hint: `AppState.addEventListener('change')`: on `background` close the EventSource; on `active` recreate it and call `fetchMailboxes()`/`refreshEmails()`/outbox `flush()`; also subscribe to `useNetworkStore` `online` transitions to recreate. Listen for the SSE `error` event and schedule a reconnect with back-off.
- [x] **SSE Authorization header captured once; stale after OAuth token refresh** — `P1` — `rn-only-bug` — fixed in edc26ce
- What WEB does: `authenticatedFetch` reads `this.authHeader` on every (re)connect, and `onTokenRefresh` retries a 401 SSE connect (`lib/jmap/client.ts:7049-7053`, `855-863`).
- What RN does: `connectEventSource` passes `{ headers: { Authorization: jmapClient.authHeader } }` at construction (`src/api/push.ts:136-138`); `react-native-sse` reuses `this.headers` on every reconnect (`EventSource.js:83-87`). After the access token expires (`ensureFreshToken` rotates it for normal requests) every SSE reconnect sends the old Bearer -> 401 -> `error` -> re-poll every 5 s forever (battery drain, server log spam, no push).
- Fix hint: on `error` with `xhrStatus === 401` close and recreate with the current `authHeader`; better, recreate the EventSource whenever `persistRefreshedTokens` runs.
- [x] **Push effect keyed on the singleton `client`: SSE stays bound to the previous account after `switchAccount`** — `P1` — `rn-only-bug` — fixed in edc26ce
- What WEB does: per-account clients; the push effect depends on `activeAccountId` and the connected-accounts signature, tearing down and re-creating streams on switch (`components/mail/mail-app.tsx:1220-1263`).
- What RN does: the effect deps are `[client, isAuthenticated]` (`App.tsx:418`); `client` is always the same `jmapClient` object (`src/stores/auth-store.ts:155`), and `isAuthenticated` stays true across `switchAccount` (`auth-store.ts:360-432`), so the effect never re-runs. The open EventSource keeps the old account's `eventSourceUrl` + credentials; its events are then dropped by `jmapClientServesActiveAccount` (`src/stores/email-store.ts:986`), and the new account gets no live updates until logout/login. The FCM setup effect already includes `activeAccountId` (`App.tsx:378`).
- Fix hint: add `activeAccountId` (and `haveLiveSession`) to the push effect deps.
- [x] **No SSE failure/fallback path (server 4xx/5xx, proxy without SSE, client cert)** — `P2` — `partial` — fixed in edc26ce
- What WEB does: non-OK SSE response -> `fallbackToPolling`; stream end -> reconnect after 3 s (rate-limit aware); `closeafter/ping` template substitution (`lib/jmap/client.ts:7049-7066`, `7129-7155`).
- What RN does: `startPolling` is used only when `eventSourceUrl` is absent (`src/api/push.ts:228-230`); no `error`/`open` listeners on the EventSource. A user with a TLS client certificate (native issue #3) is worst hit: `react-native-sse` is XHR-based and cannot present the KeyChain cert (`src/lib/client-cert.ts:40-43` explains why `fetchSecure` exists), so the SSE handshake is rejected and no polling starts -> no live updates at all.
- Fix hint: in `connectEventSource`, if `await getClientCertAlias()` is non-null skip SSE and return `startPolling(...)`; add an `error` handler that, after N consecutive non-401 failures, closes the source and falls back to polling.
- [x] **StateChange coverage: EmailSubmission, SieveScript, Identity, VacationResponse, FileNode ignored** — `P2` — `partial` — fixed in a071d05 (Sieve/vacation refetch; EmailSubmission/FileNode via `state-change-bus`)
- What WEB does: handles `Email`, `EmailSubmission` (scheduled list/metadata), `Mailbox` (any account), `Calendar`/`CalendarEvent` (+tasks), `SieveScript` (filters) (`stores/email-store.ts:3242-3310`).
- What RN does: `Email`/`EmailDelivery`/`Mailbox` (`src/stores/email-store.ts:981-1020`), `AddressBook`/`ContactCard`/`Contact` (`src/stores/contacts-store.ts:152-168`), `Calendar`/`CalendarEvent` incl. shared accounts (`src/stores/calendar-store.ts:261-283`). `ScheduledScreen`, `filter-store`, `vacation-store`, Files and Identity never refresh on push (`grep handleStateChange src/stores/filter-store.ts src/stores/vacation-store.ts` empty).
- Fix hint: add `EmailSubmission` -> scheduled list refresh (when the Scheduled screen is mounted), `SieveScript` -> `filter-store.fetch`, `VacationResponse` -> vacation store, `FileNode` -> Files screen refresh; dispatch from the `onStateChange` fan-out in `App.tsx:391-396`.
- [x] **Contacts state changes ignore shared/group accounts** — `P3` — `partial` — done in 26a34d5 (`handleStateChange` walks `getContactCapableAccountIds()`)
- What WEB does: per-account clients; `handleStateChange` reacts to the client's own account (`stores/email-store.ts:3255-3257`); calendar/contacts stores use the account of the shared collection.
- What RN does: `contacts-store.handleStateChange` only reads `change.changed[jmapClient.accountId]` (`src/stores/contacts-store.ts:154-156`) although address books from shared accounts are shown; `calendar-store` already folds shared accounts in (`calendar-store.ts:263-272`).
- Fix hint: mirror the calendar store's `known` set using `addressBook.accountId`.
- [x] **Polling fallback is coarse: primary account only, fixed 5 s, no in-flight coalescing, no pause in background** — `P3` — `partial` — fixed in edc26ce
- What WEB does: polls every session account plus Calendar/CalendarEvent/SieveScript, 3 s active / 20 s slow, skips when `document.hidden`, coalesces overlapping polls (#781) (`lib/jmap/client.ts:6948-6954`, `7157-7301`).
- What RN does: `startPolling` polls `Mailbox/get` + `Email/get` for the primary account every 5 s with `setInterval`, no in-flight guard, never paused (`src/api/push.ts:163-202`); shared accounts, calendar, contacts and sieve never change under polling.
- Fix hint: reuse the WEB request shape (`mbx:<id>`/`eml:<id>` call ids), add an in-flight flag and AppState pause, 20 s cadence in fallback.
- [x] **SSE stream keeps the whole response in memory; no periodic recycle** — `P3` — `rn-only-bug` — fixed in edc26ce
- What WEB does: reads the body as a stream and discards processed chunks; recycles a silent stream after 90 s (`lib/jmap/client.ts:7070-7105`, `7332-7373`).
- What RN does: `react-native-sse` accumulates `xhr.responseText` for the life of the connection (`EventSource.js:108`, `_lastIndexProcessed`), and RN requests `{ping}=30`, `{closeafter}=no` (`src/api/push.ts:126-129`), so memory grows for every ping/event until reconnect. No ping-timeout detection either.
- Fix hint: either request `closeafter=state` (server closes after each event; library auto-reconnects) or recycle the EventSource every ~30 min / when no `ping` event arrived in 90 s (add a `ping` listener).
- [x] **Push subscription `types` include `Email` and `Mailbox`; no `emailPush` spam filter** — `P2` — `partial` — fixed in 00faceb; the `emailPush` map was refused for ACL-shared mailboxes and never read back until d8046a8 and ccaa24e (audit B18)
- What WEB does: subscribes to `EmailDelivery` only, plus an `emailPush` filter excluding `$junk`/Junk when the server advertises `urn:ietf:params:jmap:emailpush` (`lib/web-push.ts:39-52`, `lib/jmap/client.ts:8085-8142`; changelog 1.9.x "Stop sending notifications for spam").
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`src/lib/push-notifications.ts:104`); `createPushSubscription` has no `emailPush` support (`src/api/push.ts:23-57`). Every read/flag/move on any device wakes the relay -> FCM -> headless task, which then re-queries the inbox (`src/lib/push-background-task.ts:452-455`); a spam delivery still triggers a push (only the inbox `notKeyword $seen` query hides it, at the cost of a wake-up).
- Fix hint: subscribe to `EmailDelivery` only; add `hasEmailPushCapability()` + `emailPush` filter as in WEB; the relay must forward it (see memory note: relay lacks EmailPush).
- [x] **Headless push task re-binds the singleton client while the UI may be running** — fixed in 1f2df5f — `P2` — `rn-only-bug`
- What WEB does: N/A (service worker has its own fetch path).
- What RN does: `processAccountForPush` calls `jmapClient.loadAccount(accountId)` for each candidate account (`src/lib/push-background-task.ts:445`) and the `finally` re-loads the active one (`438-440`, errors swallowed). HeadlessJS tasks run inside the live RN instance when the app is foregrounded, so UI requests issued during that window are sent with another account's credentials/session, and a failing final `loadAccount` leaves `session = null` (`jmap-client.ts:269-270`) until the next network event.
- Fix hint: use a detached fetch path like `src/api/unified-inbox.ts:116-131` (credentials read from SecureStore, own `apiUrl`) instead of mutating the singleton.
### Offline cache & outbox
- [x] **Outbox retries forever on errors that look transient but are permanent** — `P2` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `isTransientNetworkError` treats any `TypeError` and messages containing `session expired`/`not connected` as transient (`src/lib/network-error.ts:48-63`). (a) A JMAP method error makes the unchecked read path throw `TypeError: Cannot read property 'list' of undefined` (see method-error finding) -> transient -> `recordError(..., false)` never increments `attempts` (`src/stores/outbox-store.ts:203-206`, `243-254`) -> the queue is wedged on that entry until reinstall. (b) `AuthenticationError('Session expired')` from a revoked password is also transient -> same wedge. WEB has no outbox (N/A) but never mis-classifies auth errors as transient.
- Fix hint: classify by error class (`NetworkError`, `RequestTimeoutError`, `RateLimitError`) not by `TypeError`/message; treat `AuthenticationError` as terminal and pause the queue.
- [x] **Permanently failed outbox ops are dropped silently; optimistic state never reverted** — `P2` — `rn-only-bug` — fixed in 0205aa0
- What RN does: after 5 attempts the entry is removed with `console.warn` (`src/stores/outbox-store.ts:210-216`); the list/cache was already patched optimistically (`offline-cache-store.patch`, email-store mutations) and nothing refetches, so the UI shows a move/flag the server rejected until the next full refresh; `OfflineBanner` only shows the count.
- Fix hint: keep a `failed: OutboxEntry[]` in the store, surface it in `OfflineBanner`/AboutDataSettings, and trigger `refreshEmails()` for the affected mailbox when an op is dropped.
- [x] **Interrupted flush is not resumed until the next online transition or launch** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `flush()` breaks on the first transient error (`src/stores/outbox-store.ts:203-206`); the only triggers are `haveLiveSession` and NetInfo `online` flips (`App.tsx:289-295`). A blip that NetInfo never reports (server hiccup) leaves the queue idle.
- Fix hint: schedule a retry with back-off after a transient break; also flush on AppState `active`.
- [x] **Offline `onlineRun` fallback silently changes semantics (archive auto-foldering)** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `applyOrQueueBatch` runs the richer `onlineRun` (e.g. year/month archive foldering) and on transient failure re-queues the plain `mailboxes` primitives (`src/stores/outbox-store.ts:279-292`), so the replay lands in the top-level Archive without the year/month folder the user configured.
- Fix hint: encode the folder creation into the queued op (or queue an `archive` op kind that re-runs the foldering logic on replay).
- [x] **`Email/changes.hasMoreChanges` ignored in the incremental refresh** — `P3` — `partial` — fixed in f07e039
- What WEB does: WEB refetches the page instead of diffing, so it is not exposed.
- What RN does: `getEmailChanges` returns `hasMoreChanges` (`src/api/email.ts:355-376`) but `refreshEmails` uses `updated`/`destroyed` once (`src/stores/email-store.ts:836-846`); after a long offline gap the truncated lists leave stale keywords on some rows until the snapshot is invalidated. (The mailbox path does drain, `email-store.ts:575-579`, `601`.)
- Fix hint: loop while `hasMoreChanges`, or drop the snapshot and fall back to the full re-query when it is set.
- [x] **Offline cache + outbox land in Android auto-backup** — `P2` — `missing` (security) — fixed in 7f9f5af
- What WEB does: N/A (no persistent mail cache).
- What RN does: `android:allowBackup="true"` with `@xml/secure_store_backup_rules` from the expo-secure-store plugin, which only excludes SecureStore's own prefs (`android/app/src/main/AndroidManifest.xml:16`); the AsyncStorage SQLite DB (full cached bodies `webmail:offline-cache:entry:v2:*`, outbox, settings, account registry) is backed up to the user's Google account.
- Fix hint: add an app-owned `fullBackupContent`/`dataExtractionRules` excluding `databases/RKStorage` (AsyncStorage) or set `allowBackup=false` via an Expo config plugin.
- [ ] **Send has no offline path and no draft fallback** — `P2` — `missing` — partly: drafts autosave to the server since 5b72c4d, and since 92b42e4 the close dialog no longer drops the text when the draft can't be saved; an offline send is still not queued, and a draft can't be saved offline
- What WEB does: no offline queue either, but send failures leave the composer open with the draft autosaved (composer agent's area); send itself has a deadline (#702).
- What RN does: `ComposeScreen` shows `Alert('Send failed')` and keeps the modal (`src/screens/ComposeScreen.tsx:975-979`); there is no draft API in `src/api/email.ts` (`grep -n '[Dd]raft' src/api/email.ts` only finds the send create id), so the body is lost if the user leaves, and an offline send is not queued although the outbox exists.
- Fix hint: either add an `outbox` `send` op kind (blob uploads must already be done) or at minimum persist the composer state locally on failure.
### Security hardening (RN)
- [x] **`Linking.openURL` with unvalidated URIs from server data (contacts, calendar invitations)** — `P2` — `rn-only-bug` — fixed in 96272b6 (contacts 63bf392, calendar/invitation links confirm the host)
- What WEB does: links are sanitized in the viewer; iCal/vCard URLs open via anchor with `rel=noopener` (viewer agent's area); `isValidUnsubscribeUrl` gates unsubscribe links (`components/email/unsubscribe-banner.tsx:63`).
- What RN does: `openUrl(uri)` on a contact's stored URL (`src/screens/ContactDetailScreen.tsx:173-175`) and `Linking.openURL(videoUri)` on `virtualLocations[0].uri` from an incoming invitation (`src/components/calendar/EventDetailSheet.tsx:169`, `234`; `src/components/email/CalendarInvitationBanner.tsx:92`, `159`) accept any scheme: `intent://...#Intent;component=...;end` launches arbitrary exported activities on Android, `file://`, `content://`, or third-party deep links. EmailBodyView already restricts to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:715-723`).
- Fix hint: a shared `openExternalUrl()` that allows only `https?:`, `mailto:`, `tel:`, `sms:` and shows the host in a confirmation for invitation links.
- [x] **Password handoff over a custom-scheme redirect (`bulwarkmobile://…#password=`) is interceptable; OAuth `state` uses `Math.random`** — `P1` — `rn-only-bug` — fixed in 2c0dbd1 (CSPRNG state + endpoint validation; moving the password out of the fragment is a webmail change)
- What WEB does: the mobile handoff page redirects with credentials in the fragment (`lib/deep-link-handoff.ts`, changelog "Mobile handoff page with JMAP authentication verification").
- What RN does: the `password` flow returns the clear-text password in the redirect fragment (`src/lib/oauth.ts:92-99`); the scheme is a plain `intent-filter` (`AndroidManifest.xml:25-30`), which any installed app can also register (no App Links verification), so a malicious app can receive the redirect. `state` is 16 bytes of `Math.random` (`oauth.ts:41-47`) - it prevents a forged callback from being *accepted* only if unpredictable; and the fragment's `server_url`, `token_endpoint`, `client_id` are trusted as-is (`oauth.ts:101-121`), so a forged callback can point refresh traffic at an attacker host.
- Fix hint: never send the password in the redirect (mint short-lived tokens or a one-time pairing code like `redeemPairingCode`, `oauth.ts:165-208`); generate `state` with `expo-crypto` `getRandomBytes`; validate `server_url`/`token_endpoint` are https and the token endpoint host matches the server host.
- [x] **`http://` server URLs accepted without a warning; cleartext then fails opaquely** — `P2` — `missing` — fixed in ce97232
- What WEB does: warns when the JMAP URL points at a local-only host (changelog "Setup: Warn when the JMAP URL points at a local-only host"); Basic auth travels server-to-server.
- What RN does: `normalizeServerInput` accepts `http` (`src/lib/server-discovery.ts:28-30`), QR payloads accept `http` (`src/lib/oauth.ts:144`, `155`), `connect()` never checks the scheme. Android has no `usesCleartextTraffic` (`app.config.js` has no `android.usesCleartextTraffic`; manifest has none) so http fails with "Network request failed" on API 28+, while iOS ATS may or may not allow it, so the user gets an unexplained error - or, worse, Basic credentials in clear on iOS.
- Fix hint: reject `http://` in `normalizeServerInput` except `localhost`/RFC-1918 in dev builds, with a clear message.
- [x] **Weak randomness for TOTP secret / UUIDs (falls back to `Math.random`)** — `P2` — `rn-only-bug` — fixed in 2c0dbd1
- What WEB does: server-side `otpauth` `Secret({size:20})` from Node crypto; `generateUUID()` guarded (changelog "Replace unguarded crypto.randomUUID() with safe generateUUID()").
- What RN does: `randomBytes` tries `crypto.getRandomValues`, then `crypto.randomUUID`, then `Math.random` (`src/lib/totp.ts:404-437`). `expo-crypto` is not a dependency (`package.json`), Hermes exposes no `crypto` global by default, so a long-lived 2FA secret is minted from `Math.random`; the same fallback is documented for `lib/uuid.ts` and `lib/oauth.ts`.
- Fix hint: add `expo-crypto` and use `Crypto.getRandomBytes(20)` / `Crypto.randomUUID()`; remove the Math.random branches.
- [x] **Client-cert native fetch follows redirects with Authorization and turns POST into GET (#627 class)** — `P3` — `bugfix-parity` — fixed in 6f747db
- What WEB does: `postJmap` follows redirects manually, keeps POST, only to the same host or an https upgrade (`lib/stalwart/jmap-api.ts:44-93`).
- What RN does: `instanceFollowRedirects = true` on `HttpURLConnection` (`android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt:144`); a 301 in front of `/jmap/` re-issues as GET (Stalwart answers 404 problem+json) and cross-host redirects re-send the header. The non-cert `fetch` path has the same POST->GET behaviour but that matches browser semantics WEB also lives with client-side.
- Fix hint: `instanceFollowRedirects = false`, return the 3xx to JS, and handle the redirect in `secureFetch` with the same host/https-upgrade rule.
- [x] **Client-cert path buffers whole bodies through base64 over the bridge with a 30 s cap** — `P3` — `rn-only-bug` — fixed in 6f747db (blob deadline raised to 300 s; streaming still deferred)
- What RN does: the whole response is read into memory and base64-encoded (`BulwarkClientCertModule.kt:170-190`, `src/lib/client-cert.ts:204-216`); attachment downloads for cert users go through `secureFetch(...).arrayBuffer()` (`src/lib/email-export.ts:100-110`) and uploads through `bodyToBase64` (`client-cert.ts:171-188`), so a 50 MB attachment costs ~200 MB of JS heap and can time out at 30 s (`client-cert.ts:250`; WEB uses 300 s for transfers, `lib/jmap/client.ts:634`).
- Fix hint: stream to a file in the native module (`FileOutputStream`) and return the path; raise the transfer timeout.
- [x] **Blob downloads via `File.downloadFileAsync` bypass token refresh** — `P2` — `rn-only-bug` — fixed in d2ed27f
- What WEB does: `fetchBlob` goes through `authenticatedFetch`, which refreshes a Bearer token on 401 (`lib/jmap/client.ts:4150-4157`, `855-863`).
- What RN does: `downloadInto` builds the header from `jmapClient.authHeader` without `ensureFreshToken()` and does not retry on 401 (`src/lib/email-export.ts:93-97`); after the access token expires, attachment/EML download fails until some other JMAP call has refreshed the token. `fetchBlobArrayBuffer` does refresh (`src/api/jmap-client.ts:575-581`).
- Fix hint: expose `ensureFreshToken()` publicly, call it before `downloadFileAsync`, and on 401 call `forceRefreshToken()` and retry once.
- [x] **Downloaded attachment temp files are never cleaned up** — `P3` — `rn-only-bug` — fixed in d2ed27f (launch sweep wired in App.tsx)
- What RN does: `shareAttachment`/`openAttachment` write into `Paths.cache` (`src/lib/email-export.ts:127`, `195`) and only delete when the same name is reused; the APK installer deletes on failure (`src/lib/install-update.ts:78`, `143`). Cache may be purged by the OS but shared files linger (readable to apps that were granted the URI).
- Fix hint: delete the file after the share sheet resolves, and sweep `Paths.cache` older than a day at launch.
- [x] **WebView: `data:` navigations allowed inside the message frame** — `P3` — `partial` — fixed in 06742ef
- What WEB does: message HTML is sandboxed in an `srcDoc` iframe with a CSP meta tag (changelog 1.5.x); `data:` top-level navigation is blocked by the browser.
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:715-718`), so `<a href="data:text/html,...">` replaces the message with attacker HTML rendered inside the app chrome (phishing form); the WebView is otherwise well hardened (`originWhitelist=['about:blank']`, file access off, `mixedContentMode="never"`, `setSupportMultipleWindows={false}`, `incognito`, `domStorageEnabled={false}`, `EmailBodyView.tsx:675-712`).
- Fix hint: allow `data:` only for `request.isTopFrame === false` or block it entirely (the body is loaded via `source={{html}}`, not a data: URL).
- [ ] **No screenshot / recent-apps protection option** — `P3` — `missing`
- What WEB does: N/A.
- What RN does: no `FLAG_SECURE`/`expo-screen-capture` anywhere; mail content appears in the Android recents thumbnail.
- Fix hint: optional "Hide content in app switcher" setting using `expo-screen-capture` `preventScreenCaptureAsync`.
- [x] **Sender/subject of every FCM message logged to logcat** — fixed in 00faceb — `P3` — `rn-only-bug`
- What RN does: `console.log('[push] fcm message', payload.title)` in production (`App.tsx:301-303`). No credential logging found (grep for password/token/secret in console calls is empty).
- Fix hint: drop or guard with `__DEV__`.
- [x] **Custom SHA-256 implementation (verified correct) could use `expo-crypto`** — `P3` — `rn-only-bug` — fixed in n/a (kept: PKCE needs a synchronous digest; expo-crypto digest is async)
- What RN does: `src/lib/sha256.ts` is a straight FIPS 180-4 implementation (padding, 64-bit length split, unsigned hex output all correct) used only for APK checksum verification (`src/lib/install-update.ts:118-127`); verification is opt-in when the release body carries a hash.
- Fix hint: once `expo-crypto` is added (see randomness finding) replace with `Crypto.digest('SHA-256', bytes)`; keep the size check.
- [x] **TOTP helper: RFC 6238 compatible; issuer hard-coded** — `P3` — `partial` — fixed in ce97232
- What WEB does: server-verified via `x:AccountPassword/set otpAuth.otpCode`; enrolment via `otpauth` with the app/brand as issuer.
- What RN does: `generateTotpEnrolment` produces `otpauth://totp/Stalwart:<label>?secret=…&algorithm=SHA1&digits=6&period=30` (`src/lib/totp.ts:469-485`) - correct parameters, base32 without padding correct; verification is server-side (comment `totp.ts:391-396`). Issuer `Stalwart` instead of the server host/brand.
- Fix hint: use the server hostname (or configured brand) as issuer.
- [x] **Relay base URL stored unvalidated (http allowed)** — `P3` — `rn-only-bug` — fixed in f6f26f7
- What RN does: `setStoredRelayBaseUrl` only trims slashes (`src/lib/push-notifications.ts:170-176`); the relay receives the FCM token and the JMAP push-subscription URL slot. WEB's telemetry/push endpoints are validated server-side against internal hosts (changelog "Block telemetry endpoint from pointing at internal/loopback hosts").
- Fix hint: require `https://` in the settings input.
### Stalwart admin/self-service, S/MIME, encryption at rest
- [x] **Encryption at rest is read-only; no public-key management** — fixed in ddae1e6 — `P2` — `partial` — fixed in 69bedf0 (API; settings UI with the settings agent)
- What WEB does: `fetchCryptoInfo`/`updateEncryptionAtRest` with `@type` (`Disabled|Aes128|Aes256`), `publicKey`, `encryptOnAppend`, `allowSpamTraining` (spam training before encryption) and `x:PublicKey/query|get|set` CRUD with `emailAddresses`/`expiresAt` (`stores/account-security-store.ts:355-448`, `625-700`; changelog 1.7.x "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest", 1.9.x "Per-account isolation for encryption at rest").
- What RN does: `fetchEncryptionType` returns only the `@type` (`src/api/account-security.ts:150-155`); shown read-only in `AccountSecuritySettings.tsx:595`. No `x:PublicKey/*`, no set path. Per-account isolation is inherent (single-session client uses `jmapClient.accountId`).
- Fix hint: port `updateEncryptionAtRest`, `fetchPublicKeys`, `createPublicKey`, `removePublicKey` into `account-security.ts` (same `STALWART_USING`) and a key list/paste screen.
- [x] **S/MIME settings screen is a mock; no S/MIME detection in the viewer** — `P2` — `missing` — fixed in 06742ef (viewer banner; settings stub hidden in 3d0d440; crypto deferred)
- What WEB does: built-in S/MIME was removed from core into the privileged crypto plugin (changelog 1.5.0 note, "S/MIME: The built-in S/MIME implementation has been removed from core"); core keeps only detection (`isSmimeEmail`, `lib/jmap/client.ts:484-505`), the range-limited blob read for signature checks (`7503-7550`), and the plugin hook surface. Per-account key isolation, legacy 3DES/PBE and self-signed detection live in the plugin.
- What RN does: `SmimeSettings` renders `MOCK_KEYS`/`MOCK_CERTS` and three settings that nothing reads (`src/components/settings/SmimeSettings.tsx:32-61`; `src/stores/settings-store.ts:203-205`, `308-310`), reachable from Settings as `encryption` (`src/screens/SettingsScreen.tsx:130`). Nothing detects `application/pkcs7-mime`/`multipart/signed` bodies, so an encrypted message shows as an empty body with a `smime.p7m` attachment.
- Fix hint: parity for RN *core* = (1) port `isSmimeEmail` and show a "signed/encrypted with S/MIME - not supported on mobile" banner, (2) remove or hide the mock section. Full sign/verify/decrypt needs WebCrypto, which Hermes lacks: it would require `react-native-quick-crypto` (OpenSSL) plus a PKCS#7/CMS parser (`pkijs` runs on WebCrypto and would need a shim), private keys in SecureStore/Keychain, and per-account isolation of the key store; there is no plugin tier in RN to host it. Treat as a separate project.
- [x] **Display name not refreshed from the Stalwart principal on login/restore/switch (#900)** — `P3` — `bugfix-parity` — fixed in b52768e
- What WEB does: refreshes the account label from `x:Account/get` on login, restore and switch (changelog 1.9.x "#900"; memory note: principal name is copied into the Identity only once).
- What RN does: `fetchPrincipal` exists (`src/api/account-security.ts:157-181`) but is only called from the security screen (`AccountSecuritySettings.tsx:595`); `addAccount` uses `displayName: username` (`src/stores/auth-store.ts:187-195`, `123-131`).
- Fix hint: call `fetchPrincipal()` (guarded by `isStalwartSupported()`) after `applyConnectedState` and `updateAccount({displayName})`.
- [x] **Native issue #47 "Account security not accessible" - likely gating** — `P3` — `rn-only-bug` (unverified) — fixed in 032732c
- What RN does: the screen hides password/TOTP for Bearer sessions (`src/api/jmap-client.ts:66-68`) and shows "not available" when `urn:stalwart:jmap` is absent (`src/api/account-security.ts:51-55`); an OAuth handoff login therefore has no password management even on Stalwart. WEB shows the same sections for OAuth users via the passthrough with stored basic credentials.
- Fix hint: confirm against the issue; consider allowing app-password/API-key management for Bearer sessions (they only need `x:*` methods, not the password).
### Newsletter unsubscribe
- [x] **List-Unsubscribe not parsed or offered** — `P2` — `missing` — fixed in d2ed27f
- What WEB does: `extractListHeaders` parses `List-Unsubscribe`/`List-Id`/`List-Help`/`List-Post` (`lib/email-headers.ts:253-300`); the banner opens http links in a new tab (no RFC 8058 one-click POST) and sends `mailto:` unsubscribes itself via JMAP (`components/email/unsubscribe-banner.tsx:63-110`; changelog 1.5.x "Send mailto: unsubscribe ourselves instead of via the OS handler").
- What RN does: `EMAIL_FULL_PROPERTIES` requests no headers (`src/api/email.ts:11-15`); no parsing, no banner (`grep -rln unsubscribe src` hits only unrelated store code).
- Fix hint: add `header:List-Unsubscribe:asText` and `header:List-Unsubscribe-Post:asText` to the full properties; port `parseUnsubscribeUrls`; for http+`List-Unsubscribe=One-Click` do the RFC 8058 POST (`application/x-www-form-urlencoded`, body `List-Unsubscribe=One-Click`) in-app, otherwise open the link; for `mailto:` reuse `sendEmail`.
### Misc network
- [x] **Unified inbox detached fetch: no timeout, no 429/`maxConcurrentRequests` handling, N parallel sessions** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: per-account clients share the retry/timeout/rate-limit pipeline; unified fetch errors are collected per account (`stores/email-store.ts:3325-3345`).
- What RN does: `jmapPost`/`fetchInboxForAccount` do a bare `secureFetch` per account and per shared account in parallel (`src/api/unified-inbox.ts:116-131`, `215-233`), swallowing shared-account errors and only mapping 401 to "Session expired".
- Fix hint: route through the same `request()` helper once it has deadlines/back-off (pass explicit credentials instead of the singleton).
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Session discovery, `primaryAccounts` selection (mail -> core -> first account), shared/group account detection (`src/api/jmap-client.ts:388-401`, `480-493` vs `lib/jmap/client.ts:988-995`, `4421-4463`).
- Per-capability account ids for Sieve and Files (`src/api/sieve.ts:20-23`, `src/api/files.ts:27-31`); calendar/contacts use the primary account on both sides in practice.
- OAuth proactive refresh with 60 s leeway, reactive refresh on 401, single-flight refresh promise, refresh-token rotation fallback (`src/api/jmap-client.ts:193-221`, `src/lib/oauth.ts:210-265`).
- Blob download/upload URL templating with `encodeURIComponent` and RN-safe string splitting (no `new URL()`), shared-account `accountId` override (`src/api/jmap-client.ts:557-566`, `src/api/blob.ts`).
- Upload response parsing accepts both `{blobId}` and `{[accountId]: {blobId}}` shapes (`src/api/blob.ts:38-61`).
- `Mailbox/changes` incremental sync with `hasMoreChanges` drain and `cannotCalculateChanges` fallback; folder tree kept on failure (#780 behaviour) (`src/stores/email-store.ts:528-612`).
- `Email/queryChanges` + `Email/changes` incremental list refresh with snapshot guard (issue #10) and full re-query fallback (`src/stores/email-store.ts:770-980`).
- `null` for patch removal, `false` stripped from `mailboxIds` maps (`src/api/email.ts:456`, `551-556`).
- FilterOperator/FilterCondition kept separate in query filters (`src/api/email.ts:225-237`).
- `getSharedMailboxes` chunks to `maxCallsInRequest` (`src/api/email.ts:84-105`); `getEvents`/`getContacts`/offline sync chunk to `maxObjectsInGet`, and list pages are capped at it since `queryEmailPage` replaced `fetchEmailsChunked` (d987930).
- Send: `/error`, `notCreated` checked; HOLDFOR envelope for scheduled send; FUTURERELEASE capability check (`src/api/email.ts:836-873`, `src/api/jmap-client.ts:505-531`). Two gaps found later: the capability was read from the session instead of the account until e28e8b4 (#57, audit B8), and a method error after a created submission (Stalwart's failed filing into Sent) was reported as a failed send until c51a848 (audit B23).
- Push subscription create/verify/update/destroy with `using: [core]` and 90-day expiry refresh (`src/api/push.ts:9-114`, `src/lib/push-notifications.ts`); calls that read or write `emailPush` also name `urn:ietf:params:jmap:emailpush` since ccaa24e, and every call checks the response since ddfe85a.
- Outbox: idempotent full-state ops, per-account buckets, destroy-wins coalescing, oldest-first replay, account-switch guards (`src/stores/outbox-store.ts`).
- Offline cache: per-account buckets, index + entry keys, eviction by `receivedAt`, optimistic `patch`, abort on account switch (`src/stores/offline-cache-store.ts`); the offline copy is shown before the network since 33e303e (`src/lib/email-detail-cache`, which replaced `getEmailDetail`).
- Credentials in `expo-secure-store` (never AsyncStorage); SecureStore key sanitising; per-account keys; legacy migration.
- Client-cert module uses the platform default `X509TrustManager` (no trust-all) (`BulwarkClientCertModule.kt:216-222`).
- WebView hardening: `originWhitelist=['about:blank']`, no file/universal access, `mixedContentMode="never"`, no multiple windows, no cookies/DOM storage, `incognito`, external links limited to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:675-724`).
- No secrets in `console.*` output; `usesNonExemptEncryption: false` declared (`app.config.js:370`).
- Stalwart self-service via `urn:stalwart:jmap`: password change, display name, TOTP enable/disable, app passwords and API keys with IP allow-list, principal read (`src/api/account-security.ts`), with proper `error`/`notUpdated` checks.
- SHA-256 implementation correct (FIPS 180-4) and APK size + optional checksum verification (`src/lib/sha256.ts`, `src/lib/install-update.ts:100-127`).
## N/A on mobile
- Server-side SSRF/DNS-rebinding guard, endpoint allow-list, `OAUTH_ALLOW_PRIVATE_ENDPOINTS`, IPv6 transition-address checks (`lib/security/url-guard.ts`, `lib/stalwart/server-fetch.ts`; GHSA-24w9): RN talks to the server directly from the device; the only analogue is scheme validation (covered above).
- CSP `frame-src`/`connect-src`/`object-src` changes, `srcDoc` iframe sandbox, plugin bundle signing, admin dashboard, setup wizard, `SESSION_SECRET`, session-cookie slots, Stalwart JMAP passthrough (#627) and auth-context binding: RN has no proxy tier; it holds credentials itself and calls `x:*` methods directly.
- `MAX_SSE_STREAMS`/per-tab socket budget (#702), "one SSE stream per client", browser `visibilitychange`/`online` events, `document.hidden` poll pausing: RN is single-session; the equivalent concern is AppState (listed as a gap).
- Service-worker PWA caching, `blob:` object URLs, `URL.revokeObjectURL`, `window.open` for unsubscribe links.
- WEB S/MIME plugin (privileged same-origin plugin tier), `onBeforeBlobUpload` offload hooks, plugin crypto API: no plugin system in RN.
- `x-www-form-urlencoded` TOTP token exchange on the WEB server (`/api/auth/totp`): RN verifies TOTP through Stalwart's `otpCode` directly.
+11 -3
View File
@@ -1,7 +1,7 @@
import { createElement } from 'react';
import { AppRegistry } from 'react-native';
import { registerRootComponent } from 'expo';
import App from './App';
import { pushBackgroundTask } from './src/lib/push-background-task';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
@@ -9,7 +9,15 @@ import { pushBackgroundTask } from './src/lib/push-background-task';
// before the native service tries to invoke the task.
AppRegistry.registerHeadlessTask('BulwarkPushTask', () => pushBackgroundTask);
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// A headless push task evaluates this file too, so App is required when the
// UI first renders instead of imported above: importing it here would load
// every screen, store and icon before the task could run.
function Root() {
const App = (require('./App') as typeof import('./App')).default;
return createElement(App);
}
// registerRootComponent calls AppRegistry.registerComponent('main', () => Root);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
registerRootComponent(Root);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+968 -69
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+993 -89
View File
File diff suppressed because it is too large Load Diff
+956 -65
View File
File diff suppressed because it is too large Load Diff
+1001 -97
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
+1001 -97
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1002 -98
View File
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
+1037 -133
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1042
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1029
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1002 -98
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1003 -99
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1001 -97
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// babel.config.js rewrites lucide-react-native imports to per-icon modules
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
// `exports` map only lists its barrel, so resolve those paths as plain files
// instead of letting Metro warn about every one of them.
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolve = upstreamResolveRequest ?? context.resolveRequest;
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
}
return resolve(context, moduleName, platform);
};
module.exports = config;
+953
View File
@@ -0,0 +1,953 @@
<title>Bulwark Mobile — Sign-in redesign</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3z" /><path d="M21 14v3M21 21h-4" />
</symbol>
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m2 7 10 6 10-6" />
</symbol>
<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="10" width="16" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" />
</symbol>
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 12 5 5L20 6" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M12 7v6M12 16.5v.5" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 6 18 18M18 6 6 18" />
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3 5 6v6c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14" />
</symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" /><path d="m10.8 12.2 8-8M17 6l2.5 2.5M14.5 8.5 17 11" />
</symbol>
</defs>
</svg>
<style>
:root {
--page-bg: #fbfbfc;
--page-panel: #f0f3f8;
--page-ink: #14171f;
--page-ink-2: #545c6b;
--page-ink-3: #8a919f;
--page-rule: #e1e6ef;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.09);
--app-bg: #ffffff;
--app-surface: #f8fafc;
--app-surface-2: #f1f5f9;
--app-border: #e2e8f0;
--app-text: #0f172a;
--app-text-2: #64748b;
--app-muted: #94a3b8;
--app-primary: #3b82f6;
--app-primary-bg: #dbeafe;
--app-error: #ef4444;
--app-error-bg: #fee2e2;
--app-success: #16a34a;
--frame: #d5dae5;
--frame-shadow: rgba(20, 26, 40, 0.14);
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #0a0b0d;
--page-panel: #111319;
--page-ink: #eef1f6;
--page-ink-2: #a4adbb;
--page-ink-3: #6e7684;
--page-rule: #22262f;
--accent: #74aaff;
--accent-soft: rgba(116, 170, 255, 0.12);
--app-bg: #09090b;
--app-surface: #18181b;
--app-surface-2: #1f1f23;
--app-border: #27272a;
--app-text: #fafafa;
--app-text-2: #a1a1aa;
--app-muted: #71717a;
--app-primary: #3b82f6;
--app-primary-bg: #172554;
--app-error: #ef4444;
--app-error-bg: #450a0a;
--app-success: #22c55e;
--frame: #2b303a;
--frame-shadow: rgba(0, 0, 0, 0.55);
}
}
:root[data-theme="light"] {
--page-bg: #fbfbfc; --page-panel: #f0f3f8; --page-ink: #14171f; --page-ink-2: #545c6b;
--page-ink-3: #8a919f; --page-rule: #e1e6ef; --accent: #2f6fed; --accent-soft: rgba(47,111,237,0.09);
--app-bg: #ffffff; --app-surface: #f8fafc; --app-surface-2: #f1f5f9; --app-border: #e2e8f0;
--app-text: #0f172a; --app-text-2: #64748b; --app-muted: #94a3b8; --app-primary: #3b82f6;
--app-primary-bg: #dbeafe; --app-error: #ef4444; --app-error-bg: #fee2e2; --app-success: #16a34a;
--frame: #d5dae5; --frame-shadow: rgba(20,26,40,0.14);
}
:root[data-theme="dark"] {
--page-bg: #0a0b0d; --page-panel: #111319; --page-ink: #eef1f6; --page-ink-2: #a4adbb;
--page-ink-3: #6e7684; --page-rule: #22262f; --accent: #74aaff; --accent-soft: rgba(116,170,255,0.12);
--app-bg: #09090b; --app-surface: #18181b; --app-surface-2: #1f1f23; --app-border: #27272a;
--app-text: #fafafa; --app-text-2: #a1a1aa; --app-muted: #71717a; --app-primary: #3b82f6;
--app-primary-bg: #172554; --app-error: #ef4444; --app-error-bg: #450a0a; --app-success: #22c55e;
--frame: #2b303a; --frame-shadow: rgba(0,0,0,0.55);
}
body {
background: var(--page-bg);
color: var(--page-ink);
font-family: var(--serif);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.d-wrap { max-width: 1180px; margin: 0 auto; padding: 64px 28px 96px; }
/* ---------- document typography ---------- */
.d-eyebrow {
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--page-ink-3); margin: 0 0 18px;
}
.d-title {
font-family: var(--sans); font-weight: 700; font-size: clamp(34px, 5vw, 52px);
line-height: 1.04; letter-spacing: -0.028em; margin: 0; text-wrap: balance;
}
.d-deck {
max-width: 62ch; margin: 22px 0 0; font-size: 19px; line-height: 1.62; color: var(--page-ink-2);
}
.d-deck strong { color: var(--page-ink); font-weight: 600; }
.d-section { margin-top: 84px; }
.d-h2 {
font-family: var(--sans); font-weight: 700; font-size: 25px; letter-spacing: -0.018em;
margin: 0; text-wrap: balance;
}
.d-lede { max-width: 62ch; margin: 12px 0 0; color: var(--page-ink-2); }
.d-rule { height: 1px; background: var(--page-rule); border: 0; margin: 26px 0 34px; }
.d-meta {
display: flex; flex-wrap: wrap; gap: 8px 10px; margin-top: 30px;
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-2);
}
.d-meta span {
border: 1px solid var(--page-rule); border-radius: 999px; padding: 4px 11px; background: var(--page-panel);
}
/* ---------- comparison ---------- */
.d-compare {
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px;
background: var(--page-rule); border: 1px solid var(--page-rule); border-radius: 12px; overflow: hidden;
}
.d-cell { background: var(--page-bg); padding: 20px 22px; }
.d-cell dt {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--page-ink-3); margin: 0 0 10px;
}
.d-cell dd { margin: 0; font-family: var(--sans); font-size: 15px; line-height: 1.45; }
.d-was { color: var(--page-ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
.d-now { color: var(--page-ink); font-weight: 600; display: block; margin-top: 3px; }
/* ---------- filmstrip ---------- */
.d-strip {
display: flex; gap: 30px; overflow-x: auto; padding: 30px 26px 26px;
background: var(--page-panel); border-radius: 16px; scroll-snap-type: x proximity;
}
.d-strip figure { margin: 0; scroll-snap-align: center; flex: 0 0 auto; width: 288px; }
.d-strip figcaption { margin-top: 18px; }
.d-step {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); display: block; margin-bottom: 5px;
}
.d-step.is-alt { color: var(--page-ink-3); }
.d-cap-title { font-family: var(--sans); font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.d-cap-body { font-size: 14.5px; line-height: 1.5; color: var(--page-ink-2); margin: 5px 0 0; }
/* ---------- notes ---------- */
.d-notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 22px; max-width: 74ch; }
.d-notes li { display: grid; grid-template-columns: 26px 1fr; gap: 14px; align-items: start; }
.d-notes svg { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
.d-note-h { font-family: var(--sans); font-weight: 650; font-size: 15.5px; }
.d-note-b { color: var(--page-ink-2); font-size: 16px; }
code {
font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft);
color: var(--accent); padding: 1.5px 5px; border-radius: 4px;
}
.d-foot {
margin-top: 80px; padding-top: 22px; border-top: 1px solid var(--page-rule);
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-3);
}
/* ================= phone chrome ================= */
.p {
width: 288px; height: 600px; border-radius: 36px; background: var(--frame);
padding: 8px; box-shadow: 0 18px 44px -18px var(--frame-shadow); position: relative;
}
.p-screen {
width: 100%; height: 100%; border-radius: 29px; background: var(--app-bg); overflow: hidden;
position: relative; font-family: var(--sans); color: var(--app-text); font-size: 14px; line-height: 1.4;
display: flex; flex-direction: column;
}
.p-notch {
position: absolute; top: 15px; left: 50%; transform: translateX(-50%); width: 74px; height: 5px;
border-radius: 99px; background: var(--app-border); z-index: 6;
}
.p-pad { padding: 42px 24px 22px; flex: 1; display: flex; flex-direction: column; }
.p-pad.is-centered { justify-content: center; }
/* ---- in-app primitives ---- */
.s-logo {
width: 56px; height: 56px; border-radius: 17px; background: var(--app-primary);
display: grid; place-items: center; margin: 0 auto 16px; color: #fff;
}
.s-logo svg { width: 30px; height: 30px; }
.s-brand { text-align: center; margin-bottom: 30px; }
.s-brand h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
.s-brand p { font-size: 12.5px; color: var(--app-muted); margin: 5px 0 0; }
.s-h { font-size: 21px; font-weight: 700; letter-spacing: -0.022em; margin: 0; line-height: 1.22; }
.s-sub { font-size: 13px; color: var(--app-text-2); margin: 8px 0 0; line-height: 1.45; }
.s-tile {
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
border: 1px solid var(--app-border); background: var(--app-surface);
border-radius: 14px; padding: 14px 15px; color: inherit;
}
.s-tile.is-primary { background: var(--app-primary); border-color: var(--app-primary); color: #fff; }
.s-tile-ico { width: 22px; height: 22px; flex: 0 0 22px; opacity: 0.95; }
.s-tile-txt { flex: 1; min-width: 0; }
.s-tile-t { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
.s-tile-d { font-size: 11.5px; opacity: 0.72; margin-top: 2px; }
.s-tile-chev { width: 16px; height: 16px; opacity: 0.5; }
.s-stack { display: flex; flex-direction: column; gap: 10px; }
.s-field { margin-top: 20px; }
.s-label { font-size: 12px; font-weight: 600; color: var(--app-text-2); margin-bottom: 7px; }
.s-input {
border: 1px solid var(--app-border); background: var(--app-bg); border-radius: 10px;
height: 42px; padding: 0 13px; display: flex; align-items: center; gap: 8px; font-size: 14px;
}
.s-input.is-focus { border-color: var(--app-primary); box-shadow: 0 0 0 3px var(--app-primary-bg); }
.s-input.is-error { border-color: var(--app-error); }
.s-input .s-ph { color: var(--app-muted); }
.s-input svg { width: 17px; height: 17px; color: var(--app-muted); flex: 0 0 17px; }
.s-caret {
width: 1.5px; height: 17px; background: var(--app-primary); display: inline-block;
animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .s-caret { animation: none; } }
.s-hint { font-size: 11.5px; color: var(--app-muted); margin-top: 8px; line-height: 1.45; }
.s-hint.is-error { color: var(--app-error); }
.s-btn {
height: 46px; border-radius: 12px; background: var(--app-primary); color: #fff;
display: grid; place-items: center; font-size: 15px; font-weight: 600; margin-top: 20px;
}
.s-btn.is-off { background: var(--app-surface-2); color: var(--app-muted); }
.s-btn.is-quiet {
background: transparent; color: var(--app-text); border: 1px solid var(--app-border); font-weight: 500;
}
.s-link { text-align: center; font-size: 13px; color: var(--app-primary); margin-top: 16px; font-weight: 500; }
.s-link.is-muted { color: var(--app-muted); font-weight: 400; }
.s-back { width: 22px; height: 22px; color: var(--app-text-2); margin-bottom: 22px; }
.s-foot {
text-align: center; font-size: 11px; color: var(--app-muted); padding: 0 0 8px; line-height: 1.6;
}
.s-card {
border: 1px solid var(--app-border); background: var(--app-surface); border-radius: 14px;
padding: 15px; display: flex; align-items: center; gap: 12px; margin-top: 22px;
}
.s-card-ico {
width: 38px; height: 38px; border-radius: 11px; background: var(--app-primary-bg);
color: var(--app-primary); display: grid; place-items: center; flex: 0 0 38px;
}
.s-card-ico svg { width: 19px; height: 19px; }
.s-card-t { font-size: 14.5px; font-weight: 600; }
.s-card-d { font-size: 11.5px; color: var(--app-text-2); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
.s-dot { width: 5px; height: 5px; border-radius: 99px; background: var(--app-success); display: inline-block; }
.s-note {
display: flex; gap: 9px; align-items: flex-start; margin-top: 16px;
font-size: 11.5px; color: var(--app-text-2); line-height: 1.5;
}
.s-note svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-muted); }
.s-banner {
display: flex; gap: 9px; align-items: flex-start; border-radius: 11px; padding: 11px 12px;
background: var(--app-error-bg); font-size: 12px; line-height: 1.45; margin-top: 18px;
}
.s-banner svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-error); }
.s-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
.s-divider::before, .s-divider::after { content: ""; height: 1px; flex: 1; background: var(--app-border); }
.s-divider span { font-size: 11.5px; color: var(--app-muted); }
/* progress rows (setting up) */
.s-prog { display: flex; align-items: center; gap: 11px; padding: 11px 0; font-size: 13.5px; }
.s-prog .s-tick {
width: 20px; height: 20px; border-radius: 99px; background: var(--app-success);
color: #fff; display: grid; place-items: center; flex: 0 0 20px;
}
.s-prog .s-tick svg { width: 11px; height: 11px; }
.s-prog .s-spin {
width: 20px; height: 20px; border-radius: 99px; flex: 0 0 20px;
border: 2px solid var(--app-border); border-top-color: var(--app-primary); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .s-prog .s-spin { animation: none; } }
.s-prog.is-pending { color: var(--app-muted); }
/* account rows (add mode) */
.s-acct { display: flex; align-items: center; gap: 11px; padding: 11px 0; }
.s-av {
width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center;
font-size: 13px; font-weight: 600; color: #fff; flex: 0 0 34px;
}
.s-acct-t { font-size: 14px; font-weight: 550; }
.s-acct-d { font-size: 11.5px; color: var(--app-muted); }
/* scanner — deliberately always dark, it's a camera view */
.p-screen.is-camera { background: #0b0d10; color: #fff; }
.s-cam { position: absolute; inset: 0; background:
radial-gradient(120% 80% at 50% 30%, #2a3038 0%, #14171c 55%, #0b0d10 100%); }
.s-cam-grid { position: absolute; inset: 0; opacity: 0.18; background-image:
linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 34px 34px; }
.s-reticle { position: absolute; top: 168px; left: 50%; transform: translateX(-50%); width: 176px; height: 176px; }
.s-reticle i {
position: absolute; width: 34px; height: 34px; border: 3px solid #fff; border-radius: 4px;
}
.s-reticle i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.s-reticle i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
.s-reticle i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
.s-reticle i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
.s-qr {
position: absolute; inset: 22px; border-radius: 6px; background: #fff;
display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(9, 1fr); padding: 8px; gap: 2px;
}
.s-qr b { background: #0b0d10; border-radius: 1px; }
.s-cam-top {
position: absolute; top: 40px; left: 0; right: 0; display: flex; align-items: center;
justify-content: space-between; padding: 0 20px; z-index: 3;
}
.s-cam-top svg { width: 22px; height: 22px; color: #fff; }
.s-cam-copy { position: absolute; left: 0; right: 0; bottom: 46px; text-align: center; padding: 0 30px; z-index: 3; }
.s-cam-copy p { margin: 0; font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.82); }
.s-cam-copy .s-cam-h { font-size: 15.5px; font-weight: 600; color: #fff; margin-bottom: 6px; }
.s-cam-copy .s-cam-link { margin-top: 18px; font-size: 13px; font-weight: 500; color: #8ab4ff; }
/* browser handoff sheet */
.s-sheet {
position: absolute; inset: 26px 0 0; background: var(--app-bg); border-radius: 18px 18px 0 0;
box-shadow: 0 -8px 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden;
}
.s-sheet-bar {
display: flex; align-items: center; gap: 9px; padding: 13px 15px;
border-bottom: 1px solid var(--app-border); font-size: 12.5px; color: var(--app-text-2);
}
.s-sheet-bar svg { width: 13px; height: 13px; color: var(--app-success); }
.s-sheet-bar .s-url { flex: 1; color: var(--app-text); font-weight: 550; }
.s-sheet-bar .s-done { color: var(--app-primary); font-weight: 600; }
.s-sheet-body { padding: 30px 24px; flex: 1; }
.s-scrim { position: absolute; inset: 0; background: rgba(4,6,10,0.5); }
.s-web-brand { display: flex; align-items: center; gap: 9px; margin-bottom: 24px; }
.s-web-brand .s-web-mark {
width: 28px; height: 28px; border-radius: 9px; background: var(--app-primary);
color: #fff; display: grid; place-items: center;
}
.s-web-brand .s-web-mark svg { width: 16px; height: 16px; }
.s-web-brand b { font-size: 14.5px; letter-spacing: -0.01em; }
/* the "before" screen is intentionally cramped */
.s-old-field { margin-bottom: 13px; }
</style>
<div class="d-wrap">
<p class="d-eyebrow">Bulwark Mobile · sign-in redesign</p>
<h1 class="d-title">Three fields nobody knows the answer&nbsp;to</h1>
<p class="d-deck">
The current sign-in screen opens with a <strong>Server URL</strong> field. Most people don't know their
JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit
below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with
the easy paths and asks for a server only when we genuinely can't work it out.
</p>
<div class="d-meta">
<span>src/screens/LoginScreen.tsx</span>
<span>expo · react-native</span>
<span>light + dark, both shown</span>
</div>
<section class="d-section">
<h2 class="d-h2">What changes</h2>
<hr class="d-rule" />
<dl class="d-compare">
<div class="d-cell">
<dt>To reach the inbox</dt>
<dd><span class="d-was">3 fields typed</span><span class="d-now">1 field, or none with a code</span></dd>
</div>
<div class="d-cell">
<dt>Server URL</dt>
<dd><span class="d-was">Asked first, always</span><span class="d-now">Looked up from the address</span></dd>
</div>
<div class="d-cell">
<dt>Password in the app</dt>
<dd><span class="d-was">Default path</span><span class="d-now">Fallback only</span></dd>
</div>
<div class="d-cell">
<dt>QR sign-in</dt>
<dd><span class="d-was">Ghost button, disabled</span><span class="d-now">First option offered</span></dd>
</div>
</dl>
</section>
<section class="d-section">
<h2 class="d-h2">Today</h2>
<p class="d-lede">
Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a
password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck.
</p>
<hr class="d-rule" />
<div class="d-strip">
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-old-field">
<div class="s-label">Server URL</div>
<div class="s-input"><span class="s-ph">https://mail.example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Email or Username</div>
<div class="s-input"><span class="s-ph">you@example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Password</div>
<div class="s-input">
<span class="s-ph" style="flex:1">Enter your password</span>
<svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-btn is-off" style="margin-top:12px">Sign In</div>
<div class="s-divider"><span>or continue with</span></div>
<div class="s-stack">
<div class="s-btn is-quiet" style="margin-top:0;opacity:0.45">Sign in via webmail</div>
<div class="s-link is-muted" style="margin-top:2px;opacity:0.45">Scan QR code</div>
</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Current build</span>
<span class="d-cap-title">One screen, six controls</span>
<p class="d-cap-body">
The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where
to find a server URL, and nothing happens if you don't have one.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Proposed flow</h2>
<p class="d-lede">
One question per screen, cheapest question first. The screens below are the happy path, left to right.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- 1 · welcome -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-stack">
<div class="s-tile is-primary">
<svg class="s-tile-ico"><use href="#i-qr" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Scan a sign-in code</span>
<span class="s-tile-d">Already signed in on the web? Fastest way in.</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Use my email address</span>
<span class="s-tile-d">We'll find your mail server</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
<div class="s-link is-muted">Enter server details manually</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step">Step 1</span>
<span class="d-cap-title">Pick a way in</span>
<p class="d-cap-body">
Two live options instead of six mixed ones. Each tile says what it costs you before you tap it.
Manual setup stays reachable in one tap — it just isn't the greeting.
</p>
</figcaption>
</figure>
<!-- 2 · email -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">What's your email address?</h2>
<p class="s-sub">That's usually all we need — we'll look up the mail server for you.</p>
<div class="s-field">
<div class="s-input is-focus">
<svg><use href="#i-mail" /></svg>
<span>ada@example.com</span><span class="s-caret"></span>
</div>
<p class="s-hint">Works with Bulwark, Stalwart, and any JMAP mail server.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-link is-muted">I know my server address</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 2</span>
<span class="d-cap-title">One field, keyboard already up</span>
<p class="d-cap-body">
Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap,
so nobody is left staring at a dead grey button wondering which field is wrong.
</p>
</figcaption>
</figure>
<!-- 3 · found server -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Found your mail server</h2>
<p class="s-sub">Next you'll sign in on your provider's own page.</p>
<div class="s-card">
<span class="s-card-ico"><svg><use href="#i-globe" /></svg></span>
<span>
<span class="s-card-t">mail.example.com</span>
<span class="s-card-d"><i class="s-dot"></i> JMAP · secure connection</span>
</span>
</div>
<div class="s-note">
<svg><use href="#i-lock" /></svg>
<span>Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke.</span>
</div>
<div class="s-btn">Continue</div>
<div class="s-link">Use a different server</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 3</span>
<span class="d-cap-title">Confirm before the browser opens</span>
<p class="d-cap-body">
A browser jump with no warning feels like a bug. Naming the server first — and saying why the
password isn't asked for here — turns the detour into a security feature.
</p>
</figcaption>
</figure>
<!-- 4 · handoff -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered" style="opacity:0.5">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
</div>
</div>
<div class="s-scrim"></div>
<div class="s-sheet">
<div class="s-sheet-bar">
<svg><use href="#i-lock" /></svg>
<span class="s-url">mail.example.com</span>
<span class="s-done">Done</span>
</div>
<div class="s-sheet-body">
<div class="s-web-brand">
<span class="s-web-mark"><svg><use href="#i-shield" /></svg></span>
<b>Sign in to Bulwark</b>
</div>
<div class="s-label">Email</div>
<div class="s-input"><span>ada@example.com</span></div>
<div class="s-label" style="margin-top:14px">Password</div>
<div class="s-input is-focus">
<span style="flex:1">••••••••••</span><svg><use href="#i-eye" /></svg>
</div>
<div class="s-btn">Sign in</div>
<div class="s-divider"><span>or</span></div>
<div class="s-tile" style="padding:11px 13px">
<svg class="s-tile-ico"><use href="#i-key" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">Continue with passkey</span></span>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 4</span>
<span class="d-cap-title">The provider's page, in a sheet</span>
<p class="d-cap-body">
This is the existing <code>runWebmailHandoff</code> flow, unchanged. It already supports whatever
the server offers — password, OAuth, passkeys, SSO — which the in-app form never will.
</p>
</figcaption>
</figure>
<!-- 5 · setting up -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div style="text-align:center;margin-bottom:26px">
<span class="s-av" style="width:60px;height:60px;font-size:22px;background:var(--app-primary);margin:0 auto 14px">AL</span>
<h2 class="s-h" style="font-size:20px">Welcome back, Ada</h2>
<p class="s-sub">ada@example.com</p>
</div>
<div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Mailboxes
</div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Recent mail
</div>
<div class="s-prog is-pending">
<span class="s-spin"></span> Contacts and calendars
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 5</span>
<span class="d-cap-title">Land on progress, not a spinner</span>
<p class="d-cap-body">
The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and
the inbox opens as soon as mailboxes land — the rest fills in behind it.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">When it doesn't go smoothly</h2>
<p class="d-lede">
Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the
exception. These four screens carry most of the redesign's weight.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- scanner -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen is-camera">
<div class="s-cam"><div class="s-cam-grid"></div></div>
<div class="s-cam-top">
<svg><use href="#i-x" /></svg>
<span style="font-size:12.5px;opacity:0.8">Sign-in code</span>
<span style="width:22px"></span>
</div>
<div class="s-reticle">
<i></i><i></i><i></i><i></i>
<div class="s-qr">
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b><b></b>
<b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b>
<b></b><b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b>
</div>
</div>
<div class="s-cam-copy">
<p class="s-cam-h">Open Bulwark on your computer</p>
<p>Settings → Devices → Add phone shows a code. Point the camera at it.</p>
<p class="s-cam-link">Type the code instead</p>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Path A</span>
<span class="d-cap-title">The scanner tells you where the code is</span>
<p class="d-cap-body">
A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page.
Redeeming a paired code skips the browser entirely — see <code>redeemPairingCode</code>.
</p>
</figcaption>
</figure>
<!-- discovery failed -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Where does your mail live?</h2>
<p class="s-sub">We couldn't find a server for <b>example.org</b>. If you self-host, the address is the one you use for webmail.</p>
<div class="s-field">
<div class="s-label">Server address</div>
<div class="s-input is-focus">
<svg><use href="#i-globe" /></svg>
<span>mail.example.org</span><span class="s-caret"></span>
</div>
<p class="s-hint">Same address you type in the browser. We'll add <b>https://</b>.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-note">
<svg><use href="#i-qr" /></svg>
<span>Not sure? Scan a sign-in code from the webmail instead — it carries the address with it.</span>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path B</span>
<span class="d-cap-title">Ask for the server people know</span>
<p class="d-cap-body">
Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and
a trailing slash or full <code>/jmap</code> path is accepted and trimmed.
</p>
</figcaption>
</figure>
<!-- password fallback + error -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Sign in to mail.example.org</h2>
<p class="s-sub">This server doesn't support the browser sign-in, so we'll ask here.</p>
<div class="s-field" style="margin-top:18px">
<div class="s-label">Email or username</div>
<div class="s-input"><span>ada@example.org</span></div>
</div>
<div class="s-field" style="margin-top:14px">
<div class="s-label">Password</div>
<div class="s-input is-error">
<span style="flex:1">••••••••</span><svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-banner">
<svg><use href="#i-alert" /></svg>
<span><b>That password didn't work.</b> If your account has two-factor sign-in, you'll need an app password from the webmail.</span>
</div>
<div class="s-btn">Sign in</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path C</span>
<span class="d-cap-title">Errors that name the likely cause</span>
<p class="d-cap-body">
The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable
host, bad certificate, 2FA-needs-an-app-password. Each says what to do next.
</p>
</figcaption>
</figure>
<!-- add account -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<div style="display:flex;justify-content:flex-end"><svg class="s-back" style="margin-bottom:14px"><use href="#i-x" /></svg></div>
<h2 class="s-h">Add another account</h2>
<p class="s-sub">You're signed in to these already.</p>
<div style="margin-top:16px">
<div class="s-acct">
<span class="s-av" style="background:#3b82f6">AL</span>
<span><span class="s-acct-t">Ada Lovelace</span><span class="s-acct-d">ada@example.com</span></span>
</div>
<div class="s-acct">
<span class="s-av" style="background:#a855f7">AL</span>
<span><span class="s-acct-t">Work</span><span class="s-acct-d">a.lovelace@analytical.co</span></span>
</div>
</div>
<div class="s-divider"><span>add</span></div>
<div class="s-stack">
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-plus" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Another account on example.com</span>
<span class="s-tile-d">Server already known</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">A different provider</span></span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Add mode</span>
<span class="d-cap-title">Reuse what we already know</span>
<p class="d-cap-body">
<code>isAddMode</code> currently shows the same blank three-field form. Most second accounts live on
the same server as the first, so offer that first and skip discovery entirely.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Notes for the build</h2>
<hr class="d-rule" />
<ul class="d-notes">
<li>
<svg><use href="#i-globe" /></svg>
<div>
<div class="d-note-h">Discovery is three cheap probes, in order</div>
<p class="d-note-b">
From <code>ada@example.com</code>: try <code>https://example.com/.well-known/jmap</code>, then
<code>https://mail.example.com/.well-known/jmap</code>, then the same host the last account used.
First 200 wins. Budget the whole thing at two seconds and fall through to Path B on timeout — a
slow lookup must never be worse than the manual form.
</p>
</div>
</li>
<li>
<svg><use href="#i-shield" /></svg>
<div>
<div class="d-note-h">Nothing new on the wire</div>
<p class="d-note-b">
Every screen drives an existing store action — <code>loginViaWebmail</code>,
<code>loginViaPairing</code>, <code>login</code>. The redesign is routing and copy; the auth
surface, the state machine, and <code>parseQrLoginPayload</code> stay as they are.
</p>
</div>
</li>
<li>
<svg><use href="#i-alert" /></svg>
<div>
<div class="d-note-h">Back must always work</div>
<p class="d-note-b">
A multi-step flow earns a hardware-back handler on Android. Every step goes back one, and step 1
leaves the flow — never a trapped screen with a filled-in field you can't clear.
</p>
</div>
</li>
<li>
<svg><use href="#i-key" /></svg>
<div>
<div class="d-note-h">Let the OS help</div>
<p class="d-note-b">
<code>autoComplete="email"</code> and <code>textContentType="username"</code> on the address field,
<code>"password"</code> on the fallback field, so the platform password manager offers to fill and
save. The current screen sets neither.
</p>
</div>
</li>
</ul>
</section>
<p class="d-foot">Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up</p>
</div>
+591 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.48",
"version": "0.1.63",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.48",
"version": "0.1.63",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -18,17 +18,23 @@
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.8",
"expo-notifications": "^0.32.17",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
@@ -2385,6 +2391,12 @@
"node": ">=8"
}
},
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -3930,6 +3942,19 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3946,6 +3971,21 @@
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"license": "MIT",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -4206,6 +4246,12 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4411,6 +4457,53 @@
"node": ">= 0.8"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -4902,6 +4995,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -4911,6 +5021,23 @@
"node": ">=8"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5021,6 +5148,20 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5078,6 +5219,24 @@
"stackframe": "^1.3.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -5085,6 +5244,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5212,6 +5383,15 @@
}
}
},
"node_modules/expo-application": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
@@ -5232,6 +5412,17 @@
}
}
},
"node_modules/expo-clipboard": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -5246,6 +5437,18 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
@@ -5265,6 +5468,15 @@
"react-native": "*"
}
},
"node_modules/expo-haptics": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
"integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
@@ -5274,6 +5486,18 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -5407,6 +5631,26 @@
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "0.32.17",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-secure-store": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
@@ -5948,6 +6192,21 @@
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
"license": "BSD-2-Clause"
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"license": "MIT",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/freeport-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@@ -5995,6 +6254,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6013,6 +6281,30 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -6022,6 +6314,19 @@
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/getenv": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
@@ -6084,6 +6389,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -6099,6 +6416,45 @@
"node": ">=4"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -6277,12 +6633,40 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -6322,6 +6706,41 @@
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -6340,6 +6759,39 @@
"node": ">=8"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"license": "MIT",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -7280,6 +7732,15 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -7909,6 +8370,51 @@
"node": ">=0.10.0"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/object.assign": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0",
"has-symbols": "^1.1.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -8209,6 +8715,21 @@
"node": ">=4.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/postal-mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@@ -8954,6 +9475,23 @@
],
"license": "MIT"
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -9083,6 +9621,23 @@
"node": ">= 0.8"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -9877,6 +10432,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -10208,6 +10776,27 @@
"node": ">= 8"
}
},
"node_modules/which-typed-array": {
"version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
"integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.9",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

Some files were not shown because too many files have changed in this diff Show More