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.
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/ |
8025 JMAP+admin, 1025 SMTP, 1143 IMAP |
Real MTA, declaratively bootstrapped with test mailboxes |
webmail |
built from 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 pinsconnect-srcto'self' https:and would block that; dev mode widens it to allowhttp:. Dev mode also ships the test hooks from source without a production rebuild. See the header ofwebmail.Dockerfile. - CORS. Stalwart doesn't emit CORS headers by default. The bootstrap enables
usePermissiveCors(seestalwart/plan-accounts.ndjson.tpl) so the browser origin (:3000) may call the JMAP origin (:8025).
Running
# 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:
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-spamdoesn't optimistically decrement the source counter the waydeletedoes; 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.failin07-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.failin08-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-attachmentscovers 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-totalon the[data-testid="folder-counts"]element, which makes assertions locale- independent. These and the otherdata-testidhooks (folder-row,email-list-item,account-switcher,account-option,add-account,email-composer, …) were added to the app for these tests. forceSync(page)dispatches avisibilitychangeto trigger the client'scheckForStateChanges()— 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 |
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:
docker compose -f integration/docker-compose.yml down -v