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.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:15:13 +02:00
parent bc11450f3f
commit b8809c2e69
31 changed files with 1443 additions and 5 deletions
+129
View File
@@ -0,0 +1,129 @@
#!/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}"
# 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}"