Compare commits

...
98 Commits
Author SHA1 Message Date
Linus Rath 7511d8ea78 chore: update version to 1.7.8 2026-07-22 19:43:09 +02:00
Linus Rath 959d4bd6ce fix: assign uid to contact cards on creation #644 2026-07-22 19:18:59 +02:00
Linus Rath b7c8cd999e feat: collapse quoted reply text behind a "..." toggle #480 2026-07-22 19:17:23 +02:00
Linus Rath 813185e58d test: fix broken suites 2026-07-22 18:56:09 +02:00
Linus Rath 3f22a3323a i18n: add missing translation keys across 22 locales 2026-07-22 18:45:22 +02:00
Linus Rath 0e4efb5a2a fix: honor "Show time in month view" on mobile instead of forcing dots #666 2026-07-22 18:21:02 +02:00
Linus Rath b354319b82 feat: support HTML body in vacation responder 2026-07-22 17:56:05 +02:00
Linus Rath 5a8c69dac2 fix: insert mail template at caret in replies instead of prepending #539 2026-07-22 17:53:40 +02:00
Linus Rath 24056e4698 fix: eliminate full-screen flash when switching accounts 2026-07-22 17:48:39 +02:00
Linus Rath 5818e60401 fix: prevent loading flash when switching to a cached account 2026-07-22 17:42:48 +02:00
Linus Rath 7beaf991e8 fix: recognize canonicalized login usernames in account-switch guard 2026-07-22 17:37:33 +02:00
Linus Rath 5105e000f5 feat: add message-list category tabs 2026-07-22 17:22:20 +02:00
Linus RathandGitHub 66bc10fa0f Merge pull request #668 from paulhenry46/ui.rerenderFetchedEmails-hook
feat: add new plugin ui.rerenderFetchedEmails method
2026-07-22 16:21:18 +02:00
Paulhenry Saux 07c473e057 feat: add new plugin ui.rerenderFetchedEmails method 2026-07-22 13:39:21 +02:00
Linus RathandGitHub 0e47c3b039 Merge pull request #520 from maartendra/feat/login-show-totp-version
feat(login): add LOGIN_SHOW_TOTP and LOGIN_SHOW_VERSION config flags
2026-07-22 08:27:44 +02:00
Maarten DraijerandClaude Fable 5 e1a973663f Merge upstream/main to resolve conflicts
Both sides added adjacent LOGIN_* config entries (upstream:
loginShowHeading/loginShowSubtitle/logo sizing; this branch:
loginShowTotp/loginShowVersion) — resolution keeps both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
2026-07-22 02:54:55 +00:00
Linus Rath de55fb6b73 fix: honor part-type fallback when quoting replies #649 2026-07-21 23:27:40 +02:00
Linus Rath a909593dda fix: detect typing inside the QuotedHtml shadow island via composedPath #654 2026-07-21 23:26:51 +02:00
Linus Rath 4ad9267a2d chore: bump dompurify to 3.4.12 and next-intl to 4.13.3, npm audit fix for dev deps 2026-07-21 23:23:14 +02:00
xhzeemandLinus Rath 23a017d4b7 fix(rtl): set dir=ltr on identity <option> elements
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.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath c7250dc921 fix(rtl): isolate Latin address text ("Name <email>") from RTL bidi reordering
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>.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath 80f76abc38 fix(rtl): flip JS-positioned popovers (storage, logout, account switcher, calendar picker)
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
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath adb8686293 fix(rtl): anchor floating menus with logical start/end instead of left/right
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.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath d531ad1930 fix(i18n): register ar messages in the client IntlProvider
components/providers/intl-provider.tsx keeps its own static ALL_MESSAGES
map separate from i18n/request.ts's server-side loader. It was missed
when ar was added, so switching to Arabic flipped to RTL (direction.ts
knew about ar) but rendered English text (messages lookup fell through
to the en fallback).
2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath 953355d2a5 fix(i18n): use UAE flag instead of Saudi flag for ar locale 2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath 8155f98a28 fix(i18n): use Saudi flag instead of pan-Arab colours for ar locale 2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath fda898fc96 feat(i18n): add full Arabic (ar) translation
Adds a complete Arabic locale (2759 keys, full parity with en) and wires
it into routing, RTL direction detection, message loading, the language
switcher, and flag icons alongside the existing he/fa RTL locales.
2026-07-21 23:07:40 +02:00
Shuki VakninandLinus Rath 3dceecb4c5 fix(i18n): Hebrew Drafts folder label was the board game (דמקה) 2026-07-21 23:06:58 +02:00
Shuki VakninandLinus Rath 53461d1142 feat(email-viewer): message spacing setting (auto/always/edge-to-edge) 2026-07-21 23:06:43 +02:00
Linus Rath 4d9d992f3f chore: update package-lock.json metadata 2026-07-21 23:05:21 +02:00
Linus Rath 162e420a1f fix: stop HELO spf=none from downgrading a MAIL FROM spf=pass #650 2026-07-21 23:03:34 +02:00
Kristofer PettijohnandLinus Rath e8f01871c2 fix(calendar): classify self-organized imported events as editable via organizerCalendarAddress fallback 2026-07-21 21:00:53 +02:00
Shuki VakninandLinus Rath 6dfcb07b9a feat(accounts): remove a specific account from the switcher
The switcher only offered 'sign out of active' and 'sign out of all' — no
way to drop a single non-active account (e.g. one stuck in an error state you
can't switch into to sign out). Add a hover × on non-active, non-default rows
and a removeAccount(id) auth action that tears down the client, drops it from
the registry, and clears its per-slot session/token cookies.

Stacks on the switcher redesign in #517.
2026-07-21 20:59:52 +02:00
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
Stefan Hildebrandt 060c5d00d1 test(integration): draft handling and shared-folder moves
Add draft and shared-folder-move coverage (suite now 31 tests). Findings are
asserted server-side or pinned with test.fail where the UI is incomplete.

Drafts (07):
- multiple recipients (committed and typed-but-uncommitted) persist, and the
  draft reopens via the continue-draft button;
- a server-created draft (with $draft) shows the continue-draft button;
- a changed sender identity is saved to the draft on the server;
- KNOWN BUG (test.fail): reopening a draft resets the From selector to the
  default identity instead of the one the draft was saved with.

Shared-folder moves (08):
- shared -> shared (same owner) moves work in both directions (server-verified);
- KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared
  folder) don't relocate the message — the Move-to submenu offers the target
  but clicking it is a no-op.

