Commit Graph
1000 Commits
Author SHA1 Message Date
Linus Rath f7d1a86b5e fix: keep series intact when a split fails and when edited from one occurrence
Edits of a recurring meeting from one of its occurrences went wrong in
several ways (live on 1.0 and 0.16.23, attendees included):

- A "this and following" split whose new series could not be created
  stayed half done: the calendar store answers most failures with null,
  and only a thrown error put the old series back. The series stayed
  ended, told so to the attendees, and the save said it had worked. A
  null now puts the series back as well and fails the save. The rollback
  writes the old overrides back as a whole map: a pointer into a series
  without overrides fails, and the split may just have removed its only
  one.
- "This occurrence" on the series itself (a meeting opened from a link
  or a search result) went out in one write that reached no attendee
  who had answered. It now takes the same two steps as an occurrence of
  an expanded series: the override as it is without messages, then the
  change of it.
- The new series of a split started at the opened occurrence's own time
  and in the user's zone: Mondays in New York, one occurrence moved to
  Tuesday, renamed from there by a user in Berlin, became a Tuesday
  series in Berlin. It now goes on from that occurrence's recurrence id,
  in the series' zone, moved only by what the editor moved; the
  occurrence keeps its own time through its override. It takes the old
  series' details with what the user changed, not the opened
  occurrence's own ones.
- "All events" from an occurrence with an override of its own wrote that
  occurrence's own title, copied into its override, back to the series:
  a new room there renamed the series. Only the fields the user changed
  go to the series now; the opened occurrence's override takes them too
  (on 1.0 also where it holds them as pointers), and other overrides
  where they only held the series' old value.
- The move of an "all" edit was measured on the user's clock: across a
  week in which only one of the two zones changes to or from summer
  time, the series moved an hour more or less than picked. It is now
  measured on the series' own clock.
- 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock
  taken for UTC, so in a zone behind UTC a split or "delete this and
  following" kept the occurrence it was to end before. The UNTIL is now
  earlier by the zone's offset, which ends the series there on 0.16 and
  1.0 alike.
- An applied counter proposal for one occurrence of a recurring meeting
  went to the first event with its uid. It now goes to the occurrence
  the proposal names; one for a whole series of which only occurrences
  are in view is not applied to any of them.
2026-09-26 02:48:11 +02:00
Linus Rath 9f7c12ab35 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. Since 6d4b6954 Bulwark
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (live on 1.0 and 0.16.23):

- a drag, a resize or a month-view drop wrote the new time without
  scheduling messages: the attendees kept the old time and their answers
  while the owner's copy moved; before 6d4b6954 they got "Updated
  invitation"
- the editor wrote no participants, hid "Send invitations", showed the
  attendees read-only and sent nothing
- "All events" asked nobody again, and "This and following events" ended
  the series quietly and made the rest a new series without attendees

