Commit Graph
149 Commits
Author SHA1 Message Date
Linus Rath d57bdfaace fix: send writes only once after a dropped connection, retry only batches of reads 2026-10-29 03:12:41 +01:00
Linus Rath 867ef20e51 Merge branch 's10/mail' into feat/stalwart-1.0 2026-10-29 02:34:22 +01:00
Linus Rath 80962e3079 Merge branch 's10/cal' into feat/stalwart-1.0 2026-10-29 02:27:44 +01:00
Linus Rath 3047d5c495 Merge branch 's10/push' into feat/stalwart-1.0 2026-10-29 02:22:54 +01:00
Linus Rath 8893a9e21e Merge branch 's10/auth' into feat/stalwart-1.0 2026-10-29 02:22:07 +01:00
Linus Rath 111af93a09 docs: say that Stalwart 1.0.0 serves JMAP Metadata in the folder and Sieve property comments 2026-10-29 02:22:05 +01:00
Linus Rath d76f296587 test: check keywords, submissions, contacts, search, Sieve and identities live on Stalwart 0.16 and 1.0 2026-10-29 02:22:05 +01:00
Linus Rath 547d9e5939 fix: drop the deleted messages from the list when the server refuses others of a bulk delete 2026-10-29 02:20:10 +01:00
Linus Rath f733a54777 test: check the calendar live on Stalwart 1.0.0 and 0.16 with series, overrides, invitations and shares #89 2026-10-29 02:13:04 +01:00
Linus Rath b6c0cf1820 docs: say that Stalwart 1.0.0 answers the Metadata properties the calendar list leaves out 2026-10-29 02:12:16 +01:00
Linus Rath e03a38001d test: check push subscriptions live on Stalwart 0.16 and 1.0 2026-10-29 02:11:09 +01:00
Linus Rath 2f43adbc07 test: check sign-in, token renewal, sign-out and account security live on Stalwart 0.16 and 1.0 2026-10-29 02:09:21 +01:00
Linus Rath c6eb02d4cd fix: say why account security is unavailable when Stalwart 1.0 limits the app's sign-in 2026-10-29 02:07:47 +01:00
Linus Rath 4d545f8f58 fix: show inline images whose part name is a path 2026-10-29 02:06:12 +01:00
Linus Rath 18b8aa096f fix: send writes in shared folders to the account that holds them and offer the ones its rights allow 2026-10-29 02:01:47 +01:00
Linus Rath 7c775571c9 fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change 2026-10-29 01:58:24 +01:00
Linus Rath 7d8e073587 fix: list files on Stalwart 1.0 when the account holds more nodes than one get allows #1385 2026-10-29 01:54:55 +01:00
Linus Rath f3e11d4c7c fix: stop asking an account that shares only mail for its calendars on every load on Stalwart 1.0 2026-10-29 01:54:55 +01:00
Linus Rath d2bb479ac6 fix: keep inline images of drafts and sent mail on Stalwart 1.0 2026-10-29 01:53:57 +01:00
Linus Rath 174c3fc8fa fix: create folders and upload files on Stalwart 1.0 and tell its symlinks from folders 2026-10-29 01:53:51 +01:00
Linus Rath 24232bf2e9 test: seed the live Stalwart test's own mail so it runs on any account and on 1.0 2026-10-29 01:38:30 +01:00
Linus Rath f1db6b488a Merge branch 'main' into feat/stalwart-1.0 2026-10-29 01:30:03 +01:00
Linus Rath b4331a9bf9 feat: sign in with an access token 2026-10-25 12:14:30 +01:00
Linus Rath 2618680b21 feat: keep iOS credentials where a notification extension can read them 2026-10-22 22:53:42 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
Linus Rath 198e952f61 fix: tell attendees when someone with write access changes the owner's meeting
Someone who writes to a calendar shared with them (read & write) changes
the owner's meeting there on the owner's behalf: Stalwart sends the
change from the addresses of the account the event is in. The app
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (0.16 and 1.0 alike):

- the editor wrote no participants, hid "Send invitations" and sent
  nothing, so the attendees kept the old time and their answers
- "All events" asked nobody again, and "This and following events"
  made the rest a new series that kept the old answers, which Stalwart
  sends to nobody who had answered

The app now reads `isOrigin`, which both versions compute from the
addresses of the account the event is in: true for the owner's meeting
and every expanded occurrence of it, false for an invitation the owner
got. `userOrganizes` counts such a copy as the organizer's; an
invitation is still changed without messages. "This and following"
asks by the old series, since the new one carries no `isOrigin` yet.

