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.
addToast() minted toast ids with crypto.randomUUID(), which only exists in
secure contexts (https/localhost). On a plain-http LAN origin every
toast.success/toast.error threw a TypeError, so e.g. calendar event Save
crashed AFTER a successful CalendarEvent/set — before the dialog-close ran —
leaving the dialog open reporting failure while the server had the event;
retries created duplicates. Same class as the generateMessageId send bug.
Also guards the same-class contact uid fallback in lib/jmap/client.ts
(createContact). Both now use the guarded generateUUID() from lib/utils.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtFkcUA8VM2XAKZKzptpqy
Each email push showed a notification with a unique tag
("bulwark-mail:<id>"), and Android renders one notification per unique
tag — so a burst of mail stacked one toast per message (50 arrivals =
50 toasts).
Use a shared per-account tag ("bulwark-mail:<accountId>") so each push
replaces the account's single notification. The newest message stays the
headline and a Gmail-style "+N more messages" line, counted from the
account's unread total, carries the rest. A single unread still deep-links
to that message on click; a group opens the inbox.
Sending an edited draft destroys it server-side, but the viewer kept
rendering the deleted message after the composer closed: the mailbox
list refreshed (row gone, counter down) while selectedEmail still
pointed at the destroyed draft. Clear the selection like a delete does
- and return to the list on mobile - when the email on display is the
draft the compose session was opened from or its latest autosaved
successor (autosave replaces the draft under a fresh id on every save,
so matching the sent draftId alone would miss).
- hoist only font longhands from wrapper divs: applying the `font`
shorthand after the line's own styles would reset the font-family/
size the line declares itself; the CSSOM exposes shorthand
declarations through the longhand queries anyway
- only preventDefault on dblclick when the unlock actually ran, so a
read-only editor keeps its default double-click behaviour
The reveal layer used logical justify-start/-end, which flip under RTL, so
the archive/delete icon appeared on the wrong side. The exposed edge
follows the physical translate direction, not text direction, so force the
decorative layer to LTR.
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.
Since the signature became a verbatim, non-editable atom (#476), there
was no way to edit it in the composer at all (#822) - the styling
guarantee silently cost the editability #329 had established. Keep the
atom as the default so branded signatures still survive 1:1, and let an
explicit gesture opt out: double-clicking the block dissolves it into
regular editable content at the same position. Double-clicks that land
on a link are ignored (the click is working the link), and Enter on the
selected block keeps ProseMirror's default create-paragraph-after
behaviour.
Faithfulness of the dissolved signature:
- Leaf <div> lines become paragraphs; only vertical margins the line
does not declare are reset to 0 (how a div actually renders), so
explicit spacing and indents survive. Divs carrying hr/pre and other
non-line blocks are left to the schema parse.
- Inherited styles (font, color, ...) on wrapper divs - which have no
schema node and would be dropped - are hoisted onto the generated
line paragraphs, the line's own declarations winning.
- The content is parsed as a CLOSED document via the schema DOMParser
and spliced in with one transaction; insertContentAt fits an open
slice, which can split text runs at hard breaks and turned <br>-lined
signatures into one spaced paragraph per line.
The bracketing data-signature-block markers live outside the atom and
stay put, so the identity-switch splice and the send path's
already-embedded detection keep working on unlocked content, and
switching identities re-embeds a fresh locked block. Undo restores the
atom. A translated tooltip on the block makes the gesture discoverable.
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.
Extend the existing keyword write coverage to pin the three call sites
that clear a keyword. markAsRead, batchMarkAsRead and toggleStar must
patch with null and never with false, and must still send true when
setting the keyword.
The three removal cases fail against the previous boolean form, one per
call site. The three set cases pass either way, since that path was
already correct.
Review follow-up on the shared-folder mutation routing.
The owner lookup replaced the resolved client with any connected client whose
JMAP account id matched. Account ids are opaque per-server values, and personal
mailboxes carry an accountId too, so this ran on every folder mutation and could
hand a mutation to a client of a different server that happens to issue the same
id - where the bare mailbox id may exist and belong to someone else. Keep the
resolved client when it already is the owner, and require a candidate to match
the server URL as well as the account id.
Recover the owner account and the bare mailbox id from the namespaced store id
when no mailbox object is available. Previously the raw `accountId:id` went to
the server as a mailbox id with the owner dropped, so the mutation targeted the
active personal account; in setMailboxRole that cleared the personal role before
failing.
Drop the shared account header context menu when the account carries no usable
id. buildMailboxTree groups shared mailboxes without an accountId under the
placeholder "unknown", which cannot be a mutation target.