`userOrganizes` now also counts the organizer's own copy in another
account: `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. Such an
invitation is still changed without messages. The editor no longer drops
the user's own address from the attendees it opens with, only the
organizer's, so a sharee who attends the owner's meeting stays on it.
The demo's invitation from Alice now says it is not the demo user's copy.
2026-09-26 02:44:32 +02:00
Linus Rath aa99f038d1 fix: keep a scheduled message 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 original was
kept only when every recipient was refused; on a partial refusal it was
cancelled, and a recipient the original had taken lost the message
without anyone being told.

- The original's delivery status is read with its envelope. When the
  replacement refuses a recipient the original took, the replacement is
  cancelled (its record only destroyed when it took nobody) and the
  original goes at its old time. A recipient the original refused as
  well loses nothing, so the reschedule goes ahead; checked live on
  0.16.23 and 1.0 with a relay-refused recipient.
- The toast for a kept schedule leads with "The message is still
  scheduled for ..." and says "Nothing was changed: the server refused
  ...". It used to open with "Not sent", which invited sending the
  message a second time.
- Recipients refused while the others were taken are now named after
  "Send now", a reschedule from the list or the viewer, and a quick
  reply, which said "Email sent".

Files, from the review of d9ffe8a7:

- An own folder opened from search while Files showed a shared folder
  opened the shared folder again: its link had no query, and the page
  read `?shared=` from the address bar. The link now says it has none.
- The Pro shell kept only the path of a Files link across its redirect,
  so a shared folder's link (`/files?shared=...`) didn't survive a
  reload, and neither did `?preview=`. The query goes along now.
- A search hit for a file on a "Files from" page opened the first file
  of that name there; it is found by the id the link names now.
- Undoing a paste of files cut on a "Files from" page moved them to
  "*", the page's id; they go back to their own folders now.
2026-09-26 02:25:40 +02:00
Linus Rath ab72086e00 fix: remove a draft's old versions only through the login that saved it
A draft's old version is removed by id when the composer saves over it,
sends it or discards it. Email ids repeat across accounts and servers -
the same user logged in to two servers is account `e` on both - so the
removal must go to the login and account the draft lives in. Two paths
took the wrong one:

- "Edit draft" in an "All folders" search or a tag view took the account
  from the folder still selected underneath. With a group folder
  selected, an own draft was opened as the group's, and its first
  autosave, send or discard destroyed the message with that id in the
  group account, while the draft stayed in the own Drafts. It now takes
  the login and account the result is stamped with, reads the draft
  through that login, and prefers that login's identity when two
  logins share an address.
- The composer discarded through the login of the current From, and
  replaced or sent through it too. After switching From to another
  login's identity - which saves nothing by itself - discarding,
  the next save or the send removed the draft's id on the other server.
  Each save now records the login it went through (a draft opened from a
  list starts with the list's). A version saved through another login is
  removed through that one: after the new version exists when saving,
  after the message is out when sending, and on discard.

The Pro email view, folder tabs and search preview pass their login
along with the draft.
2026-09-26 02:24:35 +02:00
Linus Rath 0e0d99e7c3 fix: send unified-view archive, move and spam to each message's account
In the unified views, a tag view and an "All folders" search a
selection can span logins and accounts, and message, account and folder
ids repeat across both: Stalwart numbers the accounts and the system
folders of every install alike.

- Archive sent the whole selection through one login into the Archive of
  the selected folder's account, which is a virtual folder there, so the
  own one: messages of a group account or of another login were archived
  by their ids in the own account, where the same ids can name unrelated
  messages, or failed after the own ones had moved. The list dropped
  every selected row either way. Each login and account now archives
  into its own Archive.
- "Move to" and dragging messages applied one folder id to every
  account, so another account's messages landed in whatever folder has
  that id there, and failed moves were dropped from the list anyway.
  Only messages in the folder's own login and account change folders in
  place now; the others take the cross-account move (Email/copy within
  one login, download and import across logins), a single message moved
  with "Move to" or dragged as well.
- A drop goes to the login whose sidebar list shows the folder. It went
  to the first login whose own account id matched the folder's, which
  with two servers that both number the user `e` could be the other one.
- "Spam" did nothing while the app said the messages were marked, and
  "Not spam" failed, because the selected folder is a virtual one there.
  Both now write each message in its own account and move it to that
  account's Junk or Inbox. The spam shortcut in "All Junk" means "Not
  spam", as the toolbar does.
- What could not be archived, moved or marked stays listed and selected,
  and the error says why.
2026-09-26 02:23:28 +02:00
Linus Rath a6e6a5e201 fix: show a new series' occurrences and keep single edits off the series
A live Lite run on 1.0 and 0.16.23 found two faults with a series just
created:

- Its later occurrences did not appear until the page was reloaded. The
  store added the series itself to the grid and did not load the range
  again, and only the range load expands a series (on the server, or in
  the browser). The store now reloads the visible range after creating
  a series, and after any change to a series or one that makes an event
  a series or a single event again.
- That one item was the series itself, and "This event only" on it
  wrote the editor's data to the series: `CalendarEvent/set` on the
  series' id with its title, start and rule, so every occurrence
  changed. "Delete this event only" deleted the series. The series
  itself now stands for its first occurrence: a change becomes an
  override of that occurrence, and a delete excludes it. "This and
  following events" from the first occurrence changes, or deletes, the
  whole series instead of ending it before its first occurrence. No
  single-occurrence path falls back to the series' own id any more; only
  an occurrence the server keeps as an event of its own (an invitation
  to that occurrence alone) is changed or deleted as itself, and a
  series that cannot be found is refused with the usual error.

The delete scopes moved from calendar-app.tsx to
`lib/recurrence-scope-edit.ts`, next to the edit scopes, so they are
tested.
2026-09-26 00:27:20 +02:00
Linus Rath 6d4b695489 fix: ask attendees again when their organizer drags a meeting
A move outside the editor kept every answer. A drag or resize in the
week and day views, a drop in the month view and an applied counter
proposal wrote the new time with the attendees' stored answers:
Stalwart's update then said "accepted" for a time nobody had accepted,
and a dragged occurrence, which gets its own override, reached no
attendee who had answered (live on 1.0 and 0.16.23). When the user
organizes the meeting these moves now ask the attendees again, as the
editor does; one helper (`userOrganizes`) decides for both.

Also:
- A changed recurrence rule counts as a move for the series: a custom
  rule moved from Monday to Tuesday asks again. A rule the user did not
  touch is no change however the server spells it, and on one
  occurrence, where no rule applies, it is none.
- An event that lasts no time (a deadline) keeps its zero length on
  save instead of becoming an hour long and asking everyone again.
- An attendee's save of their own copy - an alert on one occurrence, a
  drag - wrote the participants again and sent the organizer a
  "Reply:" mail. It now leaves the participants alone and sends
  nothing. The editor shows someone else's attendee list read-only and
  no "Send invitations" box, which only ever controlled the organizer's
  messages; answers go out through the answer buttons.
- An existing override written on the base event (0.16 before 0.16.20,
  and ranges the browser expands) gets the series' organizer again when
  it has none, as every save wrote it before 4c20b09c: 0.16.19 refuses
  an answer to such an occurrence. 0.16.23 takes the key and drops it;
  1.0 refuses it inside an override once, and the store writes the
  override again without it (both live).
- The comment on `organizerCalendarAddress` said Stalwart takes the
  ORGANIZER solely from it. Both versions take it from the owner
  participant when it is missing (live).
2026-09-26 00:25:27 +02:00
Linus Rath 75b432e957 fix: reach attendees who answered with series splits and first edits
Stalwart sends a new event, and an occurrence that gets its own override
for the first time, only to attendees who have not answered (PARTSTAT
absent or NEEDS-ACTION); a change to an existing event or override goes
to everyone who has not declined. Since the editor keeps the attendees'
answers on a save (4c20b09c), two edits never reached the attendees who
had accepted (live on 1.0 and 0.16.23):

- "This and following events" made the rest of the series a new event
  with the answers the old one had, so those attendees got nothing, and
  it ended the old series without scheduling messages, so they kept
  every occurrence of it. The new series now asks every attendee again,
  and the old one is shortened with scheduling messages when the user
  organizes it and sends invitations; a failed create puts it back the
  same way. Deleting "this and following events" ends the series the
  same way.
- The first change to one occurrence that keeps the answers - a new
  title, another room - was sent only to attendees who had not
  answered. When the user organizes the event, sends invitations and
  an attendee has answered, the store now gives the occurrence its
  override first, as it is and without messages, finds it again
  (0.16.20 renumbers synthetic ids once an override exists) and then
  writes the change with messages. The attendee gets the update and
  keeps their answer. This works through synthetic ids (0.16.20 on,
  1.0) and as an override on the base event (older 0.16, and ranges
  expanded in the browser).
2026-09-26 00:23:28 +02:00
Linus Rath e8ba9e3569 fix: keep a series' dates and answers when editing all occurrences
Saving "All events" from the editor opened on one occurrence wrote that
occurrence's data to the whole series. Renaming a weekly series from its
third occurrence moved the series' start to that day: the first two
occurrences were gone, a counted series ran two weeks longer, and
overrides before the new start were orphaned. The occurrence's
participants went to every occurrence as well, so an attendee who had
declined only that day now declined the series, and Stalwart sent them
no more updates (live on 1.0 and 0.16.23). The check for a move
compared the form with the occurrence, so the rename that moved the
series did not ask anyone again.

The series now keeps its own:
- when it happens, unless the occurrence was moved; then the series
  moves by as much, from its own start and in its own time zone, and
  the length changes only when the editor changed it
- its participants and their answers, with the attendees the user added
  or removed in the editor; when the series moves, its organizer asks
  the attendees again

The scope handling moved from calendar-app.tsx to
`lib/recurrence-scope-edit.ts` so it can be tested. The editor now hands
its attendees (as opened and as saved) and its dates to the save.
2026-09-26 00:21:57 +02:00
Linus Rath e0b0dfe0cc fix: stop reporting mail as sent 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. Bulwark 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.

- the send reads the new submission's delivery status in the same
  request (`EmailSubmission/get` by its creation id; the create's answer
  has none), 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 user's draft kept,
  so the address can be fixed in the composer; held sends (undo send,
  scheduled) are refused when 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" keep the original when every recipient of
  the replacement is refused, instead of cancelling it
- the submission prune leaves a shared account that refuses its records
  alone for the session, without a warning: 0.16 lists an account that
  only shared an address book or a calendar with the submission
  capability
2026-09-26 00:08:12 +02:00
Linus Rath 7977a6ed01 fix: stop treating partial refusals as total ones in sends and exports
Four places took a narrow refusal from the server for a complete one:

- 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` 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 (checked live on 1.0 with a book shared by another user).
- Sending at a full submission quota: 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, so a send the user
  starts now always looks again; the pause stays for automatic read
  receipts.
