Commit Graph
1276 Commits
Author SHA1 Message Date
HardAndHeavyandLinus Rath 0f3459c2e5 feat: add NEXT_PUBLIC_LOCALE_PREFIX build argument to Dockerfile 2026-07-21 20:59:33 +02:00
Stefan HildebrandtandLinus Rath d5017a211f feat(email): open external links in a new tab (safely)
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.
2026-07-21 20:59:20 +02:00
Shuki VakninandLinus Rath f51ec50443 feat(settings): add "Refresh cached data" recovery action
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.
2026-07-21 20:58:55 +02:00
Shuki VakninandLinus Rath f2703bcc27 fix: guard false-positive on basic-auth accounts (identity != login)
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().
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath cda4dcbf01 fix(auth): guard account switch against slot→token desync
When switching accounts, the target client connects with the token at
the account's stored cookieSlot. If that slot→token mapping is ever
wrong — e.g. corrupted client state persisted by an older build, or any
future slot desync — the connection succeeds as a *different* account
and the UI silently shows the wrong mailbox.

Add a post-connect identity guard: derive the connected session's
accountId (primary-identity email for OAuth, else the JMAP session
username) and compare it to the account being switched to. On mismatch,
drop the poisoned slot cookies and force a clean re-auth instead of
binding the wrong session.

This is belt-and-suspenders on top of 8b164c5, which fixed the slot
allocation that caused such a desync: that prevents new corruption,
this catches any residual/leftover mapping at switch time.

Adds a unit test for the canonicalisation (email vs JMAP username), the
make-or-break detail that avoids OAuth false-positives.
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath f15edd336b feat(send): 'Send now' on the send-delay toast
The post-send undo toast ('scheduled to send' + Cancel send) now also offers a
'Send now' action that reschedules the delayed submission for immediate release,
so you can skip the undo window without waiting it out. Adds an optional
secondaryAction to the toast component and carries identityId on the pending
undo-send state so the reschedule can target the right identity.
2026-07-21 20:58:27 +02:00
HardAndHeavyandLinus Rath a3f9055541 ci: build and publish images with NEXT_PUBLIC_LOCALE_PREFIX=always 2026-07-21 20:58:02 +02:00
dealerwebandLinus Rath a779e101e6 Fix: label the close-dialog draft button with the generic Save
"Save Draft" made the third button of the save-or-discard dialog wrap
onto two lines in several languages (German "Entwurf speichern", French
"Enregistrer le brouillon", ...) while its siblings stay one line. The
dialog title already says the draft is what's being saved, so the
button now uses the existing generic common.save key - one short word
in every locale, no new translations needed.

