Drafts were always created in the primary account, so a message composed as
a group identity sat in the member's own Drafts while its Sent copy (since the
previous commit) went to the group.
- createDraft takes { accountId, previousDraftAccountId }: the draft goes to
the account that owns the From identity (resolveSendAccountId, now public
and briefly caching the identity lists for autosave), and the replaced
version is destroyed in the account it lives in.
- The composer tracks draftAccountId next to draftId (save, re-resolve of
attachment blobs, send, discard, stashed state), since draft ids are only
unique within one account.
- sendEmail destroys the sent draft in options.draftAccountId; discarding
(inline and Pro tab) deletes it there, through the identity's login.
- Re-opening a draft from a group/shared Drafts folder (or a stamped
aggregate row) keeps its account.
- resolveSendAccountId is optional on IJMAPClient; without it drafts stay in
the primary account.
- Tests: client unit tests for draft placement / replacement / send cleanup,
E2E spec 18 draft cases (autosave + send, discard).
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.
In CI
The "Integration tests" workflow (.github/workflows/integration.yml) runs
run-tests.sh every night on main, on pull requests that carry the
integration label, and on demand:
gh workflow run integration.yml
A failed run uploads the Playwright report, traces and failure videos as the
integration-report artifact and prints the container logs.
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 restores the chosen sender (fixed). It used to reset
the From selector to the default identity (
08-drafts). - Cross-account moves (fixed). Moves between the own account and shared
folders, and across owners, deliver the message, keep its read state and
remove the source (
09-shared-moves). The client destroys the source itself instead of using Stalwart'sonSuccessDestroyOriginal(support.stalw.art #1150). - 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 |
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):
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:
docker compose -f integration/docker-compose.yml down -v