Commit Graph
33 Commits
Author SHA1 Message Date
Linus Rath 1025c1973d refactor: fold the layout and themes tabs into appearance as sections 2026-10-19 17:33:39 +02:00
Linus Rath 4f9271e25b Merge pull request #1108 from hildebrandttk/fix/cross-view-shared-folder-dedup
fix: list a shared folder reachable through two logins only once in c…
2026-10-18 14:57:30 +02:00
Linus Rath a385c6e410 test: read folder counts in one snapshot so a draining badge cannot hang the poll #1194 2026-10-11 18:52:23 +02:00
Linus Rath 463309c99a test: expect cross-account moves to remove the source 2026-10-06 15:07:42 +02:00
Stefan Hildebrandt c49f13db28 fix: list a shared folder reachable through two logins only once in cross views
When a shared folder was reachable through two logged-in accounts (owner +
grantee, or two grantees), buildUnifiedAccountClients emitted one entry per
reaching login. All mail / All unread then listed each message twice and the
unread badge counted it twice.

dedupeUnifiedAccounts keeps each (owning JMAP account, mailbox) once: personal
entries win, shared entries keep only folders not already covered and are
dropped when none remain. The owner's own mailbox list is no longer overwritten
in accountMailboxes by a grantee's namespaced copy, so actions on the deduped
messages resolve trash/archive in the right id space.
2026-09-26 15:46:00 +02:00
Douwe ZijlstraandClaude Opus 5 e8aeca9602 fix: open the tag picker from the labels hover quick-action
The Labels quick-action on an email row called `onSetTag(null)`, which
clears every tag on the message rather than offering one to apply. On an
untagged message that writes nothing, so the button appeared dead: no
picker, no feedback. Assigning a tag from the opened email worked, since
the reading pane renders a real picker.

Clicking the row's tag icon now opens the same `TagPicker` used by the
context menu and the reading pane, wired to the existing `onSetTag`
handler, so a tag can be applied or removed without opening the message.

The picker is portalled to the body: every row sits inside a `transform`ed
virtual item, which would otherwise make a `fixed` child scroll with the
list and clip it at the scroller's edge. It flips above the row near the
viewport bottom, and dismisses on outside click, Escape, scroll or resize.
The hover toolbar stays visible while the picker is open, since the row is
no longer hovered once the pointer moves onto it.

Covered by integration/tests/14-hover-tag.spec.ts, which checks the picker
opens, the keyword reaches the server under `$label:`, a second click
removes it, and the click does not fall through and open the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:48:57 +02:00
Linus Rath a15af72296 fix: resolve shared-account identity and email on reschedule; isolate per-account scheduled-list failures 2026-08-25 20:26:48 +02:00
Linus Rath 1305d13a77 Merge pull request #874 from rotterp/fix/scheduled-send-shared-accounts
fix: route scheduled sends to the account that owns the submission
2026-08-25 20:23:58 +02:00
Linus Rath af5fac6baa fix: flip $junk/$notjunk keywords on spam and not-spam #850 2026-08-25 19:36:52 +02:00
Patrick Rotter 4461935a3c fix: route scheduled sends to the account that owns the submission
A delayed send composed from a shared/group identity is created in the
*shared* JMAP account, because sendEmail() passes the target account to
getSubmissionAccountId(). Every read/write on the scheduled surface,
however, asked the primary submission account only:

    getScheduledEmails / cancelEmailSubmission / rescheduleEmailSubmission
        -> getSubmissionAccountId()   // no argument = primary account

So a message scheduled from a shared address is accepted and stays
pending on the server — it *will* be delivered at sendAt — while the UI
cannot list it, cancel it or reschedule it. Cancelling appears to work:
EmailSubmission/set against the primary account answers `notFound` in
notUpdated, which the client never surfaced, and the mail goes out
anyway. Silent unrecallable mail is worse than a failed send.

Stalwart advertises urn:ietf:params:jmap:submission (with FUTURERELEASE
and a non-zero maxDelayedSend) on group accounts as well as personal
ones, so scheduling from a shared address is legitimate.

Data layer:
- getSubmissionAccountIds() enumerates every submission-capable account;
  getScheduledEmails() queries each, tags every result with the account
  that owns it, and fetches each submission's Email from that same
  account (it lives beside the submission, not in the primary account).
  It also returns per-account totals computed over all pending
  submissions, so a badge stays correct past the first page.
- resolveSubmissionAccountId() locates a submission's owning account so
  cancel/reschedule (and the sendAt/envelope helpers) act on the right
  one. Callers that know the account pass it and skip the lookup.
- sendEmail() reports the submission's account in SendEmailResult, and
  the pending-undo record carries it, so the "Undo send" / "Send now"
  toast actions — which know only a submission id — address the right
  account directly rather than searching for it inside the undo window.
- The delayed-send gate considers any reachable submission account
  rather than only the primary one.

