Provision a Stalwart group (team@example.org) with carol as a member before her
first login, and assert the composer's From selector offers the group address.
This confirms the group-membership scenario of #569 already works out of the
box: Stalwart returns the group's send-as identity on the member's own account,
so the app's normal single-account identity load surfaces it (identities.length
> 1 -> the From <select> renders with team@).
- stalwart: create the `team` Group in plan-accounts and add carol via
User.memberGroupIds in the entrypoint (id resolved after apply, like
DOMAIN_ID). carol, not alice/bob, so the sync specs stay unshared.
- helpers: GROUP config, openComposer/composerFromOptions, and JmapClient
accounts + sharedAccountNames (Identity/get needs the submission capability).
- composer: add data-testid="composer-from" to the From <select> and its
single-identity <span> fallback.
Ref: https://github.com/bulwarkmail/webmail/issues/569
Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.
- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
inspecting server state, and page helpers (login, add/switch account,
locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
burst) and multi-account (per-account isolation + cross-account Unified
Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
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.
Signatures render into the main document - the identity form's live preview
and the composer's signature block - rather than the sandboxed iframe used for
message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those
anchors were live and target-less: one click navigated the whole app away,
discarding the unsent draft or the unsaved signature with it.
Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image
restrictions but forces target="_blank" rel="noopener noreferrer" on every
anchor, and use it at the two render sites. The composer's SignatureBlock
NodeView stamps the target on its rendered DOM instead, because attrs.html is
what serializeEditorContent emits into the sent message - storage and the
recipient's copy stay exactly as the user wrote them.
Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.
The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.
Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.
RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.
On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.
When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.
New strings are added to all 20 locales.
Clicking Send while attachments were still uploading silently dropped
them from the outgoing message: every place that builds the outgoing
attachment list filters on att.blobId && !att.uploading, and the Send
button never accounted for uploads still in flight. Attach a few files,
hit Send right away, and the email could go out missing some of them
with no warning.
handleSend now detects pending uploads before validating:
- Send is disabled with an explanatory tooltip
(validation.attachments_uploading) while it waits.
- Once uploads finish cleanly, the send proceeds automatically - no
second click needed.
- If an upload FAILS while waiting, the send is aborted with an error
toast (validation.attachment_upload_failed) instead of silently
shipping the email without the failed attachment - the user may not
be looking at the composer to notice the red error chip.
- If the draft is closed or discarded while waiting, the pending send
is cancelled cleanly.
The wait/decision logic lives in waitForPendingUploads() in
lib/email-composer-utils.ts (returns completed | cancelled | failed)
with unit tests covering all three outcomes. Outgoing-attachment
call sites read the freshest state via attachmentsRef since the
render closure captured at click time won't reflect uploads that
finished during the wait.
Both new i18n keys added to all 20 locales under
email_composer.validation.
Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.
Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
The forward quote header renders "From: Name <email>", but the HTML variant
interpolated the sender string unescaped. In the rich-text composer the
"<email>" portion is parsed by the browser as a bogus HTML tag and dropped, so
the address silently disappears - the user sees only "From: Display Name". The
plain-text variant and the details panel escape correctly, which is why the
address shows there. This is the regression from #367, which added the
"<email>" into the HTML string without escaping it.
Fix: HTML-escape the user-controlled values (sender, subject, date) in every
HTML quote-header path - the production builder in lib/quote-header.ts and the
composer's inline fallback (both htmlBody and plain-body branches), for forward
and reply. The reply line keeps the bare display name by design (#367), but its
HTML form is now escaped too so a display name containing markup can't break
out. As a side benefit this closes an HTML-injection vector: a crafted subject
or display name was previously injected raw into the composer document.
Adds lib/__tests__/quote-header.test.ts covering: forward text keeps
"Name <email>"; forward HTML escapes the angle brackets (address survives) and
a markup subject/display name; reply stays bare-name and HTML-safe.
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>
Every Send control was disabled only by `canSend` (recipient/subject/body
validity), which never reflects an in-flight submission, so the composer
stayed interactive during the JMAP round-trip. Clicking Send quickly more
than once - or a click racing the keyboard send shortcut - invoked
handleSend once per click and sent the message multiple times (duplicate
deliveries and duplicate Sent entries), most easily hit on higher-latency
connections.
Add a synchronous re-entry guard: a ref (not state, which updates
asynchronously and wouldn't block a second click in the same tick) set once
handleSend clears its "don't send" early returns and reset in a finally,
plus an isSending state that disables every Send control. Covers all entry
points - the three Send buttons, the keyboard shortcut, the schedule dialog,
and the attachment-warning confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goes through the 7 exhaustive-deps warnings individually:
Added the genuinely-missing dependency (safe, no extra churn):
- email-viewer useMemo: add effectiveEmailContent.hasStyleTag (used for
hasOwnLayout; changes in lockstep with .html, closing a latent staleness gap).
- pro-compose-tab-body handleSend: add refreshCurrentMailbox (stable zustand
selector) and drop the stale fetchEmails/selectedMailbox deps — which left
those two selectors entirely unused, so remove them too.
- use-mailbox-drop handleDrop: add sourceMailboxId (changes in lockstep with
draggedEmails, already a dep).
Suppressed with a justified comment where depending on the whole object would
regress behavior — these are intentional fine-grained deps:
- email-composer signature-swap effect (keyed to signature fields + prev*Ref
guards; whole signatureIdentity would re-splice the live editor).
- email-viewer auto-mark-as-read (whole email would reset the delay timer on
any unrelated field update).
- email-viewer effective-attachments memo (derives from email.attachments;
whole email would churn the list + its layout measurement).
- email-viewer auto-MDN effect (email already captured via id +
sendReadReceiptNow; autoMdnRef guards double-send).
tsc --noEmit clean; eslint now reports 0 problems.
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 attachment chip in the composer now opens the same FilePreviewModal
the message viewer uses, instead of offering only download/remove. The chip
becomes clickable once the attachment has content (a local File, or an uploaded
blob for forwarded attachments) and the type is previewable.
- getFileContent prefers the in-memory File (no network round-trip) and falls
back to composerClient.fetchBlob for forwarded attachments (blobId only).
- Previewability (isFilePreviewable) and the open-in-new-tab safety gate are
handled inside the modal, so this adds no new egress/attack surface; the local
download path uses an <a download> (forces a save, never executes).
- No new dependencies and no new locale keys.
Replying to / forwarding a layout-heavy HTML email (nested tables, MJML,
Outlook divs) destroyed its layout: ProseMirror re-parsed the quoted body
through its strict schema and discarded anything that didn't fit. The quoted
original is now held verbatim in a new atomic QuotedHtml node and never parsed
into the schema; its NodeView renders inside a shadow root so app CSS can't
cascade in and the in-editor view matches the sent mail 1:1.
- quoted-html.ts (new): QuotedHtml atom node + shadow-DOM NodeView (inner
contentEditable for redaction), serializeEditorContent(), buildQuotedHtmlBlock().
- rich-text-editor: register the node; emit via serializeEditorContent (not
getHTML) so the verbatim island survives.
- composer: both HTML reply/forward paths embed the original as an island
(sanitize -> cid-rewrite -> buildQuotedHtmlBlock); the signature-swap effect
serializes via serializeEditorContent and treats the island as a quote
boundary so the splice never cuts into the quoted body.
atom:true means Backspace at the boundary / Ctrl+A+Delete removes the whole
quote in one go.
Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN).
End-to-end, client-side, in three parts:
- Request (compose): a toolbar toggle (MailCheck, green when on) sets
Disposition-Notification-To on the outgoing message via the JMAP
"header:<name>:asText" create property. Threaded composer -> page ->
email-store -> client.sendEmail. Default from requestReadReceiptDefault.
- Detect (viewer): reads Disposition-Notification-To case-insensitively from
the parsed headers and shows a banner (green Send / red Ignore) in the
unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled.
message/disposition-notification + message/delivery-status report parts are
filtered out of the attachment list.
- Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain +
message/disposition-notification, UTF-8/base64, localized subject + body).
client.sendReadReceipt uploads the blob, imports it into Sent via
Email/import, then submits with an explicit envelope. Both Send and Ignore
set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour
configurable: ask / always / never.
New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault,
readReceiptResponse) + UI. All 17 locales.
Replying to a reply produced "Re: Re: foo" (and German used the English
"Re:"/"Fwd:" instead of "AW:"/"WG:"). Four code paths built reply/forward
subjects and only one deduplicated - and only for the English prefix, so
cross-locale threads accumulated chains.
New lib/subject-prefix.ts strips any leading run of reply/forward markers
across ~35 tokens from all supported languages (plus Outlook Re[2]: and
Eudora Re*2: counters), then prepends the locale-appropriate prefix. All four
call sites (composer getInitialSubject, the two page.tsx sites, and the three
pro-tab handlers) now use buildReplySubject/buildForwardSubject. German prefix
corrected to AW:/WG:.
The reply/forward quote header was always emitted in English ("On {date},
{from} wrote:", "---------- Forwarded message ----------", From/Date/Subject)
regardless of UI language, in both the main path (lib/quote-header.ts) and the
composer's inline fallback. quote-header.ts now takes an optional localized
QuoteHeaderLabels set (English defaults preserved for back-compat); page.tsx
builds it from a new quote_header message namespace, and the composer fallback
uses the same keys. Added the quote_header namespace to all 17 locales.
Also folds in the forward-sender-address fix: the forward "From:" line now
shows the full "Name <email>" like every mail client (the reply line keeps the
bare name, which reads naturally in "On … wrote:").
Adds the universal "send with the platform modifier" shortcut every
mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota,
Fastmail, Thunderbird) supports. Closes#343.
Behaviour:
* Window-level keydown listener registered while the composer is
mounted. Fires when focus is anywhere inside the composer — chip
inputs, subject, body textarea, or the rich-text contentEditable.
* Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd
(macOS) triggers send. Shift/Alt modifiers are ignored so existing
autocomplete-confirm / chip-commit Enters are not hijacked.
* Routes through a ref so handleSend's per-render rebind doesn't
re-register the listener every render.
* All existing send-time validation, attachment-warning, draft-save
and undo-send flows still apply — the shortcut just calls the
same handleSend() as the toolbar button.
* Listed in the Keyboard Shortcuts dialog under the existing
Composer section.
Tested:
* Compose -> type body -> Ctrl+Enter -> Outbox.
* Cc/Bcc autocomplete suggestion + Enter still selects (alt-free
Enter without Ctrl, so the new listener bails).
* Subject input -> Ctrl+Enter -> sends.
* Body Enter without modifier -> newline.
Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.
The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.
JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).
S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.
Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.