The editor no longer leaves the user's own main address off the
attendees it opens with, only the organizer's (or the user's, for an
event that names none yet): a user who invited their main address to a
meeting organized under an alias lost it on the next save.
2026-09-26 04:00:59 +02:00
Linus Rath 3e6e3d28e1 fix: keep a scheduled message in the app when its new time loses a recipient
"Send now" and "Reschedule" create a replacement submission and then
cancel the original. The server checks the recipients again for the
replacement, and a throttle used up since the original was taken ("452
4.4.5 Rate limit exceeded") can refuse some of them. The app kept the
original only when every recipient was refused; on a partial refusal it
cancelled it, and a recipient the original had taken lost the message.
Port of the webmail's aa99f038.

- the original's delivery status is read with its envelope, in the same
  request; when the replacement refuses a recipient the original took,
  the replacement is withdrawn (cancelled, or only its record destroyed
  when it took nobody) and the original goes at its old time to
  everyone it took
- a recipient the original was refused for as well loses nothing, so
  the reschedule goes ahead and names it; without a delivery status of
  the original, every recipient counts as taken
- a replacement that can't be withdrawn lets the reschedule go ahead,
  naming the refused recipients: "Send now" holds it for a second only,
  so it may be out already, and keeping both would send the others two
  copies; a withdraw whose answer got lost is read back, and when it
  went through the original is kept, since going ahead would reach
  nobody
- the alert in Scheduled and the undo bar's toast lead with "The
  message is still scheduled for ..." and say "Nothing was changed: the
  server refused ..."; they used to open with "Not sent", which invited
  sending the message a second time

Checked live on 0.16.23 and 1.0 with a relay-refused recipient: the
lookup, going ahead, and withdrawing a partly refused replacement while
the original stays at its old time. One new string, with the webmail's
translations in all 27 languages.
2026-09-26 02:51:36 +02:00
Linus Rath 7b18063630 fix: stop a start-up crash, and stale views after sign-out or a new server
- The app crashed on every cold start whose cached folders held a
  shared or group folder, which on Stalwart 1.0 every group member has:
  the sidebar works out the accounts to span while it renders, before
  the session is up, and the JMAP client's account id throws until one
  is. The client now also names its account without throwing, for code
  that runs while rendering; the mail list's account helpers (the
  sidebar's accounts, a row's account, the viewer's pages) use it, and
  a read receipt sent by itself waits for the session. API calls still
  throw without a session.
- Signing out of the last account left the app on an empty mail view
  ("No mailboxes found") with nothing to retry: the account list was
  read before the account was removed, so the app switched to the
  account it had just signed out of, which did nothing. It is read
  again now, and the sign-in screen shows. A failed switch to the next
  account no longer puts the signed-out one back: when its server does
  not answer, that account shows offline, as on a cold start, and one
  whose sign-in is refused is skipped.
- After signing in with the same address on another server (another
  port) or on a server reinstalled at the address (0.16 to 1.0), Files
  kept showing the old server's drive without asking the new one, and
  actions on its rows would have used the old server's ids. The local
  account id leaves the port out and stays the same there, so the
  drive now also starts over when the server behind the session
  changes, and is read again with every new session. The account id
  itself is unchanged: it keys the stored credentials and every
  per-account cache, and changing it would sign such accounts out and
  orphan their data.
2026-09-26 01:44:47 +02:00
Linus Rath efd31de051 fix: never send an account's offline edits to another account
Adding an account put the new account's session in the JMAP client as
soon as it was up, while the queue of offline edits (outbox) stayed on
the account in use, and ready to replay, until the new account's server
was checked. A retry, a network flip or the return from the OAuth
browser in that window replayed the queue through the new account, and
edits to the user's own mail took its JMAP account id. Stalwart's ids
repeat across accounts, so a queued move or delete of one account's
message "x" hit the other account's "x". An edit made in that window ran
the same way, and so did the rest of a replay already under way.

Account switches could overlap too: tapping B, then C before B's session
was up, left the mail list on C while B's switch, finishing last, put B
in the client and its queue in the outbox, so edits in C's list went to
B under C's ids. An older switch that failed last put back the account
it had left, under the newer one.

- the outbox replays and runs edits only while the client serves the
  account the queue belongs to, checked before every op, and holds the
  queue while another account signs in; a failed sign-in gives the
  client and the queue back to the account in use
- the outbox resumes only for the account the mail list shows
- every account change (a switch, a sign-in, a sign-out) takes a
  generation, and one a newer change overtook touches neither the client
  nor the stores; the client no longer installs the session of a load or
  sign-in that a newer one overtook
- a failed switch puts back the account the first of overlapping
  switches left, and tapping that account while they are under way goes
  straight back to it
- a session retry gives way to a switch or sign-in under way
- tokens refreshed for an account being left stay with that account;
  they were saved into the credentials of the account loaded meanwhile,
  which then signed in as the other one
2026-09-26 01:42:35 +02:00
Linus Rath 206756e13c fix: stop treating narrow refusals as total ones in the app's sends and exports
Two places took a narrow refusal from the server for a complete one.
Port of the webmail's 7977a6ed.

- Sending at a full submission quota (Stalwart 1.0): when making room
  found nothing, no search ran for five minutes, for any send. Scheduled
  mail and undo-send holds can go once their message is out, and until
  the next search every send failed with "ask your administrator". A
  send the user starts, a read receipt included, now always looks again;
  the pause stays for automatic read receipts.
- vCard export: Stalwart answers Blob/get only in accounts the user is a
  member of, so for an address book shared with the user it came back
  `forbidden` ("You are not an owner of account ...") and the export fell
  back to one photo download after the other. It checks the access to
  each blob by itself, whichever account the request names, so
  `getBlobsAsBase64` now asks again in the user's own account, on 0.16
  and 1.0 alike.
2026-09-26 00:52:43 +02:00
Linus Rath 7326fec611 fix: stop reporting mail as sent in the app when the server refused its recipients
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. The app read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce. Port of
the webmail's e0b0dfe0.

- the send reads the new submission's delivery status in the same
  request (`EmailSubmission/get` of `#sub-1`, its creation id; the
  create's own answer has none), also with the retry after a full
  submission quota, before the record is destroyed
- every recipient refused: the send fails with the server's reply per
  address, the copy filed in Sent is removed and the previous draft
  kept, so the composer or the quick reply keeps the text and the
  address can be fixed; held sends (undo send, scheduled) are refused
  when they are created, so this covers them too
- some recipients refused: the others get the message, and a warning
  names the refused ones with their replies
- "Send now" and "Reschedule" check the replacement the same way and
  keep the original, which still goes out at its old time, when every
  recipient of the replacement is refused; a partial refusal there gets
  the warning as well
- the submission prune leaves a shared account that refuses its records
  with `forbidden` alone while the app runs, for that login, with a
  debug log instead of a warning, and the scheduled list skips it:
  0.16.23 lists an account that only shared an address book or a
  calendar with the submission capability

`RecipientsRefusedError` and `RefusedRecipient` are added to
`jmap-result.ts`. Five new strings in all 27 languages, four of them
with the webmail's translations.
2026-09-26 00:51:59 +02:00
Linus Rath 00619751d7 fix: never send a rescheduled message twice and handle partial refusals
"Send now" and "Reschedule" at a full submission quota (Stalwart 1.0)
made room and then created the replacement again. Making room can take
a while - a prune, a walk through the change log - and the original
could go out meanwhile, so the replacement sent it a second time, with
a HOLDFOR that still counted from before the wait. The original is now
read again first: nothing is resubmitted unless it is still pending
("This message was already sent" or "no longer scheduled" otherwise,
and the undo bar lets go of it), a failed read changes nothing, and the
hold is worked out again from the time the user picked. Port of the
webmail's 65eb4b10.

- the Stalwart explanation of a full quota (Bulwark removed what it
  could, an administrator has to raise the limit) needs
  `urn:stalwart:jmap` on the submission account; another server's
  `overQuota` gets a generic sentence with its own description
- when making room finds nothing that can go, the account is not
  searched again for five minutes; each send, reschedule and automatic
  read receipt at a full quota used to run the prune, the change-log
  walk and the gets again
- unsubscribing by mail at a full quota explains the quota instead of
  only saying "Unable to unsubscribe"
- push on Stalwart 1.0 (`principals:owner` on the account) takes the
  emailpush capability as the answer to which accounts notify: a group
  with only mail no longer counts as unreachable, and a refusal
  remembered under 0.16 no longer keeps a group out of the map
- a push re-registration the user asked for at the server's
  subscription limit removes the old record first and tries once more;
  the old record counted against the limit, so it could never be
  replaced
- "Mark folder as read" on a folder and its subfolders goes on past a
  folder the server refuses and reports the refusal at the end; the
  folders and the list refresh either way
- marking, starring or tagging a selection writes every account before
  reporting a refusal and shows only the messages the server updated;
  one refusing account or message used to leave the rest unwritten,
  and what was written looked unchanged
- a new photo for a contact opened from the cache is not saved when the
  card's media can't be read from the server either; it would have
  replaced the card's other media (a logo, more photos)
2026-09-25 23:51:39 +02:00
Linus Rath a562ea59b8 fix: keep attendees' answers on save and deliver occurrence changes
Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.

The series scopes did not fit that:

- "All events" wrote the opened occurrence's start, length, zone and
  participants onto the series: a stretched occurrence stretched every
  one, and one day's decline spread to the whole series. The series now
  keeps its own list and takes only the attendees the user added or
  removed, and its times change only when the occurrence moved (by the
  same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
  attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
  occurrence, only to attendees who have not answered (0.16.6 to 1.0).
  A change that keeps answers now goes out in two writes: the override
  as the occurrence stands without scheduling, then the change as an
  update of it, to the occurrence looked up again. Checked live on 1.0
  and 0.16.23.
- A save of an invitation someone else organizes writes no participants
  while the user left them alone, and never asks for scheduling, so the
  organizer gets no reply for a changed alert. The switch to send
  invitations is hidden there.

Also:

- a reminder change on one occurrence of a series that uses the
  calendar's default alerts turns the defaults off for the series first
  (keeping what it shows under ids of its own); 1.0 kept showing the
  defaults
- all-day and floating occurrences expanded on the device no longer
  carry the master's utcStart/utcEnd, so their reminders are scheduled
  for each day; master instants that are not the master's own (0.16.23's
  year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
  calendars capability's maxParticipantsPerEvent, and a refusal for it
  (Stalwart 1.0 enforces it, answers included) is explained in the
  editor, the series dialogs, the detail sheet and the invitation
  banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
2026-09-25 23:50:29 +02:00
Linus Rath f490886a8b fix: keep iCal subscriptions safe when the mail server was replaced
When the server behind an account is replaced (a fresh Stalwart 1.0
install at the address 0.16 ran on), its ids start over. The app
already dropped its caches then, but kept what the user set up with
server ids inside:

- iCal subscriptions kept the ids of their mirror calendars. The next
  sync deleted the "stale" events of whatever calendar now had the id
  and filled it with the feed, and "Remove subscription" destroyed it.
  Now the account's subscriptions keep their feed, name, colour and
  interval but lose their mirror, and the next sync makes a new one as
  adding a subscription does. Renaming or removing an unlinked one
  touches no calendar. Events are only pruned from, and remove only
  destroys, a calendar that still carries the subscription's name.
  Subscriptions now record the signed-in account, so accounts on two
  servers that got the same JMAP id no longer share them.
- the preferred sender identity kept its id, which a new server can
  give to another address (live: identity "b" is usera@ on 0.16.23 and
  team@ on 1.0). It is forgotten for the account's old and new JMAP
  id, which the app now records with the server fingerprint.
- hidden calendars kept their ids. The list is shared by all accounts,
  so it is cleared.

Offline changes the drop discards are now announced once ("3 offline
changes were discarded because the mail server changed"), in all
languages.

Revoking this device from another one turns push off here again: a
subscription gone before its recorded expiry counts as revoked, as it
did before the server check came in. A replaced server forgets the
recorded expiry, so there the subscription is re-created instead.
2026-09-25 23:49:36 +02:00
Linus Rath a2ca98e0e8 fix: send offline edits after a restart and never to another account
The queue of offline edits (outbox) and the offline mail cache were
attached to an account only when the email store switched to it. A cold
start restores the view of the account it showed last, so there was no
switch: the queue from the last session was never loaded or sent, an
edit made meanwhile was dropped with a console warning, and the offline
cache neither stored nor served bodies.

On an account switch the queue was loaded and flushed right away,
before the new account's session was up and before the check for a
replaced server. The JMAP client still served the previous account
then, so queued edits could reach it.

- the email store attaches the outbox and the offline cache whenever it
  is pointed at an account, also the one it already shows
- the outbox holds its ops until the auth store resumes it, once the
  account's session is up and its server was checked; edits made before
  that are queued, and edits made while the queue loads are kept
- the JMAP client serves no account while it loads or signs in to
  another one; a failed switch puts the previous account and its queue
  back
- dropping an account's queue or cache for a replaced server cancels a
  read of it still under way
- the offline sync stops when the cache or the client moves to another
  account, instead of filing one account's mail under another
2026-09-25 23:48:51 +02:00
Linus Rath 2e26ff7b64 fix: report refused tags and re-check the sort order after a server change
Stalwart 1.0 refuses keywords over 128 bytes and more than 100
keywords on one message, per message in Email/set `notUpdated`; a
read-only shared folder refuses any keyword on every version. The app
stopped a large selection at the first refused batch, "mark folder as
read" ignored refusals, and the list's swipe, bulk and tag actions let
the rejection go unhandled, so nothing was said. The keyword sort
polarity verdict was stored per server address for a week, so a server
reinstalled as 1.0 (which sorts as the RFC says, where 0.16.8-0.16.22
invert) kept the old reading.

- Email/set writes send every batch before reporting the first refused
  message, and "mark folder as read" marks the rest, then reports the
  refusal. The list shows "Tagging failed" or "Failed to update email"
  for swipe, bulk and tag actions, and the viewer's automatic
  mark-as-read puts the message back to unread when it is refused.
- The sort polarity verdict is keyed by the server fingerprint as well
  (stored as `keyword-sort-polarity:v2`), so a replaced or upgraded
  server is probed afresh. An upgrade that leaves the capabilities
  alone, such as 0.16.22 to 0.16.23, still waits out the week.
- Tests pin that Stalwart 1.0's `$hasattachment` / `$hasnoattachment`
  never show as tags or as tags to recover; the app lists no other
  keywords, and tag ids stay within 128 bytes by construction.
2026-09-25 20:59:19 +02:00
Linus Rath 42751a6b29 fix: keep the app's requests away from Stalwart 1.0's unfinished JMAP Metadata
Stalwart 1.0 advertises `urn:ietf:params:jmap:metadata` by default, but
its handlers are still `todo!()`: a get that selects `metadata` or
`privateMetadata`, or a /changes call with `ignoreMetadataOnlyChanges`,
panics the request on the server. The app never sends either, but its
Mailbox/get and SieveScript/get calls asked for the server's default
properties, which a later release could grow to include metadata.

- Mailbox/get (own folders, shared accounts' folders, by id, and the
  polling fallback) and SieveScript/get ask for explicit RFC property
  lists, the same as today's defaults on 0.16 and 1.0 (checked live).
- A test checks the requests the folder, filter, delta-sync and polling
  paths build, on 0.16.23 and 1.0 sessions, and scans the app's sources
  for metadata properties, the /changes argument and the capability.
2026-09-25 20:58:33 +02:00
Linus Rath 97daf76f05 fix: start over when the mail server behind an account was replaced
Stalwart 1.0 has no migration from 0.16, so the way there is a fresh
install at the same address, where ids start over ("a", "b", ...) and
name other objects. The app persists a lot that only means something on
the server it came from: JMAP states and query states, offline bodies
keyed by message id, an outbox that replays stored ids (a `destroy`
included), the calendar cache and push de-dupe lists. It also turned
push off for good when the server no longer had the subscription, and
would have taken another device's subscription for its own once the
reinstalled server handed out the recorded id again.

- Email/changes, Email/queryChanges and Mailbox/changes answers whose
  Stalwart state went below the one asked from count as not computable
  and lead to a full fetch (`stalwartChangeId`, a port of the webmail
  guard). It only reads accounts that list `urn:stalwart:jmap` and
  states in the exact form Stalwart writes, so it never trips while
  states grow or on other servers.
- Every time a session is established, a fingerprint of the server (API
  URL, primary account id, the session's and the primary account's
  capability names) is compared with the one recorded for the account.
  On a change, before anything reads them, the account's folders,
  lists, states and query states, offline bodies, queued and failed
  outbox ops, the calendar and contacts caches and the push de-dupe
  lists are dropped. Preferences, templates, tags and iCal
  subscriptions stay. The first sight of an account only records it.
- The whole outbox goes: every op names a message by its server id.
  Offline edits still waiting are lost, but never a message the user
  wrote, because the outbox holds no sends.
- A subscription the server no longer has counts as expired and is
  re-created, and the recorded one is only ours when its device client
  id matches, on setup and on teardown. Revoking this device from
  another one therefore no longer keeps push off here; turning it off
  on the device does.
2026-09-25 20:57:46 +02:00
Linus Rath e8a9c58d18 fix: keep push notifications working when a mailbox is shared with you
Stalwart refuses a push subscription's whole emailPush map as a
`forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and
later, and 1.0), and 1.0 stops listing emailpush on such accounts. The
app narrowed the map on any `forbidden`, including a refused method
call, and put every account with folders in the first attempt, so 1.0
cost one refused call per install. Worse, a failed listing counted as
"the server has no subscription" and dropped the key of a working
registration, and any failed refresh - a network error, a timeout, rate
limiting - started a replacement.

- `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same
  name, carrying `properties`) and `isJMAPSetError`. `assertSetResult`
  throws it, so every refused object can be told apart from a refused
  call; existing `instanceof` checks and the outbox's name-based error
  classification are unchanged.
- The emailPush map holds the primary account and the accounts whose
  `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which
  leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused
  map still narrows to the accounts that look owned, then to the
  primary account, but only on a `forbidden` SetError.
- A subscription ends only with a verified replacement, a user disable
  or the server's verdict: a failed listing aborts the run, and only a
  SetError on the refresh starts a replacement. Other failures leave
  the subscription and its key alone and the next run tries again.
- `forbidden` and `overQuota` read as plain text in settings and the
  onboarding prompt, and settings says that mailboxes others share
  don't send background notifications. New strings in all 27 languages.
2026-09-25 20:56:57 +02:00
Linus Rath 3f4a81b7ad fix: handle shared folders, symlinks and file names on Stalwart 1.0
Files in the app assumed Stalwart 0.16 in several places:

- Stalwart 1.0 lists the owner's folders above a node shared with the
  user, without dates and with every right false. The app took the
  owner's top folder for the shared root, so the folder actually shared
  sat several levels down, under folders where rename, delete and move
  could only fail. Those placeholders are now left out of the root and
  of folder listings.
- A node counted as a folder when it had no blobId, so 1.0's symlinks
  showed up as empty folders that could be opened. Listings ask for
  `nodeType` (returned since 0.16.6, never sent in FileNode/set), and
  a symlink is an inert row with a link icon: it can be renamed, moved
  and deleted, but not opened, downloaded, duplicated or shared.
- Only "/" was checked in names, and uploads were not checked at all,
  so `<>:"\|?*` already failed on 0.16. New folder and rename now apply
  the naming rules from the filenode capability plus 1.0's stricter
  ones (control characters, 255 bytes, reserved stems such as
  `con.txt`) and say what is wrong. Uploaded names are made acceptable
  instead: `con.txt` becomes `con_.txt`, also on 0.16.6 to 0.16.23.
- One refused file stopped a whole upload. The batch now goes on and
  reports the files that failed at the end.
- Every media type over 30 characters, which is every Office document,
  was stored as application/octet-stream. New files get the bare
  lowercase media type 1.0 requires, and no `size`, which 1.0 refuses
  when it differs from the blob.
- File errors keep their JMAP type, and the alerts say why an action
  failed: too many files or folders (`overQuota`), folders nested too
  deeply, a refused name, missing rights, or a file too large.
2026-09-25 20:46:41 +02:00
Linus Rath 90b78da8c8 fix: keep time zones, default alerts and calendar renames working on 1.0
Four smaller calendar changes that Stalwart 1.0 needs.

- Time zone: 1.0 fails every CalendarEvent get and query whose
  timeZone its database lacks, with invalidArguments "Invalid timeZone".
  It matches IANA names case-sensitively and knows no offsets, so a
  "+01:00" or "europe/berlin" that Intl accepts empties the calendar.
  0.16 reads such a zone as UTC. The zone is now checked with a get of
  no events that rides along with the first calendar list; once refused,
  reads ask in UTC and log one warning, and a read refused before the
  check answered is repeated once in UTC. The comment that said Stalwart
  ignores bad values is corrected.
- Default alerts: 1.0 fills a calendar's default alerts into the
  alerts of an event that uses them. The editor wrote them back with
  useDefaultAlerts false on every save, freezing today's defaults into
  the event. It now writes nothing while the reminders are unchanged; a
  change turns the defaults off and saves the alerts under ids of their
  own, since 1.0 drops alerts saved under a default alert's id.
- Calendar colour: renaming sent the colour along, and 1.0 accepts only
  #rgb, #rrggbb or a CSS name, so a calendar another client coloured
  with an 8-digit hex could not be renamed. The edit sheet and the iCal
  subscription settings now send only the fields that changed.
- Reply stubs: 0.16 stored an attendee's answer to one occurrence
  without a title, place or description, and 1.0 builds that occurrence
  from the override alone. Hydration now takes a missing title from the
  series, and the place and description when the override removes the
  title.
2026-09-25 20:44:34 +02:00
Linus Rath 6eeee8df54 fix: explain sharing when Stalwart 1.0 keeps its directory closed
Stalwart 1.0 refuses Principal/query with `forbidden` unless the
administrator allows directory queries, which is off by default. The
app took the missing Principal/get answer for an empty directory: the
calendar share sheet said "Sharing is not available on this server.",
the file share sheet said "No other users found", and existing shares
showed bare ids.

- `listPrincipals` says whether the listing is complete, `restricted`
  or failed. On `forbidden` it falls back to Principal/get without ids,
  which 1.0 still answers with the user's own account, their groups and
  the accounts that shared something with them.
- Principal/get asks for `id`, `type`, `name`, `description` and `email`
  only; 1.0's default list adds `capabilities` and `accounts`.
- Both share sheets explain a restricted directory under the search
  box. Only 1.0's own refusal ("The administrator has disabled directory
  queries.") gets the wording that points at Stalwart's sharing
  settings; any other refusal gets a generic one, and a failed listing
  says so.
- A sharee the app can't name shows as "Unknown user" with its id, and
  can still be changed or removed.

On 0.16 with the default roles the listing is complete and the sheets
behave as before.
2026-09-25 20:42:07 +02:00
Linus Rath 6be51a2bab fix: find multi-word, address and long-word searches in the app on Stalwart 1.0
Stalwart 1.0 reads a text filter value that ends in `*` as one prefix
over the whole string, not split into words and capped at 16 bytes. The
app added `*` to every word it searched for, so on 1.0 `project budget`
became the prefix "project* budget", an address was no single word and
`Geschäftsführung` (18 bytes) was over the cap: all three found
nothing. Quoted phrases broke too, since `"annual report" 2024` went out
as one value ending in `*`.

The search text now goes out as typed, split the way the webmail does
it (5967a1c4): each quoted phrase and each `word*` becomes a condition
of its own, ANDed with one condition that holds the other words, so a
query without quotes or stars is sent unchanged. The folder, all-folders
and tag-view searches, the unified inbox and the from, to, subject and
body filters all use it, and `searchEmails` puts `inMailbox` next to
the text conditions in one AND instead of on an operator.

0.16's built-in index dropped the `*` anyway, so its results stay the
same; there a quoted phrase is an AND of its words. On 1.0 a typed
`word*` is now a real prefix.

The fake server of the store tests now matches text the way Stalwart's
built-in index does, which the old `*` after every word fails.
2026-09-25 20:41:11 +02:00
Linus Rath 2a3febcc79 fix: edit or answer one occurrence of a series in the app on Stalwart 1.0
Stalwart 1.0 refuses series properties such as the organizer on a
single occurrence unless they repeat the series' value, and refuses
them inside a recurrence override on the base event. The event editor
wrote the user's first address as organizerCalendarAddress on every
save of an event with attendees, so editing one occurrence failed with
"This property cannot be modified on a single occurrence." whenever the
series was organized by someone else, under another alias or in
another letter case. Removing every attendee from one occurrence failed
the same way.

- leave series keys and per-event metadata out of changes to one
  occurrence, as Stalwart's validate.rs lists them, and pointers at a
  participant's calendarAddress
- write overrides on the base event as before (0.16.19 needs the
  organizer there) and, when the server refuses series keys inside
  them, repeat the write once without them and remember that
- answer one occurrence through its synthetic id without the organizer
- let the editor keep the organizer of an event: an invitation the
  user received is no longer rewritten with the user as organizer, and
  organizerCalendarAddress is only written for an event that has none
- keep the SetError type on a refused event update

0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the
base-event override payload for older servers is unchanged.
2026-09-25 20:36:58 +02:00
Linus Rath ef6f1540dd fix: keep the app's calendar filled past a year on Stalwart 1.0
Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more
than the account's maxExpandedQueryDuration (P52W1D by default) with
expandDurationTooLarge, and one that expands to more than 3000
occurrences with cannotCalculateOccurrences. The scrolling views load
windows of up to about 460 days, and refresh() reloads the whole
window on every calendar push, after series edits and on pull to
refresh, so the calendar showed a raw error over stale events from
then on. The fallback to expanding on the device only knew 0.16's
invalidArguments.

Expanded range queries are now cut into chunks a day shorter than the
advertised limit, with overlapping borders, sent together as far as
maxCallsInRequest allows, and their ids de-duplicated. A chunk the
server still refuses is halved; if a week still expands to too many
occurrences, the whole range is expanded on the device as before.

The expanded query keeps a filter of the two bounds only: 1.0 refuses
operators there, so filtering several calendars would take a query per
calendar, and the store loads every calendar anyway. 0.16 advertises
the same limit without enforcing it and returns the same events.
2026-09-25 20:35:00 +02:00
Linus Rath f52ef7db7e fix: show and keep contact photos in the app on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including photos saved as data: URIs. The app read
only `uri`, so sender avatars, the contact list, the detail screen, the
form preview and vCard export showed nothing. Worse, the form rebuilt
`media` from the card on every save and sent it without the photo, or
as `null`, so editing a phone number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and
  `uri` is optional. The photo shown is the photo entry with the lowest
  `pref`, as a URI (0.16) or a blob (1.0).
- Blob photos load from the download URL with the Authorization
  header, like file thumbnails. At most half of the server's
  `maxConcurrentRequests` load at once, a failed one is tried once more
  and then left alone for two minutes, and one that loaded skips the
  queue from then on.
- The form leaves `media` out of the save unless the photo changed. A
  changed photo is built on the card as the server has it now, so a
  stale blobId can't make 1.0 refuse the update, and a new photo keeps
  the key, `pref`, `label` and `contexts` of the one it replaces.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). Otherwise
  it goes as a data: URI, and a refusal like 0.16's "blobIds in media
  is not supported." retries once with the data: URI and is remembered
  for the account.
- vCard export fetches blob photos with batched Blob/get requests and
  writes them inline. It also writes photos whose data: URI names no
  media type, which it dropped before. Duplicate and moving a card to
  another account send the bytes instead of the source's blobIds.
- An update never clears `id`, `uid` or `addressBookIds`, and vCard
  export writes MEMBER values that are already URIs as they are instead
  of `urn:uuid:urn:uuid:…`.

The form's state and patch building moved to `src/lib/contact-form.ts`
so the save path can be tested.
2026-09-25 20:32:13 +02:00
Linus Rath fe0facfea5 fix: keep sending working in the app past Stalwart 1.0's submission limit
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. The app never removed a record, so
after about 500 sends from an account every send, quick reply, read
receipt and "Send now" failed with Stalwart's raw "There are too many
email submissions, please delete some" error. This ports the webmail
fix (48382170).

The app now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so a record EmailSubmission/get reports as
pending is never touched:
- an immediate send (no undo delay, no schedule) and a read receipt
  destroy their own record right after the send, riding along with the
  old-draft cleanup when there is one; only where `urn:stalwart:jmap`
  is advertised, and never decided by the created undoStatus, which
  0.16.10-0.16.16 report as final for held sends
- a throttled background prune (`src/api/submissions.ts`) destroys
  canceled records and final records whose sendAt has passed, going by
  EmailSubmission/get, in every submission account; it runs at app
  start, on return to the foreground and after sends and undo
- a confirmed cancel (undo, cancel, reschedule, "Send now") destroys
  the canceled record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
  walks `EmailSubmission/changes` from "n" for records older than the
  query's three-day window, retries once, and otherwise throws
  `SubmissionQuotaError`, shown as a translated explanation in the
  composer, quick reply, read receipts, the undo bar and the scheduled
  list; the copy the failed attempt made is removed as before

0.16 never enforces the limit, and destroy never cancels on any
version, so the same cleanup runs there unchanged.

The undo bar now also says when an undo or "Send now" failed instead
of only logging it. The scheduled list reads every page of
EmailSubmission/query with calculateTotal; it asked for 200 records,
oldest first, so the mail scheduled furthest ahead dropped off once the
three-day window held more than that.

`SubmissionQuotaError` is added to `jmap-result.ts` as a subclass of
`JMAPMethodError`.
2026-09-25 19:46:15 +02:00
Linus Rath 28ade775e3 fix: load calendar events and tasks in the app on Stalwart 1.0
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every event
read of the app did. The calendar showed a raw error over stale events,
and tasks, reminders, "all events" edits, contact activity, the
invitation banner's UID look-up and iCal subscriptions stopped working.
Subscriptions and imports also lost their UID check, so every sync tried
to create the whole feed again and ended in refusals.

- read a stored event's overrides with a second get in the same
  request; server-expanded occurrences get them from their base event
  as before
- read only ids, UIDs and calendars for the import and subscription
  UID look-ups
- fail a read when either get answers with an error, so the calendar
  keeps the events it has and shows the error
- keep the JMAP error type on calendar method errors (JMAPMethodError,
  plus a shared jmapErrorType helper in jmap-result)

0.16 accepts the split reads and returns the same events.
2026-09-25 19:45:19 +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 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