Three faults in how a series' changed and cancelled occurrences follow a
change of the whole series (found in review, live on 1.0 and 0.16.23 for
the web's version of the same code):
- A rule that names its days - a series on Mondays and Tuesdays, one on
the 5th and the 6th of each month - keeps its occurrences on those days
when its start moves. Moving every occurrence by a day moved each
override along anyway, so the Monday's landed on the Tuesday's key and
one silently overwrote the other: the cancelled Tuesday came back with
the Monday's title, and the Monday lost its change. Under such a rule
every override but the first occurrence's now stays on its day, at
the series' new time; the same holds for the new series of "this and
following". Two overrides that still meet on one key (an hourly series
turned all-day) no longer overwrite each other: the one that stays
where it is, else the first, is kept, and the others are left out and
logged.
- "Does not repeat" for every occurrence kept the first occurrence's
override on the single event, because a series' start counts as an
occurrence of any rule: cancelled, 1.0 left the event out of every
range query (still stored); retitled, 0.16.23 listed it twice. A series
made single now keeps no overrides.
- The SEQUENCE a change of every occurrence raises (0.16.6 to 0.16.23
refuse a lower one) came from the series as the app held it. A series
opened from a reminder or a link was that copy, possibly older than the
server's. The store now reads a series from the server every time a
scope edit asks for it, so the raise, the overrides a split or a move
rewrites and the rule all start from what the server holds.
More edits of a recurring meeting went wrong at the edges of a series
(0.16 and 1.0 alike, attendees included):
- A series the app shows as itself - opened from a reminder or an
event link, or one an edit just made recurring (0.16 before 0.16.20
showed it until the next reload) - took "This event only" as a change
of the whole series: `CalendarEvent/set` on the series' id with the
editor's title, start and rule. "Delete this event only" deleted the
series. The series now stands for its first occurrence: a change
becomes an override of it, in two writes when the user organizes the
meeting, sends invitations and an attendee has answered (Stalwart
sends a new override only to attendees who have not), and a delete
excludes it. "This and following" from the first occurrence changes,
or deletes, the whole series instead of ending it before its first
occurrence. A series opened from a reminder on a calendar shared with
the user is now known to the store, so the change goes to the owner's
account under the series' own id.
- An edit that makes an event recurring, or single again, now loads the
range again, so the grid shows the occurrences instead of the series
itself.
- 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 (the larger of the split's day and the
day before), which ends the series there on 0.16 and 1.0 alike.
- 0.16.6 to 0.16.23 send a change of one occurrence with its SEQUENCE
raised but do not keep the raise, so a later change of every
occurrence - a new title - went out with a lower number for that
occurrence and the attendee's server refused it. A change of every
occurrence that the user as organizer sends, of something the
attendees see, on a series with changed occurrences, now raises the
SEQUENCE of the series and of each changed occurrence by one. 1.0
raises and keeps the number itself; the result there is the same. The
app's update sends `sequence` as it is, so nothing has to keep it.
- An existing override written on the base event (0.16 before 0.16.20,
and ranges the device expands) gets the series' organizer again when
it has none: 0.16.19 refuses an answer to such an occurrence. 1.0
refuses the key inside an override once, and the store writes the
override again without it.
A series keeps its changed and cancelled occurrences as overrides keyed
by recurrence id, where the rule puts each occurrence. The app's series
edits left them behind and made more of a series than was asked (0.16
and 1.0 alike, attendees included):
- "All events" that moved the series kept the overrides under the old
ids: a cancelled occurrence came back, a retitled one showed twice.
It also wrote everything the editor showed to the series, so a new
room on an occurrence with a title of its own renamed the series,
and it measured a move on the calendar's clock, so across a week in
which only one of two zones changes to or from summer time the series
moved an hour more or less than picked.
- "This and following events" ended the rule but not the overrides
after the split, which showed on next to the new series; the new
series got the whole COUNT again and started at the opened
occurrence's own time in the calendar's zone. Deleting "this and
following" left the later overrides too.
- A failed create of the rest of the series put back only the rule, and
without telling the attendees the truncation had told.
Now, as on the web (`lib/recurrence-scope-edit.ts`, a port of the
webmail module of the same name):
- "All" moves each override with its occurrence in the same update: by
the series' move, onto the same day at the new time when the series
turns all-day or timed. An occurrence moved on its own keeps its time,
except the one the editor was opened on; a new rule drops the
overrides it no longer has, and overrides that move ask their
attendees again when the series does. Only the fields the user
changed go to the series, and the move is measured on the series'
own clock.
- The split removes the overrides from the split on together with the
rule and gives them to the new series, re-keyed and with its
participants (0.16 keeps an override as an event of its own). The new
series goes on from the split's recurrence id in the series' zone,
moved only by the editor's move; its COUNT is what is left, an UNTIL
stays, and a rule the user changed is taken as it is. It takes the
old series' details, not the opened occurrence's own ones.
- A failed create puts back the rule and the whole overrides map (a
pointer fails on a series left without overrides), with the
truncation's scheduling, and fails the save; a store that answers
with nothing counts as failed.
- The truncation tells the attendees only when the user organizes the
series and sends invitations; an attendee ends their own copy
quietly.
The scope handling moved from CalendarScreen into the new module, where
it is tested. The editor hands its dates to the save. An invitation to
one occurrence alone, which has no series, is changed or deleted as
itself for every scope instead of making a second event.
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.
"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.
- 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.
A first change to one occurrence that keeps the attendees' answers goes
out in two writes: the override as the occurrence stands, quietly, then
the change as an update of it. The occurrence is looked up again in
between, because the new override can give it another synthetic id.
When that lookup failed, the change went to the old id. Up to Stalwart
0.16.20 synthetic ids are positions in the series' expansion
(calendar_event/mod.rs), so after the new override the old id can name
the next occurrence, which then got the change and its scheduling
messages. 0.16.21 and 1.0 key the ids by recurrence id, and the id
stays (checked live on 1.0 and 0.16.23).
Now, when the occurrence is not found again, found twice, or the lookup
fails, nothing more is sent and the editor says the change was not
saved. The quiet override changes nothing that shows; the calendar is
read again so a retry uses the ids the series has now, and goes out as
an update of that override, which reaches every attendee. The message
is translated into all 26 languages.
The app compares the server behind an account with the one it recorded
and drops what came from a replaced one. The released version recorded
none, so on the first start of this one there is nothing to compare
with, and a server replaced before the update (Stalwart 1.0 is a fresh
install) went unnoticed:
- the push subscription expiry the released version recorded made the
subscription the new server lacks look revoked, and push was turned
off for good; it is now forgotten on the first sight of an account, so
the subscription is re-created
- the queue of offline edits the released version left was replayed
against the new server, where its ids name other messages, so a
queued delete deleted another message; it is now discarded on the
first sight, with the notice a replaced server shows. Nothing proves
a queued edit belongs to the server: the queue records none, a
reinstalled Stalwart may accept old JMAP states, and a queued move or
delete took its message out of the cached list, so there is no copy
to compare the server's message with
iCal subscriptions recognise their mirror calendar by name, but a sync
still imported the feed into whatever calendar the recorded id named:
- the feed now goes only into a calendar that carries the
subscription's name, and otherwise into a new mirror; when the name
can't be read, the sync fails and is tried again
- a rename the server refused renamed the subscription anyway, which
then no longer recognised its mirror: pruning stopped and removing the
subscription kept the calendar. Now nothing changes and the error is
shown
- a replaced server claimed subscriptions made before they recorded
their account for every account with the same JMAP id, another
server's account included. They now go only when no other signed-in
account has that id, or may have it, and a sync that finds their
mirror records the account on them
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
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.
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.
"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)
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
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.
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
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.
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.
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.
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.
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.
The app stores its filter rules as JSON inside a `/* */` comment at the
top of the Sieve script. A `*/` anywhere in a rule name, a value or the
auto-reply text ended that comment early and turned the rest of the
rules into script, and a CR or LF in a rule name did the same to its
`# Rule:` comment. Stalwart (sieve-rs, on 0.16 and 1.0) also accepts
only the letters A-Z, digits, `-` and `_` in a header name. Any of these
made the server refuse the whole script, so saving any filter, and the
auto-reply with it, failed.
- `*/` in the embedded rules is written as `*\/`, which JSON reads back
as `*/`, so every client parses the rules as before
- the `# Rule:` comment carries the rule name without line breaks, and
the parser matches the comment back to its rule by that form
- the filter editor trims header names, turns autocorrect off for them
and refuses to save one the server would reject, with a translated
message; the generator leaves a rule with such a header name out of
the script, since dropping only the condition could widen an "all of"
rule, and keeps it in the stored rules for editing
When Mailbox/changes answered with `hasMoreChanges` (Stalwart caps one
answer at 5000 changes, other servers earlier), the folder sync applied
that page but kept the old state and ran again, which asked for and got
the same first page back, without end.
The sync now follows `newState` page by page, storing each page's state
as it applies it. After five pages, or when a page does not move the
state on or can't be calculated, it reads the whole folder list instead,
which takes one request whatever changed.
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.
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.
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.
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects,
with keys like participants/p1/participationStatus and nulls that
remove. Expanding a series on the device spread them onto the
occurrence, so a declined occurrence stayed accepted, a changed room
stayed the same and the pointer keys became junk properties. Moved or
resized occurrences also kept the time and length the rule gave them,
and were placed in the range by their original date.
- apply overrides as patches: pointer paths decoded per RFC 6901, null
deletes, shorter paths first, a whole participants map completed
from the series
- time an occurrence from its patched start, time zone and duration,
and range-check a moved occurrence by where it is now
- fall back to the JSCalendar duration when the master's utcStart or
utcEnd don't parse (0.16.23 returned the year 32548 for a counted
series), and never keep the master's instants on another occurrence
- merge a change into an existing override without keys that are
prefixes of one another, and match an occurrence listed twice
against the members its override patches
The device expands series on 0.16.6 to 0.16.19, after a created series
and when a range can't be expanded on the server. 0.16 returns whole
override values, which apply the same way.
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.
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.
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.
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`.
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.
Release APKs are obfuscated since R8 was turned on, and nothing kept
the mapping that decodes their stack traces. The workflow now copies
`android/app/build/outputs/mapping/release/mapping.txt` to
`bulwark-mobile-<version>-<commit>-mapping.txt`, uploads it as a
workflow artifact and attaches it to the release next to the APK. The
in-app updater only picks `.apk` assets, so it ignores the new file.
If R8 is on (`android.enableMinifyInReleaseBuilds=true`) and no mapping
was written, the step fails instead of publishing an APK whose crashes
cannot be read. docs/android-release.md explains how to decode a trace
with `retrace`.
The splash background was #09090b in both modes, so in light mode a
cold start went from a near-black splash to the white first frame.
It is now #ffffff by day and #09090b at night (`values-night`), as
`splash.backgroundColor` and `splash.dark.backgroundColor` in
app.config.js already ask for, so a later `expo prebuild` writes the
same colours. Checked on the API 35 emulator: light mode stays white
from splash to login screen, dark mode is unchanged.
Every background push kept the app awake for the full 30 s timeout of
the BulwarkPushTask headless task, however quickly the task was done.
AppRegistry reports a finished task through the native
HeadlessJsTaskSupport module, and React Native 0.81 registers that
module only for the old bridge (CoreModulesPackage); the bridgeless
runtime's CoreReactPackage leaves it out. JS could never say it was
done, so the service, or the job, and its wake lock waited for the
timeout. BulwarkHeadlessJsTaskSupportModule provides the module under
the same name when the app runs bridgeless.
The push path also leaked a wake lock now and then. startHeadlessTask
called HeadlessJsTaskService.acquireWakeLockNow on the FCM worker
thread while the service took the same static lock on the main thread.
The method is not thread-safe, so both could create a lock, and the one
that was overwritten stayed held. The FCM thread now takes its own
wake lock, timed at 10 s, and the service drops it as soon as it holds
React Native's.
Measured on the API 35 emulator with the R8 release build:
- push handled by the job: 30,000 ms before, 75-155 ms now
- push handled by the service: the service ran 31 s before, it is
gone within a second now
- `com.facebook.react.i` wake lock still held 5 s after a push: 2 of
4 pushes before, 0 of 8 now
A push that arrived while the app was in the background started
BulwarkPushTaskService with `startService`. Android 8+ allows that only
while the app is temporarily allowlisted, which a high-priority FCM
message grants. The relay does send FCM data messages at HIGH priority,
but FCM delivers some at normal priority anyway (it downgrades apps
whose high-priority messages rarely end in a notification), and a
UnifiedPush distributor may not raise the app at all. The call then
threw `BackgroundServiceStartNotAllowedException` and the app crashed,
from the FCM thread or, for UnifiedPush, on the main thread.
The service start is kept as the first choice, so a high-priority push
works as before. When Android refuses it, the same headless task now
runs from a JobScheduler job (BulwarkPushJobService): expedited on
Android 12+, which runs at once while the app has expedited-job quota
and as a regular job after that, and a regular job on Android 8-11.
JobScheduler is a platform API, so nothing is added to the build;
WorkManager is not in the dependency tree. A generic "new mail"
notification built natively was the other option, but the relay's
payload cannot tell new mail from other changes and the native side
knows neither the notification settings nor what was already shown.
Checked on the API 35 emulator with the R8 release build, the process
killed and the push delivered past the allowlist:
- FCM data message: before, FATAL EXCEPTION on
Firebase-Messaging-Intent-Handle; now the job is scheduled 18 ms
after the refusal and runs the task
- UnifiedPush message (connector 3.3.5, sent the way a distributor
does): before, FATAL EXCEPTION on main; now the same job path
- allowlisted FCM message: still starts BulwarkPushTaskService
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
The files screen filled `files.selected_count` and
`files.delete_items_title` with its own `{name}` substitution, a
leftover from before t() took arguments, so they could not be
plurals. Both are ICU plurals now and every message on the screen gets
its values through t(). The upload progress line is one message
instead of "Uploading..." followed by the numbers.
The bottom tabs showed their route names ("Mail", "Calendar", …) in
every language; they now use the webmail's navigation names, and an
unavailable tab says so with one translated message instead of an
English "(unavailable)" suffix. The first loading screen is
translated as well.
The Unified Inbox selection toolbar, its back and clear-search buttons,
and the drawer's close, expand/collapse and remove-account buttons get
screen reader labels. Its unknown-sender fallback, selection count and
"account(s) could not be loaded" banner are proper translations and
plurals now.
The key harvest and the translation coverage test did not look at
App.tsx, which sits next to src/; they do now.
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
The week view's "all-day" label and its "Untitled" fallback were
hard-coded; they now use the catalog's "All day" and "(No title)" like
the scrolling time grid. The calendar's menu, tasks, view switch, new
event and previous/next buttons get screen reader labels (the view
buttons also say which one is selected), and a calendar i18n test
covers the calendar views.
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.