Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
Several failures around scheduled and undoable sends said the wrong thing
or nothing at all:
- "Undo send failed" also appeared when the cancel went through and only
putting the message back in Drafts failed, suggesting the mail might
still go out. Now that case says the send was cancelled and the message
is still in Sent (or, for S/MIME, that its copy could not be removed);
a failed cancel keeps the old message.
- "Cancel and edit" had no error handling, so a failure ended as an
unhandled rejection. It now reports a failed cancel and a failed restore
the same way as undo.
- A failed "Send now" or reschedule at a full submission quota did not say
that the message is still scheduled. It now does, with its time.
- The reschedule retry after making room could send a message twice: the
prune and change-log walk take a while, and the original could go out
meanwhile. The original is read again first and nothing is resubmitted
unless it is still pending ("This message was already sent" otherwise),
and HOLDFOR is worked out again after the slow step.
- The List-Unsubscribe mailto path showed only a generic error at a full
quota; it now shows the quota explanation too.
- The Stalwart explanation of a full quota (Bulwark removed what it
could, an administrator has to raise the limit) was shown for any
server's `overQuota`. It now needs `urn:stalwart:jmap` on the
submission account; other servers get a generic sentence with their
own description.
- At a full quota every attempt, including each automatic read receipt,
ran the forced prune, the change-log walk and the gets again. When they
find nothing that can go, the account is left alone for five minutes.
Stalwart 1.0 enforces maxParticipantsPerEvent (20 by default) on every
CalendarEvent create and update, answers to invitations included, and
refuses anything over it with invalidProperties "An event cannot have
more than 20 participants.". 0.16 advertises the same value but never
enforced it. Creating, editing, moving or answering a bigger meeting
failed with a generic "Failed to save event".
- read the limit from the calendars account capability and warn in the
event editor when the attendees go over it, without blocking the save
- name the limit, and that an administrator can raise it, when the
server refuses a create, edit, move, resize or answer for it, in the
calendar and in the invitation banner
- keep the SetError type on refused event creates and updates
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. Bulwark never destroyed a
submission, so after about 500 JMAP sends an account could not send at
all: every send, read receipt and reschedule failed with a raw "There
are too many email submissions" error, and each failed attempt left one
more copy of the message in Drafts.
Bulwark now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so pending records are never touched:
- an immediate send (no hold, no undo window) destroys its own record
right after the send, riding along with the old-draft cleanup; only
where `urn:stalwart:jmap` is advertised, and never decided by the
created `undoStatus`, which 0.16.10-0.16.16 report as final for held
sends
- a throttled background prune destroys canceled records and final
records whose `sendAt` has passed, going by `EmailSubmission/get`, in
every submission account; it runs at startup, after sends, after
undo and on EmailSubmission state changes
- a confirmed cancel (undo, cancel, reschedule) destroys the canceled
record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
walks the change log for records older than the query's three-day
window, retries once, and otherwise throws `SubmissionQuotaError`,
shown as a translated explanation in the composer, quick reply, read
receipts and the scheduled-send actions
Failed sends now clean up after themselves and say so:
- a failed send removes the Drafts copy it created and keeps the
user's draft; the raw send paths remove their import the same way
- a read receipt that did not go out is taken back out of Sent, a
method-level error counts as a failure, `$MDNSent` is only set after
a sent receipt, and the automatic receipt no longer retries on every
refresh of the open message
- quick-reply failures, "Send now", "Reschedule" and a failed undo are
shown instead of only logged
- the scheduled list reads every page of `EmailSubmission/query`; it
stopped after the first because `total` needs `calculateTotal`
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.
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.
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.
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>
Some senders ship the images their HTML body embeds via cid: as
application/octet-stream with no Content-Disposition and no filename.
The body rendered them fine, but the "hide inline images" check only
recognised parts declared inline with an image/* type, so they also
showed up as nameless "Attachment" chips next to the real files.
Both viewers now scan the HTML they actually render for cid: references
(the same pattern the renderer rewrites to blob URLs) and hide a
referenced part when it is an image or generically typed. An explicit
attachment disposition always keeps its chip, as does a referenced part
with a real non-image type, since that chip is its only download. Plain
text rendering references nothing, so nothing is hidden there. The
shared rule lives in lib/attachment-visibility.ts so the desktop viewer,
the thread view and the decrypted-message path cannot drift apart.
The same missing dir="auto" as the read view, in the two other places
that build an email document.
thread-conversation-view builds a full document for each card's iframe
and its <body> had no direction, so a thread of right-to-left mail read
left-to-right in the one view that shows several messages at once.
eml-preview hands srcDoc a fragment rather than a document, so the
<body> is the browser's implicit one and cannot be annotated at all.
The wrapper the fragment already needs carries dir="auto" instead: a
<div> around sanitized HTML, and the existing <pre> for plain text.
Neither changes layout - the implicit body and its default margin stay
exactly as they were.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2
On a shared, group or aliased mailbox every reply, reply-all and forward
went out as `identities[0]` - the account owner - whichever mailbox was
open. `autoSelectReplyIdentity` fused two behaviours: matching one of the
user's OWN identities, which never rewrites `From:`, and the domain
catch-all, which puts an address they have not configured into `From:`.
Only the first is safe for everyone, so it is now unconditional; the
rewrite stays behind the setting, which is what its description promises.
`findReplyIdentityId` already implemented that match and was referenced
only from tests.
`forward` is admitted to the match - it was excluded, so a forward from a
shared mailbox went out as the owner - but never takes the rewrite: a reply
continues a thread whose participants know the addressing, while a forward
introduces a rewritten From to a recipient the user just typed. Quick reply
matches own identities only, having no From row to show or correct a
rewrite, and now falls back to the primary's signature as the composer
does, so a shared-mailbox quick reply is still signed.
`findReplyIdentityId` now scans recipients in To, Cc, Bcc order rather than
scanning identities, and prefers an untagged identity over a differently
tagged sibling. Scanning identities let their order decide, and
`sortIdentities` puts the login's own address first - so `To: team@,
Cc: you@` replied as you, the case this is meant to fix.
Automatic selection is restricted to the active account: `composerClient`
follows the chosen identity, and a reply or forward still carries blobIds
only its own account's server can resolve. The dropdown still offers every
account's identities to pick by hand.
New messages are deliberately left gated: `composeFromAccountEmail` falls
back to `AccountEntry.email`, written once at first login, so resolving it
unconditionally would override a user-chosen default sender identity (#507).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dedicated pastel highlighter palette read washed-out against the
light popover. Use the same 16 TEXT_COLORS swatches, grid and styling
as the text-color picker so the two controls read as siblings (Gmail
does the same for both pickers).
Co-Authored-By: Claude <noreply@anthropic.com>
The lucide ALargeSmall icon read poorly and lingered after picking a
size. Replace it with two baseline-aligned A's drawn in text (large +
small sharing a baseline - the classic size glyph), and once a size is
selected show that number on the button as before.
Co-Authored-By: Claude <noreply@anthropic.com>
- Background color toolbar button next to text color, using the
BackgroundColor extension from @tiptap/extension-text-style (same
textStyle-mark pattern as Color) with a 2x8 soft-tint highlighter
palette and a remove action; emitted as inline
style="background-color: …" which survives email round-trips.
- Font-size button now shows a big-A/small-A glyph (ALargeSmall) with
the active size as a small badge, replacing the plain "Aa" text that
was easy to miss.
- background_color/remove_background_color toolbar labels in all 24
locales; unit tests for apply/unset/parse/coexistence with color and
font-size on the same span.
Co-Authored-By: Claude <noreply@anthropic.com>
The previous fix paired bg-muted/30 with
supports-[backdrop-filter]:bg-muted/70, intending a fallback for
browsers without backdrop-filter - but every modern browser supports
it, so the 70%-opaque variant was always active and the toolbar read
darker than main at rest. Drop the override entirely: the background
is bg-muted/30 in every state, identical to main, and pinned
legibility comes from backdrop-blur-sm alone (blurring a solid page
background is a no-op at rest; it only softens body text sliding
underneath once the toolbar is pinned).
Co-Authored-By: Claude <noreply@anthropic.com>
The sticky rework had swapped the toolbar background to opaque
bg-muted, which reads darker than main's bg-muted/30. Restore the
translucent background and pair it with backdrop-blur + a slightly
stronger muted overlay (only when backdrop-filter is supported) so
pinned-on-body-text stays legible without the flat solid band.
Also pin the font-size button's line height so the toolbar's row
height matches main exactly.
Co-Authored-By: Claude <noreply@anthropic.com>
Move the fields section out of the composer's scroll container so it
stays visible while scrolling long bodies, matching the pinned
formatting toolbar. Since the fields were the first child of the
scroll container, hoisting them above it is visually identical to
making them sticky - but needs no dynamic offset for the toolbar's
own sticky top (the fields' height varies with Cc/Bcc toggling and
recipient-chip wrapping). The scroll container now wraps only the
body and the signature preview.
Co-Authored-By: Claude <noreply@anthropic.com>
- Pin the rich-text toolbar to the top of the composer's scroll
container (sticky) so it stays visible while editing long bodies;
the previously translucent background becomes opaque so body text
doesn't bleed through while pinned.
- Add a font-size picker to the toolbar (14/16/18/20/24/32px plus
"Default") backed by a new TipTap FontSize extension that stores
the size as an inline style on the TextStyle mark, mirroring how
@tiptap/extension-color handles colour - inline styles survive
email round-trips, and unsetting the size leaves other text-style
attributes (e.g. colour) intact.
- Add font_size / font_size_default toolbar strings to all 25
locales.
Co-Authored-By: Claude <noreply@anthropic.com>
Two new extension points for third-party attachment/cloud-storage plugins
(e.g. a WebDAV/Nextcloud file browser), neither of which the plugin API
currently supports:
- attachment-actions: a new slot rendered into every attachment's hover
action row (all six render paths: beside-sender/below-header/mobile,
each in its compact and expanded-popup form), with the AttachmentInfo
(name, type, size, blobId, emailId) passed through extraProps. Lets a
plugin offer a 'save this attachment elsewhere' action next to the
existing download/preview buttons - previously the only per-attachment
hook was the read-only onAttachmentDownload observer.
- composer-attachment-source: a new slot rendered in the composer toolbar
next to the existing composer-toolbar slot, with an onAttach(upload)
callback in extraProps. Lets a plugin that has already uploaded a file to
the JMAP server itself (api.jmap.uploadBlob) - e.g. a file picked from an
external cloud storage browser - have it added to the current draft as a
real attachment. Previously there was no way for a plugin to add an
attachment to the draft at all; the closest existing mechanism
(onBeforeBlobUpload's ExternalAttachmentResult) only replaces a locally
picked file with body text/a link, it cannot add a brand-new attachment
from an external source.
Both slots follow the existing PluginSlot pattern (calendar-event-actions):
render nothing when no plugin offers the slot, so this is purely additive.
Motivating use case: the existing nextcloud-attachments community plugin
uploads local files to Nextcloud and appends a share link to the message
body - useful for large files, but there was no way for a plugin to do the
inverse (attach a file that already lives in cloud storage as a real binary
attachment, not a link) or to let attachment recipients save an incoming
attachment back out to their own cloud storage. These two slots make both
directions possible without touching plugin-store or the sandbox bridge -
existing slot/extraProps plumbing (see calendar-event-actions) already
supports arbitrary POJO props.