Adds the same "Forward as attachment" action from the message viewer's
overflow menu to the right-click context menu on a message row in the
list, right after "Forward". Reuses the handleForwardAsAttachment
handler and buildForwardAsAttachmentPayload helper introduced earlier
in this PR - no new logic, just threading the prop down EmailList ->
EmailContextMenu the same way onForward already is.
blobId is already present on list-row emails (EMAIL_LIST_PROPERTIES
includes it specifically for the existing drag-out-to-filesystem .eml
export feature), so this works without any additional fetch. The menu
item is disabled if it's ever missing, matching how other actions
degrade when their handler prop isn't supplied.
Reuses the email_viewer.forward_as_attachment translation key already
added (via a second scoped useTranslations("email_viewer") call,
matching this file's existing cross-namespace pattern for
email_viewer.color_tag) rather than adding a duplicate key under
context_menu - avoids touching all 24 locale files again.
The Pro/embedded composer-hoisting effect built its own `replyTo`
straight from `selectedEmail`, unconditionally, ignoring
`pendingDraft.replyTo`. That meant intent set by the opener - e.g.
handleForwardAsAttachment's synthetic message/rfc822 attachment -
would silently get dropped when the composer is hoisted into a Pro
tab, falling back to a normal quoted forward instead. Mirror the same
precedence the non-embedded render path already uses just below
(`pendingDraft.replyTo` wins when set).
Caught by GitHub Copilot's automated PR review.
Adds a "Forward as attachment" action to the message overflow menu
(desktop and mobile), right beside the existing "Export as .eml"
action. Opens a new forward-mode compose window with the original
message attached as a message/rfc822 file instead of quoted inline -
useful for reporting spam/phishing to an upstream gateway that expects
the raw original as an attachment (the primary motivating use case:
gateways like MxGuarddog require complete original headers, including
the full mail path, for scanning), or for preserving a message's exact
formatting/headers when forwarding.
Implementation reuses the composer's existing attachment-carry-forward
mechanism (the `attachments` useState initializer in
email-composer.tsx already carries a forwarded message's own
attachments into the new compose via `replyTo.attachments`) - this
just adds one synthetic entry representing the whole original message,
referenced by its existing blobId. No re-fetch or re-upload needed,
since JMAP blobs are account-scoped rather than per-email. The inline
quote-header step (prepareComposerQuoteHeader) is skipped, so the body
starts blank instead of quoting the original.
The core "build subject + attachment entry" logic is extracted into a
pure, unit-tested helper (lib/forward-as-attachment.ts) rather than
left inline in the already-large page component.
Adds the forward_as_attachment locale key to all 24 locales (English
text as a placeholder pending translation, following the existing
add-a-key convention) to satisfy the translations completeness test.
Drop the `accounts.length > 1 || hasGroupInboxes` gate that hid the
Unified Mailbox toggle for single-account users with no visible shared
folder. The admin `isSettingHidden('enableUnifiedMailbox')` policy gate
is preserved, so admins can still hide it.
The explicit Email/set destroy workaround for the duplicate-on-move bug is
removed now that the root cause is filed upstream (support.stalw.art #1150:
onSuccessDestroyOriginal destroys the copy's create-id instead of the source
id). copyEmailAcrossAccounts keeps requesting onSuccessDestroyOriginal, so the
move self-heals once Stalwart ships the fix.
Kept: the keyword-preservation fix (carry the source keywords into Email/copy)
so the moved message keeps its read state.
Tests: 08-shared-moves still asserts delivery + read-state on every
cross-account case; the source-removal checks are re-pinned test.fail, scoped
to a nested describe, until #1150 is fixed. Suite green (5 pass, 3 expected-fail).
Moving a message across the account boundary (own ↔ shared folder, or
between two owners' shared folders) left the original in the source
folder and showed the moved copy as unread. Same-account moves were fine.
Cause (verified against a live Stalwart):
- Email/copy drops keywords unless the create sets them, so the copy lost
$seen and arrived unread.
- onSuccessDestroyOriginal is unreliable — the implicit destroy reports
notFound and leaves the original behind (flaky), so the move duplicated.
Fix (copyEmailAcrossAccounts): read the source keywords and carry them
into the Email/copy create, then destroy the original with an explicit
Email/set on the source account instead of onSuccessDestroyOriginal.
Tests: 08-shared-moves now asserts the source is gone and the read state
survives on every cross-account case, and adds a cross-owner shared →
shared move (alice's folder → bob's folder). Confirmed red on the old
code (3 cross-account cases fail), green with the fix.
Moving a message to a folder in another account (own ↔ delegated/shared) via the
"Move to" context menu was a no-op — the handlers always issued a single-account
Email/set, which can't move between JMAP accounts. Drag-and-drop already routed
these correctly; the context menu never did.
Add moveToMailboxCrossAware: it detects a cross-account destination (own and
shared mailboxes both carry accountId) and routes through the drag-and-drop
crossAccountMoveEmails pipeline, else falls back to the single-account move.
Fix the pipeline for delegated folders too: a client can't stage a blob in a
delegated account (Blob/upload → blobNotFound), so importing into a shared folder
failed. When one client reaches both accounts, use a server-side JMAP Email/copy
(+ destroy original) instead of blob copy+import; the blob path is kept only for
separate cross-server login accounts. Adds client.copyEmailAcrossAccounts.
Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full
docker integration suite green (37 passed).
Reopening a draft reset the composer's From to the default identity. The
edit-draft handler matched the draft's saved From against the active-account
identity list by email only, so two identities sharing an address (a default +
an alias differing by name) collided — the wrong one was picked, or with
cross-account namespaced ids none was.
Add findDraftIdentityId (name+email, normalized, +tag fallback) and match against
the same list the composer renders (the flat cross-account list when multi-
account is on). Wired into both the classic and Pro edit-draft paths. Unit tests
plus the un-pinned 07-drafts integration spec.
The rich-text editor was the last hardcoded-English surface in the
composer: 21 tooltip titles, the eight table-menu entries, the "Remove
color" entry and the table size picker's "Pick size" label were plain
strings while every other menu in the app is localized.
All of them now come from a new email_composer.toolbar namespace,
translated into all 23 locales using each platform's established
editor terminology (Word/Docs conventions - de "Formatierung löschen",
ar "مسح التنسيق", ja "書式をクリア", ...). The link prompt stays "URL",
which is the same term in every language.
Tooltips and self-sizing dropdowns have no width constraints, so longer
translations are safe everywhere.
The calendar fan-out probes every shared/group account on suspicion,
because Stalwart does not always advertise calendar capability on group
accounts. A shared account that grants no calendar access at all rejects
that probe - and did so again on every calendar interaction: each range
change re-queried the account and logged a red console error
("You do not have access to account X") while working fine otherwise.
Remember the rejection instead: the thrown query error now carries the
JMAP error type, an access rejection for a probed secondary account is
logged once at debug level, and both fan-out loops (events and calendar
lists) skip the account for the rest of the session. Genuine failures on
the primary account keep the error log. Two regression tests cover the
probe-once behavior and the calendar-list skip.
Since the per-account push setup (#281), every account switch tears down
and re-creates push notifications for all connected clients. Aborting an
SSE connect that is still in flight lands in the fetch rejection handler,
which treated it as a network failure:
- fallbackToPolling() started an unsupervised 3s state poll on a client
whose push had just been intentionally closed, after the cleanup that
would have removed it had already run.
- The late rejection also nulled sseAbortController, orphaning the
replacement connection set up right after: it could never be aborted
again and reconnected itself in parallel once the server closed it.
Rapid switching multiplied both effects until the server's concurrency
limit stalled the app entirely.
Each connect attempt now tracks its own AbortController: an aborted
attempt returns silently instead of falling back to polling, and the
end-of-stream reconnect only fires if the stream is still the current
one. Regression tests simulate the switch churn both ways.
Fixes#592.
refreshCurrentMailbox merges the refreshed first page with the already
loaded list, appending existing entries beyond a cutoff. That cutoff
was derived from the refreshed list's length - so whenever a folder
shrank, the fresh page was shorter than the stale list and the loop
re-appended the deleted rows from stale local state, despite the
comment right above promising the opposite.
The visible result is the reported bug: after sending a draft, the
Drafts view keeps showing a ghost row for the already-destroyed draft.
The send actually succeeded - resending the ghost delivers the mail
again, which we reproduced with a live JMAP trace: four successful
submissions, an empty server-side Drafts folder, a notFound ghost id,
and five delivered copies. Deriving the cutoff from the page size
fixes the shrink case while preserving the merge's intent for
arrivals and loaded deeper pages; regression tests cover all three
shapes.
Also surface post-send filing failures instead of dropping them, as
flagged in 4dc76bbb's follow-up note: a rejected onSuccessUpdateEmail
patch or old-draft destroy now logs the server's error details and
returns a filingError on SendEmailResult, and the UI shows a warning
toast (all 23 locales) so a stale draft row is never again mistaken
for a failed send. A plugin veto of the send leaves a debug trace.
The From-identity picker in the composer and the template form use a
native <select>, so the earlier <bdi> fix can't apply there - browsers
render <option> as plain text and strip any nested markup. The native
OS-rendered option list still respects the dir attribute directly
though, so setting dir="ltr" on each option fixes the same bracket-
mirroring bug for "Name <email>" entries in that native popup.
Unicode's bidi algorithm treats < and > as mirrored characters. When a
plain "Name <email>" string is rendered as a text node inside an
RTL-inherited container, the browser swaps and reorders those brackets
for the whole run, producing garbled output (e.g. "<Maria Lopez
<maria.lopez@company.example" instead of "Maria Lopez
<maria.lopez@company.example>").
Wrapped the affected text in native <bdi>, which auto-detects its own
paragraph direction from its content rather than inheriting the
ancestor's - so a Latin address renders LTR and a genuinely
Arabic/Hebrew/Farsi name still renders RTL, both correctly, in:
- recipient-popover.tsx (shared by the email viewer's From/To/Cc/Bcc
detail rows and the calendar invitation banner's organizer row)
- email-composer.tsx's read-only From display
- eml-preview.tsx's From/To header lines
Left the equivalent <select><option> cases (composer identity picker,
template identity picker) and the composer's quote-header text (which
becomes actual email body content, already isolated per-paragraph by
the existing TextDirection tiptap extension) out of scope - both need
a different fix approach than <bdi>.
These popovers are portaled and positioned via inline styles computed
from getBoundingClientRect() rather than Tailwind classes, so the
logical start-0/end-0 fix doesn't reach them. They always anchored to
the physical right of their trigger (rect.right + 8), which in RTL
pushes them further into the edge the trigger is already flush against
instead of toward the visible content area.
Added isDocumentRTL() to i18n/direction.ts and used it to mirror the
computed position in:
- navigation-rail.tsx: storage quota popover, logout/switch-account menu
- account-switcher.tsx: both the rail and expanded-sidebar variants
- calendar-invitation-banner.tsx: the "add to calendar" picker
Popovers and dropdown menus across the app (sub-address helper, calendar
toolbar/color pickers, contact/template/attachment menus, rich text editor
color and table pickers, unsubscribe confirmation, composer send menu)
were anchored with physical `left-0`/`right-0`. In RTL locales those
don't flip with the trigger, so the menu detaches from the button that
opened it. Switched to Tailwind's logical `start-0`/`end-0` (and the
matching `rounded-s-*`/`rounded-e-*` corners on hover-action overlays)
so they mirror correctly for RTL locales (ar, he, fa) while staying
identical in LTR.