- Free/busy: a directory that could not be listed hid the attendees'
  free/busy behind "Free/busy isn't available on this server". Listing
  principals and looking up free/busy are separate permissions on 0.16
  and 1.0, so the line now shows only for Stalwart 1.0 with directory
  queries off or a lookup refused with `forbidden`; otherwise the
  principals the server still returns are looked up.
- Mark as read and keyword writes over several requests: a request that
  failed outright made the whole selection count as unchanged, and rows
  already written showed unread until the next refresh. The write now
  stops there and names only that batch and the ones after it.
2026-09-26 00:06:49 +02:00
Linus Rath 8a83cae6d9 fix: restore undone sends from group identities in the group account
Mail sent from a group or shared identity (team@example.org) is created
in that account, together with its submission, on Stalwart 0.16 and 1.0
alike. Undo send and "Cancel and edit" cancelled the submission there,
but then put the message back in Drafts, read it back and later removed
it through the user's own account. Email ids repeat across accounts - on
the test servers a new team message and a message in userb's inbox were
both `iaaaaac` - so an unrelated own message was moved to Drafts, opened
in the composer and destroyed on Discard, while the undone message stayed
in the group's Sent.

The account the message was sent from now travels with it:
- `restoreEmailToDraft` takes the account, the store looks up that
  account's Drafts by role, and the restored draft is read from there
- the composer keeps the draft's account (`draftAccountId`); saving,
  sending and discarding remove the old version in that account, and the
  first save moves the draft to the own Drafts, where drafts are saved
