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.
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 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.
External web links (http/https) in rendered email bodies open in a new browser
tab with target="_blank" rel="noopener noreferrer". mailto:, tel:, and in-page
#anchors keep their default behavior instead of spawning a blank tab.
The plaintext render path is already handled on main by #594 (ADD_URI_SAFE_ATTR
in PLAIN_TEXT_RENDERED_CONFIG), so this no longer adds its own hook there — the
plaintext linkifier only ever emits http(s) anchors, so the config's declarative
exemption is sufficient. This change covers the paths #594 did not:
- iframe HTML render: both anchor passes (the DOMPurify hook and the post-render
DOM walk in email-viewer.tsx) set target=_blank on EVERY <a>, including
mailto:/tel:. Now scoped to http(s) via the shared applyNewTabToAnchor()
helper (http/https -> target+rel; mailto/tel/#/other -> strip target/rel).
- sanitizeI18nHtml: the same DOMPurify strip dropped target/rel from translated
links (e.g. the docs link in settings.security.not_available, target="_blank"
in 19/22 locales). Keep the author's target and harden rel="noopener
noreferrer".
Tests: unit coverage for isHttpLinkHref / applyNewTabToAnchor / sanitizeI18nHtml
plus an integration suite over the real plaintext and HTML/iframe render
pipelines. The plaintext-hook-specific cases are dropped as redundant with #594.
When the mailbox view gets into a stale or wrong state, the only escape
was the browser's "clear site data" — which also wipes the saved account
list, forcing a re-login of every account.
Add a non-destructive "Refresh cached data" button under Settings →
Data. It clears the server-derived caches (contacts, calendars,
identities, per-account snapshots) and reloads so they re-fetch fresh,
while preserving accounts, sessions, settings, themes and user content
(templates, S/MIME). Two-click confirm to avoid an accidental reload.
English strings added across all locales (translation follow-up); unit
tests cover the cache-clear (keeps account-registry/auth/prefs) and the
reload.
Accounts whose primary sending identity differs from their login (basic
auth registers accountId from the typed login; OAuth from the identity
email) were force-re-authed on switch because the guard derived the
connected id only from the primary-identity email. Collect every
server-confirmed identifier (JMAP Session.username + primary-identity
email) and only re-auth when the target matches none. Excludes the
constructor username so a real desync still trips. Adds
JMAPClient.getSessionUsername().
Defence-in-depth on top of the strict iframe img-src/media-src/font-src CSP
that already blocks <style>-tag fetches at the network level. The per-node
DOM walk in blockExternalResourcesOnNode only sees element attributes, so a
tracker hidden in a kept <style> block (background url(), @font-face, @import)
never passed through it.
Adds stripExternalStyleSheetCss(), wired into blockExternalResourcesOnNode for
STYLE nodes (so it's gated on shouldBlockExternal and drives the blocked-content
banner like every other vector). Decodes CSS escapes over the whole block first
so the escaped-keyword form \75\72\6C( -> url( is caught - a literal `url(`
match would miss it. Removes remote @import in both url() and bare-string forms.
Feat/unified mailbox account scope
Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.
Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
per-account default-identity (#507) migration at different versions (main v6,
branch v7). Merged migration is version 7 using the refactored migrateSettings
function; the unified-mailbox rework is guarded at `version < 7` so users who
stopped at main's interim v6 identity bump still receive it, while the #507
identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
unified views) while preserving main's plugin hooks (onSearchResults /
onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
is authoritative (matches new behavior) with main's shared-identity (#569) group
infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
introduced by the auto-merge.
Closes#560.
Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.
The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.
Notes on the approach:
- The badge link is an *additional* icon link that we append and mark as
ours; we never remove or mutate a link we did not create. Next's metadata
icons are rendered by React, which keeps a fiber pointing at that DOM node,
so removing it would leave React holding a detached node and throw
"Cannot read properties of null (reading 'removeChild')" on the next
commit that deletes the fiber. Appending instead means the last-declared
icon wins, and non-SVG fallback links survive with their type/sizes intact.
(The usual recipe for this feature — assign canvas.toDataURL() to the
existing link's href — does both of the things that break here.)
- Every change of state is an *insertion* of a fresh link of ours, never a
mutation or a removal, because that is the only signal a browser reliably
re-reads the favicon on. Firefox ignores an in-place href change, and it
equally ignores a removal — so clearing the badge by deleting our link left
a stale count painted on the tab until a hard reload. Clearing it instead
inserts a new link of ours carrying the original base href.
- Holding last place has to be defended: on a client-side navigation React
re-hoists its metadata icon link into <head>, landing after ours, and the
base icon silently wins again. A MutationObserver on <head> moves our own
link back to the end whenever a foreign icon link appears — moving only our
node, never anyone else's. It no-ops once ours is last again, so a move
cannot feed itself.
- The badge is a full-width band across the foot of the icon, drawn to the
metrics measured from Gmail's own 16px favicon: band height 0.625 of the
icon, digit cap height 0.44, flush to the edges, corners rounded by about a
pixel. Full width is what keeps a three-glyph label legible — rounded ends
waste exactly the horizontal space it needs. Neutral white with black digits
rather than the conventional red: faviconUrl is admin-overridable and
Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.
- The base SVG may be admin-uploaded, and the branding route deliberately
serves it under a sandboxing CSP because SVG can carry script. Re-emitting
it as a same-origin data: URL would un-fence that, so script, foreignObject
and every on* handler are stripped before serialising.
- Mounted in the root layout, not on the mail route: the badge belongs to the
tab, so mounting it on the page would clear it on every hop to settings,
calendar or contacts.
Sending mail through Bulwark could leave the delivered message stuck in Drafts
(keeping the $draft keyword) and never file a copy into Sent, with no error
shown, for accounts whose Drafts/Sent mailbox JMAP id is a purely-numeric
string (e.g. "0").
The post-send Drafts->Sent move is expressed as onSuccessUpdateEmail on
EmailSubmission/set using `mailboxIds/<id>` JSON-Pointer patches. Stalwart
up to 0.16.4 (observed on 0.15.5) rejects an Email/set PatchObject whose
pointer token is all digits -- e.g. `mailboxIds/0` -- with invalidProperties
"Invalid patch value", treating the token as a JSON-Pointer array index even
though mailboxIds is a JSON object (cf. RFC 6901 section 4; RFC 8620 section
1.2 warns servers against such interop-hostile ids). Because the move runs
only AFTER the EmailSubmission already succeeded, the message is delivered but
the filing update is silently rejected: the send code inspects only
`notCreated`, not the onSuccessUpdateEmail `notUpdated` result, so nothing
surfaces to the user.
Stalwart fixed the pointer parsing server-side in 0.16.5
(stalwartlabs/stalwart@175f34ea, jmap-tools 0.1.4 -> 0.1.5; a sibling symptom
was stalwartlabs/stalwart#2985). The client-side change is still worthwhile:
earlier Stalwart deployments remain in the wild, and a full-property
replacement both states the actual intent of the move and emits no per-id
pointer token that another server could mishandle.
Replace the per-id pointer patches at every post-send / undo-send move site
(send, scheduled send, raw-import send, reschedule, and restoreEmailToDraft)
with a full `mailboxIds` property replacement via a new mailboxIdsReplacement()
helper. This states the actual intent -- after the move the message should
belong to exactly the target mailbox -- and is immune to the pointer-token
bug. Every one of these sites moves a message that Bulwark itself placed
solely in Drafts (or, for undo, in Sent), so the replacement is
behaviour-equivalent. Note it is a replacement: a membership added to the
message by another client between creation and send is not preserved.
restoreEmailToDraft now always lands the message in Drafts only (previously,
when no Sent mailbox id was passed, it left the Sent copy in place); the demo
client is aligned with the same contract.
Add regression tests for the full-replacement shape, a numeric ("0") Drafts id,
and restoreEmailToDraft.
Follow-up (not included here): the send paths still ignore the implicit
Email/set `notUpdated` result of onSuccessUpdateEmail, so any other post-send
filing failure would remain silent.
Bulwark currently sends Email/set create without a messageId property,
leaving Message-ID generation to the JMAP server. Servers typically fall
back to their OS hostname for this (Stalwart, via mail-builder's
`gethostname()`), which produces IDs like:
```
<175234...abc@ip-10-0-12-97.ec2.internal>
```
This is bad for every deployment, in three escalating ways:
1. Information disclosure: the Message-ID travels in every outgoing
message and permanently into archives, quoting, and In-Reply-To /
References of replies. An internal hostname (container name, private
DNS, k8s pod name) is infrastructure detail no recipient should see.
2. Deliverability: spam filters score Message-IDs whose domain part is
not a plausible FQDN or is unrelated to the sender (SpamAssassin
MSGID_FROM_MTA_HEADER and friends). Internal names like
*.ec2.internal or bare container ids read as botnet-ish.
3. Correctness of intent: RFC 5322 §3.6.4 recommends the originator
generate the Message-ID, using a domain it controls, so the id is
meaningful and plausibly unique under that domain's authority. The
sender's own domain is exactly that; the mail server's transient
runtime hostname is exactly not.
Generate the id in `sendEmail()` as `<epoch36>.<uuid>@<sender-domain>`,
taken from the From address (falling back to the login username). The
timestamp prefix keeps ids roughly sortable and adds entropy across
UUID reuse concerns; crypto.randomUUID() is available in every runtime
Bulwark supports (browsers and Node 19+). Per RFC 8621 §4.1.2.3 the
JMAP messageId property carries bare msg-ids (no angle brackets), so
none are added.
Clients that never set messageId also can't thread their own sent mail
reliably until the server echoes the message back; setting it at create
time makes the id known and stable from the start.
No behavior change for servers that honored client-provided ids all
along; servers that previously synthesized an id now simply don't need
to.
Blobs are scoped per JMAP account, but the attachment download/preview path
always used the active account's client and accountId. Opening a message from a
different account in the unified / All-Mail view and downloading (or previewing)
an attachment therefore 404'd against the active account.
Route the blob fetch to the message's source instead:
- resolveBlobSource() picks the owning login's client
(getClientForAccount(sourceClientAccountId)) and the owner accountId
(sourceAccountId) for delegated/shared blobs, in the unified view;
- handleDownloadAttachment + the attachment-preview handlers use it;
- downloadBlob / fetchBlobAsObjectUrl / fetchBlobArrayBuffer gain an accountId
param (getBlobDownloadUrl/fetchBlob already had one).
Adds 10-attachments: an attachment on another account's All-Mail message
downloads with the correct bytes (verified to fail without the routing).
Stalwart's JMAP EventSource only pushes StateChange for the session's *primary*
account — a background change in a shared/delegated (secondary) account is never
pushed — so the shared folder's counters, and the unified/All-Mail badge that
aggregates them, went stale until a full reload. (Other *login* accounts already
update live because each login has its own SSE.)
Extend the client's state poll to every account in the session:
- buildStatePollingRequest emits a Mailbox/Email `get` per account, with the
accountId encoded in the callId (`mbx:<id>` / `eml:<id>`);
- checkForStateChanges / fetchCurrentStates key polling state per account and
report a per-account `changed` map, which handleStateChange already treats as
"some mailbox changed" and refetches the full (own + delegated) mailbox list
from — the badge is a live projection over that list;
- a slow (20s) secondary-account poll runs alongside SSE (paused while hidden)
so shared counters stay current between focus events.
The unified-section sidebar badges (per-role unified folders + cross-view
All mail/Unread/Starred) failed to count down when messages were deleted/moved/
read from the unified views, and failed to count up for incoming mail - while
the underlying per-account folder counters updated correctly. Root cause: the
badges were a separate counter representation, recomputed only by a fresh server
fetch, completely decoupled from the optimistically-patched mailbox lists.
Three coordinated changes:
V1 - single source of truth: derive `unifiedCounts`/`crossUnreadCount` as a pure
live projection of `mailboxes` + `accountMailboxes` (the lists every mutation
already patches and push refreshes), over the last-known unified scope. A store
subscription re-projects whenever those lists change, so optimistic deletes and
push refreshes flow into the badges with no server round trip and no
eventual-consistency snap-back.
V3 - unified id space: searchEmails/advancedSearchEmails now namespace shared/
delegated mailboxIds (`${ownerId}:${id}`) like getEmails already did. The
cross-account views browse via advancedSearchEmails, so shared emails there
previously carried bare owner ids; now every fetch path is consistent and
emailInMailbox hits the `ids[mailbox.id]` fast path (originalId branches kept as
a defensive fallback). resolveSourceFolderName matches `m.id` first (also fixes
a latent missing source-folder name for shared emails).
Background push: bind push notifications for every connected login, not just the
active one - background accounts now drive the unified counters by rebuilding the
unified scope on their state changes. handleStateChange also refreshes the
mailbox list on a Mailbox change for ANY changed account key, so delegated
shared-folder activity arriving via the active client updates counters too.
Tests: unified-badge live projection on delete; client-level namespacing for
searchEmails/advancedSearchEmails (shared vs own account).
Enable text AND advanced search in all Unified Mailbox views (the per-role
mailboxes and the folder-selected All mail / Unread / Starred cross views). The
search input was hard-disabled for every unified view; the store fan-out already
supported text search.
- page.tsx: the search text input and the advanced-filter toggle are enabled for
all unified views (only the scheduled view stays disabled). Clear-search also
restores a cross view (not just per-role).
- Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails
ANDs the advanced filter (text + field conditions from buildJMAPFilter, built
without an inMailbox clause) onto the cross-view membership. Per-role unified
views keep using advancedSearchUnifiedEmails. Both honor the filter on the first
page, on load-more, and on the folder-switch re-run. Fixes: an active Starred
filter not applying after switching into a cross view, and the Unread filter in
the Unread view returning nothing.
- Search persistence on folder switch: an active search is kept and re-run in the
target view, preserving advanced filters. handleMailboxSelect picks
advancedSearch when filters are set (normal, per-role unified, and cross views,
after setting the unified state), text searchEmails when only a query is set,
and browses otherwise. The scheduled view is the only view that resets the
search on enter (unavailable there; setScheduledView clears searchQuery +
searchFilters).
Account scope is intentionally left unrestricted in search (it already fanned out
across all accounts); the per-view folder selection still applies via
crossIncludedMailboxIds.
Rework the sidebar "All accounts" section into a "Unified Mailbox" that, by
default, stays within the active login account and its shared/group folders.
Merging across multiple logged-in accounts becomes an opt-in sub-option instead
of the default, and the standalone per-account "All Mail" virtual folder is
folded into the unified All mail / Unread / Starred entries (its folder selection
now narrows those lists).
Scope:
- lib/unified-mailbox.ts: UnifiedAccountClient.crossIncludedMailboxIds; the cross
views honor the per-account folder selection (union across accounts = the sum
of each account's selection), falling back to inbox+custom when unset.
- stores/email-store.ts: buildUnifiedAccountClients gains scopeToClientAccountId
(the account boundary) and populates crossIncludedMailboxIds from
allMailFolderIds; remove the standalone __all_mail__ fetch/search/load-more
branches.
- page.tsx: scope to the active account unless cross-account is active (per-user
opt-in AND admin gate); the per-role unified mailboxes obey the same scope.
Folding:
- Drop ALL_MAIL_MAILBOX_ID (lib/jmap/types.ts); thread-list source-folder column
now keys on isUnifiedView only; settings folder picker moves under the unified
group and shows once any unified entry is enabled.
Config:
- User: new unifiedCrossAccount (default false); includeGroupInUnified default
flips to true; enableAllMailView retired; the three cross-view toggles now gate
the unified Unread/Starred/All mail entries.
- Admin: new unifiedCrossAccountEnabled gate, default FALSE (cross-account is an
admin opt-in; when off the per-user toggle is hidden and the scope is forced
account-bounded at runtime). allMailViewEnabled deprecated and normalized
forward into crossAllViewEnabled on policy load; cross-view gate labels reworded
to "Unified Mailbox: ...".
Header: the sidebar section shows "All accounts" when cross-account is active
(opt-in AND admin gate AND >1 connected account), else "Unified Mailbox".
Migration:
- Settings persist v5 -> v6 (exported migrateSettings) - cross-active users keep
cross-account; All-Mail-only users get the account-bounded unified All mail
entry with folder ids preserved; includeGroupInUnified enabled for every
migrated config; fresh installs are account-bounded.
- Admin policy: one-shot, marker-guarded migratePolicyUnifiedMailbox (run before
configManager.load) enables unifiedCrossAccountEnabled when a cross view was
active, so existing cross-account installs keep the behaviour despite the
default-false gate. Skipped on read-only config dirs.
Locales: sidebar all_accounts (original label) + unified_mailbox (translated, per
locale) keys; dead standalone all_mail strings removed across all 20 locales.
Docs: FEATURES.md updated to the account-bounded model, the cross-account gate,
and the folder-narrowed aggregate entries.
Verification: tsc clean, eslint clean, full vitest suite green (incl. translations
completeness, cross-view/migration coverage, and the admin policy migration test).
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.
Plain-text bodies render into the main document rather than the sandboxed
iframe, so an anchor without target="_blank" navigates the whole app away
instead of opening a new tab.
plainTextToSafeHtml emits target and rel correctly, but
sanitizePlainTextRenderedHtml stripped both back off: DOMPurify URI-tests
every attribute value not on its URI-safe list, and "_blank" does not match
PLAIN_TEXT_RENDERED_CONFIG's ALLOWED_URI_REGEXP. EMAIL_SANITIZE_CONFIG avoids
this only because its regex carries a catch-all alternation for non-URI values.
Mark target and rel as URI-safe so they survive the URI test, rather than
loosening href validation.