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.
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.
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.
- 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"
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.
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.
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.
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.
The re-sync effect depended on pushStatus.kind, so its own
setPushStatus({ kind: 'busy' }) re-fired the effect, whose cleanup
cancelled the in-flight enableWebPush - the push row then sat on
"Working…" forever and never updated the filter. Depend only on the
setting flip; read client/relay/status live from the recreated closure.
Flipping the setting only wrote to the store; the server-side JMAP push
filter stayed stale until the next app launch ran resyncWebPush. When push
is already enabled on the device, re-run enableWebPush on change so the
toggle takes effect without a restart.
Stalwart refuses a VacationResponse subject of 512 bytes or more and a
text or HTML body of 2048 bytes or more, answering only "Field could
not be set." The limit counts bytes, so 1030 "ä" already exceed it,
and the HTML from the rich-text editor reaches it quickly. The save
failed with a generic error.
On Stalwart the vacation settings now check the subject and the text
and HTML that would be sent, and name the limit instead of saving.
Bulwark read the live display name (#900) and the account profile with
`x:Account/get`, which Stalwart only allows for admins. Every other
user got `forbidden`, so the settings page showed no name and the
identity kept the stale snapshot. The name now comes from
`x:AccountSettings/get`, the account's own settings object; aliases
and quota still use `x:Account/get` where it is permitted.
With TOTP on, Stalwart requires the current code for a password change
and for switching TOTP off ("Current OTP code is required"). Neither
dialog asked for it. Both now do, and the password change sends it as
the `otpAuth/otpCode` patch, because a whole `otpAuth` object would
reset `otpUrl` and turn TOTP off.
Saving the display name also ignored a `notUpdated` answer and showed
success.
The Sieve generator wrote actions in the order they were picked in the
rule editor. `fileinto` stores the flags set at that moment, so "mark
read", "star" or "add label" placed after a move never reached the
moved message. Flag actions are now emitted first.
Moves and copies also targeted the folder by path only. Renaming the
folder silently sent the rule's mail to Inbox. Rules now store the
folder id and emit `fileinto :mailboxid "<id>" "<path>"` when the
server lists `mailboxid` (declaring `mailbox` too, which Stalwart
requires), with the path as the fallback. Saving a rule refreshes the
path from the id, so existing rules pick up the id on their next save.
Forward actions get a "Keep a copy" option (`redirect :copy`), and the
editor honours the server's `maxNumberRedirects` (1 on Stalwart):
forward is disabled once the limit is used up, with a hint when
existing rules already exceed it, because the server skips extra
redirects without telling anyone.
Stalwart allows one active Sieve script and turns the auto-reply on by
activating its own `vacation` script. Turning the auto-reply on switched
every Bulwark filter off (including junk and folder rules), and saving
any filter switched the auto-reply off again.
When the vacation script takes over from an existing filters script,
the filters script is now re-activated with
`include :personal :optional "vacation"`, so the server-built reply
(with its date window and HTML body) runs from there. Turning the
auto-reply off drops the include again. A filters save while the
vacation script is active keeps it through the same include, and the
vacation settings read the include as "on", because
VacationResponse.isEnabled reports false in that state.
Clients that only look at VacationResponse (Stalwart's web UI, phones)
still show the auto-reply as off while the include is active.
Switching folders currently keeps an active search applied and re-runs
it in the newly selected folder — deliberate behavior from #553, where
users wanted the query to follow them across folders. Others expect the
opposite: clicking Inbox/Sent should drop the search and just browse the
folder.
Add an opt-in setting (Settings → email behavior) that reconciles both.
Default is off, so #553's search-follows-you behavior is unchanged for
everyone. When on, any folder click clears the text query and advanced
filters and browses the folder instead of re-running the search.
Applied uniformly across the four folder-entry paths in MailApp — normal
mailboxes, per-account (Pro sidebar) folders, unified views, and cross
views — via one helper so no path diverges.
Stalwart seeds the default identity name from the principal "Full name" only
once, and addAccount never rewrote displayName after first login, so an admin
rename never showed up in Bulwark. Read the live name via x:Account/get
through the passthrough (falling back to the identity name elsewhere) and
prefer the synced registry name in account settings.
Adds Gmail-style horizontal swipe actions to message rows on touch
devices. Swiping a row past a threshold fires a configurable action and
snaps back; a coloured background with an icon is revealed under the
finger and grows when the release would commit.
- New `useSwipeActions` hook: claims a touch as a swipe only once the
horizontal travel dominates (vertical stays native list scrolling via
`touch-action: pan-y`, no preventDefault), resists past the commit
distance, and suppresses the tap that would otherwise follow.
- Two settings (Settings -> reading/email behavior): "Swipe right
action" (default Archive) and "Swipe left action" (default Delete),
each one of none / archive / delete / toggle read / toggle star /
spam. A direction set to an action the current folder does not expose
simply does nothing.
- Composes with the existing long-press (context menu) handlers; desktop
is unchanged (hover actions + right-click).
- Scoped to single-message rows; unit tests cover commit/short-drag/
no-action-side/vertical-scroll/tap-suppression.
A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash
inside the member name it points at has to be escaped as `~1`. Keyword
patches were built as `keywords/${keyword}` verbatim, which is fine for
every flat keyword but wrong for a nested tag: `$label:work/clients`
became the pointer `keywords/$label:work/clients`, asking for the
`clients` member of the `$label:work` keyword rather than for the keyword
itself. The patch is not a boolean at that path, so the server rejects or
ignores it and the tag never lands.
Route the keyword patch sites - setKeyword, removeKeyword, migrateKeyword
and the category-tab batch patch - through `keywordPointer`, which escapes
per RFC 6901 the way the calendar store already does for participant ids.
Flat keywords are untouched, so `$seen`/`$flagged` and friends keep the
exact pointers they had. The dev JMAP mock reads the escaping back, or
nested tags would behave differently against the mock than against a real
server.
migrateKeyword also took any answer as success: a method-level error on
the query read as "no message carries this keyword", one on the write was
ignored outright, and a per-message refusal was invisible - so a rename
that moved nothing still reported a clean migration. It now answers with
{ migrated, refused }, because the work is batched and a failure partway
through cannot undo the batches already applied. A whole batch the server
refuses stops the rest, which it would refuse too; a single message it
refuses does not, the mail behind it being unaffected. A query error
throws, nothing having been written yet, and so does a run that migrated
nothing at all - the case where the caller must not rename a tag over
messages that all kept the old keyword.
The walk over tagged mail was not paginating safely either. It stopped at
the first page shorter than the limit it asked for, but RFC 8620 section
5.5 lets a server clamp that limit, so a clamping server left most of the
mail unmigrated. Positions are also only comparable within one queryState,
which the walk ignored: mail arriving mid-walk shifts every later page. It
now restarts when the state changes under it, and gives up - before any
write, so the caller can simply repeat - once a mailbox has moved under it
three times, rather than mixing ids from states that no longer agree. A
page carrying no id it has not already seen ends the walk, which keeps it
bounded against a server that answers every position with the same page.