- discarding goes through the login the draft was saved with, as the
  send does (#461)
- "Edit draft" on a message in a shared or group folder, in the standard
  and the Pro views, opens it with that folder's account, so a draft left
  in the group's Drafts is not removed by id in the own account either
- S/MIME copies, and the unused `cancelScheduledEmail`, are deleted in
  the submission's account
2026-09-26 00:03:23 +02:00
Linus Rath d9ffe8a76e fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
2026-09-25 22:16:19 +02:00
Linus Rath 5a553a315b fix: report refused read changes and re-register push at its limit
Follow-ups to the review of the push, keyword and identity work:

- A send that names no sender, like the List-Unsubscribe mailto: of the
  unified view, fell back to the first identity in server order, which
  on Stalwart 1.0 is a group's. It now takes the login's own identity,
  with addresses compared regardless of case.
- Re-register creates the replacement subscription before it destroys
  the old one, so at Stalwart's limit (15 per account by default) it
  failed with `overQuota`. It now frees the old record's place and
  tries once more.
- On 1.0 the push settings note goes by the emailpush capability alone
  and ignores refusals remembered under 0.16. On 0.16 a group the user
  belongs to is recognised by its identity, so a member without
  calendars, contacts or files keeps the group's junk filter and is no
  longer told the group sends no notifications.
- Plugin imports can no longer set Stalwart 1.0's attachment keywords,
  and `keywords.discover` leaves them out.
- Batch mark-as-read showed messages the server refused as read. It now
  updates only what was written and reports the rest, and thread
  expansion and category-tab moves keep what a partly refused batch
  did write.
- Refused read and star changes from shortcuts, the list, the viewer
  and Pro tabs show "Failed to update email" instead of an unhandled
  rejection. The automatic mark-as-read on opening stays quiet.
- A tag whose id predates the 128-byte limit can have its colour
  changed again.
- The state-regression guard notes that a multi-node 1.0 cluster can
  trip it harmlessly, at the cost of one extra full fetch.
2026-09-25 21:57:11 +02:00
Linus Rath ab96e9cd74 fix: sort senders regardless of case on 0.16, mark shared threads read
44c77b26 kept the server's order for sender and subject sorted lists.
That is right for Stalwart 1.0, which sorts lowercased byte-order keys,
but 0.16 compares the raw bytes, capitals first, so on every 0.16
server a sender list read "Adam, Carla, Eve, bea, dave, zed".

- keep the server's order only for accounts that list
  `urn:ietf:params:jmap:principals:owner` (every 1.0 account, never
  0.16); elsewhere pages and thread groups are re-sorted
  case-insensitively, as before 44c77b26
- the client comparator puts a message without a sender or subject
  last in both directions, as both versions do

Expanding a thread marked it read in the login's own account instead
of the shared or group account it lives in, so Stalwart answered
notFound or marked an unrelated message with the same id, and since
d7004b38 the thread also stayed unread. The write now names the owning
account. Moving mail between category tabs had the same fault: it took
a listed message's display-only account reference, and now routes each
message like the other keyword writes.
2026-09-25 21:54:52 +02:00
Linus Rath a8cccf1ad1 fix: keep occurrence alert edits on 1.0 and count the organizer
Fixes from a second review of the Stalwart 1.0 calendar work.

- Changing the alerts of one occurrence of a series that uses the
  calendar's default alerts said "Event updated" and kept the default
  on 1.0: while a series uses them, 1.0 shows the defaults in place of
  an occurrence's own alerts and refuses `useDefaultAlerts` on a single
  occurrence. The series now stops using the defaults first, keeping
  the alerts it shows under ids of their own, and then the occurrence's
  alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
  1.0: 19 attendees and the organizer are accepted, 20 are refused), so
  the editor warned one attendee late. It warns at the limit now, and
  the warning and the refusal say the organizer counts, with plural
  forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
  proposal, said "could not complete" when the server refused the
  event: the import swallowed its SetError. The banner now asks the
  import to reject with the refusal and names the participant limit
  like the calendar does.
- A range load that finished after a later one could put back its own
  events, range or failure. Only the latest load writes the store, and
  a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
  to its date bounds goes out as it is instead of being expanded, which
  1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
  is used as Intl spells it. 1.0 refused it as a query argument, and
  0.16 and 1.0 both dropped it from new events, which were stored
  floating.
- A failed save no longer shows the store's English toast next to the
  translated message.
- Tests for a limited read that asks later chunks of a request again
  after a refused chunk or a full page.
2026-09-25 21:21:28 +02:00
Linus Rath fabecb2128 fix: ask attendees again when their organizer moves an event
Saving an event in the editor keeps the attendees' answers. When the
user organizes the event and the save changes when it happens, RFC
5546 has the organizer ask the attendees again, and Stalwart's update
to them carries each attendee's stored answer: the old answers would
have read as answers to the new time.

- the editor compares the event's start and end instants, and whether
  it is all-day, with its form, to the minute the form shows; writing
  the event in the user's time zone instead of its own is no change
- when the user organizes the event, under any of their addresses, and
  it moves, every other attendee goes back to needs-action; the
  organizer's own entry stays as it is
- every other save keeps the answers, and so does any save of an event
  someone else organizes
- the answers are reset in what the save covers: one occurrence, this
  and the following ones, or the whole series
2026-09-25 21:18:23 +02:00
Linus Rath 4c20b09c1c fix: show moved occurrences on 0.16.23 and keep organizers on save
- Stalwart 0.16.23 answers `utcStart` and `utcEnd` of a counted
  series' master with "32548-12-04T15:30:07Z" once every occurrence is
  overridden or excluded, and with the next plain occurrence's times
  once the first one is; 1.0 answers the master's own. Expanding such
  a series in the browser took the occurrences' length from those
  instants and copied them onto the occurrences, so a moved occurrence
  fell out of the range. The master's instants now count only where
  they fit its wall clock, the length falls back to the JSCalendar
  duration, the range check ignores instants that do not parse or fit,
  and no occurrence gets the master's instants, so the alerts of an
  all-day series expanded in the browser go off before each day, not
  before the first.
- The event editor rebuilt the participants around the user's first
  address on every save, set `organizerCalendarAddress` to it and put
  every attendee back to needs-action. An invitation the user saved in
  their own calendar became theirs and lost their answer, and one
  occurrence of a series organized under another address or letter
  case got a different owner; Stalwart 1.0 refuses a changed
  `organizerCalendarAddress` there. The editor now keeps the event's
  own organizer, writes `organizerCalendarAddress` only for an event
  without one, and keeps the id and answer of every attendee who
  stays.
