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.
The composer uploaded any file and learned about `maxSizeUpload` (per
blob) or `maxSizeAttachmentsPerEmail` (per message) only from a failed
upload or a failed send. Files over the per-file limit, and files that
would push the message past the per-message limit, are now skipped
with a message naming the limit. The limits come from the account the
message is composed in.
From 0.16.21 Stalwart fails the whole CalendarEvent/set with
`forbidden` when the account may not send scheduling messages, so an
event with attendees was not saved at all and the calendar showed only
"Failed to save event".
Such a refusal now surfaces as SchedulingDeniedError, and the calendar
names the server's reason and offers to save the event without sending
the invitations.
Bulwark asks for a 90-day push subscription, but Stalwart clamps
`expires` to 7 days. The background renewal ran once per page load,
so a tab or installed app left open for more than a week let the
subscription lapse and notifications stopped without a hint.
The renewal now runs at most once a day instead of once per load, and
also when the tab comes back to the foreground.
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.
From 0.16.6 Stalwart refuses FileNode names with `/<>:"\|?*` or a
reserved name such as `CON` ("Name contains a forbidden character."),
and publishes the rules as `forbiddenNameChars` / `forbiddenNodeNames`.
Bulwark did not read them: the error showed up only after an upload had
started, and it aborted the rest of a multi-file or folder upload.
- new folder, new text file and rename now say which characters or
names the server does not allow, before sending anything
- uploads store an accepted variant instead: forbidden characters
become `_` and a reserved name gets `_` appended
MIME types over 30 characters (most Office formats) were always stored
as `application/octet-stream`, because Stalwart before 0.16.6 refuses
them. From 0.16.6 the limit is 255, so the real type is kept there.
Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA
rejects any hold beyond `futureRelease`, 7 days by default. Scheduling
a message 8 to 30 days ahead passed the picker and then failed with
"501 5.5.4 Requested hold time exceeds maximum of 604800 seconds".
On Stalwart the advertised 30 days now counts as 7. When a submission
is still rejected for its hold time, the limit from the error message
replaces it for the session, and the composer shows the translated
"later than the server allows" message instead of the raw SMTP reply.
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.
Stalwart only moves spam to Junk when nothing else decided where the
message goes. Any Bulwark rule that moved or copied mail to a folder
therefore delivered spam there too, e.g. a "subject contains invoice"
rule collected phishing. From 0.16.22 this also applies to the "Keep"
allow-list action, which older versions still send to Junk.
Move and copy rules now add
`not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"`
to their condition when the server supports `spamtestplus` and
`relational`. 50 % is Stalwart's spam threshold on the graded scale
(0.16.19+), and older versions report 0 or 100. A new per-rule option
"Also move messages marked as spam" drops the guard, and "Keep" rules
are left unguarded as the explicit allow-list.
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.
JMAP thread ids are only unique within their account - Stalwart hands out
per-account counters such as "b" - so the cross-account views merged
unrelated conversations that happened to share an id, and every
client-side thread map (expansion, cache, loading flag, counts,
virtualizer key) mixed them up the same way.
Give ThreadGroup an account-scoped `threadKey` derived from the email's
source stamps and key grouping and all thread state by it. `threadId`
stays the raw id for Thread/get, which #281 already routes to the owning
account. fetchThreadEmailCounts now asks each source account for its own
threads instead of one client for every id. Single-account views carry no
stamps, so their key is the bare id and nothing changes there.
Resolves the two conflicts in components/calendar/event-modal.tsx created
by #967 (RSVP controls), both keep-both:
- #967's canRsvp memo and #986's resolveContactName hook are both kept;
they are independent and were simply inserted at the same point.
- #967's RsvpBar component is kept, and StatusBadge takes #986's
signature ({ status, t }) since #986 drops the isOrganizer prop and
updates both call sites to match.
Since the freely scrolling views (#759) the day, week and month views are
positioned by their own focus rather than by the selected date, and a grid
click deliberately only marks a day that is already on screen. The mini
calendar shared that handler, so picking a day there highlighted it but
left the view where it was. Give it its own handler that jumps to the day
(keeping the mobile month-to-day switch), the same path the prev/next/today
controls and deep links use.
The calendar loads the principal's alias addresses so an alias-organised
event is recognised as the user's own. The fetch fired on mount, but after
a page load the JMAP client is restored asynchronously: the call failed
with "Not authenticated" in the console, and the once-only guard then
blocked the retry for the rest of the session, leaving the aliases empty.
Move the logic into useAccountPrincipalEmails, which waits for the client
and fetches once, and cover the sequence with a hook test.
The viewer forces `img { max-width: 100% !important }` onto every image.
That !important also beats a sender's *inline* max-width, which breaks the
common newsletter idiom of sizing an image with max-width + max-height +
width:100%:
<img width="200" height="134"
style="display:block; max-height:134px; max-width:200px; width:100%;">
With the sender's max-width:200px overridden, width:100% grows the image to
the full pane, while the max-height:134px it left alone still clamps the
height - so the picture renders squashed flat.
Measured on a real Namecheap renewal notice (hero asset 300x200, natural
ratio 1.50) rendered with the viewer's own stylesheet: the hero laid out
540x134, an aspect ratio of 4.03 - stretched 2.7x. Identical at a 390px
pane. With this change it lays out 200x133, ratio 1.50.
Only force the cap on images that declare no width of their own, exactly as
the table rule alongside it already does for the same reason (#790). height
keeps its !important, so an image with a fixed inline height (the far more
common `style="width:600px;height:400px"`) is unaffected - verified
unchanged at 600x400.
Images with no inline max-width are still capped at 100%, still with
!important, so an email's own stylesheet cannot escape the pane - an email
carrying `img { max-width: none }` still measures 710px, not 2000px. An
image whose inline max-width exceeds the pane now overflows into the body's
existing horizontal scroll rather than being silently distorted: the same
trade-off #790 accepted for tables, and fitEmailBodyWidth() still scales it
to fit on a phone.
Applied to all three paths that build an email document: the reading pane,
the thread conversation cards, and the print view.
Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2
The dismiss-on-scroll listener runs in the capture phase, so it also saw
scroll events from the picker's own tag list: past ten labels, the first
scroll closed the picker and the lower labels were unreachable. Ignore
scrolls originating inside it.
Also give the portalled container `role="menu"` - its rows are
`menuitemcheckbox` and the anchor advertises `aria-haspopup="menu"` - and
drop its duplicate `max-h`/`overflow-y-auto`, which TagPicker already has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Labels quick-action on an email row called `onSetTag(null)`, which
clears every tag on the message rather than offering one to apply. On an
untagged message that writes nothing, so the button appeared dead: no
picker, no feedback. Assigning a tag from the opened email worked, since
the reading pane renders a real picker.
Clicking the row's tag icon now opens the same `TagPicker` used by the
context menu and the reading pane, wired to the existing `onSetTag`
handler, so a tag can be applied or removed without opening the message.
The picker is portalled to the body: every row sits inside a `transform`ed
virtual item, which would otherwise make a `fixed` child scroll with the
list and clip it at the scroller's edge. It flips above the row near the
viewport bottom, and dismisses on outside click, Escape, scroll or resize.
The hover toolbar stays visible while the picker is open, since the row is
no longer hovered once the pointer moves onto it.
Covered by integration/tests/14-hover-tag.spec.ts, which checks the picker
opens, the keyword reaches the server under `$label:`, a second click
removes it, and the click does not fall through and open the message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>