Commit Graph
271 Commits
Author SHA1 Message Date
Linus Rath f8b8e0b108 refactor: move S/MIME to generic crypto plugin hooks 2026-06-28 19:13:02 +02:00
Linus Rath 5e67671f57 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-25 01:05:00 +02:00
Linus Rath 155d99a069 feat: add plugin hooks for email details, headers, and source 2026-06-25 01:04:26 +02:00
Stefan HildebrandtandLinus Rath d863b1fd4b fix: HTML-escape sender/subject in reply/forward quote header (#482)
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.
2026-06-25 00:25:28 +02:00
Linus Rath 1ff23790ae Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-24 19:56:15 +02:00
198407ebf5 fix(composer): keep HTML signature styling in the editor and on send
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>
2026-06-24 19:56:10 +02:00
Linus Rath f972068143 fix: localize special-folder names by JMAP role #404 2026-06-24 19:32:13 +02:00
Linus Rath ae5d397512 feat: add "Download all" button to bundle attachments into a zip #466 2026-06-24 18:46:41 +02:00
Linus Rath 32f0a67dbc fix: gate Send-now toolbar label, translate send_now across locales 2026-06-24 16:52:36 +02:00
Shuki VakninandLinus Rath baf094d026 feat(scheduled): Send now button on scheduled/delayed messages
Adds a 'Send now' action to the scheduled-send view (both the toolbar and the
inline banner) so a queued message — whether explicitly scheduled or held by the
undo-send delay — can be sent immediately instead of only cancel/reschedule/edit.
Reuses the existing reschedule path (reschedules the submission to now), so no
new JMAP plumbing. Toolbar 'Cancel send' demoted to ghost so 'Send now' is the
single primary action. i18n added across locales.
2026-06-24 16:46:18 +02:00
Shuki VakninandLinus Rath 5f713a033b fix(mail-list): truncate long subjects so they don't overlap the timestamp
In the single-line (focused) message-list layout, the subject span used
`shrink-0`, which prevented `truncate` from engaging: a long subject sized to
its full content width and overflowed the bounded subject/preview group,
rendering on top of the timestamp on the right.

Let the subject shrink and truncate (`shrink-0` -> `min-w-0`), and give the
inline preview a high shrink factor (`shrink-[9999]`) so it collapses first —
the subject stays fully visible while there's room and only truncates with an
ellipsis once the preview is gone, never colliding with the time.

Applied to both email-list-item and thread-list-item (single-email and
thread-aggregate rows).
2026-06-24 16:33:43 +02:00
Stefan Hildebrandt a29c33b50a feat: cross-account "All accounts" views + full group/shared-account support
Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
2026-06-23 19:16:22 +02:00
3dd596ba50 fix: guard compose Send against double-submit
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>
2026-06-22 18:42:39 +02:00
Shuki VakninandLinus Rath e50fe0d4db fix(mail-list): add breathing room between the unread dot and the avatar
The unread indicator dot was absolutely positioned at `left-1` (4px), leaving
only ~4px between it and the avatar (which starts at the row's `px-4` gutter),
so the dot read as flush against the avatar. Move it to `left-0.5` so it sits
nearer the panel edge (like other mail clients) and opens the dot-to-avatar gap
to ~6px. Applied to both email-list-item and thread-list-item (all three
absolute dot instances).
2026-06-22 17:10:46 +02:00
Linus Rath d0ed4b4dfe fix: block remaining email tracking vectors #457 2026-06-22 00:09:09 +02:00
Stefan HildebrandtandLinus Rath 3f9e60843d fix: repair pre-existing failing vitest suite
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.
2026-06-19 23:53:20 +02:00
Stefan HildebrandtandLinus Rath 3516d3c727 chore: resolve react-hooks/exhaustive-deps warnings
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.
2026-06-19 12:31:07 +02:00
Stefan HildebrandtandLinus Rath 344795a8d9 feat: split a pasted address list into recipient chips
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).
2026-06-19 12:30:43 +02:00
Max HaoandLinus Rath c4f1cc23d7 fix blank space with plain-text emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath 4f1390fdeb fix toolbar re-render when opening emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath c0ca6d3102 fix: add collapse all threads functionality to email selection in thread list 2026-06-16 13:34:56 +02:00
Linus Rath 1f889b0965 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 23:16:10 +02:00
Linus Rath 0b9fe5451f fix: preserve line breaks in generated text/plain alternative #421 2026-06-15 23:15:04 +02:00
Max HaoandLinus Rath fd700f412e fix: fix inconsistent behavior with threading email messages in the inbox/folders 2026-06-15 23:00:30 +02:00
Stefan HildebrandtandLinus Rath 94b1f5aa48 refactor: model composer recipients as arrays instead of delimited strings
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.
2026-06-15 22:59:46 +02:00
Linus Rath dcea4fdd5e feat: show recipient address in chip drag preview 2026-06-15 15:28:03 +02:00
Stefan HildebrandtandLinus Rath 1ebfb286ad feat: drag and drop recipient chips between To/CC/BCC fields
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.
2026-06-15 15:23:00 +02:00
Stefan HildebrandtandLinus Rath aee4bd78db feat: add Edit contact button to email viewer contact sidebar
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.
2026-06-15 15:06:35 +02:00
Linus Rath 848ed9774d feat: show avatars in recipient autocomplete suggestions 2026-06-14 14:54:27 +02:00
Linus Rath 38570b1723 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-12 00:03:52 +02:00
Linus Rath 20fd9ff4de fix: prevent wide email tables from rendering with rotated headers #409 2026-06-11 19:24:05 +02:00
Linus Rath be58cee989 fix: dedupe scheduling emails, Stalwart-compatible calendar filters 2026-06-11 18:24:43 +02:00
Max HaoandLinus Rath 7c11e2b3c9 add localized translation placeholders 2026-06-10 19:06:44 +02:00
Max HaoandLinus Rath f2913a7c7d feat: add email display name support to the composer. 2026-06-10 19:06:44 +02:00
Linus Rath a27a5be3dd fix: correct dark-mode background-image inversion and height clipping in email viewer 2026-06-08 15:53:44 +02:00
Linus Rath d11ed904a9 feat: calendar agenda plugin sidecar + persist email detail sidebar state 2026-06-04 12:35:56 +02:00
Linus Rath 75c02f443f fix: align top bars to uniform h-14 height 2026-06-02 01:08:09 +02:00
Linus Rath 3035fb046f feat: surface most severe SPF result and hide "via" badge on spoofed mail 2026-06-01 17:46:57 +02:00
dealerwebandLinus Rath 4b4c801148 Feature: preview composer attachments inline (click to open)
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.
2026-06-01 17:30:50 +02:00
dealerwebandLinus Rath 26ccf9e3b4 Fix: email body clipped under the fold when it sets html/body height:100%
Some emails (Outlook / templated HTML) set `html, body { height: 100% }` in
their own <style>. Combined with the viewer srcDoc's `overflow: hidden` and the
scrollHeight-based iframe auto-resize, the measured height collapses to the
iframe's initial size, so everything below the first screenful (often just the
header/logo) is clipped and the rest of the message is invisible.

