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
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.
List rows lay out with items-start, because the sender line, subject and
preview stack and the body should govern the row height. The avatar
inherited that and pinned itself to the top, level with the sender line,
leaving it visibly high against a two- or three-line row.
Give the avatar self-center in both the single-message and thread rows.
The row keeps items-start, so nothing else moves — in particular the
extra-compact checkbox, which is mutually exclusive with the avatar and
carries its own mt-2 offset, is untouched.
Opening the toolbar feeds its height back into scrollTop so rows keep
their position on screen. At the top of the list there are no rows above
to hold steady, so that scroll instead slides the first message under the
toolbar — select the top message and it disappears behind the bar acting
on it.
Skip the compensation when the list is already at the top and let the
content move down there, which is what the toolbar taking up space
should do. Mid-list behaviour is unchanged, and the scroll is clamped at
zero so collapsing the toolbar cannot drive it negative.
A message with attachments showed a paperclip and nothing else, so the
only way to reach a file was to open the message, scroll to the bottom
and find it. The list rows never had the information to do better:
EMAIL_LIST_PROPERTIES asked for hasAttachment, a boolean.
Request attachments alongside it and render the files as chips under the
preview, each one opening that file directly. Chips honour the existing
mailAttachmentAction setting, so they preview or download exactly as the
viewer's attachment list does.
Inline parts are filtered out. A single Outlook message routinely carries
half a dozen cid: signature spacers of under 200 bytes; chipping those
would bury the one file the reader actually wants. Only parts that are
neither inline nor cid-referenced, and that carry a filename, are shown —
two at a time, with the remainder collapsed into a count.
Downloads resolve the blob source from the row's own message rather than
the selected one, so they stay correct in the unified inbox where each
row can belong to a different account.