Commit Graph
151 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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