Force `height: auto !important` on html/body in the rendered srcDoc so the
document grows to its real content height before scrollHeight is measured.
2026-06-01 17:24:56 +02:00
dealerwebandLinus Rath 1512b9afc0 Feature: editable layout-preserving quote island
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.
2026-05-30 16:43:50 +02:00
Linus Rath 8d79145dba Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n 2026-05-30 16:15:18 +02:00
dealerwebandLinus Rath bebb394f54 Feature: read receipts (MDN, RFC 8098)
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.
2026-05-30 15:58:39 +02:00
dealerwebandLinus Rath 4c1d0931a1 Fix: deduplicate localized reply/forward subject prefixes
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:.
2026-05-30 15:47:53 +02:00
dealerweb 05e2837f6b Fix: localize reply/forward quote header incl. sender address
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:").
2026-05-30 14:09:23 +02:00
Linus Rath 4ff05bd4ec fix: editable HTML signature in new mail; clean state on every compose entry #329 2026-05-28 19:21:44 +02:00
31e96d6a46 Feat: Scheduled send and send delay #322
* ADD DOC

* Scheduld Send

* add new shortcuts

* fix

* fix

* fix bugs

* rework

* fix draft duplicating

* fix err

* some fixes

* fixes from review

* fixes from review

* fixes from review

* disable password managers for recipients

* fix email store lazy load

* add translations

* fix styling

* fixes

---------

Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
2026-05-28 18:46:49 +02:00
Shuki VakninandLinus Rath 82be047708 fix(email-viewer): stop shattering table cells with word-break: break-word
The global rule

    td, th { word-break: break-word; }

was breaking HTML-email tables one glyph per row whenever a column was
narrow, especially for Hebrew/Arabic/CJK headers and long English
strings. The non-standard `word-break: break-word` keyword behaves
like `break-all` in some engines, splitting words at arbitrary
character boundaries even when the word would fit if the column auto-
expanded.

`overflow-wrap: break-word` is already set on body/table, so the rule
only needs to add min-content relaxation for cells. `overflow-wrap:
anywhere` does exactly that without re-introducing break-all
behaviour.

Repro: any transactional Hebrew/RTL order-summary email — each header
(`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix
they render on a single line.

Closes #341.
2026-05-28 18:32:06 +02:00
Linus Rath 7d1fb73290 fix: scope Ctrl/Cmd+Enter send to focused composer 2026-05-28 18:28:07 +02:00
Shuki VakninandLinus Rath 2818a16f06 feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft
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.
2026-05-28 18:23:37 +02:00