UI:
- Each shared account gets its own "Scheduled" sidebar row, scoped to
  that account; the own row keeps showing the combined view. The scope
  travels beside the virtual mailbox id, so the store's single
  __scheduled__ id is unchanged, and it also drives which row renders as
  selected and whether the scoped list has more to load.

Tested against a real Stalwart (integration/tests/11-shared-scheduled-send):
two server-truth tests pin the mechanism (the submission is invisible to
the primary account, and cancelling through it is a silent no-op) and two
UI tests cover the fix. All three UI-level assertions — the combined
list, the scoped row, and which row is selected — fail without this
change.
2026-08-20 11:23:23 +02:00
Linus Rath 58ddd01cf7 Merge pull request #792 from dev-hive-kazniisa/fix/shared-mailbox-folder-management
fix(email-store): route shared-folder management to the owner account
2026-08-17 12:18:24 +02:00
Linus Rath 6dc344f547 Merge pull request #811 from hildebrandttk/fix/files-stale-data-on-account-switch
fix(files): reset the account-scoped Files drive on every account switch
2026-08-13 23:59:12 +02:00
Stefan Hildebrandt 5c06ae8bc2 fix(files): reset the account-scoped Files drive on every account switch
The Files store was the only account-scoped store never wired into the
account lifecycle: account-state-manager clears email/contacts/calendar/
filter/identity/vacation on a switch, but not files. Because the Files
store is a global that outlives the FilesApp component, the previous
account's client + resources lingered in memory and resurfaced whenever
the drive was reopened after a switch - most visibly when the switch
happened on the mail view (FilesApp unmounted), the common normal-shell
flow that the FilesApp re-init effect alone does not cover.

Clear the Files store centrally in clearAllStores() alongside the other
account-scoped stores, so the reset happens on every switch path.

Tests:
- lib/__tests__/account-state-manager.test.ts: assert clearAllStores()
  resets the Files drive (client, resources, pathStack, selection,
  supportsFiles) with no cross-account leak.
- integration/tests/13-files-account-switch.spec.ts (dockerized Stalwart,
  JMAP FileNode): seeds a distinct top-level folder in alice's and bob's
  drives and asserts the on-screen drive swaps on a switch with no stale
  folder - both switching off the Files view (mail -> switch -> open
  Files) and on it (rail account switcher). Verified: passes with the fix,
  fails (path 1) without it. New FileNode seeding helpers in helpers/jmap.ts;
  openFiles/openMailView/driveEntry nav helpers in helpers/app.ts.
2026-08-13 23:01:41 +02:00
KazNIISA IT c516ba98d0 fix(email-store): route shared-folder management to the owner account
Folder management performed on a shared/group account was sent to the
logged-in user's primary JMAP account instead of the mailbox owner's
account. Subfolder creation therefore failed with invalidProperties for
parentId, root folders could be created silently in the personal account,
and rename, delete, role, and reorder targeted the wrong mailbox tree.

Shared mailboxes use namespaced store ids to avoid cross-account
collisions, while Mailbox/set requires the owner's accountId and the bare
server mailbox id. Resolve that mutation context centrally: recover the
mailbox originalId and owner account, use a directly connected owner
client when one exists, otherwise reach delegated/group accounts through
the active client with an explicit accountId.

Thread the account target through create, update, and delete JMAP methods
and all folder-management actions. Scope role replacement to the same
owner account, and expose explicit root-folder creation from shared
account headers without making personal root creation depend on the
currently selected folder.

Add unit coverage for JMAP request scoping, shared store routing, role
isolation, direct-client handling, and reorder. Add a Stalwart integration
scenario that creates root and nested group folders, renames them, and
deletes them through the UI.
2026-08-12 18:11:43 +05:00
Stefan Hildebrandt ed49e63f1f test(integration): multi-account address-book aggregation across a switch
Companion to the calendar spec for the cross-account id-namespacing fix. Logs
in two accounts, enables the Pro shell, and asserts both accounts' address books
stay aggregated (same set, no loss/duplication/corruption) after switching the
active account. Contacts keep no persisted id-selection, so this verifies the
aggregated list stays correct/usable rather than a selection reset.