Hooks added: composer From select + save-status, viewer edit-draft button,
context-menu "Move to" submenu + per-target testids (testId on
ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer
drive + move-via-submenu. README documents the findings.
2026-07-11 21:15:27 +02:00
Stefan Hildebrandt e05fbb2fe9 test(integration): All Mail, message actions, and shared-folder sync
Extend the integration suite (now 22 tests) to cover:

- All Mail view (04): single-account merge of Inbox + custom folders with
  Junk excluded, and cross-account aggregation across every logged-in account.
- Message actions from the list context menu (05): mark read/unread, delete
  (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both
  the UI counters/row state and the server mailbox the message ends up in.
- Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared
  alice→carol; the shared folder renders with its counter, and read/unread/
  delete/spam performed there land correctly (server-verified).

Hooks added: data-testid on context-menu delete/spam/read-unread items
(via a testId prop on ContextMenuItem), data-shared on folder rows, and
testId/data-expanded on sidebar section headers to drive the Shared section.

Observations surfaced by the suite (asserted server-side / with a reconcile):
- mark-as-spam doesn't optimistically decrement the *source* counter the way
  delete does; a visibility reconcile settles it.
- shared *destination* counters (shared Trash/Junk) don't refresh live —
  forceSync reconciles the active account only, not shared accounts.
2026-07-11 21:15:20 +02:00
Stefan Hildebrandt b8809c2e69 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-11 21:15:13 +02:00
Stefan Hildebrandt bc11450f3f feat(jmap): keep unified/All-Mail counters current for shared accounts
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.
2026-07-11 21:15:05 +02:00
Stefan Hildebrandt 034f7a4b9b fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` Zustand store and was never written to
the server-side synced settings. As a result the choice was lost when
clearing site data or switching browsers, and never appeared in the
exported settings JSON.

Persist the default identity 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.

- settings-store: add `preferredIdentityIds` to state, defaults, export
  (so it shows in exported JSON), import (with a non-record guard),
  rehydrate coercion, and a v6->v7 migration.
- auth-store: add `applyPreferredIdentity()`, invoked in every
  `loadFromServer().finally()` (login / OAuth / SSO / switch / restore) so
  the synced default reorders the active account's identities once server
  settings load (the composer defaults From to identities[0]).
- identity-manager-modal: the star action also writes the choice to the
  synced per-account map, triggering server sync + export inclusion.
- identity-store: keep `preferredPrimaryId` in local persist as a sync-off
  fallback; synced settings are the durable cross-device source of truth.
- tests: per-account independence, export/import round-trip, non-record
  import guard, and v6->v7 migration.
2026-07-11 21:14:56 +02:00
Stefan Hildebrandt 2e42693228 fix(unified-mailbox): single-source unified counters, unified id space, background push
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).
2026-07-11 21:14:50 +02:00
Stefan Hildebrandt fdad60cf03 fix(unified-mailbox): update shared/group folder counters on delete/move/read
In the unified All mail / Unread / Starred views, deleting (or moving /
marking read) a message from a shared/group folder left that folder's
sidebar counter at its old value.

Root cause: lib/unified-mailbox.ts decorates shared emails WITHOUT
namespacing their `mailboxIds`, so they carry the owner's bare JMAP ids,
while the shared mailbox is stored with a namespaced id (`${ownerId}:${origId}`)
and `isShared: true`. `emailInMailbox` only matched the namespaced `mailbox.id`
and disabled the `originalId` fallback for shared mailboxes, so no shared
email ever matched its folder and the counter math skipped it.

Match shared mailboxes via `originalId` too, scoped to the owning account
(`sourceAccountId === mailbox.accountId`) so a bare owner id can't collide
with another account's folder. This is the single matching helper used by all
counter paths (delete/move/markRead/spam), so they're all fixed at once.

Adds a regression test covering deletion of a shared-folder email in the
unified view.
2026-07-11 21:14:44 +02:00
Stefan Hildebrandt dc72122ed8 feat(unified-mailbox): enable search in the unified views
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.
2026-07-11 21:14:39 +02:00
Stefan Hildebrandt 7c221c4a4a feat(unified-mailbox): account-bounded Unified Mailbox with opt-in cross-account
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).
2026-07-11 21:14:34 +02:00
Stefan HildebrandtandLinus Rath c1acf58c5f test(compose): add findComposeIdentityId to reply-identity mock
The recipient chip-drag and paste tests render <EmailComposer>, which since
b716f95a (feat(compose): preselect identity of the active mailbox) calls
findComposeIdentityId() from @/lib/reply-identity in compose mode. Both tests
mock that module but only returned resolveReplyFrom, so vitest threw
"No 'findComposeIdentityId' export is defined on the mock" on mount,
failing all 18 tests. Add the missing export (returns null; the composer
guards with if (composeIdentityId)).
2026-07-11 19:36:27 +02:00
honzupandLinus Rath 42798c2b7c fix: open signature links in a new tab instead of navigating the app away
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.
2026-07-11 10:45:01 +02:00
honzupandLinus Rath 75d17d4e37 fix: keep target/rel on links in plain-text message bodies
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.
2026-07-11 10:45:01 +02:00
dealerwebandLinus Rath 38a396d150 Fix: end refresh loops on sign-out and back off failed retries
Fixes #588.

Sign-out already cleared the token-refresh timers and stopped the
keep-alive interval - the reported endless loops came from async
callbacks that were in flight at that moment. The token refresh's
failure handler re-armed its retry after logout, and a failing
keep-alive ping called reconnect() -> connect(), which restarts the
keep-alive and thereby revived the interval disconnect() had just
stopped. Only closing the tab ended it.

Two mechanisms fix that class: transiently failed token refreshes only
re-arm while the account is still signed in (checked when the failure
lands, not when the request started), and the client carries an
intentionallyDisconnected flag set by disconnect() - the ping callback,
reconnect(), the SSE reconnect scheduling and the polling fallback all
stop at it, so nothing revives after an intentional sign-out.

Failed retries also back off instead of hammering a down server every
30 seconds: the token refresh climbs 30s/1m/2m/5m (capped, reset on
success), and the keep-alive skips upcoming ticks on consecutive
failures for the same effective ladder. Recovery after an outage is
unchanged in substance - the session survives and reconnects within at
most ~5 minutes, immediately on user activity.
2026-07-10 14:13:04 +02:00
dealerwebandLinus Rath c6bd5f645a Feature: contact groups as single expandable recipient chips
Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.

The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.

Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.

RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
2026-07-09 17:46:06 +02:00
Maarten DraijerandClaude Opus 4.8 65cb6be8a9 feat(login): add LOGIN_SHOW_TOTP and LOGIN_SHOW_VERSION config flags
Two opt-out branding/login flags, both default true (no behaviour change
for existing deployments):

- LOGIN_SHOW_TOTP=false hides the manual "I have a 2FA code" toggle on the
  login form. Deployments that delegate auth to an external directory
  (LDAP/OIDC) where 2FA lives in the IdP have no server-side TOTP, so the
  toggle only ever leads to a failed login. Server-required TOTP
  (totp_required, which auto-shows the field) is unaffected.
- LOGIN_SHOW_VERSION=false hides the build version in the login footer, so
  the exact version isn't disclosed to unauthenticated visitors.

Wired through the existing config registry (CONFIG_ENV_MAP) → /api/config →
useConfig, matching the surrounding LOGIN_* options.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:04:10 +00:00
192 changed files with 16503 additions and 1511 deletions
+5
View File
@@ -9,3 +9,8 @@ scripts/
TODO.md
*.md
!README.md
# Sibling projects / test harness - not part of the webmail image
examples/
integration/
e2e/
**/node_modules
@@ -118,3 +118,114 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
build-always:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
NEXT_PUBLIC_LOCALE_PREFIX=always
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=always-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests-always
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests-always/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests-always/*
if-no-files-found: error
retention-days: 1
merge-always:
runs-on: ubuntu-latest
needs: build-always
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests-always
pattern: digests-always-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
flavor: |
suffix=-always,onlatest=true
tags: |
type=raw,value=latest
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern={{major}}
- name: Create manifest list and push
working-directory: /tmp/digests-always
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## 1.7.8 (2026-07-22)
### Features
- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509)
- **Unified Mailbox**: Search in the unified views
- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts
- **Mail**: Message-list category tabs
- **Mail**: Drag-and-drop reorder for all folders
- **Mail**: Collapse quoted reply text behind a "..." toggle (#480)
- **Mail**: Bulk Not-Spam action in the junk selection toolbar
- **Mail**: Unread count badge on the favicon
- **Mail**: Message spacing setting (auto/always/edge-to-edge)
- **Mail**: Open external links in a new tab (safely)
- **Mail**: Strip external `url()`/`@import` from `<style>` blocks in the sanitizer (#457)
- **Composer**: Text color picker in the composer toolbar
- **Composer**: Contact groups as single expandable recipient chips
- **Composer**: Drag-to-reorder To/Cc/Bcc recipient chips (#593)
- **Composer**: Auto-detect paragraph text direction by default
- **Templates**: HTML template support
- **Vacation**: HTML body support in the vacation responder
- **Send**: 'Send now' action on the send-delay toast
- **Accounts**: Remove a specific account from the switcher
- **Settings**: "Refresh cached data" recovery action
- **i18n**: Full Arabic (ar) translation with RTL support
- **Login**: `LOGIN_SHOW_TOTP` and `LOGIN_SHOW_VERSION` config flags (#520)
- **Docker**: `NEXT_PUBLIC_LOCALE_PREFIX` build argument
- **Plugins**: `ui.rerenderFetchedEmails` method (#668)
- **Plugins**: `onEmailsFetched` and `onSearchResults` hooks and `getSomeEmails` JMAP method
- **Plugins**: `onRecipientChipsChange` hook
- **Plugins**: `webauthn.getOrCreate` API method
- **Plugins**: Download files generated by a plugin (with `ui:download-file` consent permission)
- **Plugins**: Submit mail without moving to a mailbox and import-to-mailbox APIs
### Fixes
- **Mail**: Render the email body on DOM parse instead of iframe load (#635)
- **Mail**: Keep sidebar tag counts in step with read/unread changes
- **Mail**: Enable thread expansion in the focused list
- **Mail**: Show the quote bar in email replies
- **Mail**: Honor part-type fallback when quoting replies (#649)
- **Mail**: Detect typing inside the quoted-HTML shadow island (#654)
- **Mail**: Keep `target`/`rel` on links in plain-text message bodies and open signature links in a new tab
- **Accounts**: Eliminate the full-screen flash when switching accounts (including cached accounts)
- **Accounts**: Recognize canonicalized login usernames in the account-switch guard
- **Auth**: Guard account switch against slot→token desync and basic-auth identity mismatches
- **Auth**: End refresh loops on sign-out and back off failed retries
- **OAuth**: Harden OIDC discovery (timeout, retry, serve-stale)
- **JMAP**: Preserve POST across redirects in the Stalwart JMAP passthrough (#627)
- **JMAP**: File the post-send message with a full `mailboxIds` replacement
- **JMAP**: Generate the Message-ID client-side using the sender's domain
- **Identity**: Sync the default sender identity per account (#507)
- **Attachments**: Download/view attachments on cross-account All-Mail messages
- **Shared folders**: Route batch actions to the owner account
- **Templates**: Insert a mail template at the caret in replies instead of prepending (#539)
- **Templates**: Keep the signature when inserting a template (#621)
- **Templates**: Hide template buttons when templates are disabled
- **Calendar**: Honor "Show time in month view" on mobile instead of forcing dots (#666)
- **Calendar**: Classify self-organized imported events as editable
- **Contacts**: Assign a UID to contact cards on creation (#644)
- **Spam**: Stop HELO `spf=none` from downgrading a MAIL FROM `spf=pass` (#650)
- **Drafts**: Label the close-dialog draft button with the generic Save
- **RTL**: Flip JS-positioned popovers and anchor floating menus with logical start/end
- **RTL**: Isolate Latin address text from RTL bidi reordering and force LTR identity options
- **i18n**: Register Arabic messages in the client IntlProvider
- **i18n**: Fix the Hebrew Drafts folder label
- **i18n**: Add missing translation keys across 22 locales
- **Deps**: Bump `dompurify` to 3.4.12 and `next-intl` to 4.13.3
## 1.7.7 (2026-07-09)
### Features
+4
View File
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Optional: avoid next-intl rewrite loops when served under a subpath.
# Baked in at build time.
ARG NEXT_PUBLIC_LOCALE_PREFIX=
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
# Accept-Language header does not match any supported locale. Baked in at
# build time because next-intl wires it into client-side routing too.
+5 -5
View File
@@ -4,9 +4,9 @@
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
- Cross-account "All accounts" views All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
- Unified Mailbox combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
- Search inside the Unified Mailbox text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
@@ -117,7 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
@@ -125,7 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Admin policy gates for the aggregate mail views enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
- Admin policy gates for the Unified Mailbox enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
+1 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.8-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.7.7
1.7.8
+18 -10
View File
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -400,8 +400,8 @@ export default function ContactsPage() {
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
void _id; void _uid; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
@@ -513,23 +513,31 @@ export default function ContactsPage() {
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Format each member as "Name <email>" so the composer keeps the display
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
// email, case-insensitively; members without an email are skipped.
// Hand the composer a single group chip (RFC 5322 group syntax survives
// the string hand-off) instead of one entry per member - the chip expands
// into the members when the message is sent. Dedupe by email,
// case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const recipients: string[] = [];
const members: Array<{ name?: string; email: string }> = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
recipients.push(formatRecipient(getContactDisplayName(member), email));
const name = getContactDisplayName(member);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (recipients.length === 0) {
if (members.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
openComposeInApp(recipients, field);
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
const chip: Recipient = {
name: (group && getContactDisplayName(group)) || "Group",
email: "",
group: { members },
};
openComposeInApp([formatRecipientEntry(chip)], field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
+9 -4
View File
@@ -133,7 +133,7 @@ export default function LoginPage() {
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Login logo sizing: when a max height/width is configured, drop the fixed
@@ -822,7 +822,7 @@ export default function LoginPage() {
)}
</div>
)}
<VersionBadge />
{loginShowVersion && <VersionBadge />}
</div>
</div>
</div>
@@ -1154,8 +1154,12 @@ export default function LoginPage() {
</div>
</div>
{/* 2FA toggle / field */}
{/* 2FA toggle / field. The manual toggle can be hidden via
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
server has no per-account TOTP (auth delegated to an
external directory); server-required TOTP still shows. */}
{!showTotpField ? (
loginShowTotp ? (
<button
type="button"
onClick={() => {
@@ -1167,6 +1171,7 @@ export default function LoginPage() {
<Shield className="w-3.5 h-3.5" />
{t("totp_toggle")}
</button>
) : null
) : (
<div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
@@ -1361,7 +1366,7 @@ export default function LoginPage() {
)}
</div>
)}
<VersionBadge />
{loginShowVersion && <VersionBadge />}
</div>
</div>
</div>
+179 -62
View File
@@ -5,13 +5,14 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
import { MessageListTabs } from "@/components/email/message-list-tabs";
import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
@@ -70,7 +71,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
@@ -110,7 +111,7 @@ export default function Home() {
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -356,10 +357,7 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
// they require Unified Mailbox to be enabled, plus the admin gate and the
@@ -377,18 +375,36 @@ export default function Home() {
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
// Cross-account is "active" when the user opted in, the admin allows it, and
// more than one account is connected. Drives the sidebar header label: the
// old "All accounts" when spanning accounts, else "Unified Mailbox".
const crossAccountActive =
unifiedCrossAccount &&
unifiedCrossAccountGate &&
accounts.filter((a) => a.isConnected).length > 1;
// Builds the populated UnifiedAccountClient[] used by the unified-view
// effects and one-shot actions in this page. Reads the includeGroup
// setting at call time so the latest toggle value is always honored.
// effects and one-shot actions in this page. Reads the settings at call time
// so the latest toggle values are always honored. When the cross-account
// sub-option is off, the unified mailbox stays within the active account
// boundary (its own + shared folders); when on, it spans every login account.
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
// Cross-account scope requires both the per-user opt-in and the admin
// capability gate; otherwise stay within the active account boundary.
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
return buildUnifiedAccountClients({
includeGroup: useSettingsStore.getState().includeGroupInUnified,
scopeToClientAccountId: crossAccount
? undefined
: (useAccountStore.getState().activeAccountId ?? undefined),
});
}, []);
@@ -772,8 +788,7 @@ export default function Home() {
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
...getQuoteBodies(selectedEmail),
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
@@ -965,11 +980,16 @@ export default function Home() {
await refreshScheduledMetadata(client);
// Fetch emails for the selected mailbox after scheduled metadata is available.
// Fetch emails for the selected mailbox after scheduled metadata is
// available. If the list is already populated (an account switch
// restored a cached snapshot, or login prefetched it), refresh in the
// background so the visible mail doesn't flash a loading overlay; only
// a genuine empty first load shows the skeleton.
const background = state.emails.length > 0;
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
await fetchEmails(client, selectedMailboxId, { background });
} else {
await fetchEmails(client);
await fetchEmails(client, undefined, { background });
}
fetchTagCounts(client);
@@ -986,29 +1006,52 @@ export default function Home() {
};
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
// Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect
// above so it still runs when data was prefetched at login time.
// Push notifications: set up once per CONNECTED client and tear down when the
// clients go away (logout or account switch). Kept separate from the fetch
// effect above so it still runs when data was prefetched at login time.
//
// We bind every connected login, not just the active one: background accounts
// must drive the unified-section counters too. The active client keeps the
// full handler (current list / scheduled / calendar / filters); background
// logins only re-project the unified counts by rebuilding the unified scope
// (which refreshes every account's cached mailbox list), since their changes
// never touch the active `mailboxes`. (#281 background push)
useEffect(() => {
if (!isAuthenticated || !client) return;
try {
client.onStateChange((change) => handleStateChange(change, client));
const pushEnabled = client.setupPushNotifications();
if (pushEnabled) {
setPushConnected(true);
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('push', '[Push] Push notifications not available on this server');
const clients = useAuthStore.getState().getAllConnectedClients();
const cleanups: Array<() => void> = [];
for (const [accId, c] of clients) {
try {
if (accId === activeAccountId) {
c.onStateChange((change) => handleStateChange(change, c));
} else {
c.onStateChange(() => {
buildPopulatedUnifiedAccounts()
.then((built) => {
refreshCrossCounts(built);
refreshUnifiedCounts(built);
})
.catch(() => { /* per-account fetch failures surface elsewhere */ });
});
}
c.setupPushNotifications();
cleanups.push(() => c.closePushNotifications());
} catch (error) {
debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error);
}
} catch (error) {
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
if (cleanups.length > 0) {
setPushConnected(true);
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
}
return () => {
client.closePushNotifications();
cleanups.forEach((fn) => fn());
};
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
// Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts
@@ -1025,7 +1068,7 @@ export default function Home() {
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
}, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -1349,8 +1392,13 @@ export default function Home() {
const bodyText = draft.bodyValues
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
: '';
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
? draft.bodyValues[draft.htmlBody[0].partId].value
// A plain-text-only draft lists its text/plain part under htmlBody
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
const draftHtmlPart = draft.htmlBody?.[0];
const htmlBody = draftHtmlPart?.partId
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
&& draft.bodyValues?.[draftHtmlPart.partId]
? draft.bodyValues[draftHtmlPart.partId].value
: undefined;
// Try to find the identity that matches the draft's from address to preserve it
@@ -1390,6 +1438,27 @@ export default function Home() {
toast.success(t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs,
secondaryAction: (pending.emailId && pending.identityId)
? {
label: t('email_viewer.send_now'),
onClick: () => {
void (async () => {
try {
await client.rescheduleEmailSubmission(
pending.submissionId,
pending.emailId!,
pending.identityId!,
new Date(Date.now() + 1000).toISOString(),
);
clearPendingUndoSend();
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to send now:', error);
}
})();
},
}
: undefined,
action: {
label: t('email_viewer.undo_send'),
onClick: () => {
@@ -1823,7 +1892,18 @@ export default function Home() {
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
// Keep an active search across the switch and re-run it in this view
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
await searchEmails(client, searchQuery);
}
} else {
await fetchUnifiedEmailsAction(populated, role);
}
refreshUnifiedCounts(populated);
return;
}
@@ -1845,7 +1925,18 @@ export default function Home() {
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchCrossViewAction(populated, view);
// Keep an active search across the switch and re-run it in this view
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
await searchEmails(client, searchQuery);
}
} else {
await fetchCrossViewAction(populated, view);
}
refreshCrossCounts(populated);
return;
}
@@ -2191,13 +2282,16 @@ export default function Home() {
setSearchQuery("");
clearSearchFilters();
if (!client) return;
// In unified view the active "mailbox" is a virtual role, so refresh via
// the unified fan-out instead of fetchEmails.
// In unified view the active "mailbox" is a virtual role or cross view, so
// refresh via the unified fan-out instead of fetchEmails.
if (isUnifiedView) {
const populated = await buildPopulatedUnifiedAccounts();
const role = useEmailStore.getState().unifiedRole;
const cross = useEmailStore.getState().crossView;
if (role) {
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
} else if (cross) {
await fetchCrossViewAction(populated, cross);
}
return;
}
@@ -2229,41 +2323,64 @@ export default function Home() {
};
}, []);
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
// message may belong to another login (route to its client) or to a delegated
// shared account (same client, but the owner's accountId in the download URL).
// Resolve both from the email's source so attachments on cross-account
// messages can be viewed/downloaded instead of 404ing against the active
// account.
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
const blobClient = clientAccountId
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
: client;
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
return { blobClient, accountId, clientAccountId };
}, [isUnifiedView, client]);
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
if (!client) return;
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
if (!blobClient) return;
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type });
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
return;
}
await client.downloadBlob(blobId, name, type);
await blobClient.downloadBlob(blobId, name, type, accountId);
} catch (error) {
console.error("Failed to download attachment:", error);
}
};
const handlePreviewAttachmentDownload = useCallback(async () => {
if (!client || !previewAttachment) return;
const previewBlobClient = useCallback(() => {
const id = previewAttachment?.clientAccountId;
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
}, [previewAttachment, client]);
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
}, [client, previewAttachment]);
const handlePreviewAttachmentDownload = useCallback(async () => {
const c = previewBlobClient();
if (!c || !previewAttachment) return;
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
}, [previewBlobClient, previewAttachment]);
const getPreviewAttachmentContent = useCallback(async () => {
if (!client || !previewAttachment) {
const c = previewBlobClient();
if (!c || !previewAttachment) {
throw new Error('No attachment selected');
}
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
return {
blob,
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
};
}, [client, previewAttachment]);
}, [previewBlobClient, previewAttachment]);
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
@@ -2413,14 +2530,12 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2708,7 +2823,7 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
crossAccountActive={crossAccountActive}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
@@ -2835,8 +2950,8 @@ export default function Home() {
className={cn("ps-9 h-9", searchQuery && "pe-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
disabled={isScheduledView}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
{searchQuery && (
<button
@@ -2852,15 +2967,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView || isScheduledView}
disabled={isScheduledView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isScheduledView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -3017,6 +3132,9 @@ export default function Home() {
)}
<div className="flex-1 min-h-0 flex flex-col">
{/* Plugin-registered category tabs (Gmail-style). Renders nothing
unless an enabled plugin registered tabs via api.tabs.set. */}
{!isScheduledView && <MessageListTabs />}
<WelcomeBanner />
<ErrorBoundary fallback={EmailListErrorFallback}>
@@ -3195,8 +3313,7 @@ export default function Home() {
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
...getQuoteBodies(selectedEmail),
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
+7 -5
View File
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
@@ -22,10 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
};
const RESTRICTABLE_SETTINGS = [
+2
View File
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { FaviconBadge } from "@/components/favicon-badge";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
@@ -132,6 +133,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
<FaviconBadge />
{children}
</body>
</html>
+25 -8
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
/**
* POST /api/account/stalwart/jmap
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const directUrl = `${creds.serverUrl}/jmap/`;
let response = await postJmap(directUrl, creds.authHeader, body);
if (response.status === 404) {
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
// (path prefix, non-Stalwart URL layout). Resolve the session's
// advertised apiUrl on the same host and retry once.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
if (apiUrl && apiUrl !== directUrl) {
response = await postJmap(apiUrl, creds.authHeader, body);
}
}
if (!response.ok) {
logger.warn('Stalwart JMAP passthrough upstream error', {
status: response.status,
serverUrl: creds.serverUrl,
});
}
const responseText = await response.text();
return new NextResponse(responseText, {
@@ -38,6 +51,10 @@ export async function POST(request: NextRequest) {
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
if (error instanceof JmapRedirectError) {
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
return NextResponse.json({ error: error.message }, { status: 502 });
}
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
+5 -43
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;
interface JmapSession {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
capabilities?: Record<string, unknown>;
}
interface AgendaEvent {
id: string;
uid: string | null;
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
using.push('urn:ietf:params:jmap:principals:owner');
}
// Send method calls to the same-origin JMAP endpoint the app's passthrough
// uses — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = `${creds.serverUrl}/jmap/`;
// Send method calls to the session's apiUrl rebased onto serverUrl's host
// — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
const now = new Date();
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
@@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
return Math.min(max, Math.max(min, Math.round(n)));
}
/**
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
* fallback for other servers. Returns null if neither yields a usable session.
*/
async function fetchJmapSession(
serverUrl: string,
authHeader: string,
): Promise<JmapSession | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
if (!res.ok) continue;
const session = (await res.json()) as JmapSession;
if (session && typeof session === 'object' && session.primaryAccounts) {
return session;
}
} catch {
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
}
}
return null;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
): Promise<unknown> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
+2
View File
@@ -78,6 +78,8 @@ export async function GET(request: NextRequest) {
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { FaviconBadge } from '@/components/favicon-badge';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-favicon-badge', () => ({
useFaviconBadge: vi.fn(),
}));
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: patch.id,
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
isSubscribed: true,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
...patch,
} as Mailbox;
}
const initialMailboxes = useEmailStore.getState().mailboxes;
beforeEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
});
afterEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
vi.clearAllMocks();
});
describe('FaviconBadge', () => {
it('badges the unread count of the primary inbox', () => {
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
const { container } = render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
expect(container.firstChild).toBeNull(); // renders no markup
});
it('disables the badge when the setting is off', () => {
useSettingsStore.setState({ faviconUnreadBadge: false });
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
});
it('ignores a shared inbox, even when it sorts first', () => {
// Shared and group inboxes ship in the same `mailboxes` array. A plain
// `role === 'inbox'` lookup would badge somebody else's inbox on a
// delegated setup, so the store's canonical `!isShared` filter is required.
useEmailStore.setState({
mailboxes: [
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
});
it('badges zero when there is no inbox yet', () => {
useEmailStore.setState({ mailboxes: [] });
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
});
});
+23 -11
View File
@@ -9,6 +9,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
@@ -45,6 +46,13 @@ export function CalendarMonthView({
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
// On mobile the month view collapses events to dots unless the user opted
// into full entries via "Show time in month view" (#666).
const showChips = !isMobile || showTimeInMonthView;
const overlayTop = isMobile ? 34 : 30;
const rowHeight = isMobile ? 18 : 22;
const chipHeight = rowHeight - 2;
const {
weekStartsOn,
dayHeaderKeys,
@@ -157,7 +165,7 @@ export function CalendarMonthView({
<div key={wi} className={cn(
"relative flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
)} role="row" style={showChips ? { minHeight: Math.max(isMobile ? 52 : 100, overlayTop + 4 + rowCount * rowHeight + 8) } : undefined}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = checkIsSameMonth(day, selectedDate);
@@ -201,7 +209,7 @@ export function CalendarMonthView({
{formatDayNumber(day)}
</span>
</div>
{isMobile ? (
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
@@ -231,25 +239,28 @@ export function CalendarMonthView({
})}
</div>
{!isMobile && pendingPreview && (() => {
{showChips && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
top: previewRow * rowHeight,
height: chipHeight,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
className={cn(
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
)}
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
@@ -259,8 +270,8 @@ export function CalendarMonthView({
);
})()}
{!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{showChips && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{segments.map((segment) => {
const calId = getPrimaryCalendarId(segment.event);
return (
@@ -270,8 +281,8 @@ export function CalendarMonthView({
style={{
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
top: segment.row * 22,
height: 20,
top: segment.row * rowHeight,
height: chipHeight,
}}
>
<EventCard
@@ -285,6 +296,7 @@ export function CalendarMonthView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
</div>
);
+2 -2
View File
@@ -201,7 +201,7 @@ export function CalendarToolbar({
<CalendarDays className="w-4 h-4" />
</button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
@@ -335,7 +335,7 @@ export function CalendarToolbar({
<ChevronDown className="w-3 h-3 ms-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
+1 -1
View File
@@ -571,7 +571,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
+1 -1
View File
@@ -284,7 +284,7 @@ export function ContactsSidebar({
{showMenu && (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
@@ -136,6 +136,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
},
contactHooks: {
search: { call: async () => [] },
@@ -148,7 +149,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
@@ -226,8 +230,10 @@ describe('RecipientChipInput drag and drop', () => {
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
// The enrichment pass may have stamped extra display metadata on the
// chip by drag time, so match the essential fields rather than deep-equal.
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
expect(payload).toMatchObject({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
@@ -241,7 +247,7 @@ describe('RecipientChipInput drag and drop', () => {
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
expect(payload).toMatchObject({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
});
it('onDragEnd clears the opacity class on the chip', async () => {
@@ -340,4 +346,121 @@ describe('RecipientChipInput drag and drop', () => {
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
// ─── Reordering within / across fields (#593) ─────────────────────────────────
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
// read-only MouseEvent getter) and gives every element a zero-size rect at
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
const chipByText = async (text: string) =>
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(e, 'clientX', { value: clientX });
Object.defineProperty(e, 'dataTransfer', { value: dt });
act(() => { fireEvent(el, e); });
};
const BEFORE = -100;
const AFTER = 100;
/** Ordered chip labels of the field-container that holds `anchorText`. */
const orderIn = (anchorText: string) => {
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const c = containers.find(el =>
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
) as HTMLElement;
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
};
/** All draggable chips (across fields) whose label contains `text`. */
const draggableChipsWith = (text: string) =>
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const carol = await chipByText('carol@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
fireDnd('drop', carol, dt, AFTER);
expect(orderIn('bob@example.com')).toEqual([
'bob@example.com', 'carol@example.com', 'alice@example.com',
]);
});
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('carol@example.com');
const carol = await chipByText('carol@example.com');
const alice = await chipByText('alice@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
fireDnd('drop', alice, dt, BEFORE);
expect(orderIn('alice@example.com')).toEqual([
'carol@example.com', 'alice@example.com', 'bob@example.com',
]);
});
it('dropping a chip onto its own position leaves the order unchanged', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('bob@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
fireDnd('drop', bob, dt, BEFORE);
expect(orderIn('bob@example.com')).toEqual([
'alice@example.com', 'bob@example.com', 'carol@example.com',
]);
});
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com'); // To
const y = await chipByText('y@example.com'); // Cc
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
fireDnd('drop', y, dt, BEFORE);
// alice lands between x and y; To no longer holds it (count only real chips,
// not the leftover jsdom drag-preview element)
expect(orderIn('x@example.com')).toEqual([
'x@example.com', 'alice@example.com', 'y@example.com',
]);
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
});
it('shows a drop caret only while a chip is dragged over the field', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
fireDnd('dragover', bob, dt, BEFORE);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
fireEvent.dragEnd(alice);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
});
});
@@ -135,6 +135,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
},
contactHooks: {
search: { call: async () => [] },
@@ -147,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
@@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [actionError, setActionError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
@@ -1026,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
setPickerPosition(
isDocumentRTL()
? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
: { top: rect.bottom + 4, left: rect.left }
);
}
setShowCalendarPicker(true);
}}
@@ -1041,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
<div
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
style={{ top: pickerPosition.top, left: pickerPosition.left }}
style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
+306 -81
View File
@@ -5,15 +5,16 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { isEditableEventTarget } from "@/lib/keyboard";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
@@ -26,11 +27,11 @@ import { useSettingsStore } from "@/stores/settings-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useContactStore } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders } from "@/lib/template-utils";
import { substitutePlaceholders, spliceTemplateAboveSignature } from "@/lib/template-utils";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
@@ -44,16 +45,20 @@ import {
parseRecipient,
parseRecipientList,
formatRecipientList,
expandRecipients,
splitPastedRecipients,
waitForPendingUploads,
extractUserAuthoredText,
type Recipient,
enrichChipsWithColorsAndIcons,
ICON_MAP,
} from "@/lib/email-composer-utils";
import { isValidEmail } from "@/lib/validation";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
import { usePolicyStore } from "@/stores/policy-store";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -92,6 +97,10 @@ function createChipDragPreview(label: string): HTMLElement {
return preview;
}
// An autocomplete entry: a person, or a contact group (empty email) that
// inserts as a single chip and expands into its members on send.
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
export interface ComposerDraftData {
to: string;
cc: string;
@@ -285,6 +294,9 @@ export function EmailComposer({
: [];
const primaryIdentity = activeIdentities[0] ?? null;
const { isFeatureEnabled } = usePolicyStore();
const templatesEnabled = isFeatureEnabled('templatesEnabled');
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
@@ -817,12 +829,33 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const processEnrichment = async (
recipients: Recipient[],
setRecipients: (items: Recipient[]) => void
) => {
const hasUnenriched = recipients.some((r) => !r.extra?.enriched);
if (!hasUnenriched) return;
const newChips = await enrichChipsWithColorsAndIcons(recipients);
const fullyEnriched = newChips.map((chip) => ({
...chip,
extra: { ...chip.extra, enriched: true },
}));
setRecipients(fullyEnriched);
};
useEffect(() => { processEnrichment(to, setTo); }, [to]);
useEffect(() => { processEnrichment(cc, setCc); }, [cc]);
useEffect(() => { processEnrichment(bcc, setBcc); }, [bcc]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const getGroupMembers = useContactStore((s) => s.getGroupMembers);
const searchRecipients = useContactStore((s) => s.searchRecipients);
// Whether a Sent mailbox is known so the on-demand server search is worth
// offering (falls back to hiding the "search the server" row otherwise).
@@ -899,7 +932,7 @@ export function EmailComposer({
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<SuggestionItem>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
// Current trimmed query behind the open dropdown, plus the in-flight flag for
@@ -930,15 +963,26 @@ export function EmailComposer({
}
}, [plainTextMode]);
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
// Move a chip from one recipient field to another. `toIndex`, when given,
// inserts at that position in the destination (drag-and-drop reordering,
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
const sameRecipient = (a: Recipient, b: Recipient) =>
a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b);
setters[fromField](prev => {
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
setters[toField](prev => {
if (prev.some(r => sameRecipient(r, recipient))) return prev;
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
const next = [...prev];
next.splice(at, 0, recipient);
return next;
});
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
@@ -961,9 +1005,9 @@ export function EmailComposer({
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(query);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group })));
// Keep the dropdown open even without local hits when a server search is
// available, so the "search the server" row stays reachable (OWA-style).
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
@@ -998,11 +1042,30 @@ export function EmailComposer({
}
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
setter(prev => [...prev, toRecipient(suggestion)]);
if (suggestion.group) {
// Insert the group as a single chip carrying a snapshot of its members
// (deduped, members without an address skipped). The chip is expanded
// into the members when the message is sent or saved as a draft.
const seen = new Set<string>();
const members: Array<{ name?: string; email: string }> = [];
for (const m of getGroupMembers(suggestion.group.id)) {
const email = getContactPrimaryEmail(m).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
const name = getContactDisplayName(m);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (members.length > 0) {
setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]);
}
} else {
setter(prev => [...prev, toRecipient(suggestion)]);
}
inputSetter('');
setAutocompleteResults([]);
setActiveAutoField(null);
@@ -1052,13 +1115,22 @@ export function EmailComposer({
: template.body;
// In plain text mode, use template body as-is; otherwise convert to HTML
const bodyContent = plainTextMode
const bodyContent = plainTextMode || template.isHTML
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
if (mode === 'compose') {
setSubject(filledSubject);
setBody(bodyContent);
// Compose bodies carry the embedded signature (see
// shouldEmbedSignatureInNewMail) and the send path assumes it stays
// there, so replace only the message content, not the signature block.
if (plainTextMode) {
setBody(shouldEmbedSignatureInNewMail
? appendPlainTextSignature(bodyContent, signatureIdentity, { separator: signatureSeparatorEnabled })
: bodyContent);
} else {
setBody((prev) => spliceTemplateAboveSignature(prev, bodyContent));
}
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.map(parseRecipient));
}
@@ -1071,7 +1143,30 @@ export function EmailComposer({
setShowBcc(true);
}
} else {
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
// Reply/forward: insert the template at the caret so it lands after any
// text the user has already typed, instead of always prepending it (#539).
if (plainTextMode) {
const textarea = bodyRef.current;
if (textarea) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
setBody((prev) => prev.slice(0, start) + bodyContent + prev.slice(end));
// Restore the caret just past the inserted text once React re-renders.
requestAnimationFrame(() => {
const caret = start + bodyContent.length;
textarea.focus();
textarea.setSelectionRange(caret, caret);
});
} else {
setBody((prev) => bodyContent + '\n' + prev);
}
} else if (editorRef.current) {
// insertContent lands at the editor's current selection; onUpdate
// propagates the resulting HTML back through onChange → setBody.
editorRef.current.chain().focus().insertContent(bodyContent).run();
} else {
setBody((prev) => bodyContent + prev);
}
}
if (template.identityId) {
@@ -1079,14 +1174,14 @@ export function EmailComposer({
}
setShowTemplatePicker(false);
}, [mode, plainTextMode]);
}, [mode, plainTextMode, shouldEmbedSignatureInNewMail, signatureIdentity, signatureSeparatorEnabled]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const tag = target?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (target?.getAttribute('contenteditable') === 'true') return;
// composedPath-based check so editing inside the QuotedHtml shadow
// island doesn't trigger the picker (#654).
if (isEditableEventTarget(e)) return;
if (!templatesEnabled) return;
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShowTemplatePicker(true);
@@ -1094,7 +1189,7 @@ export function EmailComposer({
};
window.addEventListener('keydown', handleTemplateKey);
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
}, [templatesEnabled]);
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
@@ -1303,9 +1398,9 @@ export function EmailComposer({
const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null;
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
@@ -1474,7 +1569,9 @@ export function EmailComposer({
};
}, []);
const toAddresses = withInput(to, toInput);
// Groups expand here so validation and every outgoing payload see the
// actual member addresses.
const toAddresses = expandRecipients(withInput(to, toInput));
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
@@ -1603,8 +1700,8 @@ export function EmailComposer({
}
}
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
const ccAddresses = expandRecipients(withInput(cc, ccInput));
const bccAddresses = expandRecipients(withInput(bcc, bccInput));
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
@@ -2012,7 +2109,7 @@ export function EmailComposer({
};
return (
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
@@ -2042,7 +2139,7 @@ export function EmailComposer({
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<X className="w-5 h-5 md:w-4 md:h-4" />
</Button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
<h3 className="font-semibold text-base">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -2070,6 +2167,7 @@ export function EmailComposer({
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
@@ -2104,6 +2202,7 @@ export function EmailComposer({
</div>
) : identities.length > 1 ? (
<select
data-testid="composer-from"
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
@@ -2116,7 +2215,7 @@ export function EmailComposer({
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
<option key={identity.id} value={identity.id} dir="ltr">
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
@@ -2128,24 +2227,24 @@ export function EmailComposer({
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
<option key={identity.id} value={identity.id} dir="ltr">
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span>
) : (
<>
<bdi>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
</bdi>
)}
</span>
)}
@@ -2198,7 +2297,7 @@ export function EmailComposer({
</div>
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
chips={to}
@@ -2343,6 +2442,7 @@ export function EmailComposer({
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
data-testid="composer-subject"
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -2512,24 +2612,26 @@ export function EmailComposer({
>
<Paperclip className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
className="h-9 w-9"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
className="h-9 w-9"
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{templatesEnabled && <>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
className="h-9 w-9"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
className="h-9 w-9"
>
<BookmarkPlus className="w-4 h-4" />
</Button>
</>}
{/* Sign/encrypt controls are contributed by crypto plugins via the
composer-toolbar slot (rendered below). */}
@@ -2565,6 +2667,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="rounded-e-none border-e border-primary-foreground/20"
>
<Send className="w-4 h-4 me-2" />
@@ -2584,7 +2687,7 @@ export function EmailComposer({
{showSendMenu && (
<div
role="menu"
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
<button
type="button"
@@ -2603,6 +2706,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 me-2" />
@@ -2725,7 +2829,7 @@ export function EmailComposer({
</Button>
<Button onClick={handleSaveDraftAndClose}>
<Save className="w-4 h-4 me-2" />
{t('save_draft')}
{tCommon('save')}
</Button>
</div>
</div>
@@ -2751,13 +2855,14 @@ export function EmailComposer({
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id: string;
results: Array<{ name: string; email: string }>;
results: Array<SuggestionItem>;
selectedIndex: number;
onSelect: (suggestion: { name: string; email: string }) => void;
onSelect: (suggestion: SuggestionItem) => void;
onSearchServer?: () => void;
isSearchingServer?: boolean;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
const t = useTranslations('email_composer');
const tContacts = useTranslations('contacts');
return (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
@@ -2776,9 +2881,19 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
onSelect(r);
}}
>
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
{r.group ? (
<span className="shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
</span>
) : (
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
)}
<span className="font-medium truncate">{r.name || r.email}</span>
{r.name && (
{r.group ? (
<span className="text-muted-foreground truncate">
{tContacts('groups.member_count', { count: r.group.memberCount })}
</span>
) : r.name && (
<span className="text-muted-foreground truncate">&lt;{r.email}&gt;</span>
)}
</button>
@@ -2844,10 +2959,10 @@ function RecipientChipInput({
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
activeAutoField: 'to' | 'cc' | 'bcc' | null;
autocompleteResults: Array<{ name: string; email: string }>;
autocompleteResults: Array<SuggestionItem>;
autoSelectedIndex: number;
dropdownRef: React.RefObject<HTMLDivElement | null>;
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void;
canSearchServer: boolean;
onServerSearch: () => void;
isSearchingServer: boolean;
@@ -2855,7 +2970,7 @@ function RecipientChipInput({
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
}) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
@@ -2864,6 +2979,9 @@ function RecipientChipInput({
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
// caret and positional drop for reordering (#593). null when not dragging.
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
// Focus edit input when editing starts
@@ -2880,7 +2998,9 @@ function RecipientChipInput({
// Format a recipient for display in a chip / context menu
const formatChipDisplay = (r: Recipient): string =>
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
r.group
? `${r.name || 'Group'} (${r.group.members.length})`
: r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
// Handle saving an edited chip
const handleSaveEdit = (newValue: string) => {
@@ -2900,10 +3020,10 @@ function RecipientChipInput({
setEditingChip(null);
return;
}
newChip = { name: chip.name, email: trimmedNew };
newChip = { ...chip, email: trimmedNew };
} else {
// Update name, keep email. Empty name clears the display name.
newChip = { name: trimmedNew || undefined, email: chip.email };
newChip = { ...chip, name: trimmedNew || undefined };
}
const newChips = [...chips];
@@ -3018,27 +3138,86 @@ function RecipientChipInput({
onAutoBlur(e, field);
};
const isChipDrag = (e: React.DragEvent) =>
e.dataTransfer.types.includes('application/x-recipient-chip');
// Dragging over empty container space (past the last chip / over the input)
// targets the end of the list.
const handleContainerDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
if (!isChipDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
setDropIndex(chips.length);
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropIndex(null);
}
};
// Dragging over a chip picks the gap before or after it based on which half
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
// handler from overriding this finer target.
const handleChipDragOver = (e: React.DragEvent, index: number) => {
if (!isChipDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const rtl = typeof window !== 'undefined' &&
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
const past = rtl
? e.clientX < rect.left + rect.width / 2
: e.clientX > rect.left + rect.width / 2;
setIsDragOver(true);
setDropIndex(past ? index + 1 : index);
};
// Insert the dragged chip at `target`. Same-field is a local reorder;
// cross-field routes through onMoveChip with the destination index (#593).
const performDrop = (e: React.DragEvent, target: number) => {
e.preventDefault();
setIsDragOver(false);
setDropIndex(null);
setDraggingIndex(null);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
try {
payload = JSON.parse(raw);
} catch {
return;
}
const { recipient, fromField, fromIndex } = payload;
const to = Math.max(0, Math.min(target, chips.length));
if (fromField === field) {
const from = typeof fromIndex === 'number'
? fromIndex
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
if (from < 0 || from >= chips.length) return;
// Removing the source before `to` shifts the target left by one.
const insertAt = to > from ? to - 1 : to;
if (insertAt === from) return; // dropped onto its own position
const next = [...chips];
const [moved] = next.splice(from, 1);
next.splice(insertAt, 0, moved);
onChipsChange(next);
} else {
onMoveChip(recipient, fromField, field, to);
}
};
const handleContainerDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
onMoveChip(recipient, fromField, field);
performDrop(e, dropIndex ?? chips.length);
};
const colorStyles: Record<'success' | 'destructive' | 'warning', string> = {
success: "bg-success/15 text-secondary-foreground hover:bg-success/30 !border-success",
destructive: "bg-destructive/15 text-secondary-foreground hover:bg-destructive/30 !border-destructive",
warning: "bg-warning/15 text-secondary-foreground hover:bg-warning/30 !border-warning",
};
return (
@@ -3057,30 +3236,60 @@ function RecipientChipInput({
{chips.map((chip, i) => {
const isEditing = editingChip?.index === i;
const chipDisplay = formatChipDisplay(chip);
let IconComponent = null;
if(chip.extra?.icon){
IconComponent = ICON_MAP[chip.extra?.icon];
}
const customColor = chip.extra?.color;
return (
<React.Fragment key={`${chip.email}-${i}`}>
{dropIndex === i && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
<span
key={`${chip.email}-${i}`}
draggable={!isEditing}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(chip.email);
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
}}
onDragEnd={() => setDraggingIndex(null)}
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
onDragOver={(e) => handleChipDragOver(e, i)}
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
isEditing
? "bg-background ring-1 ring-ring"
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing",
: ( customColor && colorStyles[customColor]
? `${colorStyles[customColor]} cursor-grab active:cursor-grabbing`
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing"),
!isEditing && draggingIndex === i && "opacity-50"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
>
{IconComponent ? (
<IconComponent
className={cn(
"w-4 h-4",
customColor === "success"
? "text-success"
: customColor === "warning"
? "text-warning"
: "text-destructive"
)}
/>
) : null}
{isEditing ? (
<input
ref={editInputRef}
@@ -3109,7 +3318,13 @@ function RecipientChipInput({
data-bwignore="true"
/>
) : (
<span className="truncate max-w-[200px]">{chipDisplay}</span>
<span
className="inline-flex items-center gap-1 max-w-[200px]"
title={chip.group ? chip.group.members.map(m => m.email).join(', ') : undefined}
>
{chip.group && <Users className="w-3 h-3 shrink-0" aria-hidden />}
<span className="truncate">{chipDisplay}</span>
</span>
)}
<button
type="button"
@@ -3131,8 +3346,16 @@ function RecipientChipInput({
)}
</button>
</span>
</React.Fragment>
);
})}
{dropIndex === chips.length && chips.length > 0 && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
{!editingChip && (
<input
ref={inputRef}
@@ -3185,7 +3408,9 @@ function RecipientChipInput({
{formatChipDisplay(contextMenu.data.recipient)}
</div>
<ContextMenuSeparator />
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
{!contextMenu.data.recipient.group && (
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
)}
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
<ContextMenuSeparator />
<ContextMenuItem label={tCommon('delete')} onClick={() => {
+5 -1
View File
@@ -295,6 +295,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
@@ -306,7 +307,7 @@ export function EmailContextMenu({
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
@@ -319,6 +320,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`move-to:${node.id}`}
onClick={() =>
handleAction(() =>
showBatchActions
@@ -410,6 +412,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
onClick={() =>
handleAction(
showBatchActions
@@ -429,6 +432,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() =>
handleAction(() =>
showBatchActions
+33 -1
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -191,6 +191,22 @@ export function EmailList({
}
};
const handleBatchUndoSpam = async () => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
const emailIds = Array.from(selectedEmailIds);
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleBatchDelete = async () => {
if (!client || isProcessing) return;
@@ -357,6 +373,22 @@ export function EmailList({
<Mail className="w-4 h-4" />
)}
</Button>
{effectiveMailboxRole === 'junk' && (
<Button
variant="ghost"
size="sm"
onClick={handleBatchUndoSpam}
title={t('../context_menu.not_spam')}
disabled={isProcessing}
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
+138 -51
View File
@@ -5,8 +5,9 @@ import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -560,6 +561,8 @@ export function ContactSidebarPanel({
interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
/** Owner accountId for the blob when it lives in a delegated/shared account. */
accountId?: string;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
@@ -570,14 +573,14 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
} catch {
return null;
}
@@ -595,7 +598,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
}
return null;
},
}), [attachment, client, downloadName]);
}), [attachment, client, accountId, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
@@ -652,6 +655,7 @@ export function EmailViewer({
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
@@ -714,6 +718,18 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
// downloads) are account-scoped. In the unified / All-Mail view the open
// message may belong to another login (route to its client) or a delegated
// shared account (same client, owner accountId in the URL). Resolve both from
// the message's source so cross-account blob fetches don't 404 against the
// active account.
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
const blobClient = useMemo(() => {
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
}, [isUnifiedView, email?.sourceClientAccountId, client]);
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
@@ -1175,6 +1191,7 @@ export function EmailViewer({
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
@@ -1250,7 +1267,7 @@ export function EmailViewer({
async function processTnef() {
try {
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
@@ -1303,7 +1320,7 @@ export function EmailViewer({
processTnef();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
@@ -1340,7 +1357,7 @@ export function EmailViewer({
async function unwrapEmbedded() {
try {
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
@@ -1380,7 +1397,7 @@ export function EmailViewer({
unwrapEmbedded();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
@@ -1426,7 +1443,7 @@ export function EmailViewer({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
@@ -1448,7 +1465,7 @@ export function EmailViewer({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (pluginRenderedAttachments.length > 0) {
@@ -1647,10 +1664,8 @@ export function EmailViewer({
}
}
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
// http(s) links open in a new tab; other schemes keep their default.
applyNewTabToAnchor(node);
// No dark mode color transforms - emails render true-to-life in iframe
});
@@ -1688,7 +1703,11 @@ export function EmailViewer({
const textContent = email.bodyValues[email.textBody[0].partId].value;
return {
html: plainTextToSafeHtml(textContent),
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
}),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
@@ -1728,6 +1747,11 @@ export function EmailViewer({
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
const plainToHtml = (text: string) =>
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
if (pluginRenderedHtml) {
const htmlWithCidUrls = pluginRenderedHtml.replace(
/\bcid:([^"'\s)]+)/gi,
@@ -1739,7 +1763,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
}
if (pluginRenderedText) {
return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
@@ -1747,7 +1771,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
}
if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
@@ -1755,10 +1779,10 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
}
if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
@@ -1926,8 +1950,8 @@ export function EmailViewer({
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
if (attachment.blobId && client) {
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
if (attachment.blobId && blobClient) {
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
@@ -1959,7 +1983,7 @@ export function EmailViewer({
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, client, email]);
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
@@ -2001,8 +2025,8 @@ export function EmailViewer({
await Promise.all(imageAttachments.map(async (att) => {
let url: string | undefined;
try {
if (att.blobId && client) {
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
if (att.blobId && blobClient) {
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
} else if (att.decryptedAttachment) {
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
@@ -2033,7 +2057,7 @@ export function EmailViewer({
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -2103,9 +2127,21 @@ export function EmailViewer({
// Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
// background canvas (a width:100% element carrying a background colour) --
// the one case where the gutter shows as a frame around the email's own
// background. A <style> tag alone is too weak a signal: plenty of
// transactional mails ship one for web fonts yet have no gutter of their
// own, and zeroing the padding glues their content to the corner.
const emailHtml = effectiveEmailContent.html;
const hasFullBleedCanvas =
/<(?:table|div|body)\b[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)/i.test(emailHtml) ||
/<(?:table|div|body)\b[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)/i.test(emailHtml);
const autoDropsGutter = effectiveEmailContent.hasStyleTag && !isWordHtml && hasFullBleedCanvas;
const dropGutter =
messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
const bodyPadding = dropGutter ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = dropGutter ? '0' : '0.75rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
@@ -2166,7 +2202,7 @@ export function EmailViewer({
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
@@ -2190,8 +2226,20 @@ export function EmailViewer({
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
const [iframeReady, setIframeReady] = useState(false);
// Tracks which parsed document we've already wired up, so setup runs exactly
// once per srcDoc even though both the readiness poll below and the iframe
// 'load' event can trigger it.
const initializedDocRef = useRef<Document | null>(null);
// The document present at the instant srcDoc changed - i.e. the one about to
// be torn down. contentDocument keeps pointing at it until the browser swaps
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
const staleDocRef = useRef<Document | null>(null);
useLayoutEffect(() => {
setIframeReady(false);
initializedDocRef.current = null;
// Runs during commit, before the browser processes the new srcDoc, so
// contentDocument here is still the outgoing document.
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
}, [emailIframeSrcDoc]);
const handleIframeLoad = useCallback(() => {
@@ -2199,7 +2247,21 @@ export function EmailViewer({
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (doc?.body) {
// Ignore the outgoing document, a transient about:blank (a fresh srcDoc
// document reports URL 'about:srcdoc'), and anything that hasn't finished
// parsing yet; run the setup below at most once per document.
if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return;
if (initializedDocRef.current === doc) return;
initializedDocRef.current = doc;
{
// Collapse the quoted original of a reply behind a "•••" toggle
// (#480). Before the height wiring, so the initial measurement
// already reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
// Auto-resize iframe to fit content
// Measure max(documentElement, body): a height:100% wrapper can leave
// documentElement.scrollHeight short while the real content lives in body.
@@ -2247,11 +2309,9 @@ export function EmailViewer({
}
});
// Make links open in new tab
doc.querySelectorAll('a').forEach(a => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Second pass over the rendered iframe DOM (the hook above only sees
// DOMPurify's output); http(s) → new tab, other schemes left in place.
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
@@ -2373,7 +2433,27 @@ export function EmailViewer({
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, emailHasNativeDarkMode, email?.id]);
}, [isDark, emailHasNativeDarkMode, email?.id, t]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
// so a single unreachable remote image (server accepts the TCP connection but
// never responds) stalls it for the browser's ~60s timeout - freezing the body
// at its placeholder height that entire time. The parsed DOM we need for
// height, links and dark-mode is ready long before images resolve. Poll the
// fresh document's readyState because a sandbox without allow-scripts can't
// postMessage a DOMContentLoaded signal out, and the onLoad handler is
// idempotent per document so it stays a harmless backstop.
useEffect(() => {
if (!iframeRef.current) return;
const readyPoll = window.setInterval(() => {
handleIframeLoad();
if (initializedDocRef.current) window.clearInterval(readyPoll);
}, 50);
// Safety stop: the 'load' backstop covers anything the poll somehow misses.
const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000);
return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); };
}, [emailIframeSrcDoc, handleIframeLoad]);
// Export email as .eml file
const handleExportEmail = async () => {
@@ -2781,6 +2861,7 @@ export function EmailViewer({
variant="default"
size="sm"
onClick={() => onEditDraft()}
data-testid="edit-draft"
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -3692,7 +3773,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -3703,6 +3784,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -3730,7 +3813,7 @@ export function EmailViewer({
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
@@ -3767,13 +3850,13 @@ export function EmailViewer({
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -3791,7 +3874,7 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -4466,7 +4549,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4477,6 +4560,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4509,7 +4594,7 @@ export function EmailViewer({
</div>
<div className={cn(
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-r-md rounded-l-none",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
@@ -4546,13 +4631,13 @@ export function EmailViewer({
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4570,7 +4655,7 @@ export function EmailViewer({
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -4610,7 +4695,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4621,6 +4706,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4648,7 +4735,7 @@ export function EmailViewer({
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
@@ -4684,13 +4771,13 @@ export function EmailViewer({
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4708,7 +4795,7 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
+121
View File
@@ -0,0 +1,121 @@
'use client';
// Native tab strip for plugin-registered message-list category tabs
// (Gmail-style Primary / Promotions / Social / Updates). Renders above the
// email list; the active tab's resolved JMAP filter is ANDed into the
// mailbox query by email-store.fetchEmails. Plugins only contribute tab
// DEFINITIONS (stores/message-list-tabs-store.ts) - no plugin iframe here.
import { useEffect, useRef } from 'react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import { cn } from '@/lib/utils';
export function MessageListTabs() {
const tabs = useMessageListTabsStore((s) => s.tabs);
const mailboxRoles = useMessageListTabsStore((s) => s.mailboxRoles);
const activeTabId = useMessageListTabsStore((s) => s.activeTabId);
const tabCounts = useMessageListTabsStore((s) => s.tabCounts);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
const selectedKeyword = useEmailStore((s) => s.selectedKeyword);
const searchQuery = useEmailStore((s) => s.searchQuery);
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
const client = useAuthStore((s) => s.client);
const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox);
const role = mailbox?.role?.toLowerCase() ?? null;
// Tabs only make sense on a plain mailbox view: tag views, searches and
// unified fan-outs bypass the category filter in fetchEmails, so the strip
// must disappear rather than lie about what's being shown.
const visible =
tabs.length > 0 &&
!!role &&
mailboxRoles.includes(role) &&
!selectedKeyword &&
!searchQuery &&
!isUnifiedView;
useEffect(() => {
if (!visible || !client || !mailbox) return;
const jmapMailboxId = mailbox.originalId || mailbox.id;
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
void useMessageListTabsStore.getState().refreshCounts(client, jmapMailboxId, accountId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, client, selectedMailbox, tabs]);
// A plugin registering (or clearing) tabs after the list was fetched leaves
// the visible list out of sync with the strip's active-tab filter - refetch
// exactly when the merged tab set changes, never on ordinary view switches
// (those already refetch through their own flows).
const prevTabsRef = useRef(tabs);
useEffect(() => {
if (prevTabsRef.current === tabs) return;
prevTabsRef.current = tabs;
if (client) void useEmailStore.getState().fetchEmails(client);
}, [tabs, client]);
if (!visible) return null;
const handleSelect = (tabId: string) => {
if (tabId === activeTabId) return;
useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox);
if (client) void useEmailStore.getState().fetchEmails(client);
};
return (
<div
className="flex items-stretch gap-1 px-2 border-b border-border overflow-x-auto shrink-0"
style={{ scrollbarWidth: 'none' }}
role="tablist"
aria-label="Inbox categories"
>
{tabs.map((tab) => {
const Icon = tab.icon
? (lucideIcons[tab.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
: undefined;
const unread = tabCounts[tab.id] ?? 0;
const isActive = tab.id === activeTabId;
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(tab.id)}
className={cn(
'relative flex items-center gap-1.5 px-3.5 py-2.5 text-sm whitespace-nowrap select-none',
'border-b-2 -mb-px rounded-t-md transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isActive
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40',
)}
style={isActive && tab.color ? { borderBottomColor: tab.color } : undefined}
>
{Icon && (
<Icon
className={cn('h-4 w-4 flex-shrink-0', !isActive && 'opacity-70')}
style={isActive && tab.color ? { color: tab.color } : undefined}
/>
)}
<span>{tab.label}</span>
{tab.showUnreadBadge !== false && unread > 0 && (
<span
className={cn(
'text-xs font-semibold tabular-nums',
isActive ? 'text-foreground' : 'text-muted-foreground',
)}
title={`${unread} unread`}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
);
})}
</div>
);
}
+6 -3
View File
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
/**
* QuotedHtml an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
dom.style.cssText = QUOTE_BAR_STYLE;
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
}
+1 -1
View File
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
{displayLabel || name || email}
<bdi>{displayLabel || name || email}</bdi>
</button>
{isOpen &&
+66 -1
View File
@@ -41,6 +41,7 @@ import {
Heading1,
Heading2,
Table as TableIcon,
Baseline,
Trash2,
Rows3,
Columns3,
@@ -143,6 +144,14 @@ function ToolbarSeparator() {
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
// round-trips; the TextStyle/Color extensions are already registered to
// preserve pasted colours - this palette just adds a UI to set them.
const TEXT_COLORS = [
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
];
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
@@ -336,6 +345,19 @@ export function RichTextEditor({
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const colorWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenuOpen) return;
const handler = (e: MouseEvent) => {
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
setColorMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [colorMenuOpen]);
useEffect(() => {
if (!tableMenuOpen) return;
@@ -386,6 +408,49 @@ export function RichTextEditor({
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<div ref={colorWrapperRef} className="relative">
<ToolbarButton
active={!!editor.getAttributes("textStyle").color}
onClick={() => setColorMenuOpen((v) => !v)}
title="Text color"
>
{/* The icon itself previews the active colour - no layout shift. */}
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
</ToolbarButton>
{colorMenuOpen && (
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
{TEXT_COLORS.map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
editor.chain().focus().setColor(color).run();
setColorMenuOpen(false);
}}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="h-px bg-border my-1.5" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
onClick={() => {
editor.chain().focus().unsetColor().run();
setColorMenuOpen(false);
}}
>
<RemoveFormatting className="w-4 h-4" /> Remove color
</button>
</div>
)}
</div>
<ToolbarSeparator />
@@ -494,7 +559,7 @@ export function RichTextEditor({
<TableIcon className="w-4 h-4" />
</ToolbarButton>
{tableMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
{editor.isActive("table") ? (
<div className="flex flex-col gap-0.5">
<button
+29 -3
View File
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
/**
* Force every link in the rendered signature to open in a new tab.
*
* Applied to the NodeView's DOM only, never to `attrs.html` that attribute is
* what serializeEditorContent emits into the sent message, and the recipient's
* copy should stay exactly as the user wrote it. Without this the composer's
* signature is a set of live, target-less anchors in the main document (the
* message body gets a sandboxed iframe; this does not), so one stray click
* navigates the whole app away and takes the unsent draft with it.
*/
function forceLinksToNewTab(root: HTMLElement): void {
root.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}
/**
* SignatureBlock an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
inner.innerHTML = node.attrs.html || "";
// Track what we were given, not what's in the DOM: forceLinksToNewTab
// rewrites the markup, so inner.innerHTML no longer round-trips against
// attrs.html and comparing the two would rewrite on every transaction.
let appliedHtml = node.attrs.html || "";
inner.innerHTML = appliedHtml;
forceLinksToNewTab(inner);
shadow.appendChild(inner);
return {
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
inner.innerHTML = updatedNode.attrs.html || "";
const nextHtml = updatedNode.attrs.html || "";
if (nextHtml !== appliedHtml) {
appliedHtml = nextHtml;
inner.innerHTML = nextHtml;
forceLinksToNewTab(inner);
}
return true;
},
+9 -4
View File
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
/**
* Adds a `dir` attribute to block nodes so the composer can mark individual
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
*
* The default is `"auto"`: each block detects its own direction from its first
* strong character, so a paragraph typed in English renders LTR and one typed
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
* attribute round-trips to HTML so the direction is preserved in the sent mail.
*/
export const TextDirection = Extension.create({
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
types: this.options.types,
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir") || null,
default: "auto",
parseHTML: (element) => element.getAttribute("dir") || "auto",
renderHTML: (attributes) =>
attributes.dir ? { dir: attributes.dir } : {},
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
},
},
},
+17 -3
View File
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
@@ -424,7 +425,14 @@ function EmailCard({
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
return {
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}
}
@@ -438,7 +446,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
@@ -469,6 +477,12 @@ function EmailCard({
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
// before the first resize so the height reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
});
const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
};
@@ -482,7 +496,7 @@ function EmailCard({
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, []);
}, [t]);
return (
<div className={cn(
+9 -5
View File
@@ -2,7 +2,7 @@
import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
@@ -97,7 +97,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
ref={ref}
{...dragHandlers}
{...longPressHandlers}
data-testid="email-list-item"
data-email-id={email.id}
data-subject={email.subject || ''}
data-unread={isUnread ? 'true' : 'false'}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
@@ -459,7 +463,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always
@@ -660,7 +664,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onToggle={toggleThreadSelection}
selectLabel={tBatch('select')}
/>
{!isMobile && !isFocusedMailLayout && (
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
@@ -892,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
</div>
{isExpanded && !isMobile && !isFocusedMailLayout && (
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+1 -1
View File
@@ -147,7 +147,7 @@ export function UnsubscribeBanner({
{showConfirm && isDesktop && (
<div
ref={popoverRef}
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
>
<p className="text-sm text-foreground mb-2">
{t('email_viewer.unsubscribe_banner.confirm_title')}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
/**
* Badges the browser-tab favicon with the inbox unread count, so new mail is
* visible without focusing the tab. See issue #560.
*
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
* default.
*
* Mounted in the root layout rather than on the mail route: the badge belongs
* to the tab, not to a page. Mounting it on the mail page unmounted it and so
* cleared the badge, and flickered the icon on every hop to /settings,
* /calendar or /contacts.
*
* Renders nothing.
*/
export function FaviconBadge() {
// The store's canonical inbox selector. `role === 'inbox'` alone is not
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
// a delegated setup the first match can be somebody else's inbox.
const inboxUnread = useEmailStore(
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
useFaviconBadge(inboxUnread, enabled);
return null;
}
+2 -2
View File
@@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
{message.from && (
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
<div><span className="font-medium text-foreground">{t("from")}: </span><bdi>{formatAddress(message.from)}</bdi></div>
)}
{message.to && message.to.length > 0 && (
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
)}
{message.date && (
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useSettingsStore } from '@/stores/settings-store';
function useSyncIdentities() {
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id);
// Persist to the synced settings (keyed by username, matching how
// loadIdentities reads it back) so the choice survives a new browser /
// cleared site data and reaches other devices (#507).
const username = useAuthStore.getState().username || '';
if (username) {
// Persist the choice per account in the synced settings store so it
// survives clearing site data, follows the user across devices, and shows
// up in exported settings (issue #507). JMAP identity ids are account-
// scoped, so the default is keyed by the active account.
const activeAccountId = useAccountStore.getState().activeAccountId;
if (activeAccountId) {
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
...current,
[username]: identity.id,
[activeAccountId]: identity.id,
});
}
// Re-sort: move the preferred identity to the front
+1 -1
View File
@@ -137,7 +137,7 @@ export function SubAddressHelper({
<div
ref={popoverRef}
className={cn(
'absolute top-full right-0 mt-1 z-50',
'absolute top-full end-0 mt-1 z-50',
'bg-background border border-border rounded-lg shadow-lg',
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
)}
+56 -13
View File
@@ -2,11 +2,12 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
@@ -48,23 +49,41 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const activeAccount = accounts.find((a) => a.id === activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const rtl = isDocumentRTL();
if (variant === "rail") {
setPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
});
setPopoverStyle(
rtl
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
: {
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
);
} else {
setPopoverStyle({
position: "fixed",
left: rect.left,
top: rect.bottom + 4,
});
setPopoverStyle(
rtl
? {
position: "fixed",
right: window.innerWidth - rect.right,
top: rect.bottom + 4,
}
: {
position: "fixed",
left: rect.left,
top: rect.bottom + 4,
}
);
}
}, [variant]);
@@ -100,6 +119,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
router.push(`/login?mode=add-account` as never);
};
const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
e.stopPropagation();
const label = account.email || account.username;
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
removeAccount(account.id);
};
const handleLogout = () => {
setOpen(false);
logout();
@@ -151,6 +177,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
data-testid="account-switcher"
data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
@@ -213,10 +241,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
<button
onClick={() => handleSwitch(account.id)}
data-testid="account-option"
data-account-id={account.id}
data-account-email={account.email || account.username}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isDraggable && "pe-7"
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
)}
role="menuitem"
disabled={isActive}
@@ -259,11 +290,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
{isDraggable && (
<span
aria-hidden
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
>
<GripVertical className="w-4 h-4" />
</span>
)}
{!isActive && !account.isDefault && (
<button
type="button"
onClick={(e) => handleRemove(e, account)}
aria-label={t("remove_account")}
title={t("remove_account")}
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
@@ -274,6 +316,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<div className="border-t border-border">
<button
onClick={handleAddAccount}
data-testid="add-account"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
+27 -10
View File
@@ -18,6 +18,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getMaxAccounts } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -70,11 +71,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
setPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: window.innerHeight - rect.bottom,
});
setPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: window.innerHeight - rect.bottom,
}
: {
position: "fixed",
left: rect.right + 8,
bottom: window.innerHeight - rect.bottom,
}
);
}, []);
useEffect(() => {
@@ -218,11 +227,19 @@ export function NavigationRail({
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
});
setLogoutPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
: {
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
);
}, []);
useEffect(() => {
+46 -27
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
@@ -79,9 +80,10 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean;
/** Gated cross-account views in the "All accounts" section. */
/** True when the unified view spans multiple login accounts (cross-account).
* Drives the section header: "All accounts" when true, else "Unified Mailbox". */
crossAccountActive?: boolean;
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
showCrossUnread?: boolean;
showCrossStarred?: boolean;
showCrossAll?: boolean;
@@ -220,8 +222,13 @@ function SidebarRowCounts({
) : null;
return (
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
>
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span>
@@ -249,6 +256,11 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** Stable identifiers for integration tests (not user-visible). */
testRole?: string | null;
testName?: string;
testMailboxId?: string;
testShared?: boolean;
}
function SidebarRow({
@@ -269,6 +281,10 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
testRole,
testName,
testMailboxId,
testShared,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -277,6 +293,11 @@ function SidebarRow({
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
data-testid="folder-row"
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
data-shared={testShared ? 'true' : undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -356,6 +377,7 @@ function SidebarSectionHeader({
first,
icon,
sub,
testId,
}: {
label: string;
expanded: boolean;
@@ -366,6 +388,7 @@ function SidebarSectionHeader({
first?: boolean;
icon?: ReactNode;
sub?: boolean;
testId?: string;
}) {
if (isCollapsed) {
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
@@ -380,6 +403,9 @@ function SidebarSectionHeader({
return (
<button
onClick={onToggle}
data-testid={testId}
data-section-name={label}
data-expanded={expanded ? 'true' : 'false'}
className={cn(
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
paddingX,
@@ -477,6 +503,10 @@ function MailboxTreeItem({
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={label}
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
testShared={node.isShared}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -692,7 +722,7 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
crossAccountActive = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
@@ -858,16 +888,8 @@ export function Sidebar({
// window listener, so without this guard typing in a new email (the
// contentEditable composer, the subject field, search, etc.) toggled the
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return;
}
// composedPath-based so it also sees the QuotedHtml shadow island (#654).
if (isEditableEventTarget(e)) return;
if (!selectedMailbox || isCollapsed) return;
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
@@ -1017,20 +1039,10 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
{showAllMailMailbox && (
<SidebarRow
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('mailboxes.all_mail')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
onClick={() => onMailboxSelect?.('__all_mail__')}
isCollapsed={isCollapsed}
/>
)}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div>
<SidebarSectionHeader
label={t("all_accounts")}
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
expanded={unifiedExpanded}
onToggle={toggleUnified}
isCollapsed={isCollapsed}
@@ -1047,6 +1059,9 @@ export function Sidebar({
key={unifiedId}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
label={t(`unified_${count.role}`)}
testRole={count.role}
testName={`unified-${count.role}`}
testMailboxId={unifiedId}
depth={0}
isSelected={isSelected}
unread={count.unreadEmails}
@@ -1068,6 +1083,8 @@ export function Sidebar({
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
testName={id}
testMailboxId={id}
depth={0}
isSelected={isSelected}
unread={unread}
@@ -1197,6 +1214,7 @@ export function Sidebar({
expanded={sharedExpanded}
onToggle={toggleShared}
isCollapsed={isCollapsed}
testId="section-shared"
/>
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
<>
@@ -1211,6 +1229,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
sub
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
testId="section-shared-account"
/>
{accountExpanded && !isCollapsed && account.children.map((child) => (
<MailboxTreeItem
+9 -6
View File
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getQuoteBodies } from "@/lib/email-composer-utils";
interface ProEmailTabBodyProps {
tabId: string;
@@ -19,8 +20,6 @@ interface ProEmailTabBodyProps {
}
function buildReplyContext(email: Email): ProReplyContext {
const textPartId = email.textBody?.[0]?.partId ?? '';
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
return {
from: email.from,
replyToAddresses: email.replyTo,
@@ -28,8 +27,7 @@ function buildReplyContext(email: Email): ProReplyContext {
cc: email.cc,
bcc: email.bcc,
subject: email.subject,
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
...getQuoteBodies(email),
receivedAt: email.receivedAt,
accountId: email.accountId,
attachments: email.attachments,
@@ -235,8 +233,13 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const bodyText = email.bodyValues
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
: '';
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
? email.bodyValues[email.htmlBody[0].partId].value
// A plain-text-only draft lists its text/plain part under htmlBody
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
const draftHtmlPart = email.htmlBody?.[0];
const htmlBody = draftHtmlPart?.partId
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
&& email.bodyValues?.[draftHtmlPart.partId]
? email.bodyValues[draftHtmlPart.partId].value
: undefined;
// Preserve the identity that matches the draft's From address.
+2
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import arMessages from '@/locales/ar/common.json';
import csMessages from '@/locales/cs/common.json';
import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
@@ -31,6 +32,7 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
ar: arMessages,
cs: csMessages,
da: daMessages,
de: deMessages,
@@ -11,6 +11,7 @@ import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getPathPrefix } from '@/lib/browser-navigation';
import { clearCachedData } from '@/lib/clear-cached-data';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
@@ -54,6 +55,7 @@ export function AboutDataSettings() {
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
@@ -105,6 +107,15 @@ export function AboutDataSettings() {
reader.readAsText(file);
};
const handleRefreshCache = () => {
if (showRefreshConfirm) {
clearCachedData(); // reloads the page
} else {
setShowRefreshConfirm(true);
setTimeout(() => setShowRefreshConfirm(false), 5000);
}
};
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
@@ -187,6 +198,16 @@ export function AboutDataSettings() {
</SettingItem>
)}
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
<Button
variant={showRefreshConfirm ? 'default' : 'outline'}
size="sm"
onClick={handleRefreshCache}
>
{showRefreshConfirm ? tCommon('yes') : t('refresh_cache.button')}
</Button>
</SettingItem>
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
@@ -475,7 +475,7 @@ export function CalendarManagementSettings() {
{colorPickerId === cal.id && (
<div
ref={colorPickerRef}
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
className="absolute start-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<CalendarColorPicker
value={color}
+84 -5
View File
@@ -17,7 +17,16 @@ import {
type LucideIcon,
} from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
import {
DndContext, closestCenter, PointerSensor, KeyboardSensor,
useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext, verticalListSortingStrategy, useSortable,
arrayMove, sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
@@ -81,7 +90,7 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
return (
<div
ref={ref}
className="absolute left-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
className="absolute start-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
>
{ICON_CHOICES.map(({ name, icon: Icon }) => (
<button
@@ -102,10 +111,47 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
);
}
/**
* Wraps a folder row with a drag handle so it can be reordered within its
* sibling group. The handle carries the dnd-kit listeners; the rest of the row
* (buttons, inline editors) stays fully interactive.
*/
function SortableFolderRow({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div ref={setNodeRef} style={style} className="flex items-stretch">
<button
type="button"
{...attributes}
{...listeners}
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-none focus:ring-2 focus:ring-ring flex-shrink-0"
title={title}
aria-label={title}
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes } = useEmailStore();
const sensors = useSensors(
// Small activation distance so clicking the row's buttons still works.
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
@@ -129,6 +175,31 @@ export function FolderSettings() {
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const folderTree = buildMailboxTree(ownMailboxes);
// Reorder folders within a sibling group (same parent). Drops onto a folder
// in a different group are ignored — this reorders, it doesn't reparent.
const handleFolderDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !client) return;
const groups: MailboxNode[][] = [];
const collectGroups = (nodes: MailboxNode[]) => {
groups.push(nodes);
nodes.forEach(n => { if (n.children.length > 0) collectGroups(n.children); });
};
collectGroups(folderTree);
const group = groups.find(g => g.some(n => n.id === active.id));
if (!group) return;
const oldIndex = group.findIndex(n => n.id === active.id);
const newIndex = group.findIndex(n => n.id === over.id);
if (newIndex < 0) return; // dropped outside the active folder's sibling group
const orderedIds = arrayMove(group, oldIndex, newIndex).map(n => n.id);
reorderMailboxes(client, orderedIds).catch(() => {
toast.error(t('reorder_error'));
});
};
const getRoleMailboxId = (role: string): string => {
const mb = ownMailboxes.find(m => m.role === role);
return mb?.id ?? '';
@@ -385,6 +456,7 @@ export function FolderSettings() {
return (
<div key={mb.id}>
<SortableFolderRow id={mb.id} title={t('reorder')}>
<div
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
style={{ paddingLeft: 12 + depth * 16 }}
@@ -482,12 +554,15 @@ export function FolderSettings() {
)}
</div>
</div>
</SortableFolderRow>
{/* Inline subfolder creation */}
{renderCreateInline(mb.id, depth + 1)}
{/* Render children if expanded */}
{hasChildren && isExpanded && (
<div>
{node.children.map(child => renderFolderNode(child))}
<SortableContext items={node.children.map(c => c.id)} strategy={verticalListSortingStrategy}>
{node.children.map(child => renderFolderNode(child))}
</SortableContext>
</div>
)}
</div>
@@ -505,7 +580,11 @@ export function FolderSettings() {
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
</div>
) : (
folderTree.map(node => renderFolderNode(node))
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleFolderDragEnd}>
<SortableContext items={folderTree.map(n => n.id)} strategy={verticalListSortingStrategy}>
{folderTree.map(node => renderFolderNode(node))}
</SortableContext>
</DndContext>
)}
</div>
+36 -18
View File
@@ -118,19 +118,26 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Cross-account "All accounts" views, each gated independently by the admin.
const connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled');
// Unified Mailbox entries (All mail / Unread / Starred), each gated independently
// by the admin. Scope (single account vs. cross-account) is governed by
// `unifiedCrossAccount`; the folder picker below narrows which own folders feed them.
const crossViews = [
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
] as const;
// The folder picker narrows the own folders included in the entries above; show
// it once the user has enabled at least one of them.
const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView;
const anyCrossAllowed = crossViews.some(c => c.allowed);
// Own (non-shared) folders and the active account's All Mail selection. The
// selection is per account: a missing entry = never configured, which
@@ -231,6 +238,13 @@ export function LayoutSettings() {
/>
</SettingItem>
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
<ToggleSwitch
checked={faviconUnreadBadge}
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
@@ -244,6 +258,21 @@ export function LayoutSettings() {
</SettingItem>
)}
{enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
<SettingItem
label={t('unified_mailbox.cross_account.label')}
description={t('unified_mailbox.cross_account.description')}
locked={isSettingLocked('unifiedCrossAccount')}
>
<ToggleSwitch
checked={unifiedCrossAccount}
onChange={(v) => updateSetting('unifiedCrossAccount', v)}
/>
</SettingItem>
</div>
)}
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
<SettingItem
@@ -259,8 +288,9 @@ export function LayoutSettings() {
</div>
)}
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
{enableUnifiedMailbox && anyCrossAllowed && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
<SettingItem
@@ -279,21 +309,9 @@ export function LayoutSettings() {
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
description={t('all_mail.description')}
locked={isSettingLocked('enableAllMailView')}
>
<ToggleSwitch
checked={enableAllMailView}
onChange={(v) => updateSetting('enableAllMailView', v)}
/>
</SettingItem>
)}
{allMailViewAllowed && enableAllMailView && (
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
+15
View File
@@ -35,6 +35,7 @@ export function ReadingSettings() {
hoverActionsCorner,
hideInlineImageAttachments,
attachmentImagePreviewsEnabled,
messageSpacing,
updateSetting,
} = useSettingsStore();
@@ -115,6 +116,20 @@ export function ReadingSettings() {
</SettingItem>
)}
{!isSettingHidden('messageSpacing') && (
<SettingItem label={t('message_spacing.label')} description={t('message_spacing.description')} locked={isSettingLocked('messageSpacing')}>
<Select
value={messageSpacing}
onChange={(value) => updateSetting('messageSpacing', value as typeof messageSpacing)}
options={[
{ value: 'auto', label: t('message_spacing.auto') },
{ value: 'always', label: t('message_spacing.always') },
{ value: 'edge', label: t('message_spacing.edge') },
]}
/>
</SettingItem>
)}
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
+85 -25
View File
@@ -4,9 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { RichTextEditor } from '@/components/email/rich-text-editor';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
import { htmlToPlainText } from '@/lib/html-to-text';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -27,6 +30,7 @@ export function VacationSettings() {
toDate,
subject,
textBody,
htmlBody,
isLoading,
isSaving,
error,
@@ -40,6 +44,8 @@ export function VacationSettings() {
const [localToDate, setLocalToDate] = useState(toDate || '');
const [localSubject, setLocalSubject] = useState(subject);
const [localTextBody, setLocalTextBody] = useState(textBody);
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
const [showPreview, setShowPreview] = useState(false);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
@@ -55,7 +61,9 @@ export function VacationSettings() {
setLocalToDate(toDate || '');
setLocalSubject(subject);
setLocalTextBody(textBody);
}, [isEnabled, fromDate, toDate, subject, textBody]);
setHtmlEnabled(!!htmlBody);
setLocalHtmlBody(htmlBody || '');
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
const validate = useCallback(() => {
const warnings: string[] = [];
@@ -70,13 +78,14 @@ export function VacationSettings() {
warnings.push(t('warnings.start_in_past'));
}
if (localEnabled && !localTextBody.trim()) {
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
warnings.push(t('warnings.empty_body'));
}
setValidationWarnings(warnings);
return warnings;
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, t]);
useEffect(() => {
validate();
@@ -87,7 +96,8 @@ export function VacationSettings() {
(localFromDate || null) !== (fromDate || null) ||
(localToDate || null) !== (toDate || null) ||
localSubject !== subject ||
localTextBody !== textBody;
localTextBody !== textBody ||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
@@ -96,13 +106,25 @@ export function VacationSettings() {
validate();
if (hasBlockingError) return;
const sanitizedHtml =
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
? sanitizeEmailHtml(localHtmlBody)
: null;
// Keep a plain-text part as the fallback for clients that don't render
// HTML. If the user left it blank, derive it from the HTML body.
const textBody =
localTextBody.trim() || !sanitizedHtml
? localTextBody
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
try {
await updateVacationResponse(client, {
isEnabled: localEnabled,
fromDate: localFromDate || null,
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
textBody,
htmlBody: sanitizedHtml,
}, managedAccountId ?? undefined);
toast.success(tNotifications('vacation_saved'));
@@ -217,28 +239,66 @@ export function VacationSettings() {
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
/>
</div>
<SettingItem
label={t('message.html_label')}
description={t('message.html_description')}
>
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
</SettingItem>
{htmlEnabled && (
<div className="pb-3">
<div className="rounded-md border border-border overflow-hidden">
<RichTextEditor
content={localHtmlBody}
onChange={setLocalHtmlBody}
placeholder={t('message.html_placeholder')}
/>
</div>
</div>
)}
</SettingsSection>
{localTextBody.trim() && (
<SettingsSection title={t('preview.title')}>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? t('preview.hide') : t('preview.show')}
</button>
{showPreview && (
<div className="mt-3 p-4 rounded border border-border bg-background">
{localSubject && (
<p className="font-medium text-foreground mb-2">{localSubject}</p>
)}
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
</div>
)}
</SettingsSection>
)}
{(() => {
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (!localTextBody.trim() && !showHtmlPreview) return null;
return (
<SettingsSection title={t('preview.title')}>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? t('preview.hide') : t('preview.show')}
</button>
{showPreview && (
<div className="mt-3 p-4 rounded border border-border bg-background">
{localSubject && (
<p className="font-medium text-foreground mb-2">{localSubject}</p>
)}
{showHtmlPreview ? (
<div
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
// Preview renders into the app's own DOM. Intercept anchor
// clicks so following a link doesn't navigate the whole app
// away (and lose the unsaved responder), opening a new tab.
onClick={(e) => {
const anchor = (e.target as HTMLElement).closest('a');
if (anchor?.href) {
e.preventDefault();
window.open(anchor.href, '_blank', 'noopener,noreferrer');
}
}}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
/>
) : (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
)}
</div>
)}
</SettingsSection>
);
})()}
{validationWarnings.length > 0 && (
<div className="space-y-2">
@@ -94,7 +94,7 @@ export function PlaceholderFillModal({
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">{t('preview')}</p>
<div className="text-sm text-foreground whitespace-pre-wrap p-3 rounded-md bg-muted/50 border border-border max-h-32 overflow-y-auto">
{preview}
{template.isHTML ? <div dangerouslySetInnerHTML={{ __html: preview }}></div> : preview}
</div>
</div>
)}
+13 -3
View File
@@ -4,7 +4,7 @@ import { useState, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Star, Plus } from 'lucide-react';
import { Star, Plus, Square, CheckSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
import { validateTemplateName } from '@/lib/template-utils';
import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types';
@@ -38,6 +38,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
const [category, setCategory] = useState(template?.category || '');
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
const [body, setBody] = useState(template?.body || initialData?.body || '');
const [isHTML, setIsHTML] = useState(template?.isHTML || false);
const [toRecipients, setToRecipients] = useState(
template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || ''
);
@@ -76,6 +77,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
name: name.trim(),
subject,
body,
isHTML,
category: category.trim(),
defaultRecipients: to.length || cc.length || bcc.length
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
@@ -190,6 +192,14 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
rows={6}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
/>
<button
type="button"
onClick={() => setIsHTML(!isHTML)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{isHTML ? <CheckSquare className={cn('w-4 h-4')}></CheckSquare> : <Square className={cn('w-4 h-4')}></Square>}
HTML
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
@@ -232,7 +242,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
>
<option value="">{tSettings('default_identity')}</option>
{identities.map((id) => (
<option key={id.id} value={id.id}>
<option key={id.id} value={id.id} dir="ltr">
{id.name ? `${id.name} <${id.email}>` : id.email}
</option>
))}
@@ -275,7 +285,7 @@ function PlaceholderDropdown({
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="absolute right-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
<div className="absolute end-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
<div className="p-1">
{BUILT_IN_PLACEHOLDERS.map((p) => (
<button
+8
View File
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
}
export function ContextMenuItem({
@@ -117,10 +119,12 @@ export function ContextMenuItem({
disabled = false,
destructive = false,
shortcut,
testId,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
data-testid={testId}
disabled={disabled}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
@@ -153,12 +157,15 @@ interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
testId,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
@@ -232,6 +239,7 @@ export function ContextMenuSubMenu({
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
data-testid={testId}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
+13
View File
@@ -274,6 +274,18 @@ const skCross =
" 7.22 49.54 7.11-.62-20.5-6.6-46.33-6.6-46.33s12.3.96 17.22.96c4.92 0" +
" 17.21-.96 17.21-.96s-5.97 25.83-6.6 46.33c12.18.1 29.72-.49 49.55-7.11" +
" 0 0-.62 8.3-.62 17.98 0 9.67.62 17.98.62 17.98-19.86-6.64-37.42-7.22-49.6-7.12v32.37";
/** United Arab Emirates Red hoist stripe, green/white/black horizontal bands */
export function FlagAE(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width={W} height={H} className={flagClass} {...props}>
<rect width="4" height="3" fill="#fff" />
<rect x="1" width="3" height="1" fill="#00732F" />
<rect x="1" y="2" width="3" height="1" fill="#000" />
<rect width="1" height="3" fill="#FF0000" />
</svg>
);
}
const skHills =
"M270 329.1c-24.87 0-38.19 34.46-38.19 34.46s-7.4-16.34-27.68-16.34" +
"c-13.73 0-23.82 12.2-30.25 23.5 24.97 39.7 64.8 64.2 96.11 79.28" +
@@ -319,4 +331,5 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
zh: FlagCN,
fa: FlagIR,
he: FlagIL,
ar: FlagAE,
};
+1
View File
@@ -8,6 +8,7 @@ import { flagComponents } from './flag-icons';
const languages = [
{ value: 'auto', label: 'Auto' },
{ value: 'ar', label: 'العربية' },
{ value: 'cs', label: 'Česky' },
{ value: 'sk', label: 'Slovenčina' },
{ value: 'da', label: 'Dansk' },
+42 -18
View File
@@ -21,6 +21,7 @@ export interface Toast {
onClick?: () => void;
icon?: React.ReactNode;
action?: ToastAction;
secondaryAction?: ToastAction;
}
interface ToastProps {
@@ -117,25 +118,48 @@ export function ToastItem({ toast, onClose }: ToastProps) {
{toast.message && (
<p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p>
)}
{toast.action && (
<button
onClick={(e) => {
e.stopPropagation();
try {
toast.action!.onClick();
dismiss();
} catch {
// Don't close toast on error so user can retry
}
}}
className={cn(
"mt-2 text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
"text-foreground"
{(toast.action || toast.secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
{toast.secondaryAction && (
<button
onClick={(e) => {
e.stopPropagation();
try {
toast.secondaryAction!.onClick();
dismiss();
} catch {
// Don't close toast on error so user can retry
}
}}
className={cn(
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{toast.secondaryAction.label}
</button>
)}
>
{toast.action.label}
</button>
{toast.action && (
<button
onClick={(e) => {
e.stopPropagation();
try {
toast.action!.onClick();
dismiss();
} catch {
// Don't close toast on error so user can retry
}
}}
className={cn(
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
"text-foreground"
)}
>
{toast.action.label}
</button>
)}
</div>
)}
</div>
+2
View File
@@ -79,6 +79,8 @@ export default [
"e2e/**",
"local-data/**/*.mjs",
"benchmark/**",
"examples/**",
"integration/**",
],
},
];
+559
View File
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { StrictMode } from 'react';
import { render, renderHook, waitFor } from '@testing-library/react';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
vi.mock('@/lib/favicon-badge', () => ({
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
),
}));
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
/** The link the hook owns: the only one it may ever touch. */
function badgeLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
}
/** The base icon link the page (or React) rendered: must survive untouched. */
function baseLinks(): HTMLLinkElement[] {
return Array.from(
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
);
}
/** Every icon link in <head>, in document order. The browser honours the last. */
function iconLinks(): HTMLLinkElement[] {
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
}
function lastIconLink(): HTMLLinkElement | null {
return iconLinks().at(-1) ?? null;
}
/**
* What Next/React does on a client-side navigation: it re-hoists its metadata
* icon link into <head>, appending a *fresh* node after everything already
* there including our badge link.
*/
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
const link = document.createElement('link');
link.rel = 'icon';
link.href = href;
document.head.appendChild(link);
return link;
}
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
}
function Badger({ count }: { count: number }) {
useFaviconBadge(count);
return null;
}
beforeEach(() => {
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// document.head outlives every test, so a spy on it that a failing assertion
// never got to restore would leak into the next test's counts.
vi.restoreAllMocks();
});
describe('useFaviconBadge', () => {
it('appends its own badged icon link when the count is positive', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
// Last-declared icon wins, so ours must be last in <head>.
expect(document.head.lastElementChild).toBe(badgeLink());
});
it('never removes or mutates an icon link it did not create', async () => {
const before = baseLinks()[0];
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(before.isConnected).toBe(true);
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
expect(baseLinks()).toHaveLength(1);
});
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const survivors = baseLinks();
expect(survivors).toHaveLength(2);
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
expect(survivors[1].getAttribute('href')).toBe('/a.png');
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
});
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
// that DOM node. Removing it out from under React makes the commit phase throw
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
// later deleted. The hook must therefore never touch a node it did not create.
document.head.innerHTML = '';
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
// The React-owned node is still there, untouched.
const reactOwned = baseLinks();
expect(reactOwned).toHaveLength(1);
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
expect(() => unmount()).not.toThrow();
expect(badgeLink()).toBeNull();
});
it('replaces its own link rather than mutating its href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const first = badgeLink();
rerender({ n: 4 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
});
// Firefox ignores an in-place href change on the favicon link.
expect(badgeLink()).not.toBe(first);
expect(first!.isConnected).toBe(false);
});
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
// The field bug: Firefox does not re-evaluate the favicon when an icon link
// is *removed* — a removal is not an insertion, so it keeps painting the
// last icon it was handed and the stale "99+" badge sticks until a hard
// reload. Clearing must therefore be an insertion: our own link is replaced
// by a brand-new node carrying the original base href.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('inserts the base-href link exactly once while the count stays at zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () =>
appendSpy.mock.calls.filter(
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
).length;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(ownAppends()).toBe(1);
// Neither further renders at the same count nor unrelated <head> churn (an
// observer tick) may re-insert it: no remove/append thrash on every tick.
rerender({ n: 0 });
rerender({ n: 0 });
document.head.appendChild(document.createElement('meta'));
await settle();
expect(ownAppends()).toBe(1);
expect(badgeLink()).toBe(restored);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('keeps its own link last even while it is only carrying the base href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
it('badges again with a fresh insertion when the count leaves zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ n: 7 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('removes only its own link on unmount', async () => {
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does not fetch, and adds no link, while the count is zero', async () => {
renderHook(() => useFaviconBadge(0));
await Promise.resolve();
await Promise.resolve();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
});
it('leaves the icon alone when the base is not SVG', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
);
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
// Assert on the observable end state, not merely on the href being
// unchanged: the href is also unchanged *before* the catch block runs,
// so a href-only assertion would pass even if the code went on to swap
// the icon a tick later. The renderer must never be reached.
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('leaves the icon alone when the base fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does nothing when there is no icon link to read', async () => {
document.head.innerHTML = '';
renderHook(() => useFaviconBadge(3));
await Promise.resolve(); // let any deferred async work start
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
});
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
// StrictMode double-invokes effects, and a count change while the fetch is in
// flight re-runs the effect: neither may issue a second request.
let resolveFetch: (response: Response) => void = () => {};
const inFlight = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
vi.stubGlobal(
'fetch',
vi.fn(() => inFlight),
);
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 1 },
wrapper: StrictMode,
});
rerender({ n: 2 });
rerender({ n: 5 });
expect(fetch).toHaveBeenCalledTimes(1);
resolveFetch(svgResponse());
await waitFor(() => {
// The badge lands on the latest count, not the one in flight at fetch time.
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
});
rerender({ n: 6 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not re-render the badge when the count is unchanged', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 3 },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const settled = badgeLink();
renderBadgedFaviconMock.mockClear();
rerender({ n: 3 });
// The element identity alone is not evidence: `count` is the effect's only
// dependency, so React would skip the effect regardless. Assert the
// renderer was not invoked again — that is the behaviour under test.
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBe(settled);
});
describe('when the setting is off', () => {
it('adds no link and does not fetch, however high the count', async () => {
renderHook(() => useFaviconBadge(3, false));
await settle();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
// Same guarantee as the count-back-to-zero clear, and for the same reason:
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
// Turning the setting off by *removing* our link would leave the stale
// badge painted on the tab until a hard reload.
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ on: true });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('keeps its base-href link last when React re-hoists its icon', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
});
describe('when React re-hoists its icon link on a client-side navigation', () => {
it('moves its own link back to the end so the badge keeps winning', async () => {
// The field bug: Inbox badges the tab, a hop to /calendar makes React
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
// wins again and the badge vanishes.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(lastIconLink()).toBe(badgeLink());
const own = badgeLink()!;
const rehoisted = rehoistBaseIcon();
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(document.head.lastElementChild).toBe(own);
expect(badgeLink()).toBe(own); // moved, not recreated
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
});
it('restores the badge without the count changing', async () => {
// The "never comes back" half of the bug. Navigating back to the inbox
// does not change the unread count, so nothing re-runs the count effect:
// the observer alone must put the badge back on top.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
renderBadgedFaviconMock.mockClear();
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
rerender({ n: 3 }); // same count: no effect re-run to lean on
await settle();
expect(lastIconLink()).toBe(badgeLink());
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('re-applies the badge if its own link is removed entirely', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
badgeLink()!.remove(); // React blows away part of <head>
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
});
it('settles: re-appending its own link does not feed the observer a loop', async () => {
// Moving our link fires the observer again. If the move is not guarded by
// "am I already last?", that second run moves it again, for ever. Count
// the appends of *our* node: exactly one, and it must stop growing.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const own = badgeLink()!;
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(ownAppends()).toBe(1);
await settle();
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
expect(lastIconLink()).toBe(own);
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
expect(iconLinks()).toHaveLength(3);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('still never removes or mutates a link it did not create', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
const rehoisted = rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
const survivors = baseLinks();
expect(survivors).toHaveLength(4);
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
'/a.svg',
'/a.png',
ORIGINAL_HREF,
ORIGINAL_HREF,
]);
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
expect(rehoisted.isConnected).toBe(true);
// The React-owned node is still React's to delete.
expect(() => unmount()).not.toThrow();
});
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(disconnect).toHaveBeenCalled();
expect(badgeLink()).toBeNull();
// A post-unmount re-hoist must not resurrect the badge.
rehoistBaseIcon();
await settle();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(2);
disconnect.mockRestore();
});
});
});
+8
View File
@@ -30,6 +30,8 @@ interface ConfigData {
loginLogoMaxWidth: string;
loginShowHeading: boolean;
loginShowSubtitle: boolean;
loginShowTotp: boolean;
loginShowVersion: boolean;
demoMode: boolean;
autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean;
@@ -113,6 +115,8 @@ export function useConfig(): AppConfig {
loginLogoMaxWidth: configCache?.loginLogoMaxWidth || '',
loginShowHeading: configCache?.loginShowHeading ?? true,
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
loginShowTotp: configCache?.loginShowTotp ?? true,
loginShowVersion: configCache?.loginShowVersion ?? true,
demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
@@ -152,6 +156,8 @@ export function useConfig(): AppConfig {
loginLogoMaxWidth: configCache.loginLogoMaxWidth,
loginShowHeading: configCache.loginShowHeading,
loginShowSubtitle: configCache.loginShowSubtitle,
loginShowTotp: configCache.loginShowTotp,
loginShowVersion: configCache.loginShowVersion,
demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
@@ -192,6 +198,8 @@ export function useConfig(): AppConfig {
loginLogoMaxWidth: data.loginLogoMaxWidth,
loginShowHeading: data.loginShowHeading,
loginShowSubtitle: data.loginShowSubtitle,
loginShowTotp: data.loginShowTotp,
loginShowVersion: data.loginShowVersion,
demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useRef } from 'react';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
import { debug } from '@/lib/debug';
// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
// layout.tsx) renders <link rel="icon"> through React, which hoists it into
// <head> and keeps a fiber pointing at that DOM node. Removing it out from
// under React leaves the fiber holding a detached node, and the next commit
// that deletes that fiber throws "Cannot read properties of null (reading
// 'removeChild')". So we never remove or mutate a node we did not create:
// instead we append an *extra* icon link, marked as ours. The last-declared
// icon wins in browsers, so ours overrides the base without deleting it.
//
// Ours is never removed to clear the badge, though — only on unmount. Firefox
// re-evaluates the favicon on an *insertion* and on nothing else: a removal
// leaves it painting the last icon it was handed, which is how a read inbox
// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
// the original base href in place of the badge (see `apply`).
const MARKER = 'data-favicon-badge';
const OWN_SELECTOR = `link[${MARKER}]`;
const ICON_SELECTOR = 'link[rel~="icon"]';
const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
// Both the badged icon and the untouched base we fall back to are SVG: the hook
// disables itself unless the fetched base is served as image/svg+xml, so by the
// time either link exists that content type is a proven fact, not a guess.
const ICON_TYPE = 'image/svg+xml';
function removeOwnLink(): void {
document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
}
function ownLink(): HTMLLinkElement | null {
return document.head.querySelector<HTMLLinkElement>(OWN_SELECTOR);
}
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
function isLastIconLink(link: HTMLLinkElement): boolean {
const icons = document.head.querySelectorAll<HTMLLinkElement>(ICON_SELECTOR);
return icons[icons.length - 1] === link;
}
/**
* Appends a fresh icon link of ours, replacing any previous one of ours.
*
* Always a remove-then-append of a *new* node, never an href mutation: Firefox
* only re-evaluates the favicon when an icon link is inserted. It ignores an
* in-place href change, and the count-back-to-zero bug it equally ignores a
* removal, happily painting the last icon it was handed. So even *clearing* the
* badge is done by inserting: see `apply`, which re-inserts our link carrying
* the original base href rather than deleting it.
*/
function setOwnLink(href: string): void {
removeOwnLink();
const link = document.createElement('link');
link.rel = 'icon';
link.type = ICON_TYPE;
link.href = href;
link.setAttribute(MARKER, '');
document.head.appendChild(link);
}
/**
* Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
* false (the `faviconUnreadBadge` setting).
*
* The base icon is read from the rendered <link rel="icon">, so admin and
* per-domain branding overrides (configManager `faviconUrl`) are respected
* without plumbing config to the client.
*
* Every failure no icon link, a fetch error, a non-SVG base, unparseable
* source leaves the existing favicon untouched.
*/
export function useFaviconBadge(count: number, enabled = true): void {
// Disabled is just "nothing to show", i.e. exactly a count of zero, so it
// rides the same paths: no fetch while we have never badged, and — the part
// that matters — clearing an *existing* badge by inserting a fresh link
// carrying the base href rather than removing ours, which Firefox would
// ignore (see `apply`). Switching the setting off therefore restores the
// plain icon immediately, with no reload.
const effectiveCount = enabled ? count : 0;
const baseSource = useRef<string | null>(null);
const baseHref = useRef<string | null>(null);
// The href our own link currently carries, and the hook's whole state machine:
// null -> nothing of ours is in <head> (we have never badged)
// baseHref -> ours is in <head>, showing the unbadged base icon
// a data: URL -> ours is in <head>, showing the badge
const appliedHref = useRef<string | null>(null);
const disabled = useRef(false);
const fetchStarted = useRef(false);
const unmounted = useRef(false);
const latestCount = useRef(effectiveCount);
latestCount.current = effectiveCount;
// Declared before the badge effect so that on a StrictMode remount it runs
// first and clears `unmounted` before the badge effect reads it.
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
// Restore the server-rendered favicon by removing our override. Nothing
// else in <head> is ours to touch.
removeOwnLink();
appliedHref.current = null;
};
}, []);
// Reads the refs rather than a closure over `count`, so that the reply to an
// in-flight fetch — and the MutationObserver below, which outlives any single
// render — lands on the newest count, not the one that started it.
const apply = useCallback(() => {
if (unmounted.current || disabled.current) return;
const current = latestCount.current;
if (current <= 0) {
// Clearing the badge is an *insertion*, not a removal.
//
// The field bug: with 133 unread the tab showed "99+", the user read
// everything, the store went to 0 — and Firefox kept painting "99+" until
// a hard reload. Removing our link is not an insertion, and Firefox only
// re-evaluates the favicon on an insertion; a removal leaves it painting
// the last icon it was handed. So instead of deleting our link we replace
// it with a fresh one carrying the *original* base href: same pixels as
// the untouched base link below it, but handed to the browser as a new
// icon, which it does repaint.
//
// Never badged (`appliedHref` still null)? Then nothing of ours is in
// <head> and nothing should be: a fully-read inbox adds no link at all.
const base = baseHref.current;
if (appliedHref.current === null || base === null) return;
if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
setOwnLink(base);
appliedHref.current = base;
return;
}
const source = baseSource.current;
if (source === null) return; // still fetching; the fetch will call back
const next = renderBadgedFavicon(source, current);
if (!next) return;
if (next === appliedHref.current && ownLink()) return;
setOwnLink(next);
appliedHref.current = next;
}, []);
useEffect(() => {
if (disabled.current) return;
// Nothing to show and nothing applied: do not even fetch. A fully-read
// inbox — or the setting switched off before we ever badged — should cost
// no request.
if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
// The base is fetched at most once, ever. Without this guard a StrictMode
// double-invoke issues two requests, and any count change while the fetch
// is in flight issues another.
if (baseSource.current !== null || fetchStarted.current) {
apply();
return;
}
// The one and only read of the base link. Its href is both what we fetch the
// source from and what we hand back to the browser when the badge clears.
const link = document.querySelector<HTMLLinkElement>(BASE_SELECTOR);
const href = link?.getAttribute('href');
if (!href) {
disabled.current = true;
return;
}
baseHref.current = href;
fetchStarted.current = true;
void (async () => {
try {
const response = await fetch(href);
if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('image/svg+xml')) {
throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
}
baseSource.current = await response.text();
apply();
} catch (error) {
disabled.current = true;
debug.log('[favicon-badge] disabled:', error);
}
})();
}, [effectiveCount, apply]);
// Keep ours the last icon link in <head>.
//
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
// <link rel="icon"> from app/(main)/layout.tsx into <head>. The re-inserted
// node lands *after* our badge link, the last-declared icon wins, and the
// badge vanishes. Coming back to the inbox did not bring it back either: the
// count is unchanged, so the effect above never re-ran and our link just sat
// there outranked. Watching <head> fixes both halves at once.
//
// Termination: moving our own link is itself a <head> mutation, so it feeds
// the observer a fresh record. The guard is `isLastIconLink` — on that second
// run ours *is* last, so we do nothing and the cascade stops. One move per
// foreign insertion, never two.
const keepOwnLinkLast = useCallback(() => {
if (unmounted.current || disabled.current) return;
// Ours must stay last in *both* states — badged, and showing the base href
// after a clear (`appliedHref` is only null when we have never badged, and
// then nothing of ours is in <head> to keep last). Gating this on the count
// instead would strand our base-href link behind a re-hoisted React icon,
// and the next badge would have to fight its way back on top.
if (appliedHref.current === null) return;
const own = ownLink();
if (!own) {
// React blew our link away with the rest of the head: re-apply from scratch.
apply();
return;
}
if (isLastIconLink(own)) return;
// Re-appending *our own* element is the only mutation we ever make; a node
// we did not create is never removed, moved or touched (see above).
document.head.appendChild(own);
}, [apply]);
useEffect(() => {
const observer = new MutationObserver(keepOwnLinkLast);
observer.observe(document.head, { childList: true });
return () => observer.disconnect();
}, [keepOwnLinkLast]);
}
+5 -14
View File
@@ -2,6 +2,7 @@
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
export interface KeyboardShortcutHandlers {
// Navigation
@@ -43,18 +44,6 @@ export interface UseKeyboardShortcutsOptions {
handlers: KeyboardShortcutHandlers;
}
// Check if user is typing in an input field
function isInputFocused(): boolean {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
return isInput || isContentEditable;
}
// Shortcuts must fire regardless of the active keyboard layout (e.g. Cyrillic,
// Greek). Derive the key from the PHYSICAL key (event.code) instead of the
// layout-dependent event.key: letters from KeyA..KeyZ, and the symbol shortcuts
@@ -94,8 +83,10 @@ export function useKeyboardShortcuts({
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (isInputFocused()) return;
// Don't trigger shortcuts when typing in inputs. Must be event-based
// (composedPath), not document.activeElement: the QuotedHtml island's
// shadow root retargets activeElement to its plain-div host (#654).
if (isEditableEventTarget(event)) return;
const h = handlersRef.current;
const key = physicalShortcutKey(event);
+12 -2
View File
@@ -1,6 +1,16 @@
// RTL locales: Hebrew (he) and Persian/Farsi (fa). Everything else is LTR.
const rtlLocales = new Set(['he', 'fa']);
// RTL locales: Arabic (ar), Hebrew (he), and Persian/Farsi (fa). Everything else is LTR.
const rtlLocales = new Set(['ar', 'he', 'fa']);
export function getLocaleDirection(locale: string): 'ltr' | 'rtl' {
return rtlLocales.has(locale) ? 'rtl' : 'ltr';
}
/**
* Whether the document is currently rendering right-to-left. For popovers
* positioned in JS via getBoundingClientRect() (Tailwind's logical start-0/
* end-0 utilities don't apply to inline fixed-position styles), check this
* to anchor on the correct physical side.
*/
export function isDocumentRTL(): boolean {
return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
}
+3
View File
@@ -33,6 +33,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// Use static imports for better compatibility
let messages;
switch (locale) {
case 'ar':
messages = (await import('../locales/ar/common.json')).default;
break;
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
const SUPPORTED_LOCALES = ['ar', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
// Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+2 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate";
import { detectSetupState } from "./lib/setup/state";
import { ensureSetupToken } from "./lib/setup/token";
@@ -14,6 +14,7 @@ console.info(`Bulwark Webmail v${current}`);
// Initialize admin config and password bootstrap. Migration runs first so
// existing v1 layouts are split before anything reads admin.json.
migrateLegacyAdminLayout()
.then(() => migratePolicyUnifiedMailbox())
.then(() => configManager.load())
.then(() => initAdminPassword())
.then(async () => {
+11
View File
@@ -0,0 +1,11 @@
# Credentials for the integration-test Stalwart mail server.
# Copy to `.env` (docker compose reads it automatically): cp .env.example .env
# Recovery admin (format user:password). Stays valid after bootstrap so the
# Stalwart admin UI on http://localhost:8025 remains reachable and stalwart-cli
# can be invoked via `docker exec`.
STALWART_RECOVERY_ADMIN=admin:bootstrap-secret
# Shared password for every test mailbox (alice/bob/carol @ example.org).
# The Playwright harness reads the same value from IT_ACCOUNT_PASSWORD.
TEST_ACCOUNT_PASSWORD=test-pass-123
+10
View File
@@ -0,0 +1,10 @@
# Local docker env (copied from .env.example)
.env
# Arch-specific stalwart-cli binary, fetched by stalwart/prepare-stalwart-cli.sh
stalwart/stalwart-cli
# Playwright/test artifacts
node_modules/
test-results/
playwright-report/
+167
View File
@@ -0,0 +1,167 @@
# Integration tests — webmail ⇆ Stalwart
End-to-end tests that run the **Bulwark webmail against a real Stalwart mail
server** in Docker and drive it with Playwright. The focus is the mail/folder
**synchronisation** behaviour that multi-account webmail clients get wrong:
unread/total counters, folder-list sync, and the account-scoped Unified Mailbox.
Everything here is self-contained and separate from the app's root
`playwright.config.ts` (which only smoke-tests the UI against `npm run dev`).
## What's in the stack
| Service | Image | Ports (host) | Purpose |
| --------- | --------------------------------------- | ---------------------------------- | -------------------------------------------------------- |
| `stalwart`| built from [`stalwart/`](stalwart/) | `8025` JMAP+admin, `1025` SMTP, `1143` IMAP | Real MTA, declaratively bootstrapped with test mailboxes |
| `webmail` | built from [`webmail.Dockerfile`](webmail.Dockerfile) | `3000` | The app under test (Next.js, **dev mode** — see below) |
Provisioned mailboxes (domain `example.org`, shared password `test-pass-123`):
`alice`, `bob`, `carol`. Admin: `admin` / `bootstrap-secret`.
### Two things worth knowing
- **The webmail runs in Next.js dev mode.** The browser talks JMAP *directly*
to Stalwart at `http://localhost:8025` (cross-origin, plain HTTP). The app's
production CSP pins `connect-src` to `'self' https:` and would block that;
dev mode widens it to allow `http:`. Dev mode also ships the test hooks from
source without a production rebuild. See the header of `webmail.Dockerfile`.
- **CORS.** Stalwart doesn't emit CORS headers by default. The bootstrap enables
`usePermissiveCors` (see `stalwart/plan-accounts.ndjson.tpl`) so the browser
origin (`:3000`) may call the JMAP origin (`:8025`).
## Running
```bash
# One-shot: brings the stack up and runs the whole suite in the Playwright
# container (browsers preinstalled, host networking to reach the stack).
integration/run-tests.sh
# A single spec:
integration/run-tests.sh 01-login
```
`run-tests.sh` is the recommended entry point because Playwright's browser
bundles can't always be downloaded/installed on the host; the official
`mcr.microsoft.com/playwright` image sidesteps that.
### Running against a host browser instead
If you *can* install Playwright browsers on your machine:
```bash
cd integration && cp .env.example .env
bash stalwart/prepare-stalwart-cli.sh
docker compose up -d --build --wait
npx playwright test -c playwright.integration.config.ts # from the repo root
```
The Playwright `globalSetup` brings the stack up for you (unless `IT_NO_DOCKER=1`).
## Layout
```
integration/
├── docker-compose.yml # stalwart + webmail
├── webmail.Dockerfile # dev-mode webmail image (built from repo source)
├── webmail-config/policy.json # enables the cross-account Unified Mailbox feature gate
├── run-tests.sh # bring up stack + run suite in the Playwright container
├── stalwart/ # bootstrap image (adapted from examples/docker/stalwart)
│ ├── Dockerfile
│ ├── entrypoint.sh # two-phase declarative bootstrap
│ ├── plan-bootstrap.ndjson # domain + datastore
│ ├── plan-accounts.ndjson.tpl # alice/bob/carol + listeners + CORS
│ └── prepare-stalwart-cli.sh # host-side fetch of stalwart-cli (offline-friendly build)
└── tests/
├── global-setup.ts / global-teardown.ts
├── helpers/
│ ├── config.ts # accounts, URLs, ports (env-overridable)
│ ├── smtp.ts # dependency-free SMTP submission client
│ ├── jmap.ts # JMAP client for seeding/inspecting server state
│ └── app.ts # login, add/switch account, folder-counter reads
├── 01-login.spec.ts
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
├── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
├── 04-all-mail.spec.ts # All Mail view: single-account merge + cross-account
├── 04-shared-identity.spec.ts# composer From offers shared/group send-as identities (issue #569)
├── 05-actions.spec.ts # context-menu read/unread, delete, spam (inbox)
├── 06-shared-folders.spec.ts # delegated folder: appears + read/unread/delete/spam
├── 07-drafts.spec.ts # multiple recipients, changed sender, continue-draft button
├── 08-shared-moves.spec.ts # moving mail across own/shared and shared/shared
├── 09-live-counters.spec.ts # live unified/All-Mail counters (login + shared)
└── 10-attachments.spec.ts # cross-account attachment download from All Mail
```
## Findings surfaced by the suite
Some tests assert server-side truth (or use `test.fail` to pin a known gap)
because the UI behaviour is currently incomplete. Worth a look:
- **Shared-account counters now reconcile on focus/interval** (`09-live-counters`).
Stalwart's SSE only pushes StateChange for the *primary* account, so a
background change in a shared/delegated account is never pushed. The client
now also polls the session's secondary accounts, so their folder badges and
the unified/All-Mail counter refresh on the visibility reconcile and on a slow
background poll. (A *login* account already updates live via its own SSE.)
Note: these shared counters still don't update the instant a local action
runs — they follow the reconcile, not the optimistic path.
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
way `delete` does; it settles after a reconcile.
- **Reopening a draft resets the From selector** to the default identity even
though the draft was saved with (and the server retains) the chosen sender.
Pinned with `test.fail` in `07-drafts`.
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
"Move to" submenu offers the shared folder, but clicking it is a no-op.
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
`08-shared-moves`.
- **Cross-account attachments & inline images (fixed).** Blobs are account-
scoped, so viewing/downloading/previewing an attachment, rendering an inline
`cid:` image, dragging out, and the bundle/S-MIME/TNEF/embedded-message
fetches on an All-Mail message from another account 404'd against the active
account. Every viewer blob fetch now routes to the message's owning client +
accountId (`10-attachments` covers download + inline image).
## How the tests work
- **Mutations** are made out-of-band — mail is injected over SMTP
(`helpers/smtp.ts`) and server-side reads/moves/deletes/folder-creates are
driven over JMAP (`helpers/jmap.ts`). Assertions are on the **rendered UI**,
so a test tells you whether the webmail *synced* the change.
- **Counters** are read from `data-unread` / `data-total` on the
`[data-testid="folder-counts"]` element, which makes assertions locale-
independent. These and the other `data-testid` hooks (`folder-row`,
`email-list-item`, `account-switcher`, `account-option`, `add-account`,
`email-composer`, …) were added to the app for these tests.
- **`forceSync(page)`** dispatches a `visibilitychange` to trigger the client's
`checkForStateChanges()` — the same reconcile a real user gets when tabbing
back. It makes external-mutation assertions deterministic instead of racing
the SSE push channel right after login.
## Environment knobs
| Var | Default | Effect |
| --------------- | ------------------ | ---------------------------------------------------------- |
| `IT_NO_DOCKER` | unset | `1` = don't manage docker in global-setup (stack already up) |
| `IT_TEARDOWN` | unset | `1` = `docker compose down -v` after the suite |
| `IT_WEBMAIL_URL`| `http://localhost:3000` | Webmail origin |
| `IT_JMAP_URL` | `http://localhost:8025` | Stalwart JMAP/admin base URL |
| `IT_SMTP_PORT` | `1025` | Stalwart submission port |
| `IT_VIDEO` | `retain-on-failure`| Video capture: `on` records a `.webm` for **every** test; also `off` / `on-first-retry`. Videos land at `integration/test-results/<test>/video.webm` |
Record videos for a whole run (passing tests included):
```bash
IT_VIDEO=on integration/run-tests.sh # or a single spec: IT_VIDEO=on integration/run-tests.sh 01-login
```
By default the stack is **left running** after the suite so re-runs are fast and
you can poke around (webmail on :3000, Stalwart admin on :8025). Tear it down
with `IT_TEARDOWN=1` or `docker compose -f integration/docker-compose.yml down -v`.
## Resetting
The Stalwart data lives in the `bulwark-it-stalwart-data` volume. To re-run the
bootstrap from scratch:
```bash
docker compose -f integration/docker-compose.yml down -v
```
+78
View File
@@ -0,0 +1,78 @@
name: bulwark-integration
# Integration-test backend for the Bulwark webmail. A single Stalwart mail
# server (JMAP + SMTP submission + IMAP), declaratively bootstrapped with the
# alice/bob/carol test mailboxes. The webmail itself is started by Playwright
# (webServer in playwright.integration.config.ts) so the dev-loop / debugger
# stays on the host; only the hard-to-provision mail backend is containerised.
services:
stalwart:
build:
context: stalwart
container_name: bulwark-it-stalwart
# JMAP + Webmail + admin on 8025, SMTP submission on 1025 (internal 587),
# IMAP on 1143 (internal 143). The browser talks JMAP to localhost:8025;
# the test harness submits mail over SMTP to localhost:1025.
ports:
- "8025:8080"
- "1025:587"
- "1143:143"
volumes:
- stalwart-data:/var/lib/stalwart
- stalwart-config:/etc/stalwart
environment:
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN:?set in .env}
TEST_ACCOUNT_PASSWORD: ${TEST_ACCOUNT_PASSWORD:?set in .env}
healthcheck:
# /jmap/session answers 200 only once the account bootstrap has finished
# and the server is in normal mode.
test: ["CMD-SHELL", "curl -fsS -u alice@example.org:$${TEST_ACCOUNT_PASSWORD} http://127.0.0.1:8080/jmap/session >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
restart: unless-stopped
webmail:
build:
context: ..
dockerfile: integration/webmail.Dockerfile
container_name: bulwark-it-webmail
ports:
- "3000:3000"
volumes:
# Admin policy that turns on the cross-account Unified Mailbox feature
# gate (off by default), so the multi-account unified sync tests can
# exercise it. Read by /api/admin/policy -> usePolicyStore.isFeatureEnabled.
- ./webmail-config/policy.json:/app/data/admin/policy.json:ro
environment:
# Browser-facing JMAP URL. The browser (Playwright) reaches Stalwart on
# the host-published port; the webmail server never fetches this URL
# itself for trusted basic-auth logins, so it need not be container-
# reachable. Setting JMAP_SERVER_URL also puts the app in "env-managed"
# mode, which skips the first-run setup wizard.
JMAP_SERVER_URL: http://localhost:8025
STALWART_FEATURES: "true"
APP_NAME: "Bulwark Webmail (Integration)"
# Enables "Remember me" / settings-sync cookies. Not required for the
# sync tests but harmless and avoids noisy warnings.
SESSION_SECRET: integration-not-a-real-secret
LOG_LEVEL: info
depends_on:
stalwart:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 5s
retries: 30
# next dev compiles routes lazily; give the first boot ample runway.
start_period: 120s
restart: unless-stopped
volumes:
stalwart-data:
name: bulwark-it-stalwart-data
stalwart-config:
name: bulwark-it-stalwart-config
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Run the Playwright integration suite.
#
# Playwright's browser download host is often unreachable (and some host OSes
# aren't supported by the browser bundles), so the tests run inside the official
# Playwright container, which ships the browsers. The container uses host
# networking to reach the published stack ports (webmail :3000, Stalwart :8025).
#
# The docker stack itself is brought up here (on the host) and the in-container
# run is told to skip its own docker management via IT_NO_DOCKER=1.
#
# Usage:
# integration/run-tests.sh # whole suite
# integration/run-tests.sh 01-login # a single spec (grep on file name)
#
# Env:
# IT_VIDEO=on record a video.webm for every test (not just failures);
# also: off | retain-on-failure (default) | on-first-retry.
# e.g. IT_VIDEO=on integration/run-tests.sh 01-login
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
INTEGRATION_DIR="${REPO_ROOT}/integration"
PW_IMAGE="mcr.microsoft.com/playwright:v1.59.1-noble"
cd "${INTEGRATION_DIR}"
[ -f .env ] || cp .env.example .env
echo "== bringing up stack =="
bash stalwart/prepare-stalwart-cli.sh
docker compose --env-file .env up -d --build --wait --wait-timeout 300
echo "== running Playwright in ${PW_IMAGE} =="
FILTER="${1:-}"
docker run --rm --network host \
--user "$(id -u):$(id -g)" \
-v "${REPO_ROOT}":/work -w /work \
-e IT_NO_DOCKER=1 \
-e HOME=/tmp \
-e IT_VIDEO="${IT_VIDEO:-}" \
"${PW_IMAGE}" \
npx playwright test -c playwright.integration.config.ts ${FILTER:+"$FILTER"}
+2
View File
@@ -0,0 +1,2 @@
prepare-stalwart-cli.sh
README.md
+29
View File
@@ -0,0 +1,29 @@
# Stalwart Mail Server with a declarative bootstrap for webmail integration
# testing.
#
# Extends the official image with stalwart-cli and an entrypoint that, on first
# container start, applies the bootstrap + account plans against the freshly
# started server. On subsequent starts the `.bootstrap-applied` marker short-
# circuits both phases and Stalwart boots straight into normal mode.
#
# NOTE: stalwart-cli is COPYed in rather than downloaded during the build. The
# binary is fetched by ./prepare-stalwart-cli.sh (run for you by the Playwright
# global-setup / integration runner). This keeps the build offline-friendly and
# avoids the base image's apt sources, which are unreachable in sandboxed CI.
FROM stalwartlabs/stalwart:v0.16
USER root
# Host-prefetched stalwart-cli matching the build architecture.
COPY stalwart-cli /usr/local/bin/stalwart-cli
RUN mkdir -p /etc/stalwart-bootstrap
COPY plan-bootstrap.ndjson /etc/stalwart-bootstrap/plan-bootstrap.ndjson
COPY plan-accounts.ndjson.tpl /etc/stalwart-bootstrap/plan-accounts.ndjson.tpl
COPY entrypoint.sh /usr/local/bin/stalwart-bootstrap-entrypoint.sh
RUN chmod +x /usr/local/bin/stalwart-cli /usr/local/bin/stalwart-bootstrap-entrypoint.sh
USER stalwart
ENTRYPOINT ["/usr/local/bin/stalwart-bootstrap-entrypoint.sh"]
+154
View File
@@ -0,0 +1,154 @@
#!/bin/sh
# Declarative bootstrap for the integration-test Stalwart Mail Server.
#
# Phase 1 (first start only, marker absent):
# - Stalwart starts in bootstrap mode (no config.json -> HTTP on :8080).
# - plan-bootstrap.ndjson is applied via stalwart-cli. This writes
# config.json, initialises RocksDB and creates the default domain +
# admin account.
# - Stalwart is restarted in normal mode (config.json now exists).
# - plan-accounts.ndjson.tpl is materialised with the resolved DOMAIN_ID
# and the shared TEST_ACCOUNT_PASSWORD, then applied (test accounts +
# submission/IMAP listeners + cleartext auth for the dev lanes).
# - Stalwart is stopped and the marker is written.
#
# Phase 2 (regular start, marker present):
# - exec stalwart as PID 1.
#
# Adapted from examples/docker/stalwart for webmail<->Stalwart integration
# testing: no ticket/service accounts, no Sieve, a single shared password for
# the alice/bob/carol test mailboxes.
set -eu
# stalwart-cli caches its schema under $HOME/.cache/stalwart-cli. The stalwart
# user has no home, so redirect to /tmp.
export HOME=/tmp
DATA_DIR=/var/lib/stalwart
MARKER="${DATA_DIR}/.bootstrap-applied"
PLAN_DIR=/etc/stalwart-bootstrap
STALWART_BIN=/usr/local/bin/stalwart
STALWART_CLI=/usr/local/bin/stalwart-cli
STALWART_CFG=/etc/stalwart/config.json
LOCAL_URL=http://127.0.0.1:8080
log() { printf '[stalwart-bootstrap] %s\n' "$*" >&2; }
wait_for_http() {
for _ in $(seq 1 60); do
if curl -fsS -u "admin:${ADMIN_PASS}" "${LOCAL_URL}/jmap/session" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
log "Stalwart HTTP on :8080 did not come up in time"
return 1
}
run_stalwart_bg() {
"${STALWART_BIN}" --config "${STALWART_CFG}" &
STALWART_PID=$!
}
stop_stalwart_bg() {
if [ -n "${STALWART_PID:-}" ]; then
kill -TERM "${STALWART_PID}" 2>/dev/null || true
wait "${STALWART_PID}" 2>/dev/null || true
STALWART_PID=
fi
}
if [ ! -f "${MARKER}" ]; then
: "${STALWART_RECOVERY_ADMIN:?must be set for first-run bootstrap}"
: "${TEST_ACCOUNT_PASSWORD:?must be set for first-run bootstrap}"
ADMIN_PASS=${STALWART_RECOVERY_ADMIN#*:}
log "Phase 1: starting Stalwart in bootstrap mode"
run_stalwart_bg
wait_for_http
log "Applying plan-bootstrap.ndjson"
STALWART_URL=${LOCAL_URL} \
STALWART_USER=admin \
STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" apply --file "${PLAN_DIR}/plan-bootstrap.ndjson" --quiet
log "Restarting Stalwart to leave bootstrap mode"
stop_stalwart_bg
run_stalwart_bg
wait_for_http
log "Resolving DOMAIN_ID for example.org"
DOMAIN_ID=$(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query Domain --json 2>/dev/null \
| head -1 \
| sed -E 's/.*"id":"([^"]+)".*/\1/')
if [ -z "${DOMAIN_ID}" ]; then
log "Could not resolve DOMAIN_ID after bootstrap"
stop_stalwart_bg
exit 1
fi
log "DOMAIN_ID=${DOMAIN_ID}"
# Materialise the account plan. gettext/envsubst is not in the base image,
# so substitute the two placeholders with sed. Passwords are escaped for the
# sed replacement (& and / are the only metacharacters that matter here).
PLAN_ACCOUNTS=/tmp/plan-accounts.ndjson
esc_pw=$(printf '%s' "${TEST_ACCOUNT_PASSWORD}" | sed -e 's/[&/\\]/\\&/g')
sed -e "s/\${DOMAIN_ID}/${DOMAIN_ID}/g" \
-e "s/\${TEST_ACCOUNT_PASSWORD}/${esc_pw}/g" \
"${PLAN_DIR}/plan-accounts.ndjson.tpl" > "${PLAN_ACCOUNTS}"
log "Applying plan-accounts.ndjson"
STALWART_URL=${LOCAL_URL} \
STALWART_USER=admin \
STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet
rm -f "${PLAN_ACCOUNTS}"
# Make 'carol' a member of the 'team' group *before her first login*, so she
# already has the shared group mailbox (its folders show under "Shared") and
# the team@ send-as identity. This provisions the issue #569 scenario: the
# composer's From dropdown should then offer the group address. Membership is
# a set keyed by the group's server-assigned account id (see the User schema's
# memberGroupIds), so the ids are resolved here, mirroring DOMAIN_ID above.
# carol (not alice/bob) is used so the single-/multi-account sync specs, which
# drive alice and bob, keep a clean unshared environment.
q_account_id() {
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query Account --json 2>/dev/null \
| grep "\"emailAddress\":\"$1\"" \
| sed -E 's/.*"id":"([^"]+)".*/\1/'
}
CAROL_ID=$(q_account_id "carol@example.org")
TEAM_ID=$(q_account_id "team@example.org")
if [ -n "${CAROL_ID}" ] && [ -n "${TEAM_ID}" ]; then
log "Adding carol (${CAROL_ID}) to the team group (${TEAM_ID}) [issue #569]"
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" update Account "${CAROL_ID}" \
--field "memberGroupIds={\"${TEAM_ID}\":true}" >/dev/null
else
log "WARNING: could not resolve account ids (carol='${CAROL_ID}' team='${TEAM_ID}'); skipping group membership"
fi
# Default inbound throttles (sender->recipient + sender-IP) otherwise trip
# 452 4.4.5 when a test blasts many messages. Stalwart re-seeds the defaults
# on every start when absent, so deleting is useless; disable them instead,
# which survives restarts.
for tid in $(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query MtaInboundThrottle --json 2>/dev/null \
| sed -E 's/.*"id":"([^"]+)".*/\1/'); do
log "Disabling MtaInboundThrottle ${tid}"
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" update MtaInboundThrottle "${tid}" --field enable=false >/dev/null
done
log "Stopping bootstrap instance, marking complete"
stop_stalwart_bg
touch "${MARKER}"
fi
log "Starting Stalwart (final, foreground)"
exec "${STALWART_BIN}" --config "${STALWART_CFG}"
@@ -0,0 +1,9 @@
{"@type":"create","object":"Account","value":{"alice":{"@type":"User","name":"alice","domainId":"${DOMAIN_ID}","description":"Integration test mailbox alice","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"bob":{"@type":"User","name":"bob","domainId":"${DOMAIN_ID}","description":"Integration test mailbox bob","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"carol":{"@type":"User","name":"carol","domainId":"${DOMAIN_ID}","description":"Integration test mailbox carol","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"team":{"@type":"Group","name":"team","domainId":"${DOMAIN_ID}","description":"Team shared mailbox"}}}
{"@type":"create","object":"NetworkListener","value":{"submission":{"name":"submission","protocol":"smtp","bind":{"[::]:587":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
{"@type":"create","object":"NetworkListener","value":{"imap":{"name":"imap","protocol":"imap","bind":{"[::]:143":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
{"@type":"update","object":"MtaStageAuth","value":{"saslMechanisms":{"match":{"0":{"if":"local_port != 25","then":"[plain, login, oauthbearer, xoauth2]"}},"else":"false"}}}
{"@type":"update","object":"Imap","value":{"allowPlainTextAuth":true}}
{"@type":"update","object":"Http","value":{"usePermissiveCors":true}}
@@ -0,0 +1 @@
{"@type":"update","object":"Bootstrap","value":{"serverHostname":"mail.example.org","defaultDomain":"example.org","generateDkimKeys":false,"requestTlsCertificate":false,"dataStore":{"@type":"RocksDb","path":"/var/lib/stalwart/data","blobSize":16834,"bufferSize":134217728}}}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Fetch the stalwart-cli binary used by the Stalwart bootstrap image.
#
# The Dockerfile COPYs ./stalwart-cli instead of downloading it during the
# build, because the base image's apt sources and the build network are
# unreachable in the sandboxed CI environment. This script does the fetch on
# the host (which has working outbound HTTPS) and extracts the binary with
# Python's lzma module (xz is not guaranteed to be installed).
#
# Idempotent: skips the download when a matching binary already exists.
set -euo pipefail
CLI_VERSION="${STALWART_CLI_VERSION:-1.0.6}"
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${HERE}/stalwart-cli"
case "$(uname -m)" in
x86_64) TRIPLE=x86_64-unknown-linux-gnu ;;
aarch64|arm64) TRIPLE=aarch64-unknown-linux-gnu ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
if [ -x "${OUT}" ] && "${OUT}" --version 2>/dev/null | grep -q "${CLI_VERSION}"; then
echo "stalwart-cli ${CLI_VERSION} already present at ${OUT}"
exit 0
fi
URL="https://github.com/stalwartlabs/cli/releases/download/v${CLI_VERSION}/stalwart-cli-${TRIPLE}.tar.xz"
TARBALL="$(mktemp)"
trap 'rm -f "${TARBALL}"' EXIT
echo "Downloading ${URL}"
curl -sfL -o "${TARBALL}" "${URL}"
python3 - "${TARBALL}" "${OUT}" <<'PY'
import io, lzma, os, sys, tarfile
tarball, out = sys.argv[1], sys.argv[2]
with lzma.open(tarball) as f:
data = f.read()
tf = tarfile.open(fileobj=io.BytesIO(data))
member = next(m for m in tf.getmembers() if m.name.endswith("stalwart-cli"))
with open(out, "wb") as w:
w.write(tf.extractfile(member).read())
os.chmod(out, 0o755)
print(f"wrote {out}")
PY
"${OUT}" --version
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { login, folderRow, accountSwitcher, activeAccountEmail } from './helpers/app';
import { JmapClient } from './helpers/jmap';
test.describe('Login & session', () => {
test('logs in against Stalwart and loads the mailbox', async ({ page }) => {
await login(page, ACCOUNTS.alice);
// The Inbox folder row is a reliable "mailbox loaded" signal.
await expect(folderRow(page, { role: 'inbox' }).first()).toBeVisible();
// The active account in the switcher is alice. The account id is
// `${email}@${serverHost}`, so assert on the email it reports instead.
await expect(accountSwitcher(page)).toBeVisible();
expect(await activeAccountEmail(page)).toBe(ACCOUNTS.alice.email);
});
test('rejects invalid credentials', async ({ page }) => {
await page.goto('/');
await page.fill('#username', ACCOUNTS.alice.email);
await page.fill('#password', 'definitely-wrong');
await page.click('button[type="submit"]');
await expect(
page.locator('[role="alert"], .text-red-600, .text-destructive').first(),
).toBeVisible({ timeout: 15000 });
});
test('JMAP helper can reach every provisioned account', async () => {
for (const acct of Object.values(ACCOUNTS)) {
const client = await JmapClient.connect(acct.email, acct.password);
expect(client.accountId).toBeTruthy();
const inbox = await client.mailboxByRole('inbox');
expect(inbox, `${acct.email} has an inbox`).toBeTruthy();
}
});
});
+125
View File
@@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
folderRow,
folderCounts,
expectFolderUnread,
expectFolderTotal,
expectFolderCountsSynced,
emailItem,
expectEmailVisible,
} from './helpers/app';
/**
* Single-account mail & folder synchronisation.
*
* These exercise the webmail's ability to reflect *external* changes to the
* mailbox new deliveries, server-side reads/moves/deletes, and folder
* creation which is where "my counts are wrong / my folder didn't show up"
* sync bugs live. Mutations are made over SMTP/JMAP and the assertions are on
* the rendered UI.
*/
const alice = ACCOUNTS.alice;
// Unique subject per test run avoids cross-test contamination if a reset lags.
let seq = 0;
const subj = (label: string) => `IT ${label} ${Date.now()}-${seq++}`;
test.describe('Single-account sync', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('incoming mail appears and bumps the Inbox unread counter', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subject = subj('incoming');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'hi' });
await expectFolderUnread(page, { role: 'inbox' }, 1);
await expectEmailVisible(page, subject);
});
test('opening a message clears its unread state (UI -> server -> counter)', async ({ page }) => {
const subject = subj('read');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'read me' });
await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
await emailItem(page, subject).first().click();
await expectFolderUnread(page, { role: 'inbox' }, 0);
});
test('a folder created on the server shows up in the sidebar', async ({ page }) => {
await login(page, alice);
await expect(folderRow(page, { name: 'SyncFolder' })).toHaveCount(0);
await jmap.createMailbox('SyncFolder');
await expect(folderRow(page, { name: 'SyncFolder' }).first()).toBeVisible({ timeout: 20000 });
});
test('a server-side move updates both source and destination counters', async ({ page }) => {
const subject = subj('move');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'move me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
// Create destination + move the message there (server-side).
const destId = await jmap.createMailbox('Archive2');
const inbox = await jmap.mailboxByRole('inbox');
await jmap.request([
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
]);
// Source Inbox drains, destination gains the message. These follow a
// reconcile (not live push), so nudge one before every poll to stay robust
// against a single missed reconcile under load.
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
expect(inbox).toBeTruthy();
});
test('a server-side delete drains the Inbox total', async ({ page }) => {
const subject = subj('delete');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'delete me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
// The folder counter is the sync-critical signal and drains to zero (via a
// reconcile, nudged before every poll). (The already-rendered list view is
// not re-queried on a background delete, so we don't assert on the row
// disappearing here.)
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
});
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subjects = Array.from({ length: 3 }, (_, i) => subj(`burst-${i}`));
for (const s of subjects) {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: s, body: 'burst' });
}
await expectFolderUnread(page, { role: 'inbox' }, 3);
const counts = await folderCounts(page, { role: 'inbox' });
expect(counts.total).toBe(3);
for (const s of subjects) await expectEmailVisible(page, s);
});
});
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
switchAccount,
accountSwitcher,
seedUnifiedSettings,
folderRow,
expectFolderUnread,
expectFolderCountsSynced,
} from './helpers/app';
/**
* Multi-account synchronisation the account-scoped Unified Mailbox.
*
* Covers the two failure modes that dog multi-account webmail: counters
* bleeding between accounts, and the cross-account unified view mis-aggregating
* (or not updating when a background account receives mail).
*/
const { alice, bob } = ACCOUNTS;
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
async function send(to: typeof alice, subject: string) {
await sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
}
test.describe('Multi-account sync', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('both accounts connect and their Inbox counters stay isolated', async ({ page }) => {
// Pre-seed: two unread for alice, one for bob.
await send(alice, subj('iso-a1'));
await send(alice, subj('iso-a2'));
await send(bob, subj('iso-b1'));
await login(page, alice);
// Active = alice: her own Inbox shows 2 unread.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
await addAccount(page, bob);
// Both accounts are now registered in the switcher.
await accountSwitcher(page).click();
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
await page.keyboard.press('Escape');
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
// Switch back to alice: her count is intact.
await switchAccount(page, alice.email);
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 2 });
});
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
await send(alice, subj('agg-a'));
await send(bob, subj('agg-b'));
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob);
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
// (bob) still reports just its own 1.
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
});
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob); // bob is now the active account
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
// Mail lands in alice's inbox while bob is the active account.
await send(alice, subj('bg'));
// The unified counter reflects the background account's new mail.
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 1 });
// bob (active) own Inbox is unaffected.
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
seedAllMailSettings,
folderRow,
openFolder,
expectFolderCountsSynced,
expectEmailVisible,
emailItem,
forceSync,
} from './helpers/app';
/**
* The "All Mail" view a virtual folder that merges messages across an
* account's folders (Inbox + custom, excluding junk/sent/trash/drafts/archive),
* and across every logged-in account when the cross-account sub-option is on.
*/
const { alice, bob } = ACCOUNTS;
const ALL_MAIL = '__cross_all__';
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
const send = (to: typeof alice, subject: string) =>
sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
test.describe('All Mail — single account', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('merges Inbox + custom folders and excludes Junk', async ({ page }) => {
const inboxSubj = subj('am-inbox');
const folderSubj = subj('am-folder');
const junkSubj = subj('am-junk');
await send(alice, inboxSubj);
await send(alice, folderSubj);
await send(alice, junkSubj);
// File one into a custom folder and one into Junk (excluded from All Mail).
const folderMail = await jmap.waitForEmail(folderSubj);
await jmap.moveEmailToFolder(folderMail.id, 'Projects');
const junkMail = await jmap.waitForEmail(junkSubj);
const junk = await jmap.mailboxByRole('junk');
await jmap.moveEmail(junkMail.id, junk!.id);
await seedAllMailSettings(page, { crossAccount: false });
await login(page, alice);
// The All Mail entry is present and shows the two included-folder unreads.
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
// Its list merges the Inbox and custom-folder messages, but not Junk.
await openFolder(page, { name: ALL_MAIL });
await forceSync(page);
await expectEmailVisible(page, inboxSubj);
await expectEmailVisible(page, folderSubj);
await expect(emailItem(page, junkSubj)).toHaveCount(0);
});
});
test.describe('All Mail — cross account', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('merges mail from every logged-in account', async ({ page }) => {
const aSubj = subj('am-a');
const bSubj = subj('am-b');
await send(alice, aSubj);
await send(bob, bSubj);
await seedAllMailSettings(page, { crossAccount: true });
await login(page, alice);
await addAccount(page, bob);
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
await openFolder(page, { name: ALL_MAIL });
await forceSync(page);
await expectEmailVisible(page, aSubj);
await expectEmailVisible(page, bSubj);
});
});
@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS, GROUP } from './helpers/config';
import { JmapClient } from './helpers/jmap';
import {
login,
forceSync,
openComposer,
composerFromOptions,
selectComposerFrom,
selectedComposerFrom,
} from './helpers/app';
/**
* Issue #569 the composer's "From" dropdown should include identities from
* shared/group accounts, not only the logged-in (connected) accounts.
*
* Scenario under test (the one expected to already work): a Stalwart *group*
* mailbox `team@example.org` is provisioned and `carol` is made a member of it
* *before her first login* (integration/stalwart/plan-accounts.ndjson.tpl +
* entrypoint.sh). As a member she gets the group's shared folders (shown under
* "Shared") and per Stalwart a `team@` send-as identity (Stalwart returns
* it among the member's own account identities). The composer should therefore
* offer `team@example.org` as a sender alongside her own address.
*/
const member = ACCOUNTS[GROUP.team.memberOf];
const { team } = GROUP;
test.describe('Composer From: shared/group identities (issue #569)', () => {
test('the pre-provisioned group account is reachable in the members JMAP session', async () => {
// Server-side guard for the UI expectation below: if this fails, the
// bootstrap group provisioning is broken (not the app). The member must see
// the group account in her session, and it must expose a team@ identity she
// can send as.
const memberClient = await JmapClient.connect(member.email, member.password);
expect(memberClient.sharedAccountNames()).toContain(team.email);
const groupAccountId = Object.entries(memberClient.accounts).find(
([, name]) => name === team.email,
)?.[0];
expect(groupAccountId).toBeTruthy();
const res = await memberClient.request([
['Identity/get', { accountId: groupAccountId! }, '0'],
]);
const groupIdentityEmails = (res.methodResponses[0][1].list as { email: string }[]).map(
(i) => i.email,
);
expect(groupIdentityEmails).toContain(team.email);
});
test('the composer From selector offers and can select the group address', async ({ page }) => {
await login(page, member);
// Shared accounts/identities are discovered from the JMAP session; give the
// client a beat to settle them after the first render.
await forceSync(page);
await openComposer(page);
// The group address the member can send as should be one of the From
// choices. If #569 were unaddressed the control would collapse to her own
// address only and this poll would time out — which is the point: it pins
// the expected behaviour.
await expect
.poll(async () => (await composerFromOptions(page)).join(' | '), { timeout: 15000 })
.toContain(team.email);
// Pick the group address as the sender and confirm it becomes the selected
// From identity (not just a listed option).
await selectComposerFrom(page, team.email);
await expect.poll(() => selectedComposerFrom(page), { timeout: 5000 }).toContain(team.email);
// Hold on the composer so the final video frames clearly show the group
// address selected in the From field.
await page.waitForTimeout(2000);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expectFolderUnread,
expectFolderTotal,
expectFolderCountsSynced,
expectEmailVisible,
expectEmailUnread,
emailContextAction,
emailItem,
openFolder,
} from './helpers/app';
/**
* Message actions from the list context menu mark read/unread, delete, spam
* performed in the Inbox, with the outcome checked on both the UI (counters,
* row state) and the server (which mailbox the message ended up in).
*/
const alice = ACCOUNTS.alice;
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
const send = (subject: string) =>
sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
test.describe('Inbox message actions', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('mark read then unread toggles the row state and Inbox unread counter', async ({ page }) => {
const s = subj('act-read');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
await expectEmailUnread(page, s, true);
await emailContextAction(page, s, 'ctx-mark-read');
await expectEmailUnread(page, s, false);
await expectFolderUnread(page, { role: 'inbox' }, 0);
await emailContextAction(page, s, 'ctx-mark-unread');
await expectEmailUnread(page, s, true);
await expectFolderUnread(page, { role: 'inbox' }, 1);
});
test('delete moves the message to Trash and updates both counters', async ({ page }) => {
const s = subj('act-del');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await emailContextAction(page, s, 'ctx-delete');
// Leaves the Inbox, lands in Trash — on the UI...
await expectFolderTotal(page, { role: 'inbox' }, 0);
await expectFolderTotal(page, { role: 'trash' }, 1);
await expect(emailItem(page, s)).toHaveCount(0);
// ...and on the server.
const trash = await jmap.mailboxByRole('trash');
const found = await jmap.findEmailBySubject(s, trash!.id);
expect(found, 'deleted message is in Trash on the server').toBeTruthy();
});
test('mark as spam moves the message to Junk', async ({ page }) => {
const s = subj('act-spam');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await emailContextAction(page, s, 'ctx-spam');
// The destination (Junk) counter updates optimistically, but the source
// (Inbox) counter isn't always decremented until the next reconcile when
// the action fires moments after login — unlike delete, which decrements
// the source immediately. The synced assertion nudges a reconcile per poll.
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 });
const junk = await jmap.mailboxByRole('junk');
const found = await jmap.findEmailBySubject(s, junk!.id);
expect(found, 'spammed message is in Junk on the server').toBeTruthy();
});
test('spam then not-spam round-trips the message back out of Junk', async ({ page }) => {
const s = subj('act-notspam');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await emailContextAction(page, s, 'ctx-spam');
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
// Open Junk, then mark not-spam.
await openFolder(page, { role: 'junk' });
await expectEmailVisible(page, s);
await emailContextAction(page, s, 'ctx-not-spam');
// The message leaves the open Junk list (optimistic) and round-trips on the
// server: out of Junk, back in Inbox. (Asserted on the optimistic list +
// authoritative server state rather than the Junk badge, whose reconcile
// can stall under heavy concurrent load.)
await expect(emailItem(page, s)).toHaveCount(0);
const junk = await jmap.mailboxByRole('junk');
const inbox = await jmap.mailboxByRole('inbox');
expect(await jmap.findEmailBySubject(s, junk!.id), 'message no longer in Junk').toBeFalsy();
expect(await jmap.findEmailBySubject(s, inbox!.id), 'message back in Inbox').toBeTruthy();
});
});
+128
View File
@@ -0,0 +1,128 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expandSharedFolders,
folderRow,
openFolder,
expectFolderCountsSynced,
expectEmailVisible,
expectEmailUnread,
emailContextAction,
emailItem,
forceSync,
} from './helpers/app';
/**
* Shared (delegated) folders. Alice shares a custom folder plus her Trash and
* Junk so delete/spam can route to the owner's system folders with carol, who
* then acts on the mail from her own session and checks the shared counters.
*
* carol is the grantee (not asserted on by other specs), so the shared-account
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
*/
const { alice, carol } = ACCOUNTS;
const SHARED = 'TeamShared';
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
test.describe('Shared folder actions', () => {
let ja: JmapClient; // owner (alice)
let sharedId: string;
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
const jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
// Delegate a custom folder + Trash + Junk to carol.
sharedId = await ja.createSharedFolder(SHARED, carol.email);
await ja.shareMailboxByRole('trash', carol.email);
await ja.shareMailboxByRole('junk', carol.email);
});
async function seedIntoShared(subject: string): Promise<void> {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
const m = await ja.waitForEmail(subject);
await ja.moveEmail(m.id, sharedId);
}
test('shared folder appears with its counter and message', async ({ page }) => {
const s = subj('sh-show');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await openFolder(page, { name: SHARED, shared: true });
await forceSync(page);
await expectEmailVisible(page, s);
});
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
const s = subj('sh-read');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await emailContextAction(page, s, 'ctx-mark-read');
await expectEmailUnread(page, s, false);
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
await emailContextAction(page, s, 'ctx-mark-unread');
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
// Owner sees the same state on the server.
const found = await ja.findEmailBySubject(s, sharedId);
expect(found.keywords?.$seen).toBeFalsy();
});
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
const s = subj('sh-del');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-delete');
// Source shared folder drains, and the message really is in the owner's
// Trash on the server. (We assert the destination server-side rather than
// the shared Trash badge to keep the check independent of sidebar layout.)
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
const trash = await ja.mailboxByRole('trash');
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
});
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
const s = subj('sh-spam');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-spam');
// The message leaves the shared folder's list, and on the server it has
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
// spam doesn't optimistically drain the source *counter*, and forceSync
// can't reconcile a shared account — so we assert list + server state.)
await expect(emailItem(page, s)).toHaveCount(0);
const junk = await ja.mailboxByRole('junk');
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
});
});
+146
View File
@@ -0,0 +1,146 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { JmapClient } from './helpers/jmap';
import {
login,
openComposer,
addRecipient,
setFrom,
setSubject,
waitDraftSaved,
closeComposer,
composerRecipients,
openFolder,
emailItem,
} from './helpers/app';
/**
* Draft handling. Focus areas reported as flaky by the user:
* - the "continue draft" (edit-draft) button in the message view,
* - multiple recipients being persisted to the draft,
* - a changed sender identity being persisted to the draft.
*
* Each test drives the composer, lets it auto-save, then verifies the draft on
* the server (JMAP) and by reopening it in the UI.
*/
const { alice, bob, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
async function draftBody(page: import('@playwright/test').Page, text: string) {
await page.locator('.ProseMirror').first().fill(text);
}
test.describe('Drafts', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('multiple recipients save and reopen via the continue-draft button', async ({ page }) => {
const subject = subj('draft-multi');
await login(page, alice);
await openComposer(page);
await addRecipient(page, bob.email);
await addRecipient(page, carol.email);
await setSubject(page, subject);
await draftBody(page, 'draft body');
await waitDraftSaved(page);
await closeComposer(page);
// Server: the draft carries BOTH recipients.
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
expect(to).toEqual([bob.email, carol.email].sort());
// UI: opening the draft shows the continue-draft button, which reopens the
// composer with both recipients intact.
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
await page.locator('[data-testid="edit-draft"]').click();
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
const recips = await composerRecipients(page);
expect(recips).toContain(bob.email);
expect(recips).toContain(carol.email);
});
test('a recipient typed but not committed to a chip is still saved', async ({ page }) => {
const subject = subj('draft-uncommitted');
await login(page, alice);
await openComposer(page);
await addRecipient(page, bob.email); // committed chip
// Type a second address but do NOT press Enter — leave it as raw input.
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(carol.email);
await setSubject(page, subject); // blur the To field
await draftBody(page, 'uncommitted body');
await waitDraftSaved(page);
await closeComposer(page);
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
// Both the committed and the still-in-the-input recipient must survive.
expect(to).toEqual([bob.email, carol.email].sort());
});
test('a server-created draft shows the continue-draft button when viewed', async ({ page }) => {
const subject = subj('draft-server');
await jmap.createDraft(subject, bob.email);
await login(page, alice);
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
// The edit-draft ("continue draft") button must be present for any message
// carrying the $draft keyword, regardless of how the draft was created.
await expect(page.locator('[data-testid="edit-draft"]')).toBeVisible();
});
test('a changed sender identity is saved to the draft (server)', async ({ page }) => {
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
const subject = subj('draft-from');
await login(page, alice);
await openComposer(page);
await setFrom(page, altId);
await addRecipient(page, bob.email);
await setSubject(page, subject);
await draftBody(page, 'from-change body');
await waitDraftSaved(page);
await closeComposer(page);
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
expect((draft.from ?? [])[0]?.name, 'draft From carries the selected identity').toBe('Alice Team');
});
// KNOWN BUG (documented via test.fail): a draft composed with a non-default
// identity is saved with the right From on the server (see the test above),
// but reopening the draft resets the composer's From selector to the default
// identity instead of restoring the one the draft was written with. If this
// starts passing, the reopen path was fixed — flip this back to a plain test.
test.fail('reopening a draft restores the changed sender in the From selector', async ({ page }) => {
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
const subject = subj('draft-from-reopen');
await login(page, alice);
await openComposer(page);
await setFrom(page, altId);
await addRecipient(page, bob.email);
await setSubject(page, subject);
await draftBody(page, 'reopen body');
await waitDraftSaved(page);
await closeComposer(page);
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
await page.locator('[data-testid="edit-draft"]').click();
await expect(page.locator('[data-testid="composer-from"]')).toHaveValue(altId);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expandSharedFolders,
openFolder,
folderMailboxId,
moveEmailTo,
forceSync,
} from './helpers/app';
/**
* Moving mail across the own-account / shared-folder boundary, in both
* directions, and between two shared folders. The move is driven from the list
* context menu's "Move to" submenu; the authoritative check is the server-side
* mailbox the message ends up in, with the reliably-updating (own-account)
* counters checked in the UI too.
*/
const { alice, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
test.describe('Shared-folder moves', () => {
let ja: JmapClient; // owner
let jc: JmapClient; // grantee
let teamA: string;
let teamB: string;
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
teamA = await ja.createSharedFolder('TeamA', carol.email);
teamB = await ja.createSharedFolder('TeamB', carol.email);
});
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
const acct = owner === ja ? alice : carol;
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
const m = await owner.waitForEmail(subject);
await owner.moveEmail(m.id, mailboxId);
}
test('shared folder A -> shared folder B', async ({ page }) => {
const s = subj('mv-a2b');
await seedInto(teamA, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
});
test('shared folder B -> shared folder A', async ({ page }) => {
const s = subj('mv-b2a');
await seedInto(teamB, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { name: 'TeamB', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
});
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
// shared folder as a destination for an own-account message, but clicking it
// does NOT relocate the message across the account boundary — it stays put.
// Same in reverse (shared -> own). If cross-account moves get implemented,
// these will start passing; flip them back to plain tests then.
test.fail('own account -> shared folder', async ({ page }) => {
const s = subj('mv-own2sh');
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
await jc.waitForEmail(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { role: 'inbox', shared: false });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Expected (once supported): the message moves to the owner's shared TeamA.
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
});
test.fail('shared folder -> own account', async ({ page }) => {
const s = subj('mv-sh2own');
await seedInto(teamA, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Expected (once supported): the message arrives in carol's own Inbox.
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
});
});
@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
seedUnifiedSettings,
seedAllMailSettings,
expandSharedFolders,
folderCounts,
expectFolderUnread,
forceSync,
} from './helpers/app';
/**
* Live currency of the unified / All-Mail counters across every source folder.
*
* Stalwart's SSE only pushes StateChange for the *primary* account, so:
* - a background *login* account updates the badge live (each login has its
* own SSE) asserted with no reconcile;
* - a *shared/delegated* account gets no push at all, so the client polls the
* session's secondary accounts too; the badge reconciles on focus/interval.
* (Regression test for the shared-account state-poll.)
*/
const { alice, bob, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
async function deliverIntoSharedFolder(owner: JmapClient, folderId: string, subject: string) {
const acct = ACCOUNTS.alice;
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
const m = await owner.waitForEmail(subject);
await owner.moveEmail(m.id, folderId);
}
test.describe('Live unified/All-Mail counters', () => {
test('a background login account updates the unified counter live (no reconcile)', async ({ page }) => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob); // bob active, alice in the background
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
// Mail lands in alice's inbox while bob is active — no focus/forceSync here.
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: subj('bg-live'), body: 'x' });
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
});
test('a shared-folder change reconciles the All-Mail counter on focus', async ({ page }) => {
const ja = await JmapClient.connect(alice.email, alice.password);
const jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
const shared = await ja.createSharedFolder('TeamShared', carol.email);
await seedAllMailSettings(page, { crossAccount: false });
await login(page, carol);
await expandSharedFolders(page, alice.email);
expect((await folderCounts(page, { name: '__cross_all__' })).unread).toBe(0);
// A background change in the shared (delegated) account gets no SSE push.
await deliverIntoSharedFolder(ja, shared, subj('sh-live'));
// Focus reconcile now polls the shared account too, so the All-Mail badge
// picks up the shared folder's new unread.
await forceSync(page);
await expect
.poll(async () => (await folderCounts(page, { name: '__cross_all__' })).unread, { timeout: 15000 })
.toBe(1);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
switchAccount,
seedSettings,
folderRow,
openFolder,
emailItem,
expectEmailVisible,
forceSync,
} from './helpers/app';
/**
* Attachments on a message that belongs to a *different* account, opened from
* the cross-account All-Mail view. Blobs are account-scoped, so downloading one
* must route to the owning account's client + accountId otherwise it 404s
* against the active account (the reported bug).
*/
const { alice, bob } = ACCOUNTS;
const ATT = { filename: 'report.bin', contentType: 'application/octet-stream', content: 'hello-attachment-content-12345' };
test.describe('Cross-account attachments', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('an attachment on another account\'s All-Mail message downloads correctly', async ({ page }) => {
const subject = `IT attach ${Date.now()}`;
// Deliver a message with an attachment to bob.
await sendMail({ from: bob.email, authPass: bob.password, to: bob.email, subject, body: 'see attachment', attachment: ATT });
// Cross-account All Mail + always download attachments (don't preview).
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
mailAttachmentAction: 'download',
});
// Make alice the active account, with bob added, so bob's message is
// genuinely cross-account when opened.
await login(page, alice);
await addAccount(page, bob);
await switchAccount(page, alice.email);
await forceSync(page);
// Open the All-Mail view and bob's message.
await expect(folderRow(page, { name: '__cross_all__' }).first()).toBeVisible();
await openFolder(page, { name: '__cross_all__' });
await forceSync(page);
await expectEmailVisible(page, subject);
await emailItem(page, subject).first().click();
// The attachment chip is present; clicking it downloads the blob from bob's
// account (pre-fix this 404s against alice and no download fires).
const chip = page.locator(`[data-testid="attachment"][data-attachment-name="${ATT.filename}"]`).first();
await chip.waitFor({ state: 'visible', timeout: 15000 });
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 15000 }),
chip.click(),
]);
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
expect(Buffer.concat(chunks).toString()).toContain(ATT.content);
});
test('an inline image on another account\'s All-Mail message renders', async ({ page }) => {
const subject = `IT inline ${Date.now()}`;
// 1x1 PNG referenced from the HTML body via cid.
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
await sendMail({
from: bob.email, authPass: bob.password, to: bob.email, subject, body: '',
inlineImage: { cid: 'inlinepic', contentType: 'image/png', base64: png, html: '<p>see below</p><img src="cid:inlinepic" alt="pic" width="1" height="1" />' },
});
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
});
await login(page, alice);
await addAccount(page, bob);
await switchAccount(page, alice.email);
await forceSync(page);
await openFolder(page, { name: '__cross_all__' });
await forceSync(page);
await expectEmailVisible(page, subject);
await emailItem(page, subject).first().click();
// The inline cid: image resolves to a blob URL fetched from bob's account.
// Pre-fix the fetch 404s and it falls back to the data:image/gif placeholder.
const img = page.frameLocator('iframe[title="Email content"]').locator('img').first();
await expect
.poll(async () => (await img.getAttribute('src').catch(() => '')) ?? '', { timeout: 15000 })
.toMatch(/^blob:/);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Brings the integration stack up before the suite runs:
* 1. fetch the arch-specific stalwart-cli (offline-friendly build input),
* 2. ensure integration/.env exists (compose credentials),
* 3. docker compose up -d --build --wait (Stalwart + webmail),
* 4. block until Stalwart JMAP and the webmail health endpoint answer.
*
* Set IT_NO_DOCKER=1 to skip container management entirely (useful when the
* stack is already running, e.g. during test authoring against `npm run dev`).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, copyFileSync } from 'node:fs';
import path from 'node:path';
import { JMAP_URL, WEBMAIL_URL, ACCOUNTS, ACCOUNT_PASSWORD } from './helpers/config';
const INTEGRATION_DIR = path.resolve(__dirname, '..');
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
function run(cmd: string, args: string[], cwd = INTEGRATION_DIR): void {
execFileSync(cmd, args, { cwd, stdio: 'inherit' });
}
async function waitFor(label: string, url: string, check: (r: Response) => boolean, timeoutMs = 240000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(url, { headers: { Authorization: 'Basic ' + Buffer.from(`${ACCOUNTS.alice.email}:${ACCOUNT_PASSWORD}`).toString('base64') } });
if (check(res)) return;
} catch {
/* not up yet */
}
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label} at ${url}`);
await new Promise((r) => setTimeout(r, 2000));
}
}
export default async function globalSetup(): Promise<void> {
if (process.env.IT_NO_DOCKER === '1') {
console.log('[global-setup] IT_NO_DOCKER=1 — skipping docker compose management');
} else {
console.log('[global-setup] fetching stalwart-cli');
run('bash', [path.join(INTEGRATION_DIR, 'stalwart', 'prepare-stalwart-cli.sh')]);
if (!existsSync(ENV_FILE)) {
console.log('[global-setup] creating integration/.env from .env.example');
copyFileSync(path.join(INTEGRATION_DIR, '.env.example'), ENV_FILE);
}
console.log('[global-setup] docker compose up -d --build --wait');
run('docker', [
'compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE,
'up', '-d', '--build', '--wait', '--wait-timeout', '300',
]);
}
console.log('[global-setup] waiting for Stalwart JMAP');
await waitFor('Stalwart JMAP', `${JMAP_URL}/jmap/session`, (r) => r.ok);
console.log('[global-setup] waiting for webmail');
await waitFor('webmail', `${WEBMAIL_URL}/api/health`, (r) => r.ok, 240000);
console.log('[global-setup] stack ready');
}
+23
View File
@@ -0,0 +1,23 @@
/**
* By default the stack is left running after the suite so re-runs are fast and
* the state can be inspected (webmail on :3000, Stalwart admin on :8025).
* Set IT_TEARDOWN=1 to tear the containers (and volumes) down instead.
*/
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const INTEGRATION_DIR = path.resolve(__dirname, '..');
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
export default async function globalTeardown(): Promise<void> {
if (process.env.IT_TEARDOWN !== '1' || process.env.IT_NO_DOCKER === '1') {
console.log('[global-teardown] leaving stack up (set IT_TEARDOWN=1 to remove it)');
return;
}
console.log('[global-teardown] docker compose down -v');
execFileSync('docker', ['compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE, 'down', '-v'], {
cwd: INTEGRATION_DIR,
stdio: 'inherit',
});
}
+375
View File
@@ -0,0 +1,375 @@
/**
* Page-level helpers for driving the Bulwark webmail in integration tests.
*
* Selectors rely on the data-testid hooks added to the mail UI (sidebar folder
* rows + counters, account switcher, composer). Folder counters are read from
* the `data-unread` / `data-total` attributes on `[data-testid=folder-counts]`
* rather than parsing rendered text, so assertions are locale-independent.
*/
import { expect, type Page, type Locator } from '@playwright/test';
import type { TestAccount } from './config';
/**
* The account switcher renders twice (collapsed nav rail + expanded sidebar);
* both carry the same data-testid and state, so always target the first.
*/
export function accountSwitcher(page: Page): Locator {
return page.locator('[data-testid="account-switcher"]').first();
}
/**
* The Next.js dev-mode overlay (`<nextjs-portal>`) sits in the bottom-left
* corner and intercepts pointer events over the account switcher. Disable
* pointer events on the portal host (light DOM) so it can't swallow clicks.
* Registered as an init script so it survives navigations within the test.
*/
export async function neutralizeDevOverlay(page: Page): Promise<void> {
await page.addInitScript(() => {
const inject = () => {
const s = document.createElement('style');
s.textContent = 'nextjs-portal{pointer-events:none!important}';
document.documentElement.appendChild(s);
};
if (document.documentElement) inject();
else document.addEventListener('DOMContentLoaded', inject);
});
}
/**
* Seed the persisted settings store before the app boots. Merges over the
* store defaults on rehydrate. Must be called before {@link login} so the init
* script is registered before the first navigation.
*/
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
await page.addInitScript((s) => {
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
}, settings);
}
/**
* Enable the cross-account Unified Mailbox. Requires the
* `unifiedCrossAccountEnabled` admin feature gate (provided by
* integration/webmail-config/policy.json).
*/
export async function seedUnifiedSettings(page: Page): Promise<void> {
await seedSettings(page, {
enableUnifiedMailbox: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
});
}
/**
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
* bounded (spans the active account's own + shared folders). The "All mail"
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
*/
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
includeGroupInUnified: true,
unifiedCrossAccount: !!opts.crossAccount,
});
}
/** Fill and submit the login form (works for first login and add-account). */
async function submitCredentials(page: Page, account: TestAccount): Promise<void> {
await page.locator('#username').waitFor({ state: 'visible', timeout: 30000 });
await page.fill('#username', account.email);
await page.fill('#password', account.password);
await page.click('button[type="submit"]');
}
/** Log in as `account` from a clean context and wait for the mailbox to load. */
export async function login(page: Page, account: TestAccount): Promise<void> {
await neutralizeDevOverlay(page);
await page.goto('/');
await submitCredentials(page, account);
// Landed in the app once the account switcher (sidebar chrome) is present.
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 30000 });
}
/** Add a second (or later) account via the account switcher + login form. */
export async function addAccount(page: Page, account: TestAccount): Promise<void> {
await accountSwitcher(page).click();
await page.locator('[data-testid="add-account"]').click();
await submitCredentials(page, account);
// Wait until the switcher reports the newly added account as active.
await expect
.poll(async () => activeAccountEmail(page), { timeout: 30000 })
.toBe(account.email);
}
/** Email of the currently active account, read from the switcher option list. */
export async function activeAccountEmail(page: Page): Promise<string | null> {
const switcher = accountSwitcher(page);
const id = await switcher.getAttribute('data-active-account-id');
if (!id) return null;
await switcher.click();
const email = await page
.locator(`[data-testid="account-option"][data-account-id="${id}"]`)
.first()
.getAttribute('data-account-email');
// Close the popover again.
await page.keyboard.press('Escape');
return email;
}
/** Switch the active account to the one matching `email`. */
export async function switchAccount(page: Page, email: string): Promise<void> {
await accountSwitcher(page).click();
await page.locator(`[data-testid="account-option"][data-account-email="${email}"]`).first().click();
await expect.poll(async () => activeAccountEmail(page), { timeout: 30000 }).toBe(email);
}
/**
* Nudge the app to reconcile mailbox state immediately.
*
* The JMAP client refetches on `visibilitychange` (tab focus) via
* checkForStateChanges(). Dispatching it makes reconciliation deterministic
* after an *external* mutation, sidestepping the small window right after
* login where a change can land before the SSE push channel has settled.
* Mirrors what happens when a real user tabs back to the mailbox.
*/
export async function forceSync(page: Page): Promise<void> {
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
}
// ─── Composer / drafts ────────────────────────────────────────────────────
/** Open the composer via the keyboard shortcut and wait for it to render. */
export async function openComposer(page: Page): Promise<void> {
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
}
/** Add a recipient to the To field (commits it as a chip with Enter). */
export async function addRecipient(page: Page, email: string): Promise<void> {
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(email);
await input.press('Enter');
}
/** Select a sending identity in the From dropdown by its identity id. */
export async function setFrom(page: Page, identityId: string): Promise<void> {
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
}
/** Fill the subject field. */
export async function setSubject(page: Page, subject: string): Promise<void> {
await page.locator('[data-testid="composer-subject"]').fill(subject);
}
/** Wait until the composer reports the draft as saved. */
export async function waitDraftSaved(page: Page): Promise<void> {
await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', {
timeout: 20000,
});
}
/** Close the composer (draft is auto-saved). */
export async function closeComposer(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
/** Recipient chips currently shown in the composer's To field. */
export async function composerRecipients(page: Page): Promise<string[]> {
const to = page.locator('[data-testid="composer-to"]');
const text = (await to.innerText()).toLowerCase();
return text.split(/\s+/).filter((t) => t.includes('@'));
}
/**
* The sender addresses the composer's From control offers.
*
* With more than one identity the control is a <select> and each choice is an
* <option>; with a single identity it collapses to a static <span> that shows
* only that address. Returning the raw text of whichever is rendered lets a
* test assert on the *set of senders* without caring which shape it took.
*/
export async function composerFromOptions(page: Page): Promise<string[]> {
const from = page.locator('[data-testid="composer-from"]').first();
await from.waitFor({ state: 'visible', timeout: 10000 });
if ((await from.locator('option').count()) > 0) {
return from.locator('option').allTextContents();
}
return [await from.innerText()];
}
export interface FolderSelector {
role?: string;
name?: string;
mailboxId?: string;
/** true = only shared-account folders, false = only own folders. */
shared?: boolean;
}
/** Locator for a sidebar folder row. */
export function folderRow(page: Page, sel: FolderSelector): Locator {
let s = '[data-testid="folder-row"]';
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
if (sel.shared === true) s += '[data-shared="true"]';
if (sel.shared === false) s += ':not([data-shared="true"])';
return page.locator(s);
}
/**
* Expand the sidebar "Shared" section and the given sharer's shared-account
* group so its folders (data-shared="true") render. Idempotent.
*/
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
const section = page.locator('[data-testid="section-shared"]');
await section.waitFor({ state: 'visible', timeout: 30000 });
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
await account.waitFor({ state: 'visible', timeout: 30000 });
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
}
export interface FolderCounts {
unread: number;
total: number;
}
/**
* Read a folder's unread/total counts. When both are zero the counts element
* is not rendered, so a missing element is reported as {0,0}.
*/
export async function folderCounts(page: Page, sel: FolderSelector): Promise<FolderCounts> {
const row = folderRow(page, sel).first();
const counts = row.locator('[data-testid="folder-counts"]');
if ((await counts.count()) === 0) return { unread: 0, total: 0 };
const unread = await counts.getAttribute('data-unread');
const total = await counts.getAttribute('data-total');
return { unread: Number(unread ?? 0), total: Number(total ?? 0) };
}
/** Poll until a folder's unread count reaches `expected`. */
export async function expectFolderUnread(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
await expect
.poll(async () => (await folderCounts(page, sel)).unread, { timeout })
.toBe(expected);
}
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
return id;
}
/**
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
*/
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
const row = emailItem(page, subject).first();
await row.waitFor({ state: 'visible' });
const submenu = page.locator('[data-testid="ctx-move-to"]');
await expect(async () => {
await row.click({ button: 'right' });
await submenu.waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await submenu.hover();
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
await target.waitFor({ state: 'visible', timeout: 5000 });
await target.click();
}
/** Poll until a folder's total count reaches `expected`. */
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
await expect
.poll(async () => (await folderCounts(page, sel)).total, { timeout })
.toBe(expected);
}
/**
* Assert a folder's counts, nudging a reconcile (visibilitychange ->
* checkForStateChanges) before *every* poll. Use for counters that update via
* reconcile rather than live SSE push after a server-side move/delete, a
* mark-as-spam, or a shared-account change where a single missed reconcile
* would otherwise flake. Only the provided fields are compared.
*/
export async function expectFolderCountsSynced(
page: Page,
sel: FolderSelector,
expected: { unread?: number; total?: number },
timeout = 45000,
): Promise<void> {
await expect
.poll(
async () => {
await forceSync(page);
const c = await folderCounts(page, sel);
return {
...(expected.unread !== undefined ? { unread: c.unread } : {}),
...(expected.total !== undefined ? { total: c.total } : {}),
};
},
{ timeout, intervals: [500, 1000, 1500, 2000, 2000, 3000] },
)
.toEqual(expected);
}
/** Click a folder row to select it. */
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
await folderRow(page, sel).first().click();
}
/**
* Select the composer's From sender whose option text contains `emailNeedle`
* (e.g. a shared/group address). Requires the multi-identity <select> to be
* rendered. Scrolls it into view first so the change is captured on video.
*/
export async function selectComposerFrom(page: Page, emailNeedle: string): Promise<void> {
const from = page.locator('[data-testid="composer-from"]').first();
await from.scrollIntoViewIfNeeded();
const value = await from.locator('option', { hasText: emailNeedle }).first().getAttribute('value');
if (!value) throw new Error(`No composer From option matching "${emailNeedle}"`);
await from.selectOption(value);
}
/** The display text of the currently selected From sender. */
export async function selectedComposerFrom(page: Page): Promise<string> {
const from = page.locator('[data-testid="composer-from"]').first();
return (await from.locator('option:checked').first().textContent())?.trim() ?? '';
}
/** Locator for an email row by (exact) subject. */
export function emailItem(page: Page, subject: string): Locator {
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
}
/** Poll until an email with `subject` is present in the list. */
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
}
/** Assert an email row's unread state (from its `data-unread` attribute). */
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
}
/**
* Open an email's right-click context menu and click one of its actions.
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
* `ctx-mark-read`, `ctx-mark-unread`.
*/
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
const row = emailItem(page, subject).first();
await row.waitFor({ state: 'visible' });
await row.scrollIntoViewIfNeeded();
const item = page.locator(`[data-testid="${testId}"]`);
// Right-click can occasionally land before the list row is interactive;
// retry opening the menu until the action item is actually present.
await expect(async () => {
await row.click({ button: 'right' });
await item.waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await item.click();
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Shared configuration for the integration tests. Values mirror the Stalwart
* bootstrap (integration/stalwart/*) and the docker-compose port mappings.
* Everything is overridable via env so the suite can run against a differently
* mapped stack (e.g. remote CI) without code changes.
*/
export const DOMAIN = process.env.IT_DOMAIN ?? 'example.org';
/** Shared password for every test mailbox (TEST_ACCOUNT_PASSWORD in .env). */
export const ACCOUNT_PASSWORD = process.env.IT_ACCOUNT_PASSWORD ?? 'test-pass-123';
/** Webmail app origin (containerised, published on the host). */
export const WEBMAIL_URL = process.env.IT_WEBMAIL_URL ?? 'http://localhost:3000';
/** Stalwart JMAP + admin base URL (host-published). */
export const JMAP_URL = process.env.IT_JMAP_URL ?? 'http://localhost:8025';
/** Stalwart SMTP submission listener (host-published, maps to container 587). */
export const SMTP_HOST = process.env.IT_SMTP_HOST ?? 'localhost';
export const SMTP_PORT = Number(process.env.IT_SMTP_PORT ?? 1025);
/** Recovery admin — `user:password`, used for stalwart-cli style admin JMAP. */
export const ADMIN_CREDENTIALS = process.env.IT_ADMIN ?? 'admin:bootstrap-secret';
export interface TestAccount {
/** Local part, e.g. "alice". */
user: string;
/** Full address, e.g. "alice@example.org". */
email: string;
password: string;
}
function acct(user: string): TestAccount {
return { user, email: `${user}@${DOMAIN}`, password: ACCOUNT_PASSWORD };
}
/** The mailboxes provisioned by the Stalwart bootstrap plan. */
export const ACCOUNTS = {
alice: acct('alice'),
bob: acct('bob'),
carol: acct('carol'),
} as const;
export type AccountKey = keyof typeof ACCOUNTS;
/**
* The shared *group* account provisioned by the bootstrap (a Stalwart Group
* principal, not a login). `carol` is made a member before her first login, so
* she sees the group's folders under "Shared" and can send as its address.
* Groups have no password of their own access is via a member's session.
* (carol, rather than alice/bob, keeps the sync specs' accounts unshared.)
*/
export const GROUP = {
team: { user: 'team', email: `team@${DOMAIN}`, memberOf: 'carol' as AccountKey },
} as const;
+292
View File
@@ -0,0 +1,292 @@
/**
* Minimal JMAP client for test setup/inspection against Stalwart.
*
* Uses global fetch (Node 18+). Not a full JMAP implementation just the
* pieces the integration tests need: authenticate, read/reset mailboxes,
* create folders, and poll for delivery. Assertions on *server* state (via
* this client) are kept separate from assertions on *UI* state (via the page),
* so a failing test can tell whether the bug is in delivery or in the webmail's
* sync.
*/
import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
const SUBMISSION = 'urn:ietf:params:jmap:submission';
/** Rights granted on a shared mailbox (JMAP ACL). */
export const FULL_MAILBOX_RIGHTS = {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
interface JmapMailbox {
id: string;
name: string;
role: string | null;
parentId: string | null;
totalEmails: number;
unreadEmails: number;
}
type MethodCall = [string, Record<string, unknown>, string];
export class JmapClient {
private authHeader: string;
private apiUrl: string;
accountId = '';
/** Every account visible in this user's session (own + shared/group),
* keyed by accountId -> account name (its email address). */
accounts: Record<string, string> = {};
private constructor(private email: string, password: string) {
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
// rewrite onto the reachable origin, exactly as the app client does.
this.apiUrl = `${JMAP_URL}/jmap/`;
}
static async connect(email: string, password: string): Promise<JmapClient> {
const c = new JmapClient(email, password);
const res = await fetch(`${JMAP_URL}/jmap/session`, {
headers: { Authorization: c.authHeader },
});
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
const session = await res.json();
const primary = session.primaryAccounts?.[MAIL];
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
c.accountId = primary;
c.accounts = Object.fromEntries(
Object.entries(session.accounts ?? {}).map(([id, a]) => [id, (a as { name: string }).name]),
);
return c;
}
/** Names (email addresses) of the shared/group accounts this user can access,
* i.e. everything in the session except the user's own primary account. */
sharedAccountNames(): string[] {
return Object.entries(this.accounts)
.filter(([id]) => id !== this.accountId)
.map(([, name]) => name);
}
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL, SUBMISSION]): Promise<any> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();
}
/** All sending identities of this account. */
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
return r.methodResponses[0][1].list;
}
/**
* Ensure a second sending identity `name <email>` exists (idempotent by
* name). Returns its id. Used to make the composer's From selector appear so
* a changed sender can be exercised.
*/
async ensureIdentity(name: string, email: string): Promise<string> {
const existing = (await this.identities()).find((i) => i.name === name);
if (existing) return existing.id;
const r = await this.request(
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
[CORE, SUBMISSION],
);
const created = r.methodResponses[0][1].created?.alt;
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Resolve another user's principal id (needed as the key in `shareWith`). */
async principalIdByEmail(email: string): Promise<string> {
const r = await this.request(
[
['Principal/query', { accountId: this.accountId, filter: { email } }, '0'],
['Principal/get', { accountId: this.accountId, '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' } }, '1'],
],
[CORE, PRINCIPALS],
);
const list = r.methodResponses[1][1].list as Array<{ id: string; email?: string }>;
const match = list.find((p) => p.email === email) ?? list[0];
if (!match) throw new Error(`No principal found for ${email}`);
return match.id;
}
/**
* Create a folder in this account and share it with `granteeEmail`. Returns
* the new mailbox id. The grantee then sees this account as a shared account
* in their JMAP session.
*/
async createSharedFolder(name: string, granteeEmail: string): Promise<string> {
const principalId = await this.principalIdByEmail(granteeEmail);
const r = await this.request([
['Mailbox/set', {
accountId: this.accountId,
create: { shared: { name, shareWith: { [principalId]: FULL_MAILBOX_RIGHTS } } },
}, '0'],
]);
const created = r.methodResponses[0][1].created?.shared;
if (!created) throw new Error(`createSharedFolder failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Grant `granteeEmail` access to an existing mailbox of this account. */
async shareMailbox(mailboxId: string, granteeEmail: string): Promise<void> {
const principalId = await this.principalIdByEmail(granteeEmail);
await this.request([
['Mailbox/set', {
accountId: this.accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: FULL_MAILBOX_RIGHTS } },
}, '0'],
]);
}
/** Grant `granteeEmail` access to a system folder (by role) of this account. */
async shareMailboxByRole(role: string, granteeEmail: string): Promise<string> {
const mb = await this.mailboxByRole(role);
if (!mb) throw new Error(`No ${role} mailbox to share`);
await this.shareMailbox(mb.id, granteeEmail);
return mb.id;
}
async mailboxes(): Promise<JmapMailbox[]> {
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
return r.methodResponses[0][1].list as JmapMailbox[];
}
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.role === role);
}
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.name === name);
}
/** Create a folder (top-level) and return its id. Idempotent by name. */
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
const existing = await this.mailboxByName(name);
if (existing) return existing.id;
const r = await this.request([
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
]);
const created = r.methodResponses[0][1].created?.new;
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
async deleteMailboxByName(name: string): Promise<void> {
const mb = await this.mailboxByName(name);
if (!mb) return;
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
]);
}
private async allEmailIds(): Promise<string[]> {
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
return r.methodResponses[0][1].ids as string[];
}
/**
* Reset a mailbox to a clean slate: destroy every message and delete any
* non-system (custom) folder. System folders (Inbox/Sent/Trash/) are kept.
*/
async reset(): Promise<void> {
const ids = await this.allEmailIds();
if (ids.length) {
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
}
const custom = (await this.mailboxes()).filter((m) => !m.role);
if (custom.length) {
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
]);
}
}
/** Move an email so it lives solely in `toMailboxId`. */
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true } } } }, '0'],
]);
}
/** Deliver-and-file: create/find a custom folder and drop a message id into it. */
async moveEmailToFolder(emailId: string, folderName: string): Promise<string> {
const id = await this.createMailbox(folderName);
await this.moveEmail(emailId, id);
return id;
}
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
async createDraft(subject: string, toEmail: string): Promise<string> {
const drafts = await this.mailboxByRole('drafts');
if (!drafts) throw new Error('No Drafts mailbox');
const r = await this.request([
['Email/set', {
accountId: this.accountId,
create: {
d: {
mailboxIds: { [drafts.id]: true },
keywords: { $draft: true },
from: [{ email: this.email }],
to: [{ email: toEmail }],
subject,
bodyValues: { b: { value: 'server-created draft body' } },
textBody: [{ partId: 'b', type: 'text/plain' }],
},
},
}, '0'],
]);
const created = r.methodResponses[0][1].created?.d;
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Set or clear the $seen keyword on an email. */
async setSeen(emailId: string, seen: boolean): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$seen`]: seen ? true : null } } }, '0'],
]);
}
/** Look up an email id by subject within an optional mailbox. */
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
const filter: Record<string, unknown> = { subject };
if (mailboxId) filter.inMailbox = mailboxId;
const r = await this.request([
['Email/query', { accountId: this.accountId, filter }, '0'],
['Email/get', {
accountId: this.accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
}, '1'],
]);
return r.methodResponses[1][1].list[0];
}
/** Poll until a message with `subject` is delivered (or throw on timeout). */
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
for (;;) {
const found = await this.findEmailBySubject(subject, opts.mailboxId);
if (found) return found;
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
await new Promise((r) => setTimeout(r, 500));
}
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Dependency-free SMTP submission client.
*
* Speaks just enough SMTP to authenticate against Stalwart's plaintext
* submission listener (AUTH LOGIN, no STARTTLS) and inject a message. Used to
* simulate real inbound mail so the webmail's sync behaviour can be observed.
* A raw socket keeps the test harness free of a nodemailer dependency.
*/
import net from 'node:net';
import { SMTP_HOST, SMTP_PORT } from './config';
interface SendOptions {
host?: string;
port?: number;
/** Envelope + auth sender, e.g. "alice@example.org". */
from: string;
/** Auth username; defaults to `from`. */
authUser?: string;
authPass: string;
/** One or more envelope recipients. */
to: string | string[];
subject: string;
/** Plain-text body. */
body: string;
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
headers?: Record<string, string>;
/** Optional single attachment (sent as multipart/mixed, base64). */
attachment?: { filename: string; contentType: string; content: string };
/**
* Optional inline image referenced by the HTML body via `cid:<cid>`. Sent as
* multipart/related; `base64` is the pre-encoded image payload.
*/
inlineImage?: { cid: string; contentType: string; base64: string; html: string };
}
class SmtpError extends Error {}
function crlf(s: string): string {
return s.replace(/\r?\n/g, '\r\n');
}
/**
* Submit a single message. Resolves once the server has accepted it (250 after
* end-of-DATA). Rejects on any non-2xx/3xx reply or socket error.
*/
export async function sendMail(opts: SendOptions): Promise<void> {
const host = opts.host ?? SMTP_HOST;
const port = opts.port ?? SMTP_PORT;
const recipients = Array.isArray(opts.to) ? opts.to : [opts.to];
const authUser = opts.authUser ?? opts.from;
const socket = net.createConnection({ host, port });
socket.setEncoding('utf8');
socket.setTimeout(15000);
let buffer = '';
let resolveLine: ((line: string) => void) | null = null;
let pendingError: Error | null = null;
socket.on('data', (chunk: string) => {
buffer += chunk;
// A complete reply ends with "<code> ...\r\n" (space, not hyphen, after code).
const lines = buffer.split('\r\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (/^\d{3} /.test(line) && resolveLine) {
const r = resolveLine;
resolveLine = null;
buffer = lines.slice(i + 1).join('\r\n');
r(line);
return;
}
}
});
socket.on('timeout', () => { pendingError = new SmtpError('SMTP timeout'); socket.destroy(); });
socket.on('error', (e) => { pendingError = e; });
const waitReply = (expect: string): Promise<string> =>
new Promise((resolve, reject) => {
if (pendingError) return reject(pendingError);
resolveLine = (line) => {
if (!line.startsWith(expect)) {
reject(new SmtpError(`Expected ${expect}, got: ${line}`));
} else {
resolve(line);
}
};
});
const send = (line: string): void => { socket.write(line + '\r\n'); };
const b64 = (s: string) => Buffer.from(s).toString('base64');
try {
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
await waitReply('220');
send('EHLO integration-tests');
await waitReply('250');
send('AUTH LOGIN');
await waitReply('334');
send(b64(authUser));
await waitReply('334');
send(b64(opts.authPass));
await waitReply('235');
send(`MAIL FROM:<${opts.from}>`);
await waitReply('250');
for (const rcpt of recipients) {
send(`RCPT TO:<${rcpt}>`);
await waitReply('250');
}
send('DATA');
await waitReply('354');
const headers: Record<string, string> = {
From: opts.from,
To: recipients.join(', '),
Subject: opts.subject,
...opts.headers,
};
let mime: string;
if (opts.inlineImage) {
const boundary = 'itrelated_boundary_0001';
headers['MIME-Version'] = '1.0';
headers['Content-Type'] = `multipart/related; boundary="${boundary}"`;
const b64 = opts.inlineImage.base64.replace(/(.{76})/g, '$1\r\n');
mime = [
`--${boundary}`,
'Content-Type: text/html; charset=utf-8',
'',
crlf(opts.inlineImage.html),
`--${boundary}`,
`Content-Type: ${opts.inlineImage.contentType}`,
`Content-ID: <${opts.inlineImage.cid}>`,
'Content-Disposition: inline',
'Content-Transfer-Encoding: base64',
'',
b64,
`--${boundary}--`,
].join('\r\n');
} else if (opts.attachment) {
const boundary = 'itmixed_boundary_0001';
headers['MIME-Version'] = '1.0';
headers['Content-Type'] = `multipart/mixed; boundary="${boundary}"`;
const b64 = Buffer.from(opts.attachment.content).toString('base64').replace(/(.{76})/g, '$1\r\n');
mime = [
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'',
crlf(opts.body),
`--${boundary}`,
`Content-Type: ${opts.attachment.contentType}; name="${opts.attachment.filename}"`,
`Content-Disposition: attachment; filename="${opts.attachment.filename}"`,
'Content-Transfer-Encoding: base64',
'',
b64,
`--${boundary}--`,
].join('\r\n');
} else {
headers['Content-Type'] = 'text/plain; charset=utf-8';
mime = crlf(opts.body);
}
const headerBlock = Object.entries(headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
// Dot-stuff any line that begins with '.'
const safeBody = mime.replace(/\r\n\./g, '\r\n..');
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
await waitReply('250');
send('QUIT');
await waitReply('221').catch(() => { /* some servers drop before 221 */ });
} finally {
socket.destroy();
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"features": {
"unifiedCrossAccountEnabled": true,
"crossUnreadViewEnabled": true,
"crossAllViewEnabled": true
}
}
+33
View File
@@ -0,0 +1,33 @@
# Webmail image for integration testing — runs Next.js in DEVELOPMENT mode.
#
# Why dev mode rather than the production Dockerfile at the repo root?
# The browser talks JMAP directly to Stalwart at http://localhost:8025 (plain
# HTTP, cross-origin). The app's production Content-Security-Policy pins
# connect-src to `'self' https:`, which would block that plaintext cross-origin
# fetch. In development mode proxy.ts widens connect-src to `'self' http:
# https: ws: wss:` — exactly what a local, TLS-less Stalwart needs. Running
# from source also ships the integration-test data-testid hooks without a
# production rebuild.
#
# Build context is the repo root (see docker-compose.yml `context: ..`), so the
# root .dockerignore keeps examples/, integration/ and node_modules out.
FROM node:24-alpine
WORKDIR /app
# Install dependencies first for layer caching.
COPY package.json package-lock.json ./
RUN npm ci
# App source (data-testid hooks included).
COPY . .
ENV NODE_ENV=development
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
EXPOSE 3000
# Bind to 0.0.0.0 so the published port is reachable from the host/browser.
CMD ["npx", "next", "dev", "-H", "0.0.0.0", "-p", "3000"]
@@ -132,6 +132,42 @@ describe('isOrganizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, [])).toBe(false);
});
it('matches the event-level organizerCalendarAddress when no owner role is set', () => {
// Stalwart / imported self-organized events: the user's participant only
// carries `attendee`, the organizer lives in organizerCalendarAddress.
const event = makeEvent({
self: {
'@type': 'Participant',
name: 'Alice',
email: '',
roles: { attendee: true },
participationStatus: 'accepted',
sendTo: { imip: 'mailto:alice@example.com' },
kind: 'individual',
},
});
event.organizerCalendarAddress = 'mailto:alice@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('matches the event-level organizerCalendarAddress case-insensitively', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:Alice@Example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('falls back to replyTo when organizerCalendarAddress is absent', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.replyTo = { imip: 'mailto:alice@example.com' };
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('returns false when the event organizer is someone else', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:someoneelse@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
});
describe('getUserParticipantId', () => {
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { clearCachedData } from '../clear-cached-data';
describe('clearCachedData', () => {
let reload: ReturnType<typeof vi.fn>;
let originalLocation: Location;
beforeEach(() => {
localStorage.clear();
reload = vi.fn();
originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, reload },
});
});
afterEach(() => {
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
});
it('clears re-fetchable caches but keeps accounts, sessions and prefs', () => {
localStorage.setItem('contact-storage', '1');
localStorage.setItem('calendar-storage', '1');
localStorage.setItem('identity-storage', '1');
localStorage.setItem('calendar-notification-storage', '1');
// Must survive — losing these is exactly the pain we're avoiding.
localStorage.setItem('account-registry', 'accounts');
localStorage.setItem('auth-storage', 'session');
localStorage.setItem('settings-storage', 'prefs');
localStorage.setItem('template-storage', 'my templates');
clearCachedData();
expect(localStorage.getItem('contact-storage')).toBeNull();
expect(localStorage.getItem('calendar-storage')).toBeNull();
expect(localStorage.getItem('identity-storage')).toBeNull();
expect(localStorage.getItem('calendar-notification-storage')).toBeNull();
expect(localStorage.getItem('account-registry')).toBe('accounts');
expect(localStorage.getItem('auth-storage')).toBe('session');
expect(localStorage.getItem('settings-storage')).toBe('prefs');
expect(localStorage.getItem('template-storage')).toBe('my templates');
});
it('reloads so data is re-fetched fresh', () => {
clearCachedData();
expect(reload).toHaveBeenCalledOnce();
});
});
+10 -1
View File
@@ -1,6 +1,15 @@
import { unlink, writeFileSync } from "fs";
import { mkdtempSync, unlink, writeFileSync } from "fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default
// data/admin) before env vars. Point it at an empty temp dir so local admin
// state on the developer's machine can't leak into these env-driven
// assertions. Must happen before the first GET, because the config manager
// singleton loads the directory once and caches it.
process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-'));
// Mock NextResponse before importing the route
vi.mock('next/server', () => ({
NextResponse: {

Some files were not shown because too many files have changed in this diff Show More