2026-09-25 20:20:00 +02:00
Linus Rath 65eb4b1029 fix: report failed undo and reschedule with where the mail stands
Several failures around scheduled and undoable sends said the wrong thing
or nothing at all:

- "Undo send failed" also appeared when the cancel went through and only
  putting the message back in Drafts failed, suggesting the mail might
  still go out. Now that case says the send was cancelled and the message
  is still in Sent (or, for S/MIME, that its copy could not be removed);
  a failed cancel keeps the old message.
- "Cancel and edit" had no error handling, so a failure ended as an
  unhandled rejection. It now reports a failed cancel and a failed restore
  the same way as undo.
- A failed "Send now" or reschedule at a full submission quota did not say
  that the message is still scheduled. It now does, with its time.
- The reschedule retry after making room could send a message twice: the
  prune and change-log walk take a while, and the original could go out
  meanwhile. The original is read again first and nothing is resubmitted
  unless it is still pending ("This message was already sent" otherwise),
  and HOLDFOR is worked out again after the slow step.
- The List-Unsubscribe mailto path showed only a generic error at a full
  quota; it now shows the quota explanation too.
- The Stalwart explanation of a full quota (Bulwark removed what it
  could, an administrator has to raise the limit) was shown for any
  server's `overQuota`. It now needs `urn:stalwart:jmap` on the
  submission account; other servers get a generic sentence with their
  own description.
- At a full quota every attempt, including each automatic read receipt,
  ran the forced prune, the change-log walk and the gets again. When they
  find nothing that can go, the account is left alone for five minutes.
2026-09-25 20:10:01 +02:00
Linus Rath f7deb92d16 fix: explain a closed directory and refused free/busy in the right words
With directory queries off on Stalwart 1.0, the free/busy list under the
attendees disappeared without a word. The share dialog explained every
`forbidden` from Principal/query with the 1.0 text: "only your groups
and people who have shared with you are listed", and a hint at
Stalwart's sharing settings. On 0.16 that refusal only means the role
lacks the permission, Principal/get then lists everyone, and the setting
has no effect over JMAP.

- When the server refuses free/busy for the user (`mayGetAvailability`
  false on the user's own principal, or a lookup refused with
  `forbidden`), one muted line says "Free/busy isn't available on this
  server". The block stays empty only when the server offers no
  free/busy at all.
- The share dialog keeps the 1.0 wording for Stalwart 1.0's own refusal
  ("The administrator has disabled directory queries.") and otherwise
  says "Your server doesn't let you browse its user directory.", still
  listing whoever Principal/get returned.
2026-09-25 20:07:46 +02:00
Linus Rath 6bd55c9501 fix: export contact photos in batches on Stalwart 1.0
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.

- Export fetches the blob media of all cards with Blob/get
  (`data:asBase64`), asking for the sizes first and packing the requests
  to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
  when the session advertises it; without it, or when a request is
  refused, the rest is downloaded one by one behind avatars and print,
  which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
  the detail menu show that an export is running and ignore clicks until
  it is done.
- When the photo changes, the form rebuilds `media` on the card as the
  server has it now. A logo or sound blobId from the list's older copy
  made 1.0 refuse the whole update ("The following blobIds do not exist
  or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
  one it replaces, so it stays the photo shown on cards with several.
  Removing the photo removes every photo the card had instead of only the
  shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
  the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
  instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
  in media, not only `media` itself.
2026-09-25 20:05:19 +02:00
Linus Rath 60c2dbdcb4 fix: reload a failed calendar range and expand multi-word event searches
Four smaller calendar fixes found in review of the Stalwart 1.0 work.

- A range load that failed left `dateRange` on the range loaded before,
  so a push or a pull-to-refresh reloaded that one instead of the range
  on screen, and any action that reset `error` hid the "may be out of
  date" notice. The store now takes the range asked for and keeps the
  failure in `eventsLoadError`, which only a successful range load
  clears.
- The agenda plugin's expanded query had no sort, so its limit could
  keep later occurrences instead of the next ones. It now sorts by
  start, looks less far ahead when the server refuses the range as too
  long (`expandDurationTooLarge`, an administrator's shorter
  `maxExpandedQueryDuration`) instead of answering 502, and sends its
  gets several per request.
- With a limit (search, contact activity) the chunks of a long range
  went out one request each. Up to four now share a request, and what
  the later ones return past the limit is dropped. Global search passes
  its AbortSignal down, so a replaced search stops between requests.
- A search of several words sends its date bounds as conditions of a
  flat AND. The client did not see them as a range: it neither expanded
  recurrences nor turned the UTC bounds into the request's wall clock,
  so such a search found series instead of occurrences, over a range
  shifted by the zone's offset. The bounds are now lifted out of the
  AND, converted and chunked, and each chunk keeps one flat AND of
  single conditions - the shape 1.0 expands; it refuses an AND holding
  a condition with both bounds.
2026-09-25 19:42:38 +02:00
Linus Rath 9041cbcd08 fix: keep iCal subscriptions on refresh, rename calendars of any colour
- send a calendar's colour only when the user changed it: Stalwart 1.0
  accepts only #rgb, #rrggbb or a CSS name, so renaming a calendar that
  another client coloured with an 8-digit hex (Apple Calendar) failed
- "Refresh cached data" kept nothing of the calendar store's persisted
  state, so it deleted the user's iCal subscriptions, which exist only
  there; keep them and drop the rest
- show a notice with a retry when the task list fails to load; it keeps
  the tasks it had
- treat a method-level error on creating a task as a failure with its
  JMAP type instead of "no id returned"
2026-09-25 19:41:00 +02:00
Linus Rath 81b196a6ca fix: explain Stalwart 1.0's participant limit on events
Stalwart 1.0 enforces maxParticipantsPerEvent (20 by default) on every
CalendarEvent create and update, answers to invitations included, and
refuses anything over it with invalidProperties "An event cannot have
more than 20 participants.". 0.16 advertises the same value but never
enforced it. Creating, editing, moving or answering a bigger meeting
failed with a generic "Failed to save event".

- read the limit from the calendars account capability and warn in the
  event editor when the attendees go over it, without blocking the save
- name the limit, and that an administrator can raise it, when the
  server refuses a create, edit, move, resize or answer for it, in the
  calendar and in the invitation banner
- keep the SetError type on refused event creates and updates
2026-09-25 19:39:20 +02:00
Linus Rath d6d8cd0881 fix: keep alert edits on events that use default alerts on Stalwart 1.0
While an event or task uses its calendar's default alerts, Stalwart 1.0
shows those defaults in `alerts` instead of the item's own, and drops
alerts under the default alerts' ids on save. The event editor renamed
every alert to alert1..N and sent them on every save, with the defaults
left on, so on 1.0 an alert change never showed up again, and every
save stored hidden copies of the defaults.

- send `alerts` only when the user changed them; untouched alerts keep
  their id and stay exactly as they were
- when the alerts of an item that uses the defaults change, turn the
  defaults off for it (`useDefaultAlerts: false`, as the native app
  does) and give its alerts ids of their own, in the event and the
  task editor

The local reminder engine reads either the calendar's defaults or the
item's own alerts, never both, so the defaults 1.0 fills in do not
fire twice.
2026-09-25 19:36:23 +02:00
Linus Rath 5b0988cb54 fix: send from your own address by default when Stalwart 1.0 lists a group's first
For members of a group, Stalwart 1.0 lists the group's identity (for
example `team@example.org`) before the user's own. The identity sort
put the identity matching the login first, but compared addresses
case-sensitively, so a login typed as `UserA@Example.org` defaulted to
the group address. The identity manager kept its own copy of the same
sort.