Adds a data-testid on address-book rows (address-book-item + data-book-name/
-account) and openContacts / addressBookRows helpers.
2026-08-10 22:45:35 +02:00
Stefan Hildebrandt ce3c5db315 test(integration): multi-account calendar switch preserves selection
Docker/Playwright regression for the cross-account id-namespacing fix: log in
two accounts, enable the Pro shell, deselect a calendar, switch the active
account, and assert the deselection survives (it reset before the fix because
the previously-active account's ids flipped raw<->namespaced on the switch).

Adds the minimal test hooks: data-testid on calendar sidebar rows
(calendar-item + data-calendar-name/-visible), the settings tab buttons
(settings-tab-<id>), and a testId passthrough on ToggleSwitch used for the Pro
interface toggle. New app helpers: openCalendar (in-app nav, since a hard goto
to a deep link bounces to login), calendarRows, enableProInterface (toggles via
the settings UI - the app doesn't survive a hard reload and seeding proInterface
before login destabilises the add-account popover).

Verified green in the dockerized Stalwart harness.
2026-08-10 22:45:28 +02:00
Stefan Hildebrandt b39f4b714a test(integration): renumber specs to drop the duplicate 04 prefix
Both 04-all-mail and 04-shared-identity shared the same 04 prefix. Shift
shared-identity and everything after it up by one (05-shared-identity …
12-cross-view-retain), leaving 04-all-mail as the sole 04 spec. Pure file
renames; no content changes.
2026-08-09 22:50:07 +02:00
Stefan Hildebrandt 88a809bd69 test(integration): verify unread/starred cross views keep the acted-on row
Adds 11-cross-view-retain.spec.ts: with the Unread cross view open, reading a
mail keeps it in the list (status + unread counter update only, no removal);
with the Starred cross view open, un-starring keeps it. Both force the
focus/push refresh that previously dropped the row and assert it survives.

Supporting test hooks:
- thread-list-item: expose data-starred on the row.
- email-context-menu: testId ctx-star / ctx-unstar on the star item.
- integration helpers: expectEmailStarred (app.ts), setFlagged (jmap.ts).
- integration policy.json: enable crossStarredViewEnabled.

Verified: 08-shared-moves-style run of 11-cross-view-retain → 2/2 passed
against the dockerized stack.
2026-08-09 22:17:58 +02:00
Stefan Hildebrandt b48b6e0871 fix(email): defer source removal on cross-account move to Stalwart
The explicit Email/set destroy workaround for the duplicate-on-move bug is
removed now that the root cause is filed upstream (support.stalw.art #1150:
onSuccessDestroyOriginal destroys the copy's create-id instead of the source
id). copyEmailAcrossAccounts keeps requesting onSuccessDestroyOriginal, so the
move self-heals once Stalwart ships the fix.

Kept: the keyword-preservation fix (carry the source keywords into Email/copy)
so the moved message keeps its read state.

Tests: 08-shared-moves still asserts delivery + read-state on every
cross-account case; the source-removal checks are re-pinned test.fail, scoped
to a nested describe, until #1150 is fixed. Suite green (5 pass, 3 expected-fail).
2026-07-24 18:30:50 +02:00
Stefan Hildebrandt 6248bb9825 fix(email): preserve read state and remove source on cross-account move
Moving a message across the account boundary (own ↔ shared folder, or
between two owners' shared folders) left the original in the source
folder and showed the moved copy as unread. Same-account moves were fine.

Cause (verified against a live Stalwart):
- Email/copy drops keywords unless the create sets them, so the copy lost
  $seen and arrived unread.
- onSuccessDestroyOriginal is unreliable — the implicit destroy reports
  notFound and leaves the original behind (flaky), so the move duplicated.

Fix (copyEmailAcrossAccounts): read the source keywords and carry them
into the Email/copy create, then destroy the original with an explicit
Email/set on the source account instead of onSuccessDestroyOriginal.

Tests: 08-shared-moves now asserts the source is gone and the read state
survives on every cross-account case, and adds a cross-owner shared →
shared move (alice's folder → bob's folder). Confirmed red on the old
code (3 cross-account cases fail), green with the fix.
2026-07-24 18:30:50 +02:00
Stefan Hildebrandt 15ad783848 fix(email): make the "Move to" context menu work across accounts
Moving a message to a folder in another account (own ↔ delegated/shared) via the
"Move to" context menu was a no-op — the handlers always issued a single-account
Email/set, which can't move between JMAP accounts. Drag-and-drop already routed
these correctly; the context menu never did.

Add moveToMailboxCrossAware: it detects a cross-account destination (own and
shared mailboxes both carry accountId) and routes through the drag-and-drop
crossAccountMoveEmails pipeline, else falls back to the single-account move.

Fix the pipeline for delegated folders too: a client can't stage a blob in a
delegated account (Blob/upload → blobNotFound), so importing into a shared folder
failed. When one client reaches both accounts, use a server-side JMAP Email/copy
(+ destroy original) instead of blob copy+import; the blob path is kept only for
separate cross-server login accounts. Adds client.copyEmailAcrossAccounts.

Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full
docker integration suite green (37 passed).
2026-07-24 18:30:50 +02:00
Stefan Hildebrandt 9c6292b4b7 fix(email): restore the sender identity when reopening a draft
Reopening a draft reset the composer's From to the default identity. The
edit-draft handler matched the draft's saved From against the active-account
identity list by email only, so two identities sharing an address (a default +
an alias differing by name) collided — the wrong one was picked, or with
cross-account namespaced ids none was.

Add findDraftIdentityId (name+email, normalized, +tag fallback) and match against
the same list the composer renders (the flat cross-account list when multi-
account is on). Wired into both the classic and Pro edit-draft paths. Unit tests
plus the un-pinned 07-drafts integration spec.
2026-07-23 19:36:33 +02:00
Stefan Hildebrandt 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
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
Stefan Hildebrandt 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 Hildebrandt 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
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