The conversation view used on phones built its own sanitizer hook that
only caught `<img src>` starting with a lowercase scheme and inline
style `url()`, and its iframe CSP always allowed http(s) images. With
remote content blocked (the default `ask` policy) uppercase schemes,
srcset, `<source>`, video posters and background attributes still
loaded, so trackers fired when a phone user opened a thread.
The view now uses the desktop viewer's `blockExternalResourcesOnNode`,
and the strict iframe CSP is shared as `emailIframeCsp()` and applied
whenever remote content is blocked, together with `no-referrer`.
A newsletter's List-Unsubscribe `mailto:` was sent from the user's own
account as written: every `to=` field became a recipient, and subject
and body were the sender's choice. The confirmation said only
"Unsubscribe from this sender?", so one click could mail
"I resign" to someone's boss and HR.
The unsubscribe now goes to the single address in the URL (a list of
addresses is refused and `to=` is ignored), the subject is kept on one
line and both subject and body are capped. Both confirmations show the
recipient, subject and body before anything is sent.
Attachment thumbnails were object URLs typed with the sender's MIME
type. Object URLs share the webmail origin, so an `image/svg+xml`
attachment opened from its thumbnail ("Open image in new tab", or
dragged to the tab strip) ran the sender's script as the webmail in
Chromium and Firefox. The drag-out URL and Files thumbnails had the
same shape.
`fetchBlobAsObjectUrl` now re-types every blob to an inert type, and
image thumbnails in mail and Files go through `imageBlobUrl`, which
hands an SVG over as a `data:` URL: it still renders in the `<img>`,
but opened on its own it gets an opaque origin.
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.