Both now use one sort that compares addresses regardless of case, and
with an internationalized domain in either its Unicode or its `xn--`
form.
2026-09-25 19:30:58 +02:00
Linus Rath 4f5e667a53 fix: keep filter scripts valid when a rule contains */ or a bad header name
The rule metadata is JSON inside a Sieve bracket comment, so a `*/` in a
rule name, a value or the vacation text ended the comment early and
broke the script. A line break in a rule name escaped the `# Rule:`
comment line. And a header name outside `[A-Za-z0-9_-]` made Stalwart
(0.16 and 1.0) refuse the whole script, every other rule with it.

- `*/` in the metadata JSON is written as `*\/`, which `JSON.parse`
  reads back as `/`, so older versions still parse it.
- Line breaks in a rule name are flattened on the `# Rule:` line, and
  the parser matches its own blocks the same way.
- The rule editor refuses to save an invalid header name, with a
  translated message. The generator skips such a rule (it stays in the
  metadata, so it can be fixed) instead of writing a script the server
  refuses.
2026-09-25 19:29:30 +02:00
Linus Rath d7004b3851 fix: report refused tags and keep Stalwart 1.0's attachment keywords intact
Stalwart 1.0 puts `$hasattachment` or `$hasnoattachment` on every
message and derives `hasAttachment` from them alone. It also refuses
keywords over 128 bytes (0.16 cuts them to 128 characters) and more than
100 keywords on one message. The keyword writers ignored Email/set
`notUpdated`, so a refused tag showed until the next reload and then
vanished.

- `markAsRead`, `batchMarkAsRead`, `toggleStar`, `updateEmailKeywords`,
  `setKeyword`, `removeKeyword` and `batchUpdateKeywords` check the
  Email/set outcome, and the errors carry the JMAP type. Tagging from
  the list or the viewer shows "Tagging failed"; a refused category-tab
  move rejects the plugin's promise and skips the categorize hooks.
- Tag ids are capped at 121 characters (128 bytes with `$label:`).
- The attachment keywords are hidden in view source, the plugin
  projection and the `getKeywords` fallback labels, reserved for
  category tabs, and refused by the plugin keyword API. Email/copy and
  the cross-server import still carry them over.
- A tag comes off by leaving its key out of the full keyword map instead
  of being sent as false, which RFC 8621 does not allow;
  `updateEmailKeywords` also drops the false entries local updates leave
  behind (`$seen: false` after marking unread).
- The automatic mark-as-read no longer leaves an unhandled rejection
  when the server refuses it.
2026-09-25 19:24:30 +02:00
Linus Rath e712454ec2 fix: store awkward file names and explain file errors on Stalwart 1.0
Stalwart 1.0 checks names more strictly than its capability says: a
reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and
control characters are refused (macOS `Icon\r`). Names over 255 bytes
of UTF-8 were never accepted. Uploaded names are now made acceptable
before they are sent: control characters become "_", a reserved stem
gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is
shortened, keeping its extension. The rename and new-folder dialogs
refuse such names with their own messages. The rules apply wherever the
server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too.

One refused file used to abort a whole upload. Uploads now go on and
report the files that failed at the end, and a folder upload skips the
contents of a folder it could not create instead of dropping them into
the folder the upload started in. What would sit deeper than 1.0's
`maxFileNodeDepth` (64) is left out before it is uploaded, and a folder
copy that would go past it fails before the first create.

