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>
- 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>
- 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.
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.
- 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
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.
When the server refuses the draft save, the composer resets its explicit-
close flag so the unmount stash hands the text to the continue-draft slot
(#702). The close follow-up must be dropped in that case - running it
would open the new session on top of the rescue. This was the one branch
of the close guard without a test.
Opening a mailto: link while the composer held unsaved text used to
replace the session outright and drop the text on the floor. The mailto
entry point now hands a follow-up to the composer's dirty-aware close
handler, so the "Save or discard draft?" dialog runs first: save and
discard continue into the mailto draft, cancel leaves the current draft
untouched. When a failed save hands the text back to the continue-draft
slot (#702), that rescue wins and the follow-up is dropped.
Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.
This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.
A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
This code is nowhere used, so to prevent extra work during an upcoming refactor
of tags, it is removed and some related tests are now actually made useful
Recipient chips could already be dragged between the To/Cc/Bcc fields, but a
drop always appended and same-field drops were a no-op, so recipients could not
be rearranged without deleting and re-adding them.
Add positional drag-and-drop: while dragging a chip, an insertion caret shows
the gap it would land in (based on which half of the hovered chip the pointer
is over, mirrored for RTL); dropping inserts it there.
- same-field drop reorders the chip locally (via onChipsChange), using the
source index carried in the drag payload (fromIndex) and adjusting for the
removal shift; dropping onto its own position is a no-op;
- cross-field drop inserts at the drop position: handleMoveChip gained an
optional toIndex (omitted = append, e.g. dropping onto a hidden Cc/Bcc
button, preserving existing behaviour);
- per-chip onDragOver computes the target gap; the container handles the
trailing gap (past the last chip / over the input).
No new user-facing strings (the caret is purely visual), so no locale changes.
Tests (components/email/__tests__/recipient-chip-drag.test.tsx): reorder to end
/ front, self-drop no-op, cross-field positional insert, and caret visibility.
Also add the missing findComposeIdentityId export to the reply-identity mock in
the recipient drag/paste suites so <EmailComposer> mounts in compose mode.
The recipient chip-drag and paste tests render <EmailComposer>, which since
b716f95a (feat(compose): preselect identity of the active mailbox) calls
findComposeIdentityId() from @/lib/reply-identity in compose mode. Both tests
mock that module but only returned resolveReplyFrom, so vitest threw
"No 'findComposeIdentityId' export is defined on the mock" on mount,
failing all 18 tests. Add the missing export (returns null; the composer
guards with if (composeIdentityId)).
Wrap the message-list avatar in a SelectableAvatar control: clicking the
avatar toggles the row into the current selection instead of opening it,
matching Thunderbird's correspondent-avatar selection affordance. A check
overlay appears on hover (hinting it is clickable) and stays while selected.
- email-list-item + thread single-email: toggle that message's id
- thread header: toggle the whole thread (reuses existing thread-select logic)
- focused-mail and extra-compact layouts render no avatar, so unaffected
selectRangeEmails was only wired to shift-clicking the row, but the
checkbox handler called stopPropagation and a plain toggle — so shift-
clicking checkboxes (the obvious affordance in selection mode) selected
single messages instead of the range. Make all three checkbox handlers
(email-list-item, thread single-email, thread header) shift-aware:
shift -> selectRangeEmails, otherwise toggle. Adds a regression test.
Rich, table-based identity signatures lost all their inline CSS
(background/text colors, fonts, border-radius, bgcolor). The composer
embeds the signature into the TipTap editor, and parsing it into the
ProseMirror schema flattened it to a generic bordered table. That
normalized version was then shown while composing AND delivered to the
recipient, even though Identity settings stored and previewed it
correctly.
Hold the signature as a dedicated, non-editable atom node
(SignatureBlock) that keeps the verbatim HTML in an attribute and renders
it inside a Shadow Root, mirroring the existing QuotedHtml island. The
markup is never parsed into the schema, so the styling survives 1:1 both
in the in-editor preview and in the outgoing mail
(serializeEditorContent inlines the verbatim HTML, as it already does for
quoted originals). The signature stays a single unit: select it and
Backspace/Delete to remove it; identity switching still swaps it via the
existing data-signature-block markers.
Adds unit coverage (parse + serialize round-trip preserves inline styles).
Fixes#475
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes failures across the suite that fail on main independently of any branch.
Documented + skipped
- smime/smime-crypto: this suite OOMs its worker (~4 GB heap) generating and
using real 2048-bit RSA keys via pkijs/asn1js — a pre-existing memory issue,
not a logical failure. Skipped behind a single SKIP_SMIME_CRYPTO_OOM flag with
an in-file explanation and re-enable instructions, and the beforeAll bails
early so the skipped file runs in ~2s instead of crashing the worker.
Code fixes
- jmap/client: getSubmissionAccountId honoured the requested (mail) account
even when it lacks the submission capability, so EmailSubmission/set was
addressed to the wrong account when JMAP hosts submission in a separate
account. Prefer an account that actually advertises submission, falling back
to primaryAccounts['…:submission'].
- plugin-sandbox/loader: deactivateAllSandboxed used require('./registry'),
which is unresolvable under the Vite/ESM test runtime. registry only imports
types (no cycle), so use a static import; all() already returns a copy, so
iterating while deregister mutates is safe.
Test fixes (tests trailed intentional code/behaviour changes)
- vitest.setup: add a matchMedia stub (jsdom lacks it) — unblocks 8
email-list-item tests.
- calendar-utils: pin TZ=UTC for the timezone-sensitive bounds/layout assertions
(host runs at UTC+2) and update expected minutes to UTC.
- calendar-participants: buildParticipantMap keys entries by generated UUIDs
(RFC 8984), not 'organizer'/'attendee-N'. Look entries up by identity so the
test no longer depends on a generateUUID mock leaking from another file.
- email-headers: softfail now returns the semantic 'text-warning' token.
- email-list-item: unknown keyword ids intentionally render a gray fallback badge.
- plugin-loader: exposePluginExternals is now a documented no-op.
- plugin-slot: PluginSlot reads the sandbox registry and renders iframe slots;
rewrite the tests against that architecture with a referentially stable snapshot.
- plugin-types: MAX_THEME_SIZE was raised to 2 MB.
Pasting a list of addresses into To/Cc/Bcc now creates one chip per
address instead of dropping the whole blob in as a single invalid chip.
A paste is split only when it actually contains a separator; a lone
address falls through to normal editing.
- Separators: commas, semicolons, and any whitespace/newline - covers
comma/space dumps, spreadsheet columns and Outlook-style `;` lists.
- Display names are preserved: `Name <email>`, a fully-quoted
`"Name <email>"` entry, and `"Doe, John" <email>` (comma inside a
quoted name) each stay a single chip with the name intact.
- Bare-address runs split per address; a `<addr>` token is unwrapped;
tokens that aren't valid addresses are left behind in the input for
the user to fix rather than becoming junk chips.
- Deduped case-insensitively within the paste and against existing chips.
Implemented as splitPastedRecipients in email-composer-utils, layered on
the shared quote/angle-aware splitter: splitRecipients gains an optional
`separators` argument so the composer/mailto serialization boundary
(comma-only) and the paste path (`,;\n\r`) share one implementation.
Wired into the recipient chip input's onPaste handler (To/Cc/Bcc).
Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.
- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
and parseRecipientList/formatRecipientList for the (de)serialization
boundary (ComposerDraftData stays a string; quoting keeps it lossless).
Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
drop (payload now carries the structured recipient), send/draft/validation
and template paths all operate on arrays. withInput() folds uncommitted
typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
Adds native HTML5 drag-and-drop so users can move recipient email
address chips between the To, CC, and BCC fields in the composer.
Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden
field and place the chip there.
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.