email_composer.save_draft had exactly this one consumer; the dead key
is removed from all 22 locales.
2026-07-21 20:57:29 +02:00
Shuki VakninandLinus Rath c38bcc4a95 feat(email-list): add bulk Not-Spam action to selection toolbar in junk 2026-07-21 20:57:08 +02:00
Shuki VakninandLinus Rath 5716d91115 feat(folders): drag-and-drop reorder for all folders 2026-07-21 20:56:38 +02:00
Stefan HildebrandtandLinus Rath 2f791318df test(integration): select the group From address in the #569 spec
Extend 04-shared-identity's UI test to not just assert team@example.org is
offered but to actually select it as the sender and confirm it becomes the
active From identity, then hold on the composer so the selected group address is
visible in the recorded video. Adds selectComposerFrom / selectedComposerFrom
helpers.
2026-07-21 20:56:27 +02:00
Stefan HildebrandtandLinus Rath 60b9ae66ea test(integration): add IT_VIDEO option to record test videos
Make Playwright's video capture configurable via IT_VIDEO (on | off |
retain-on-failure [default] | on-first-retry) so a whole run — passing tests
included — can be recorded, e.g. for a demo or to inspect a flow. Forward the
env through the Playwright container in run-tests.sh and document it in the
README's environment-knobs table.
2026-07-21 20:56:27 +02:00
Stefan HildebrandtandLinus Rath abd493fb4c feat: strip external url()/@import from <style> blocks in sanitizer (#457)
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.
2026-07-21 20:56:08 +02:00
Paulhenry SauxandLinus Rath b4739c111f feat: add new plugin API to submit without moving to box mail and import to box 2026-07-21 20:55:52 +02:00
KazNIISA ITandLinus Rath 88b07a1713 fix(email-store): route shared-folder batch actions to the owner account
Batch actions (delete, move, archive, mark-as-read) performed while
viewing a shared/group mailbox directly from the "Shared" sidebar section
were dispatched to the user's OWN account instead of the shared owner
account. Emails in that view are undecorated (no sourceAccountId, that is
only set in unified/cross-account views) and are reached through the
active client, so they fell into the '__default__' bucket / non-unified
else-branch, which defaults the JMAP accountId to the active account.
batchArchive independently picked the archive folder from the merged
mailbox list, where the user's own archive is listed first.

Stalwart then applies Email/set to the wrong account: because the ids
belong to the shared account it returns them as `updated: null` with an
unchanged state (a silent no-op, not `notUpdated`), so the UI drops the
rows optimistically and they reappear on the next reload. It only appears
to work when the own and shared folder ids happen to collide.

Add resolveViewAccountId() — the owner accountId of the directly-viewed
shared folder (from the selected namespaced mailbox), undefined for a
normal own-account view, mirroring fetchEmails and the single-email path.
Route the four batch actions to that owner account (via the active
client); batchMoveToMailbox also resolves the destination to its bare
originalId, and batchArchive scopes the archive folder to that account.
Own-account and unified/cross-account views are unchanged.

Adds email-store-shared-folder-actions.test.ts covering all four batch
actions in the non-unified shared view plus an own-account regression.
2026-07-21 20:55:26 +02:00
Paulhenry SauxandLinus Rath 18e9cf6ee6 fix: use fixed tailwind classes for chips icon 2026-07-20 20:23:11 +02:00
Paulhenry SauxandLinus Rath 2cb5c739b4 feat(plugins) : add onRecipientChipsChange hook 2026-07-20 20:23:11 +02:00
Marc SportielloandLinus Rath 0a30b2fb3a feat(templates): add support for HTML templates 2026-07-19 10:10:31 +02:00
Marc SportielloandLinus Rath 0b62afb0f8 fix(email-composer): hide template buttons when templates are disabled 2026-07-19 10:08:09 +02:00
Linus Rath f749ee1f2a fix: preserve POST across redirects in Stalwart JMAP passthrough #627 2026-07-16 22:51:07 +02:00
Linus Rath 4a4950c3e5 Fix: keep signature when inserting a template #621 2026-07-16 20:13:07 +02:00
Linus Rath 739b72d251 Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope
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.
2026-07-16 19:57:51 +02:00
Linus Rath 682e47c970 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-16 18:08:40 +02:00
Linus Rath a6d8671306 Fix: render email body on DOM parse, not iframe load #635 2026-07-16 18:08:14 +02:00
dealerwebandLinus Rath 6f278845f3 Feature: text color picker in the composer toolbar
The rich-text editor already registers the TextStyle and Color
extensions so that colored text pasted or quoted from incoming mail
survives editing - but there was no way to set a color yourself.

Adds a "Text color" toolbar button next to the strikethrough control,
wired to the already-loaded extensions: a 2x8 preset swatch grid plus a
"Remove color" entry, following the table button's dropdown pattern
(wrapper ref, outside-click close, same popover styling) and the table
size picker's swatch grid. The button's baseline icon renders in the
currently active color, so the selection is visible without any extra
indicator element.

No new dependencies and no locale changes; the toolbar titles in this
file are plain English throughout, and Clear Formatting already removes
colors via unsetAllMarks.
2026-07-16 18:01:18 +02:00
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
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.
2026-07-16 18:00:41 +02:00
Stefan HildebrandtandLinus Rath 578339c400 test(integration): composer From offers shared/group identities (#569)
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
2026-07-16 17:59:52 +02:00
Stefan HildebrandtandLinus Rath 8d8bc7cb13 test(integration): dockerized webmail⇆Stalwart Playwright sync suite
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.
2026-07-16 17:59:52 +02:00
KazNIISA ITandLinus Rath 4dc76bbb47 fix(jmap): file post-send message with a full mailboxIds replacement
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.
2026-07-16 17:58:19 +02:00
Shuki VakninandLinus Rath 04444003b2 feat(composer): auto-detect paragraph text direction by default 2026-07-16 17:56:50 +02:00
Joe PolastreandLinus Rath 7da3d4ae80 fix(email): show quote bar in email replies
Without the inline style in the serialized wrapper, the email reply quote bar gets lost (becomes invisible). Pull the style out into a const, and then use it in both the editor (NodeView) and the content wrapper for the email content that is sent.
2026-07-16 17:56:38 +02:00
LoneExileandLinus Rath cb5d754113 fix(oauth): harden OIDC discovery (timeout, retry, serve-stale) 2026-07-16 17:56:19 +02:00
Jesper OrdrupandLinus Rath 511f9e5195 fix: enable thread expansion in focused list 2026-07-14 16:20:52 +02:00
Joe PolastreandLinus Rath 996fa7eea6 fix: Generate Message-ID client-side using the sender's domain
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.
2026-07-14 16:20:31 +02:00
Stefan HildebrandtandLinus Rath 01e5cd69cf fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` store and was never written to the synced
settings, so the choice was lost on clearing site data / switching browsers and
never appeared in exported settings.

Persist it in the synced settings store, keyed **per account**
(`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing
per-account `allMailFolderIds`. Per-account keying is required because JMAP
identity ids are account-scoped and would otherwise collide across accounts /
the unified mailbox.

This supersedes the earlier username-keyed fix that had landed on main: the
username-keyed map, `loadIdentities()` fallback write, and the
`applyPreferredIdentityOrdering` store action (plus its settings-store hook)
are removed so a single account-keyed mechanism remains.

- settings-store: `preferredIdentityIds` (accountId -> identityId) in state,
  defaults, export, import (non-record guard), rehydrate coercion, v6 migration.
- auth-store: `applyPreferredIdentity(accountId?)` reorders the active account's
  identities once synced settings load, and performs the one-time migration of
  the pre-#507 browser-local default into the synced map (keyed by accountId).
  Invoked from every `loadFromServer().finally()` (login / OAuth / SSO / switch
  / restore). `loadIdentities()` now only applies the local fallback ordering.
- identity-manager-modal: the star action writes the choice by `activeAccountId`.
- identity-store: `preferredPrimaryId` kept as a local (sync-off) fallback.
- tests: per-account independence, export/import round-trip, import guard, and
  applyPreferredIdentity reorder / active-account gating / local-default
  migration.
2026-07-13 21:21:34 +02:00
Paulhenry SauxandLinus Rath 20d02214df fix: add new plugin api methods introduced by #586 to protocol plugin sandbox 2026-07-13 21:21:07 +02:00
Paulhenry SauxandLinus Rath 432ba0516b fix: add bodyValues to onRenderEmailBody hook 2026-07-13 21:21:07 +02:00
Stefan HildebrandtandLinus Rath 37152504b4 feat(composer): drag-to-reorder To/Cc/Bcc recipient chips (#593)
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.
2026-07-13 21:19:37 +02:00
honzupandLinus Rath 9072bf8470 fix: keep sidebar tag counts in step with read/unread changes
Marking mail as read left the sidebar's tag unread counts untouched — the
folder counts cleared, but a tag went on showing "47 unread" in bold until
the page was reloaded.

tagCounts is fetched from the server (Email/query per $label keyword) rather
than derived from state, and no read/unread mutation refreshed or adjusted
it. The per-mailbox unreadEmails counters were kept current by a local delta;
tags simply had no equivalent.

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and
apply it wherever the affected emails are known locally: markAsRead,
batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a
count, so re-marking a read email as read cannot drift it, and unread is
clamped at zero. A tag's total is never touched by a read-state change.

markMailboxAsRead is the exception and refetches instead: it is a server-side
bulk operation over an entire mailbox, so it also marks emails that were never
loaded into state.emails, and a local delta would leave the counts high.
2026-07-13 21:19:08 +02:00
Paulhenry SauxandLinus Rath b1f6758f98 fix: add ui:download-file permission to consent screen. 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a679d82cc3 feat: add download file method for files generated by plugin 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a08a9e9ed3 feat(plugins) : add new api api method : webauthn.getOrCreate 2026-07-13 21:17:45 +02:00
Paulhenry SauxandLinus Rath 622adc34de feat: add onEmailsFetched and onSearchResults hook + new JMAP method getSomeEmails 2026-07-13 18:18:57 +02:00
Stefan Hildebrandt 4a3394cf8c test(vitest): exclude integration/ and examples/ Playwright specs
vitest was collecting the dockerized integration Playwright specs (run via
`npm run test:integration`) and the untracked examples/ sample code, which
fail under the vitest runner. Exclude both so `npm test` only runs the unit
suite.
2026-07-11 21:16:19 +02:00
Stefan Hildebrandt a8598db44d i18n(unified-mailbox): sync he/sk locales with unified mailbox keys
The unified-mailbox rework added settings.appearance.unified_mailbox.
cross_account.{label,description} and sidebar.unified_mailbox, and dropped
the legacy settings.appearance.all_mail.{label,description}, in en and every
other locale except Hebrew (he) and Slovak (sk). Bring he/sk in line so the
translations-completeness test passes (no missing/extra keys vs en).
2026-07-11 21:16:11 +02:00
Stefan Hildebrandt d3addf54b4 fix(rebase): reconcile viewer blob routing and dedupe settings after main rebase
Rebasing feat/unified-mailbox onto main hit deep, divergent conflicts in the
mail-view/settings area (main added its own All-Mail + RTL refactor + a
username-keyed #507 identity impl). Post-rebase reconciliation:

- re-apply the cross-account blob routing to the message viewer (inline images,
  drag-out, TNEF, embedded messages, thumbnails, bundle download) on main's
  restructured file — every fetch goes through blobClient/blobAccountId derived
  from the message's source account;
- drop the duplicate `preferredIdentityIds` declaration that both main
  (username-keyed) and the branch (accountId-keyed) introduced — the branch's
  account-scoped map is kept, matching the resolved modal/store logic.

tsc + eslint clean; unified-mailbox unit tests pass (settings-store all-mail /
preferred-identity, unified-mailbox-cross, jmap-client-resilience, migrate-policy).
2026-07-11 21:16:02 +02:00
Stefan Hildebrandt e1e83c4a83 test(integration): make reconcile-dependent counter assertions robust
The single `forceSync` + assert pattern flaked under full-suite load: one
reconcile can miss (or a shared/cross-account counter refresh lands late) with
no retry, so the assertion polls stale DOM until timeout. The flake moved
between reconcile-dependent tests (server-side move, spam source drain, unified
/ shared counters) run to run.

- Add expectFolderCountsSynced(): nudges a reconcile (visibilitychange ->
  checkForStateChanges) before *every* poll, so a missed reconcile is retried
  for the full window. Compares only the provided unread/total fields.
- Use it for the reconcile-dependent counter checks in 02 (move/delete),
  03 (multi-account isolation + unified aggregation), 04 (All Mail), 05 (spam
  source), 06 (shared folders); drop the now-redundant standalone forceSync.
  Pure live-push assertions (incoming/burst, background-login-live) keep the
  plain helpers so they still prove push works.
- The spam->not-spam round-trip could stall the Junk badge reconcile even with
  retries under load; assert the optimistic list removal + authoritative server
  round-trip (out of Junk, back in Inbox) instead of the badge.

Validated with two back-to-back full-suite runs: 35 passed each.
2026-07-11 21:15:57 +02:00
Stefan Hildebrandt cdb31634a6 fix(attachments): route all viewer blob fetches for cross-account messages
Extend the cross-account blob routing beyond download/preview to every blob
fetch in the message viewer, so a message opened from a different account in the
unified / All-Mail view renders and exports correctly instead of 404ing against
the active account:

- inline cid: images, drag-to-desktop, attachment thumbnails, the "download all"
  zip bundle, and the S/MIME / TNEF / embedded-rfc822 blob reads now use a
  resolved blobClient (getClientForAccount(sourceClientAccountId)) and the owner
  blobAccountId (sourceAccountId), computed once from the open message's source;
- fetchBlobAsObjectUrl / fetchBlobArrayBuffer / fetchBlob calls pass the
  accountId (the client methods gained the param in the previous commit);
- non-cross-account behaviour is unchanged (blobClient === active client).

Extends 10-attachments with an inline-image case (verified to fall back to the
placeholder without the routing). The SMTP helper can now send multipart/related
inline images.
2026-07-11 21:15:49 +02:00
Stefan Hildebrandt 26c3d07d56 fix(attachments): download/view attachments on cross-account All-Mail messages
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).
2026-07-11 21:15:43 +02:00
Stefan Hildebrandt c3acb537d0 test(integration): live unified/All-Mail counter coverage
Add 09-live-counters: a background-login account updates the unified counter
live (no reconcile), and a shared-folder change reconciles the All-Mail counter
on focus. Document the shared-account counter behaviour in the README.
2026-07-11 21:15:35 +02:00