File errors keep their JMAP type, and the Files toasts now 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. New
and copied files get a bare lowercase media type, which 1.0 requires.
2026-09-25 18:37:41 +02:00
Linus Rath 92de8e99bd fix: show Stalwart 1.0 symlinks as links instead of empty folders
Bulwark counted a FileNode as a folder when it had no blobId. Stalwart
1.0 adds symlinks, which have no blob either, so they showed up as
empty folders that could be opened, and folder copies recreated them
as such. Listings now ask for `nodeType` (returned since 0.16.6) and a
single helper, `fileNodeKind()`, decides what a node is in the Files
store, the copy code and the global search, falling back to the blobId
rule when `nodeType` is missing. `nodeType` is never sent in
FileNode/set.

A symlink is shown as an inert row with a link icon. It can be renamed,
moved and deleted, but not opened, previewed, downloaded, copied or
shared, and folder copies skip it.
2026-09-25 18:35:47 +02:00
Linus Rath 1cf9944b53 fix: keep the date of edited documents and open them in shared drives
Stalwart 1.0 treats a FileNode's `modified` as the client's: a content
update leaves it alone unless the patch asks for it. Saves from the
document editor therefore kept the old date in the file list, and the
editor's conflict check (409 with COOLStatusCode 1010) never noticed a
change made by another JMAP client. PutFile now sends `modified: null`,
which has the server stamp the save. 1.0 echoes the new time in
`updated`; 0.16.6+ answer `null` as before and the batched FileNode/get
reads it.

The editor also could not open a file in someone else's shared drive,
on any version: the Files app sent the listing's "<owner>:<id>" id for
the user's own account, and the launch failed with a 404. It now names
the owner's account and the node's own id, so owner and sharee join
the same editor session. A save uploads into the user's own account
and then points the owner's node at that blob, because Stalwart only
lets members of an account use a blob uploaded into it. The access
token carries that upload account; tokens minted before it upload into
the document's account as before.

The editor now also uses the cookie slot of the login the Pro shell is
browsing, not always the active one.
2026-09-25 18:30:08 +02:00
Linus Rath f8e42a4a24 fix: cache and batch free/busy lookups under Stalwart 1.0's rate limit
Stalwart 1.0 allows 100 Principal/getAvailability calls per account a
minute and counts every call, however they are batched. The event form
sent one request per attendee whenever the time or the attendees
changed, and showed "Available" for an empty answer, which is also what
the server returns when the user may not see that person's free/busy.

- Answers are cached for three minutes per principal and the local
  days the event touches, so moving an event within its day asks
  nothing new; the status for the exact time is worked out locally.
- Lookups already on their way are shared, and the principals without
  an answer go out in one request, split by `maxCallsInRequest`.
- After `rateLimit` nothing is sent for a minute, and attendees without
  an answer show "Couldn't check".
- An empty answer shows "No busy times shared" instead of "Available".

0.16 has no rate limit; it gets the same labels with fewer requests.
2026-09-25 18:02:55 +02:00
Linus Rath ffa909600c fix: keep web push on when someone shares a mailbox with you
Stalwart refuses a push subscription's whole emailPush map as
`forbidden` when it names a mailbox shared by ACL (0.16.16 and later,
and 1.0). Bulwark put every account with mailboxes in the map, so
enabling push showed raw JSON, and the background re-sync destroyed the
working subscription after its refresh was refused and dropped its key:
push stopped without a word and could not be turned back on while the
share existed. A network error, a timeout or rate limiting during the
refresh ended it the same way.

- The map holds the primary account and the accounts that list
  `urn:ietf:params:jmap:emailpush`, which 1.0 leaves off ACL shares.
  0.16 lists it everywhere, so a refused map narrows to the accounts
  that look owned, then to the primary account, as the mobile app does.
  Refused accounts are remembered under
  `bulwark.push.emailPushRefused.v1.<id>`; Re-register and disable
  clear the list.
- A subscription ends only when a verified replacement exists, the user
  turns push off, or the server no longer has it. The old record is
  destroyed after the new one is verified. A failed listing, a network
  error or a refused method call leave it and its key alone.
- PushSubscription/set failures throw typed errors (`jmapSetError` for
  a refused object, `jmapError` for a refused call) instead of JSON in
  the message or `false`, and the client exposes `getAccountInfo`.
- Settings says that mailboxes others share don't send background
  notifications, and `forbidden` and `overQuota` read as plain text in
  settings and the onboarding prompt.
2026-09-25 17:59:17 +02:00
Linus Rath 6152e45b88 fix: explain sharing and free/busy when Stalwart 1.0 keeps its directory closed
Stalwart 1.0 refuses Principal/query and Principal/getAvailability with
`forbidden` unless the administrator allows directory queries, which is
off by default. `getPrincipals` turned that into an empty list with no
signal: the share dialog said "No other users or groups found", existing
shares showed bare ids, and free/busy called every attendee "Not a user
on this server".

- `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`.
- One listing per client, kept for five minutes, serves the share
  dialog, recipient autocomplete and free/busy, which used to download
  the directory three times.
- The share dialog explains a restricted directory while still offering
  its candidates, says when the list failed to load, and shows "Unknown
  user" with the id for sharees it can't name.
- Free/busy first reads `mayGetAvailability` from the user's own
  principal and stays hidden when it is false or a lookup is refused
  with `forbidden`. "Not a user on this server" now needs a complete
  listing; failures and `rateLimit` show "Couldn't check".

