feat(dev): add one-command local Stalwart test server + workflow docs

Add docker-compose.yml: a disposable Stalwart instance with a fixed dev
admin account (STALWART_RECOVERY_ADMIN), matching the credentials
scripts/dev-token.ps1 already expected. Wired up via new npm scripts
(dev:server, dev:server:down, dev:server:logs).

scripts/dev-token.ps1 was previously untracked (the whole scripts/
directory was gitignored) even though it's part of the documented dev
workflow — un-ignored it, and added scripts/dev-token.sh, a POSIX
equivalent for non-Windows shells and AI agents without PowerShell.

New DEVELOPMENT.md documents the full loop end-to-end (start server,
get a token, run the dev server, verify), written so it's actionable by
both humans and AI coding agents without needing a browser. Linked from
AGENTS.md (Commands) and README.md (Getting started).

Verified manually: docker compose up brings the server to a healthy
state, /api/auth + /auth/token issue a working bearer token, and
/jmap/session returns 200 with it end-to-end.
This commit is contained in:
Steven RYDELL
2026-08-01 18:05:43 +02:00
parent 38abba39f8
commit f4c8f8f21c
8 changed files with 277 additions and 4 deletions
+4 -1
View File
@@ -23,13 +23,16 @@ dist-ssr
*.sln
*.sw?
.ignore
scripts/
scripts/*
!scripts/dev-token.ps1
!scripts/dev-token.sh
*.md
!README.md
!CHANGELOG.md
!AGENTS.md
!CLAUDE.md
!SCHEMA_DEVIATIONS.md
!DEVELOPMENT.md
!.agents/rules/*.md
/SPEC-*
# Tool-generated artifacts during agent sessions
+5 -1
View File
@@ -8,12 +8,16 @@ Community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui), a
## Commands
- `npm run dev` - Dev server
- `npm run dev:server` - Start a local disposable Stalwart test server (Docker); `npm run dev:server:down` to stop it
- `bash scripts/dev-token.sh` (or `pwsh ./scripts/dev-token.ps1` on Windows) - Get a dev access token from that server
- `npm run dev` - Dev server (proxies `/api` and `/jmap` to the test server)
- `npm run typecheck` - `tsc --noEmit`
- `npm run lint` - ESLint
- `npm run test` - Vitest (`npm run test:watch` to watch)
- `npm run build` - `tsc -b && vite build`
Full local dev workflow, including how to run it end-to-end without a browser: [DEVELOPMENT.md](DEVELOPMENT.md).
## Rules
The detailed rules live in `.agents/rules/`. Read the relevant file before acting:
+108
View File
@@ -0,0 +1,108 @@
# Development
How to run a local Stalwart test server and develop this WebUI against it.
This file is meant to be read by humans and AI coding agents alike — see
[AGENTS.md](AGENTS.md) for the rules that also apply while doing this.
## Prerequisites
- Node.js 18+
- Docker (with the `docker compose` CLI plugin)
## 1. Start a local test server
```bash
npm run dev:server
```
This runs `docker compose up -d`, which starts a disposable Stalwart
instance ([`docker-compose.yml`](docker-compose.yml)) with:
- The management/JMAP HTTP API on `http://localhost:8080` (the only port
the WebUI dev proxy needs — see `server.proxy` in
[`vite.config.ts`](vite.config.ts)).
- Mail protocol ports (SMTP/IMAP/POP3/ManageSieve) exposed too, only
needed if you're testing actual mail flows, not just admin UI screens.
- A fixed admin account baked in via `STALWART_RECOVERY_ADMIN`:
`admin@example.org` / `c8321iEscHDy0GWV`. **Disposable dev credentials
only — never reuse them for anything real.**
- Named Docker volumes (`stalwart-etc`, `stalwart-data`) so data survives
a restart. Data persists until you tear the volumes down.
Useful companions:
```bash
npm run dev:server:logs # tail the container's logs
npm run dev:server:down # stop it (add `-v` via `docker compose down -v` to also wipe data)
```
The server takes a couple of seconds to come up; `docker compose logs stalwart`
will show `Network listener started ... localPort = 8080` once it's ready.
## 2. Get an access token
The WebUI normally authenticates through an OAuth flow in the browser, but
for local development it's simpler to skip that and use a bearer token
directly via `VITE_ACCESS_TOKEN` (see `.env.development`).
```bash
# Windows / PowerShell
pwsh ./scripts/dev-token.ps1
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
bash ./scripts/dev-token.sh
```
Both scripts log in as the dev container's admin account, run the full
OAuth PKCE flow against it, and write the resulting token to
`.env.development.local` (gitignored, never committed). Tokens expire
after 1 hour — re-run the script and restart `npm run dev` if the UI
starts returning 401s.
## 3. Run the WebUI
```bash
npm install # first time only
npm run dev
```
Open `http://localhost:5173`. You should land directly in the admin panel
(no login screen) since `VITE_ACCESS_TOKEN` is set.
## 4. Verify your change
```bash
npm run typecheck
npm run lint
npm test
npm run build
```
For UI changes, actually look at the running app (browser or a browser
automation tool) — passing typecheck/lint/tests proves the code compiles
and existing behavior didn't regress, it doesn't prove the new UI works.
## Resetting the test server
To start from a completely clean server (e.g. to re-test first-run
behavior):
```bash
npm run dev:server:down
docker compose down -v # also removes the stalwart-etc/stalwart-data volumes
npm run dev:server
```
## Notes for AI agents
- This whole workflow (steps 13) is scriptable end-to-end without a
browser: `npm run dev:server`, then `bash scripts/dev-token.sh`, then
the app is reachable at `http://localhost:5173` with
`VITE_ACCESS_TOKEN` already set. Verify backend connectivity directly
with `curl`, e.g. `curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`.
- Read [AGENTS.md](AGENTS.md) before touching anything under `src/`
the schema-fidelity rule applies to all development, local test server
or not.
- Don't commit `.env.development.local` (it holds a live token) or leave
the dev container running unexpectedly — `npm run dev:server:down` when
you're done.
+4 -2
View File
@@ -118,7 +118,7 @@ Every tagged release of this fork publishes a `webui.zip` build via CI (see [`.g
Prerequisites:
- Node.js 18 or later
- A running Stalwart instance (for JMAP API calls)
- A running Stalwart instance (for JMAP API calls) — see [DEVELOPMENT.md](DEVELOPMENT.md) for how to spin up a disposable local test server with Docker in one command, no manual Stalwart setup required.
Install dependencies:
@@ -146,12 +146,14 @@ VITE_OAUTH_SCOPES=
### Bypassing OAuth for development
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key:
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel:
```
VITE_ACCESS_TOKEN=your-bearer-token-here
```
Against the local test server from [DEVELOPMENT.md](DEVELOPMENT.md), `scripts/dev-token.ps1` / `scripts/dev-token.sh` fetch one for you automatically.
### Running the dev server
```
+30
View File
@@ -0,0 +1,30 @@
services:
stalwart:
image: stalwartlabs/stalwart:latest
container_name: stalwart-webui-dev
restart: unless-stopped
ports:
# HTTP management/JMAP API — the only port the WebUI dev proxy needs
# (see server.proxy in vite.config.ts).
- "8080:8080"
# Mail protocols, only needed if you're testing actual mail flows
# (sending/receiving, not just admin UI screens).
- "25:25"
- "587:587"
- "465:465"
- "143:143"
- "993:993"
- "110:110"
- "995:995"
- "4190:4190"
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
environment:
# Disposable local dev credentials — do not reuse for anything real.
# Matches scripts/dev-token.ps1's defaults.
STALWART_RECOVERY_ADMIN: "admin@example.org:c8321iEscHDy0GWV"
volumes:
stalwart-etc:
stalwart-data:
+3
View File
@@ -6,6 +6,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "docker compose up -d",
"dev:server:down": "docker compose down",
"dev:server:logs": "docker compose logs -f stalwart",
"build": "tsc -b && vite build",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"lint": "eslint .",
+60
View File
@@ -0,0 +1,60 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Generates a fresh OAuth access token from the local Stalwart dev container
and writes it to .env.development.local (gitignored).
.DESCRIPTION
Local development only. Requires the dev container from docker-compose.yml
(`docker compose up -d`) to be running. Tokens expire after 1 hour; re-run
this script and restart "npm run dev" when the UI starts returning 401s.
The credentials below belong to the disposable local Stalwart container.
See DEVELOPMENT.md for the full workflow. Non-Windows shells (and AI
agents without PowerShell) can use scripts/dev-token.sh instead.
#>
param(
[string]$ApiBaseUrl = "http://localhost:8080",
[string]$AccountName = "admin@example.org",
[string]$AccountSecret = "c8321iEscHDy0GWV"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
# PKCE pair (S256)
$chars = (48..57) + (65..90) + (97..122)
$verifier = -join ($chars | Get-Random -Count 64 | ForEach-Object { [char]$_ })
$sha = [System.Security.Cryptography.SHA256]::Create()
$challenge = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($verifier))).Replace('+', '-').Replace('/', '_').TrimEnd('=')
$redirectUri = "http://localhost:3005/oauth/callback"
$authPayload = @{
type = "authCode"
accountName = $AccountName
accountSecret = $AccountSecret
clientId = "stalwart-webui"
redirectUri = $redirectUri
scope = "openid email profile offline_access"
state = [guid]::NewGuid().ToString("N")
codeChallenge = $challenge
codeChallengeMethod = "S256"
} | ConvertTo-Json -Compress
$auth = Invoke-RestMethod -Uri "$ApiBaseUrl/api/auth" -Method Post -ContentType "application/json" -Body $authPayload -TimeoutSec 15
if ($auth.type -ne "authenticated" -or -not $auth.client_code) {
throw "Unexpected /api/auth response: $($auth | ConvertTo-Json -Compress)"
}
$tokenBody = "grant_type=authorization_code&code=$($auth.client_code)&code_verifier=$verifier&client_id=stalwart-webui&redirect_uri=$([uri]::EscapeDataString($redirectUri))"
$token = Invoke-RestMethod -Uri "$ApiBaseUrl/auth/token" -Method Post -ContentType "application/x-www-form-urlencoded" -Body $tokenBody -TimeoutSec 15
$envPath = Join-Path $root ".env.development.local"
@"
# Generated by scripts/dev-token.ps1 - gitignored, do not commit.
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
VITE_API_BASE_URL=
VITE_ACCESS_TOKEN=$($token.access_token)
"@ | Set-Content -Path $envPath -Encoding ascii
Write-Host "Token written to $envPath (expires in $($token.expires_in)s). Restart 'npm run dev' to pick it up."
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Local development only. Generates a fresh OAuth access token from the local
# Stalwart dev container (see docker-compose.yml) and writes it to
# .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for
# non-Windows shells (and AI agents without PowerShell).
#
# Tokens expire after 1 hour; re-run this script and restart "npm run dev"
# when the UI starts returning 401s.
# The credentials below belong to the disposable local Stalwart container.
set -euo pipefail
API_BASE_URL="${1:-http://localhost:8080}"
ACCOUNT_NAME="${2:-admin@example.org}"
ACCOUNT_SECRET="${3:-c8321iEscHDy0GWV}"
REDIRECT_URI="http://localhost:3005/oauth/callback"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
b64url() {
base64 | tr '+/' '-_' | tr -d '=\n'
}
VERIFIER="$(head -c 48 /dev/urandom | b64url | head -c 64)"
CHALLENGE="$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | b64url)"
STATE="$(head -c 16 /dev/urandom | xxd -p)"
AUTH_PAYLOAD=$(cat <<JSON
{"type":"authCode","accountName":"$ACCOUNT_NAME","accountSecret":"$ACCOUNT_SECRET","clientId":"stalwart-webui","redirectUri":"$REDIRECT_URI","scope":"openid email profile offline_access","state":"$STATE","codeChallenge":"$CHALLENGE","codeChallengeMethod":"S256"}
JSON
)
AUTH_RESPONSE=$(curl -sf "$API_BASE_URL/api/auth" -X POST -H "Content-Type: application/json" -d "$AUTH_PAYLOAD")
CLIENT_CODE=$(printf '%s' "$AUTH_RESPONSE" | grep -o '"client_code":"[^"]*"' | cut -d'"' -f4)
if [ -z "$CLIENT_CODE" ]; then
echo "Unexpected /api/auth response: $AUTH_RESPONSE" >&2
exit 1
fi
TOKEN_RESPONSE=$(curl -sf "$API_BASE_URL/auth/token" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CLIENT_CODE" \
--data-urlencode "code_verifier=$VERIFIER" \
--data-urlencode "client_id=stalwart-webui" \
--data-urlencode "redirect_uri=$REDIRECT_URI")
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
EXPIRES_IN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"expires_in":[0-9]*' | cut -d':' -f2)
if [ -z "$ACCESS_TOKEN" ]; then
echo "Unexpected /auth/token response: $TOKEN_RESPONSE" >&2
exit 1
fi
ENV_PATH="$ROOT_DIR/.env.development.local"
cat > "$ENV_PATH" <<EOF
# Generated by scripts/dev-token.sh - gitignored, do not commit.
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
VITE_API_BASE_URL=
VITE_ACCESS_TOKEN=$ACCESS_TOKEN
EOF
echo "Token written to $ENV_PATH (expires in ${EXPIRES_IN}s). Restart 'npm run dev' to pick it up."