Commit Graph
154 Commits
Author SHA1 Message Date
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 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 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 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 a3193ebaf7 fix: create new groups in the address book being viewed 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 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 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 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 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 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 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 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 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 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 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 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 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