On 0.16 with the default roles the listing is complete and the dialog
behaves as before.
2026-09-25 17:45:22 +02:00
Linus Rath 990c5e0f77 fix: edit or answer one occurrence of a series on Stalwart 1.0
Stalwart 1.0 refuses series properties such as privacy and 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 modal sent privacy "public" on every save, so editing one
occurrence of a series without CLASS failed, and so did every change to
an occurrence the browser expanded.

- leave series and per-event keys out of changes to one occurrence
- write base-event overrides as before and, if the server refuses
  series keys there, repeat the write once without them and remember
  that
- stop resetting status, free/busy and privacy when an event is edited
2026-09-25 17:40:49 +02:00
Linus Rath c23e306789 fix: show and keep contact photos on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including every photo Bulwark saved as a data:
URI. Bulwark read only `uri`, so avatars, the contact list and detail,
print, vCard export and the "has photo" filter showed nothing, and the
form sent `media` without the photo on every save, so editing a phone
number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`; `uri`
  is optional, and `getContactPhotoRef`/`hasContactPhoto` read both.
- Blob photos are downloaded once each, at most two at a time and at a
  paced rate, only while on screen, into an LRU of object URLs revoked
  on eviction, account switch and logout. They share Stalwart's
  per-account budget of 4 concurrent requests and 1000 a minute.
- Avatars look senders up in an index built once per contact list.
- The form leaves `media` out unless the photo changed, and otherwise
  passes the other entries through as the server returned them.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). 0.16 refuses
  blobIds in media and keeps getting a data: URI; a refusal at runtime
  falls back the same way for the rest of the session.
- Print and vCard export download blob media first; duplicate and
  cross-account move send the bytes instead of the source's blobIds.
- vCard export also writes photos whose data: URI names no media type
  (`data:;base64,`, `data:base64,`), which it dropped before.
2026-09-25 17:35:56 +02:00
Linus Rath 483821706d fix: keep sending working 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. Bulwark never destroyed a
submission, so after about 500 JMAP sends an account could not send at
all: every send, read receipt and reschedule failed with a raw "There
are too many email submissions" error, and each failed attempt left one
more copy of the message in Drafts.

Bulwark 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 pending records are never touched:
- an immediate send (no hold, no undo window) destroys its own record
  right after the send, riding along with the old-draft cleanup; 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 destroys canceled records and final
  records whose `sendAt` has passed, going by `EmailSubmission/get`, in
  every submission account; it runs at startup, after sends, after
  undo and on EmailSubmission state changes
- a confirmed cancel (undo, cancel, reschedule) destroys the canceled
  record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
  walks the change log 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 and the scheduled-send actions

Failed sends now clean up after themselves and say so:
- a failed send removes the Drafts copy it created and keeps the
  user's draft; the raw send paths remove their import the same way
- a read receipt that did not go out is taken back out of Sent, a
  method-level error counts as a failure, `$MDNSent` is only set after
  a sent receipt, and the automatic receipt no longer retries on every
  refresh of the open message
- quick-reply failures, "Send now", "Reschedule" and a failed undo are
  shown instead of only logged
- the scheduled list reads every page of `EmailSubmission/query`; it
  stopped after the first because `total` needs `calculateTotal`
2026-09-25 17:33:31 +02:00
Linus Rath 8db65135c6 fix: load calendar events and tasks on Stalwart 1.0
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every calendar
read did. The grid, tasks, reminders, search, contact activity and the
agenda plugin came back empty with no error, and creating an event or
task threw after the server had created it, so a retry made a
duplicate.

- read overrides with a second get in the same request, only where
  they are needed
- treat an error answer to a get or query as a failure; the calendar
  keeps the last events and says it could not load them, with a retry
- never throw after the server created an event or task
- let the server expand series for /api/calendar-agenda
2026-09-25 17:30:42 +02:00
Linus Rath e147835f0c refactor: carry the JMAP error type on thrown errors through one helper 2026-09-25 16:04:12 +02:00
Luis Felipe Marzagao aef8ed2b9e fix: update dark/light mode titles and add translations for multiple languages 2026-09-24 23:07:15 -03:00
Linus Rath 3823d2da17 fix: sign in and show addresses on internationalized domains #1100 2026-09-24 21:15:22 +02:00
Linus Rath f97218375e fix: end the identity provider session on sign-out #905 2026-09-23 20:44:46 +02:00
Linus Rath 1c00cf75cb fix: use the from override as envelope sender where the server allows it #1009 2026-09-23 19:58:26 +02:00
Linus Rath 53dff446a2 Merge pull request #1032 from douwezijlstra-frl/fix/hover-tag-quick-action
fix: open the tag picker from the labels hover quick-action
2026-09-23 19:16:55 +02:00
Linus Rath 7e5b996ee6 feat: open office attachments in mail read-only in the document editor #1047 2026-09-23 19:01:47 +02:00
Linus Rath dc9d609c43 fix: re-authenticate against the account's own server when linking the mobile app 2026-09-23 18:35:15 +02:00
Linus Rath 691cde6468 fix: explain why calendar subscriptions fail with not authenticated #1073 2026-09-23 18:31:27 +02:00
Linus Rath 6f82493dda fix: show the calendar import dropdown above the day and week views #1049 2026-09-23 18:24:36 +02:00
Linus Rath a69d8a71e9 fix: load attachment chips lazily instead of in the message list #1089 2026-09-23 18:09:35 +02:00
Linus Rath 28fd880582 fix: keep single-occurrence edits on that occurrence and keep its details 2026-09-23 13:40:01 +02:00