Josef Straßl пре 2 недеља
родитељ
комит
239f422b9b

+ 5 - 0
.gitignore

@@ -1,8 +1,13 @@
 config.toml
+config.toml.bak
+.config.*.tmp
 *.db
 *.db-journal
+*.db-wal
+*.db-shm
 *.log
 *.log.*
+.pytest_cache/
 venv/
 __pycache__/
 *.pyc

+ 129 - 0
AGENTS.md

@@ -0,0 +1,129 @@
+# AGENTS.md — PPS Quarantine Manager
+
+Read this first. It is the map plus the non-obvious invariants that are expensive to
+rediscover. Human-readable too, but written so an agent can act correctly without
+re-deriving the traps.
+
+## What this is
+
+A Flask web app for operators to manage a Proofpoint Protection Server (PPS) email
+quarantine: list quarantined mail, read messages, and run bulk actions (Release,
+Report & Release, Delete, Move) as **asynchronous background jobs**. Okta OIDC login,
+an allowed-users list with roles, and an admin panel that edits the config live.
+
+## Run it
+
+```bash
+python3 -m venv venv && venv/bin/pip install -r requirements.txt
+cp config.example.toml config.toml   # then edit: Okta, PPS creds, users, secret_key
+venv/bin/python wsgi.py              # NOT app.py — see "App factory" below
+```
+
+Dev without Okta: set `[auth] mode = "static"` and use `[auth.static]` credentials.
+Tests / alternate config: `PPSQ_CONFIG=/path/to/config.toml`. Plain-http localhost dev
+also needs `PPSQ_ALLOW_INSECURE=1` (allows an `http://` PPS base_url).
+
+```bash
+venv/bin/python -m pytest       # 57 tests, no Okta tenant or real config needed
+```
+
+## Module map
+
+| File | Role |
+|------|------|
+| `wsgi.py` | **Composition root.** The only place that builds the store/queue/prefs, starts the worker thread, configures logging, and serves via waitress. |
+| `app.py` | `create_app(store, queue, prefs)` — Flask routes. **No import-time side effects.** |
+| `config_store.py` | `ConfigStore`: tomlkit load/validate/atomic-write, immutable snapshots, PPSClient rebuild. The heart of the hot-reload design. |
+| `auth.py` | Okta OIDC + static dev mode; `login_required`/`admin_required`; `is_allowed`/`safe_next` (pure); CSRF. |
+| `pipeline.py` | Data-driven Report & Release step registry (release → move → delete). No Flask. |
+| `worker.py` | `JobQueue`: SQLite-backed background job thread; ops audit log. |
+| `prefs.py` | `PrefStore`: per-user preferences in jobs.db (key-value). |
+| `admin.py` | `/admin` page + `/api/admin/*` (config, pps-test, jobs, logs). |
+| `logs.py` | Safe backwards log tailing + server-side level classification. |
+| `pps_client.py` | Thin PPS REST client (`search`/`get_raw`/`act`). Cheap to rebuild. |
+| `static/`, `templates/` | Vanilla-JS frontend; `app.js` (quarantine), `admin.js` (panel). |
+
+## Invariants — break these and things fail silently
+
+1. **Single process only.** The worker thread and both SQLite connections live in-process.
+   Running multiple web workers (`--workers N`, gunicorn forks) starts competing queue
+   threads on one DB and an in-process config lock that doesn't coordinate file writes.
+   `wsgi.py` serves single-process on purpose. Do not "scale" it horizontally.
+
+2. **App factory, no import side effects.** `import app` must never read config or start a
+   thread — otherwise every test touches the real `config.toml` and spawns a worker.
+   Construction happens only in `wsgi.py` (prod) and `create_app(...)` (tests).
+
+3. **`localguid` is folder-local and dies on move; `guid` is stable.** A message's
+   `localguid` is only valid in its current folder and becomes invalid the moment the
+   message is moved/deleted. The `guid` is global and stable. To act on a message after a
+   relocating step, re-find its current `localguid` by matching `guid` (`pipeline.refind_localguids`).
+
+4. **Report & Release: release FIRST, with NO `deletedfolder`.** `release` must run before
+   `move`, and must not pass `deletedfolder`, so the message stays in place and its
+   `localguid` survives for the move. Passing `deletedfolder` on release relocates the
+   message and invalidates every localguid — the exact bug the guid-refind fallback exists
+   to paper over. See the comment in `pipeline._release`.
+
+5. **Snapshot per job / per request.** Config is an immutable `Snapshot`; `apply()` builds a
+   new one and rebinds one attribute (atomic under the GIL). A worker reads one snapshot at
+   job start and threads it through, so a mid-job admin edit affects the *next* job, never a
+   job in flight. Never re-read `store.snapshot()` inside a running job.
+
+6. **WAL + busy_timeout are mandatory.** Two connections (queue + prefs) share `jobs.db`.
+   Without `PRAGMA journal_mode=WAL` and `busy_timeout`, a prefs write concurrent with the
+   per-chunk job-progress write throws `database is locked`. Set in `JobQueue._init_db` and
+   every prefs connection.
+
+7. **PPS search: 1000-row cap, no offset paging.** Each `search` returns at most 1000 rows,
+   newest first. The client pages the whole folder backwards by date using the oldest date
+   seen as a `before` cursor. `page.oldest` MUST be computed over the raw batch, before the
+   acted-on filter (`app.py` `api_messages`), or paging terminates early and silently
+   truncates the folder. >1000 messages sharing one exact timestamp is unpageable (accepted
+   edge case). Coverage is also bounded by `default_days_back`.
+
+8. **`list_query = "from=*"` is a workaround, not a preference.** The PPS API rejects a
+   folder-only search; it requires a from/rcpt/subject filter. `from=*` means "everything".
+   If a deployment doesn't treat it as match-all, change it (e.g. `rcpt=@yourdomain.com`).
+
+9. **Secrets are write-only.** `pps.password`, `okta.client_secret`, `auth.static.password`,
+   `app.secret_key` are never returned by any endpoint (`ConfigStore.redacted()` emits a
+   `<name>_set` boolean). A blank value on save means "leave unchanged". Do not add an
+   endpoint that echoes these back.
+
+10. **Log source is an enum, never a path.** `/api/admin/logs?source=ops|app` resolves to a
+    path server-side. A `?path=` parameter would be arbitrary file read — never add one.
+
+## Config zones
+
+`config.toml` is one file with two zones. The admin panel writes only the admin zone and
+round-trips the file with **tomlkit** (comments/formatting preserved).
+
+- **Server-only** (edit the file, restart): `[okta]`, `[auth] mode` + `[auth.static]`,
+  `[app] secret_key/listen/port/db_path/worker_log/app_log/cookie_secure`.
+- **Admin-editable** (panel, mostly live): `[pps]`, `[quarantine]` incl.
+  `[quarantine.report_release]`, `[auth] users` + `denied_message`, `[app] log_level`.
+
+`ADMIN_EDITABLE` in `config_store.py` is the explicit allowlist. Adding an editable key
+means adding it there. `docs/configuration.md` is the full reference.
+
+## Conventions
+
+- `from __future__ import annotations` at the top of every module.
+- Loggers are namespaced `pps.<area>` (`pps.app`, `pps.worker`, `pps.pipeline`, …).
+- `%`-style lazy log formatting.
+- Private helpers prefixed `_`; routes named `api_*` / `admin_*`.
+- Bare `except Exception` only where justified, always with `# noqa: BLE001` + a reason.
+- PPS failures raise `PPSError` (carries `.status/.body/.reqid`), funneled through
+  `_pps_error_response` → 502.
+- Schema evolution: `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` (see `worker._init_db`).
+
+## Extending
+
+- **New Report & Release action:** add a `@step("name")` function in `pipeline.py` and put
+  `"name"` in `ALLOWED_STEPS` (canonical order). If it relocates messages, teach
+  `pipeline_destination`. The admin checkboxes render from the schema — no frontend change.
+- **New config key:** add to `config.example.toml` (with a comment), read it from the
+  snapshot, and if admin-editable add it to `ADMIN_EDITABLE` + validation in `config_store.py`.
+- **New per-user pref:** add a `PrefSpec` to `PREF_SPECS` in `prefs.py` (default path +
+  coercion + validator). No migration needed (key-value store).

+ 53 - 83
README.md

@@ -1,100 +1,70 @@
-# PPS Quarantine Manager (PoC)
+# PPS Quarantine Manager
 
-A small web client for the Proofpoint Protection Server (PPS) quarantine. It lists
-quarantined mail by folder and runs bulk actions — **Release**, **Report & Release**,
-**Delete**, **Move** — as **asynchronous background jobs**, so the operator never waits
-on slow PPS API calls (deletes in particular).
+A web app for operators to manage a Proofpoint Protection Server (PPS) email quarantine:
+list quarantined mail by folder, read messages (headers, body, attachments, raw headers),
+and run bulk actions — **Release**, **Report & Release**, **Delete**, **Move** — as
+**asynchronous background jobs**, so slow PPS calls never block the operator.
 
-> Proof of concept: static login, plaintext secrets in a config file, no permission model.
-> SAML/OIDC and RBAC come later.
+- **Okta OIDC login** with an allowed-users list and roles (admin/user).
+- **Admin panel** (`/admin`, admin-only) to manage users, the PPS connection, folders,
+  defaults, the Report & Release pipeline, background jobs, and logs — editing `config.toml`
+  live (comment-preserving), with write-only credentials.
+- **Per-user preferences** (show limit, default folder, sort) layered over admin defaults.
+- **Audit log** of every message acted on, attributed to the acting user.
 
-## How it works
+## Quick start
 
-- **Frontend** (`templates/`, `static/`): one page. A folder switcher scopes the whole
-  view to one quarantine folder; the list shows date / sender / recipient / subject with a
-  checkbox per row. Clicking a row opens the message content in an overlay over the list
-  (HTML parts render in a sandboxed iframe). Actions apply to all checked rows, which then
-  disappear from the list immediately.
-- **Backend** (`app.py`, `pps_client.py`): Flask serves the UI and proxies the PPS
-  Quarantine Search REST API. Actions are queued and return instantly (HTTP 202).
-- **Worker** (`worker.py`): a daemon thread drains a SQLite-backed job queue, batching
-  message ids into chunked PPS POSTs. Jobs are persistent, so queued work survives a
-  restart; a job left mid-flight is requeued on startup.
+```bash
+python3 -m venv venv
+venv/bin/pip install -r requirements.txt
+cp config.example.toml config.toml     # edit: [okta], [pps] creds, [[auth.users]], secret_key
+venv/bin/python wsgi.py                 # http://127.0.0.1:8080
+```
 
-## Action semantics
+Generate a session key: `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
 
-| UI action        | PPS API call(s)                                                                 |
-|------------------|---------------------------------------------------------------------------------|
-| Release          | `release` without rescan; `deletedfolder` set so it leaves the folder            |
-| Report & Release | `release` (in place, no rescan) **then** `move` a copy to `report_release_folder` |
-| Delete           | `delete` with `deletedfolder` (moves to deleted items, not a hard delete)         |
-| Move             | `move` to the folder chosen in the dropdown                                        |
+**Local dev without Okta:** set `[auth] mode = "static"`, use `[auth.static]` credentials,
+and run with `PPSQ_CONFIG=/tmp/dev.toml PPSQ_ALLOW_INSECURE=1 venv/bin/python wsgi.py`.
+See [docs/development.md](docs/development.md).
 
-**Report & Release** keeps a copy in `report_release_folder` (default `"Debugging - Josef"`)
-for manual false-positive submission while the mail is delivered. If this PPS deployment's
-`release` relocates the message to the deleted folder (like the admin GUI does), the worker
-re-finds it there by its stable `guid` and moves it from there — no configuration needed.
+## How it works
 
-## Operations log (audit trail)
+`wsgi.py` is the single-process entrypoint: it builds the config store, the SQLite-backed
+job queue, and the per-user prefs store, starts one background worker thread, and serves via
+waitress. The browser fires an action and gets an immediate `202`; the worker drains the
+queue and calls PPS. Jobs survive a restart. **Run as a single process only.**
 
-Every message the background worker acts on is recorded in a dedicated, rotating log file
-(`worker_log`, default `worker.log` — 5 MB × 5 files). One line per message, e.g.:
+Report & Release is a configurable pipeline of steps (`release → move → delete`, pick any
+subset) — release delivers the mail in place, move copies it to a report folder, delete
+removes it. Configured with checkboxes in the admin panel.
 
-```
-2026-07-14 17:39:56 job=#12 action=delete result=ok src='Quarantine' dst='Deleted' localguid=6:6:1 guid=g-aaa from='sender@spam.example' rcpt='victim@corp.com' subject='You won a prize'
-```
+## Documentation
 
-`result` is `ok` or `FAILED` (failures also carry `error=...`). Fields are `key=value` for
-easy `grep`. This is separate from the general application log (stdout, controlled by
-`log_level`), which covers requests, job lifecycle, and PPS API calls.
+| Doc | |
+|-----|--|
+| [AGENTS.md](AGENTS.md) | **Start here.** Module map + the invariants you must not break. |
+| [docs/architecture.md](docs/architecture.md) | System design, request/job lifecycle, hot-reload. |
+| [docs/configuration.md](docs/configuration.md) | Every config key, zones, live-vs-restart. |
+| [docs/api.md](docs/api.md) | HTTP API reference. |
+| [docs/okta-setup.md](docs/okta-setup.md) | Okta OIDC app setup + access model. |
+| [docs/operations.md](docs/operations.md) | Deploy, logs, backup, credential rotation, troubleshooting. |
+| [docs/development.md](docs/development.md) | Dev setup, running without Okta/PPS, tests. |
+| [docs/security.md](docs/security.md) | Auth, sessions/CSRF, secrets, known limitations. |
 
-## Setup
+## Tests
 
 ```bash
-python3 -m venv venv
-venv/bin/pip install -r requirements.txt
-cp config.example.toml config.toml
-# edit config.toml: PPS host, API credentials, folders, login
-venv/bin/python app.py
+venv/bin/pip install -r requirements-dev.txt
+venv/bin/python -m pytest        # ~57 tests, no Okta tenant or real config needed
 ```
 
-Open http://127.0.0.1:8080 and sign in with the `[auth]` credentials from `config.toml`.
-
-## Configuration notes
-
-- **Folders are maintained locally.** The PPS API has no endpoint to list folders, so the
-  `folders` array in `config.toml` is the source of truth for the switcher, the Move
-  dropdown, and the delete/report targets. Names must match PPS **exactly** (case- and
-  space-sensitive, e.g. `"Debugging - Josef"`); a wrong name only errors at action time.
-- **`list_query` = `"from=*"`.** The search API requires a `from`/`rcpt`/`subject` filter —
-  it can't list a folder by folder alone. A bare wildcard means "everything in the folder".
-  If your PPS doesn't treat `from=*` as match-all, set it to something like
-  `rcpt=@yourdomain.com`.
-- **`default_days_back`.** The search API alone returns only the last 24h; this widens the
-  window (uses `startdate`).
-- **Limits.** Up to 1000 messages per search (API cap, no pagination). `default_limit` sets
-  the UI default; the operator can raise it to 1000.
-- **`verify_tls`.** PPS admin certs are often self-signed; `false` skips verification (PoC).
-  Point it at a CA bundle path to verify instead.
-- **Mutual TLS (client certificate).** If PPS is fronted by nginx requiring a client cert,
-  requests without one fail with `400 No required SSL certificate was sent`. Set
-  `client_cert` (a combined cert+key PEM, or a cert PEM plus `client_key` for the key).
-  Note this is a **TLS client cert**, separate from the `[pps]` Basic-auth credentials —
-  the endpoint may require both.
-
-## API credentials
-
-The account in `[pps]` must be a PPS admin with an **API Role** that has the Quarantine
-module enabled (see the PPS management interface / a Proofpoint support ticket for PoD).
-Auth is HTTP Basic against the admin port (default 10000).
-
-## Files
+## Requirements
 
-```
-app.py               Flask app: routes, login, config, worker startup
-pps_client.py        PPS Quarantine REST client (search / get_raw / act)
-worker.py            SQLite job queue + background worker thread
-config.example.toml  Config template (copy to config.toml)
-templates/           login.html, index.html
-static/              app.js, style.css
-```
+Python 3.11+ (uses `tomllib`/tomlkit; developed on 3.12). Flask, Authlib, tomlkit, requests,
+waitress — see `requirements.txt` (ranges) / `requirements.lock` (pinned).
+
+## Status
+
+Internal production release, evolved from the original PoC. Single-process; secrets are
+plaintext-on-disk (0600); roles are app-managed, not Okta groups. See
+[docs/security.md](docs/security.md) for the accepted limitations.

+ 141 - 0
admin.py

@@ -0,0 +1,141 @@
+"""Admin panel — detached page at /admin plus /api/admin/* endpoints.
+
+All routes require the admin role. Settings are read/written through the ConfigStore
+(atomic write, comment-preserving, secrets never returned). Users and the denied message
+are ordinary config keys, so they go through the same single PUT /api/admin/config write
+path — one atomic write, no interleaving between "save users" and "save folders".
+"""
+
+from __future__ import annotations
+
+import logging
+
+from flask import Blueprint, jsonify, render_template, request
+
+import logs
+import pipeline
+from auth import admin_required, current_user
+from config_store import ADMIN_EDITABLE, RESTART_KEYS, ConfigError
+from pps_client import PPSClient, PPSError
+
+log = logging.getLogger("pps.admin")
+
+
+def init_admin(store, queue) -> Blueprint:
+    bp = Blueprint("admin", __name__)
+
+    @bp.route("/admin")
+    @admin_required
+    def admin_page():
+        return render_template("admin.html")
+
+    @bp.route("/api/admin/config", methods=["GET", "PUT"])
+    @admin_required
+    def admin_config():
+        if request.method == "GET":
+            snap = store.snapshot()
+            return jsonify(
+                {
+                    "config": store.redacted(),
+                    "schema": _schema(snap),
+                    "version": snap.version,
+                }
+            )
+        # PUT
+        patch = request.get_json(silent=True) or {}
+        try:
+            result = store.apply(patch, actor=current_user()["email"])
+        except ConfigError as exc:
+            return jsonify({"errors": exc.errors}), 400
+        return jsonify(
+            {
+                "version": result.version,
+                "applied": result.applied,
+                "restart_required": result.restart_required,
+                "config": store.redacted(),
+            }
+        )
+
+    @bp.route("/api/admin/pps-test", methods=["POST"])
+    @admin_required
+    def admin_pps_test():
+        """Build a throwaway client from the PENDING form values and probe PPS.
+
+        Never persists, never touches the live client. Blank password falls back to the
+        stored one so the admin can test other fields without re-typing the secret.
+        """
+        body = request.get_json(silent=True) or {}
+        snap = store.snapshot()
+        p = snap.pps
+        password = body.get("password") or p.get("password")
+        try:
+            client = PPSClient(
+                base_url=body.get("base_url") or p["base_url"],
+                username=body.get("username") or p["username"],
+                password=password,
+                verify_tls=body.get("verify_tls", p.get("verify_tls", False)),
+                timeout=int(body.get("timeout") or p.get("timeout", 120)),
+                client_cert=(body.get("client_cert") or p.get("client_cert")) or None,
+                client_key=(body.get("client_key") or p.get("client_key")) or None,
+            )
+            q = snap.quarantine
+            records = client.search(
+                q.get("default_folder", "Quarantine"),
+                q.get("list_query", "from=*"),
+                limit=1,
+                days_back=int(q.get("default_days_back", 7)),
+            )
+            return jsonify({"ok": True, "detail": f"Connected. {len(records)} record(s) sampled."})
+        except PPSError as exc:
+            return jsonify({"ok": False, "detail": str(exc)})
+        except Exception as exc:  # noqa: BLE001 - surface any client/build error to the admin
+            return jsonify({"ok": False, "detail": f"{type(exc).__name__}: {exc}"})
+
+    @bp.route("/api/admin/jobs")
+    @admin_required
+    def admin_jobs():
+        limit = _int(request.args.get("limit"), 50, 1, 500)
+        return jsonify({"jobs": queue.recent_jobs(limit=limit), "active": queue.has_active_jobs()})
+
+    @bp.route("/api/admin/jobs/<int:job_id>/retry", methods=["POST"])
+    @admin_required
+    def admin_retry(job_id: int):
+        ok = queue.retry(job_id)
+        if not ok:
+            return jsonify({"error": "job not found or not failed"}), 404
+        return jsonify({"job_id": job_id, "status": "pending"})
+
+    @bp.route("/api/admin/logs")
+    @admin_required
+    def admin_logs():
+        source = request.args.get("source", "ops")
+        if source not in logs.LOG_SOURCES:
+            return jsonify({"error": f"unknown log source: {source}"}), 400
+        lines = _int(request.args.get("lines"), 200, 1, logs.MAX_LINES)
+        query = request.args.get("q") or None
+        app_cfg = store.snapshot().app
+        return jsonify(logs.read_log(source, app_cfg, lines=lines, query=query))
+
+    return bp
+
+
+def _schema(snap) -> dict:
+    """Static choices the admin UI needs to render generically."""
+    return {
+        "folders": list(snap.quarantine.get("folders", [])),
+        "steps": list(pipeline.ALLOWED_STEPS),
+        "roles": ["admin", "user"],
+        "sort_fields": ["subject", "date", "from", "rcpt"],
+        "sort_dirs": ["asc", "desc"],
+        "auth_mode": snap.auth.get("mode", "static"),
+        "editable_keys": sorted(ADMIN_EDITABLE),
+        "restart_keys": sorted(RESTART_KEYS),
+        "pipeline_sentence": pipeline.describe_pipeline(snap.quarantine),
+    }
+
+
+def _int(raw, default: int, lo: int, hi: int) -> int:
+    try:
+        return max(lo, min(int(raw), hi))
+    except (TypeError, ValueError):
+        return default

+ 211 - 249
app.py

@@ -1,8 +1,11 @@
-"""PPS Quarantine Manager — Flask frontend + API.
+"""PPS Quarantine Manager — Flask app factory.
 
-Serves the single-page UI and proxies to the Proofpoint Quarantine Search REST API.
-Slow actions (release/move/delete) are handed to the background JobQueue and return
-immediately; the UI polls /api/jobs for progress.
+`create_app(store, queue, prefs)` wires the routes with no import-time side effects
+(no config read, no worker thread, no network). The composition root is `wsgi.py`;
+tests build an app directly with a temp config and a fake PPS client.
+
+Slow actions (release/move/delete) are queued to the background JobQueue and return
+immediately; the browser polls /api/jobs for progress.
 """
 
 from __future__ import annotations
@@ -10,257 +13,227 @@ from __future__ import annotations
 import email
 import logging
 import re
-import tomllib
 from email import policy
-from functools import wraps
-from pathlib import Path
-
-from flask import (
-    Flask,
-    abort,
-    jsonify,
-    redirect,
-    render_template,
-    request,
-    session,
-    url_for,
-)
-
-from pps_client import PPSClient, PPSError
-from worker import VALID_ACTIONS, JobQueue
 
-CONFIG_PATH = Path(__file__).with_name("config.toml")
+from flask import Flask, jsonify, render_template, request
 
+import auth
+from admin import init_admin
+from pps_client import PPSError
+from worker import VALID_ACTIONS, JobQueue
 
-def load_config() -> dict:
-    if not CONFIG_PATH.exists():
-        raise SystemExit(
-            f"Missing {CONFIG_PATH.name}. Copy config.example.toml to config.toml and edit it."
-        )
-    with CONFIG_PATH.open("rb") as fh:
-        return tomllib.load(fh)
+log = logging.getLogger("pps.app")
 
 
-config = load_config()
-_pps_cfg = config["pps"]
-_q_cfg = config["quarantine"]
-_app_cfg = config["app"]
-_auth_cfg = config["auth"]
+def create_app(store, queue: JobQueue, prefs) -> Flask:
+    app = Flask(__name__)
+    snap = store.snapshot()
+
+    app.secret_key = snap.app["secret_key"]
+    app.config.update(
+        STORE=store,
+        QUEUE=queue,
+        PREFS=prefs,
+        SESSION_COOKIE_HTTPONLY=True,
+        # Lax (not Strict): the Okta callback is a cross-site top-level GET; Strict would
+        # withhold the cookie and every login would fail with mismatching_state.
+        SESSION_COOKIE_SAMESITE="Lax",
+        SESSION_COOKIE_SECURE=bool(snap.app.get("cookie_secure", True)),
+        SESSION_COOKIE_NAME="ppsq_session",
+        MAX_CONTENT_LENGTH=4 * 1024 * 1024,
+    )
 
-logging.basicConfig(
-    level=getattr(logging, str(_app_cfg.get("log_level", "INFO")).upper(), logging.INFO),
-    format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
-)
-log = logging.getLogger("pps.app")
+    app.register_blueprint(auth.init_auth(app, store))
+    app.register_blueprint(init_admin(store, queue))
+    app.before_request(auth.check_csrf)
 
-pps = PPSClient(
-    base_url=_pps_cfg["base_url"],
-    username=_pps_cfg["username"],
-    password=_pps_cfg["password"],
-    verify_tls=_pps_cfg.get("verify_tls", False),
-    timeout=int(_pps_cfg.get("timeout", 120)),
-    client_cert=_pps_cfg.get("client_cert") or None,
-    client_key=_pps_cfg.get("client_key") or None,
-)
-
-queue = JobQueue(
-    _app_cfg.get("db_path", "jobs.db"),
-    pps,
-    _q_cfg,
-    ops_log_path=_app_cfg.get("worker_log", "worker.log"),
-)
-queue.start()
-
-app = Flask(__name__)
-app.secret_key = _app_cfg["secret_key"]
-
-
-# ------------------------------------------------------------------ auth
-
-def login_required(view):
-    @wraps(view)
-    def wrapped(*args, **kwargs):
-        if not session.get("user"):
-            if request.path.startswith("/api/"):
-                abort(401)
-            return redirect(url_for("login", next=request.path))
-        return view(*args, **kwargs)
-
-    return wrapped
-
-
-@app.route("/login", methods=["GET", "POST"])
-def login():
-    error = None
-    if request.method == "POST":
-        username = request.form.get("username", "")
-        password = request.form.get("password", "")
-        if username == _auth_cfg["username"] and password == _auth_cfg["password"]:
-            session["user"] = username
-            return redirect(request.args.get("next") or url_for("index"))
-        error = "Invalid credentials"
-    return render_template("login.html", error=error)
-
-
-@app.route("/logout")
-def logout():
-    session.clear()
-    return redirect(url_for("login"))
-
-
-# ------------------------------------------------------------------ pages
-
-@app.route("/")
-@login_required
-def index():
-    return render_template("index.html")
-
-
-# ------------------------------------------------------------------ JSON API
-
-@app.route("/api/config")
-@login_required
-def api_config():
-    return jsonify(
-        {
-            "folders": _q_cfg.get("folders", []),
-            "default_folder": _q_cfg.get("default_folder", "Quarantine"),
-            "default_limit": int(_q_cfg.get("default_limit", 200)),
-            "report_release_folder": _q_cfg.get("report_release_folder"),
-            "deleted_folder": _q_cfg.get("deleted_folder"),
-        }
-    )
+    @app.context_processor
+    def _inject_user():
+        return {"user": auth.current_user()}
 
+    # ------------------------------------------------------------------ pages
 
-@app.route("/api/messages")
-@login_required
-def api_messages():
-    """Return one page of a folder, newest-first.
+    @app.route("/")
+    @auth.login_required
+    def index():
+        return render_template("index.html")
 
-    The client pages through the whole folder by passing `before` (the oldest date
-    it has seen) as a cursor, since the PPS API caps each response at 1000 rows and
-    offers no offset paging. `page.has_more` and `page.oldest` drive that loop.
-    Messages with a pending/running/done action are filtered out server-side.
-    """
-    folder = request.args.get("folder") or _q_cfg.get("default_folder", "Quarantine")
-    page_size = _clamp_limit(request.args.get("limit"))
-    before = request.args.get("before") or None
-    try:
-        records = pps.search(
-            folder,
-            _q_cfg.get("list_query", "from=*"),
-            limit=page_size,
-            days_back=int(_q_cfg.get("default_days_back", 7)),
-            enddate=before,
+    # ------------------------------------------------------------------ JSON API
+
+    @app.route("/api/config")
+    @auth.login_required
+    def api_config():
+        cfg = store.snapshot().quarantine
+        user_email = auth.current_user()["email"]
+        return jsonify(
+            {
+                "folders": list(cfg.get("folders", [])),
+                "deleted_folder": cfg.get("deleted_folder"),
+                "report_release": {
+                    "steps": list(cfg.get("report_release", {}).get("steps", [])),
+                    "move_target": cfg.get("report_release", {}).get("move_target"),
+                },
+                "prefs": prefs.effective(user_email, store.snapshot()),
+                "csrf_token": auth.csrf_token(),
+                "config_version": store.snapshot().version,
+                "is_admin": auth.current_user().get("role") == "admin",
+            }
         )
-    except PPSError as exc:
-        return _pps_error_response(exc, f"search folder={folder!r} before={before!r}")
-
-    raw_count = len(records)
-    hidden = queue.acted_localguids(folder)
-    messages = [
-        {
-            "date": r.get("date"),
-            "from": r.get("from"),
-            "rcpts": r.get("rcpts", []),
-            "subject": r.get("subject"),
-            "guid": r.get("guid"),
-            "localguid": r.get("localguid"),
-            "size": r.get("size"),
-        }
-        for r in records
-        if r.get("localguid") not in hidden
-    ]
-    # Cursor is the oldest date in the RAW batch (before the acted-on filter), so
-    # paging never terminates early just because a page was mostly filtered out.
-    dates = [r.get("date") for r in records if r.get("date")]
-    oldest = min(dates) if dates else None  # fixed-width strings sort chronologically
-    return jsonify(
-        {
-            "folder": folder,
-            "messages": messages,
-            "page": {
-                "raw_count": raw_count,
-                "oldest": oldest,
-                "has_more": raw_count >= page_size,
-            },
-        }
-    )
 
+    @app.route("/api/messages")
+    @auth.login_required
+    def api_messages():
+        """Return one page of a folder, newest-first.
+
+        The client pages through the whole folder by passing `before` (the oldest date
+        it has seen) as a cursor, since the PPS API caps each response at 1000 rows and
+        offers no offset paging. `page.has_more`/`page.oldest` drive that loop.
+        """
+        cfg = store.snapshot().quarantine
+        folder = request.args.get("folder") or cfg.get("default_folder", "Quarantine")
+        page_size = _clamp_limit(request.args.get("limit"), cfg)
+        before = request.args.get("before") or None
+        try:
+            records = store.pps().search(
+                folder,
+                cfg.get("list_query", "from=*"),
+                limit=page_size,
+                days_back=int(cfg.get("default_days_back", 7)),
+                enddate=before,
+            )
+        except PPSError as exc:
+            return _pps_error_response(exc, f"search folder={folder!r} before={before!r}")
 
-@app.route("/api/message/<path:guid>")
-@login_required
-def api_message(guid: str):
-    try:
-        raw = pps.get_raw(guid)
-    except PPSError as exc:
-        return _pps_error_response(exc, f"get_raw guid={guid!r}")
-    return jsonify(_parse_message(raw))
-
-
-@app.route("/api/actions", methods=["POST"])
-@login_required
-def api_actions():
-    body = request.get_json(silent=True) or {}
-    action = body.get("action")
-    folder = body.get("folder")
-    messages = body.get("messages", [])  # [{localguid, guid, subject, sender, recipient}, ...]
-    targetfolder = body.get("targetfolder")
-
-    if action not in VALID_ACTIONS:
-        return jsonify({"error": f"unknown action: {action}"}), 400
-    if not folder:
-        return jsonify({"error": "folder is required"}), 400
-    if not messages:
-        return jsonify({"error": "no messages selected"}), 400
-    if action == "move" and not targetfolder:
-        return jsonify({"error": "move requires a targetfolder"}), 400
-
-    items = [
-        {
-            "localguid": m.get("localguid"),
-            "guid": m.get("guid"),
-            "subject": m.get("subject"),
-            "sender": m.get("sender"),
-            "recipient": m.get("recipient"),
-        }
-        for m in messages
-        if m.get("localguid")
-    ]
-    if not items:
-        return jsonify({"error": "no valid messages (missing localguid)"}), 400
-
-    extra = {"targetfolder": targetfolder} if action == "move" else {}
-    job_id = queue.enqueue(action, folder, items, extra)
-    return jsonify({"job_id": job_id, "queued": len(items)}), 202
-
-
-@app.route("/api/jobs")
-@login_required
-def api_jobs():
-    return jsonify({"jobs": queue.recent_jobs(limit=25)})
-
-
-# ------------------------------------------------------------------ helpers
-
-def _pps_error_response(exc: PPSError, context: str):
-    """Log a PPS failure with full context and return a clean 502 JSON body."""
-    log.error("%s failed: %s", context, exc)
-    return (
-        jsonify(
+        raw_count = len(records)
+        hidden = queue.acted_localguids(folder)
+        messages = [
             {
-                "error": str(exc),
-                "detail": exc.body,
-                "status": exc.status,
-                "reqid": exc.reqid,
+                "date": r.get("date"),
+                "from": r.get("from"),
+                "rcpts": r.get("rcpts", []),
+                "subject": r.get("subject"),
+                "guid": r.get("guid"),
+                "localguid": r.get("localguid"),
+                "size": r.get("size"),
             }
-        ),
-        502,
-    )
+            for r in records
+            if r.get("localguid") not in hidden
+        ]
+        # Cursor is the oldest date in the RAW batch (before the acted-on filter), so
+        # paging never terminates early just because a page was mostly filtered out.
+        dates = [r.get("date") for r in records if r.get("date")]
+        oldest = min(dates) if dates else None
+        return jsonify(
+            {
+                "folder": folder,
+                "messages": messages,
+                "page": {
+                    "raw_count": raw_count,
+                    "oldest": oldest,
+                    "has_more": raw_count >= page_size,
+                },
+            }
+        )
 
+    @app.route("/api/message/<path:guid>")
+    @auth.login_required
+    def api_message(guid: str):
+        try:
+            raw = store.pps().get_raw(guid)
+        except PPSError as exc:
+            return _pps_error_response(exc, f"get_raw guid={guid!r}")
+        return jsonify(_parse_message(raw))
+
+    @app.route("/api/actions", methods=["POST"])
+    @auth.login_required
+    def api_actions():
+        cfg = store.snapshot().quarantine
+        folders = set(cfg.get("folders", []))
+        body = request.get_json(silent=True) or {}
+        action = body.get("action")
+        folder = body.get("folder")
+        messages = body.get("messages", [])
+        targetfolder = body.get("targetfolder")
+
+        if action not in VALID_ACTIONS:
+            return jsonify({"error": f"unknown action: {action}"}), 400
+        if not folder:
+            return jsonify({"error": "folder is required"}), 400
+        if not messages:
+            return jsonify({"error": "no messages selected"}), 400
+        if action == "move":
+            if not targetfolder:
+                return jsonify({"error": "move requires a targetfolder"}), 400
+            # Validate against the folder list — otherwise any user could move mail to
+            # an arbitrary folder name.
+            if targetfolder not in folders:
+                return jsonify({"error": f"unknown target folder: {targetfolder}"}), 400
+
+        items = [
+            {
+                "localguid": m.get("localguid"),
+                "guid": m.get("guid"),
+                "subject": m.get("subject"),
+                "sender": m.get("sender"),
+                "recipient": m.get("recipient"),
+            }
+            for m in messages
+            if m.get("localguid")
+        ]
+        if not items:
+            return jsonify({"error": "no valid messages (missing localguid)"}), 400
+
+        extra = {"targetfolder": targetfolder} if action == "move" else {}
+        user = auth.current_user()["email"]
+        job_id = queue.enqueue(action, folder, items, extra, user=user)
+        return jsonify({"job_id": job_id, "queued": len(items)}), 202
+
+    @app.route("/api/jobs")
+    @auth.login_required
+    def api_jobs():
+        # Users see only their own jobs; admins see everything.
+        user = auth.current_user()
+        scope = None if user.get("role") == "admin" else user["email"]
+        return jsonify(
+            {"jobs": queue.recent_jobs(limit=25, user=scope),
+             "config_version": store.snapshot().version}
+        )
 
-def _clamp_limit(raw: str | None) -> int:
-    default = int(_q_cfg.get("default_limit", 200))
+    @app.route("/api/prefs", methods=["GET", "PUT", "DELETE"])
+    @auth.login_required
+    def api_prefs():
+        user_email = auth.current_user()["email"]
+        snap = store.snapshot()
+        if request.method == "GET":
+            return jsonify(prefs.describe(user_email, snap))
+        if request.method == "PUT":
+            patch = request.get_json(silent=True) or {}
+            try:
+                effective = prefs.set_many(user_email, patch, snap)
+            except ValueError as exc:
+                return jsonify({"error": str(exc)}), 400
+            return jsonify({"prefs": effective})
+        # DELETE
+        keys = (request.get_json(silent=True) or {}).get("keys")
+        prefs.clear(user_email, keys)
+        return jsonify({"prefs": prefs.effective(user_email, snap)})
+
+    # ------------------------------------------------------------------ helpers
+
+    def _pps_error_response(exc: PPSError, context: str):
+        log.error("%s failed: %s", context, exc)
+        return (
+            jsonify(
+                {"error": str(exc), "detail": exc.body, "status": exc.status, "reqid": exc.reqid}
+            ),
+            502,
+        )
+
+    return app
+
+
+def _clamp_limit(raw: str | None, cfg) -> int:
+    default = int(cfg.get("default_limit", 200))
     try:
         value = int(raw) if raw is not None else default
     except (TypeError, ValueError):
@@ -269,7 +242,7 @@ def _clamp_limit(raw: str | None) -> int:
 
 
 def _parse_message(raw: bytes) -> dict:
-    """Parse raw RFC822 bytes into headers + text/html bodies."""
+    """Parse raw RFC822 bytes into headers + text/html bodies + attachments."""
     msg = email.message_from_bytes(raw, policy=policy.default)
     headers = {
         "from": msg.get("From", ""),
@@ -295,7 +268,6 @@ def _parse_message(raw: bytes) -> dict:
         html_body = ""
 
     if not text_body and not html_body:
-        # Non-multipart or odd structure: fall back to the raw payload.
         try:
             text_body = msg.get_content()
         except Exception:  # noqa: BLE001
@@ -332,13 +304,3 @@ def _raw_headers(raw: bytes) -> str:
     """The literal RFC822 header block (everything before the first blank line)."""
     head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0]
     return head.decode("utf-8", errors="replace")
-
-
-if __name__ == "__main__":
-    from waitress import serve
-
-    host = _app_cfg.get("listen", "127.0.0.1")
-    port = int(_app_cfg.get("port", 8080))
-    log.info("PPS Quarantine Manager listening on http://%s:%s", host, port)
-    # Single process so the worker thread and Flask share memory + SQLite connection.
-    serve(app, host=host, port=port, threads=8)

+ 226 - 0
auth.py

@@ -0,0 +1,226 @@
+"""Authentication and authorisation.
+
+Two modes, selected by `[auth] mode` in config (server-file only, never admin-editable):
+
+* **oidc** — Okta via Authlib. `/login` redirects to Okta; `/authorize` validates the
+  token, reads the email, and checks it against the admin-managed allowed-users list.
+* **static** — a single shared credential for local development, so the app runs and is
+  testable without an Okta tenant. Loud startup warning; refuses non-loopback binds.
+
+Roles come from the app's own users list (`[[auth.users]]`), re-resolved on every
+request against the live config snapshot, so removing/demoting a user takes effect
+immediately without them re-logging-in.
+
+`is_allowed` and `safe_next` are pure and Flask-free — the security-critical logic is
+unit-testable without a browser or an Okta tenant.
+"""
+
+from __future__ import annotations
+
+import hmac
+import logging
+import secrets
+from functools import wraps
+from urllib.parse import urlparse
+
+from authlib.integrations.flask_client import OAuth
+from flask import (
+    Blueprint,
+    abort,
+    current_app,
+    redirect,
+    render_template,
+    request,
+    session,
+    url_for,
+)
+
+log = logging.getLogger("pps.auth")
+
+_oauth = OAuth()
+
+
+# ------------------------------------------------------------------ pure helpers
+
+def is_allowed(email: str, users) -> tuple[bool, str | None]:
+    """Return (allowed, role). Case-insensitive email match against the users list."""
+    key = (email or "").strip().casefold()
+    if not key:
+        return False, None
+    for u in users:
+        if str(u.get("email", "")).strip().casefold() == key:
+            return True, u.get("role", "user")
+    return False, None
+
+
+def safe_next(target: str | None) -> str:
+    """Sanitise a post-login redirect target to a local path (blocks open redirects)."""
+    if not target:
+        return "/"
+    # Reject anything that could send the browser off-site: absolute URLs, protocol-
+    # relative (//host), and backslash tricks that some browsers treat as a slash.
+    if not target.startswith("/") or target.startswith("//") or target.startswith("/\\"):
+        return "/"
+    parsed = urlparse(target)
+    if parsed.scheme or parsed.netloc:
+        return "/"
+    return target
+
+
+# ------------------------------------------------------------------ session/user
+
+def current_user() -> dict | None:
+    return session.get("user")
+
+
+def _resolve_role(store) -> str | None:
+    """The logged-in user's current role.
+
+    In OIDC mode this is re-resolved against the LIVE users list, so a removed/demoted
+    user loses access immediately. In static (dev) mode the shared account isn't in the
+    users list, so the role fixed at login (from [auth.static]) is trusted.
+    """
+    user = current_user()
+    if not user:
+        return None
+    if store.snapshot().auth.get("mode") == "static":
+        return user.get("role")
+    allowed, role = is_allowed(user.get("email", ""), store.snapshot().auth.get("users", []))
+    return role if allowed else None
+
+
+def login_required(view):
+    @wraps(view)
+    def wrapped(*args, **kwargs):
+        if not current_user():
+            if request.path.startswith("/api/"):
+                abort(401)
+            return redirect(url_for("auth.login", next=request.path))
+        return view(*args, **kwargs)
+
+    return wrapped
+
+
+def admin_required(view):
+    @wraps(view)
+    def wrapped(*args, **kwargs):
+        if not current_user():
+            if request.path.startswith("/api/"):
+                abort(401)
+            return redirect(url_for("auth.login", next=request.path))
+        store = current_app.config["STORE"]
+        if _resolve_role(store) != "admin":
+            abort(403)
+        return view(*args, **kwargs)
+
+    return wrapped
+
+
+# ------------------------------------------------------------------ CSRF
+
+def csrf_token() -> str:
+    tok = session.get("csrf")
+    if not tok:
+        tok = secrets.token_urlsafe(32)
+        session["csrf"] = tok
+    return tok
+
+
+def check_csrf() -> None:
+    """before_request hook: require a matching X-CSRF-Token on state-changing /api calls."""
+    if request.method in ("GET", "HEAD", "OPTIONS"):
+        return
+    if not request.path.startswith("/api/"):
+        return  # static-mode /login form post is exempt (has no session yet)
+    tok = session.get("csrf")
+    if not tok or not hmac.compare_digest(tok, request.headers.get("X-CSRF-Token", "")):
+        abort(400, "CSRF token missing or invalid")
+
+
+# ------------------------------------------------------------------ blueprint
+
+def init_auth(app, store) -> Blueprint:
+    cfg = store.snapshot()
+    mode = cfg.auth.get("mode", "static")
+    bp = Blueprint("auth", __name__)
+
+    if mode == "oidc":
+        okta = cfg.okta
+        _oauth.init_app(app)
+        _oauth.register(
+            name="okta",
+            server_metadata_url=str(okta["issuer"]).rstrip("/")
+            + "/.well-known/openid-configuration",
+            client_id=okta["client_id"],
+            client_secret=okta["client_secret"],
+            client_kwargs={"scope": "openid email profile"},
+        )
+
+    @bp.route("/login", methods=["GET", "POST"])
+    def login():
+        store = current_app.config["STORE"]
+        snap = store.snapshot()
+        target = safe_next(request.args.get("next"))
+        if snap.auth.get("mode") == "static":
+            return _static_login(snap, target)
+        # OIDC: stash next in the session and bounce to Okta.
+        session["next"] = target
+        return _oauth.okta.authorize_redirect(redirect_uri=snap.okta["redirect_uri"])
+
+    @bp.route("/authorize")
+    def authorize():
+        store = current_app.config["STORE"]
+        snap = store.snapshot()
+        token = _oauth.okta.authorize_access_token()  # validates state/nonce/id_token
+        info = token.get("userinfo") or _oauth.okta.userinfo(token=token)
+        email = (info or {}).get("email", "")
+        name = (info or {}).get("name", email)
+        return _finish_login(snap, email, name, safe_next(session.pop("next", None)))
+
+    @bp.route("/logout")
+    def logout():
+        session.clear()
+        return redirect(url_for("auth.login"))
+
+    return bp
+
+
+def _static_login(snap, target: str):
+    static = snap.auth.get("static", {})
+    error = None
+    if request.method == "POST":
+        u = request.form.get("username", "")
+        p = request.form.get("password", "")
+        ok_u = hmac.compare_digest(u, str(static.get("username", "")))
+        ok_p = hmac.compare_digest(p, str(static.get("password", "")))
+        if ok_u and ok_p:
+            return _finish_login(
+                snap,
+                static.get("email", "admin@example.invalid"),
+                static.get("username", "admin"),
+                target,
+                forced_role=static.get("role", "admin"),
+            )
+        error = "Invalid credentials"
+    return render_template("login.html", error=error, next_url=target)
+
+
+def _finish_login(snap, email: str, name: str, target: str, forced_role: str | None = None):
+    """Common tail: check access, set the session, redirect (or render denied)."""
+    if forced_role is not None:
+        allowed, role = True, forced_role
+    else:
+        allowed, role = is_allowed(email, snap.auth.get("users", []))
+    if not allowed:
+        log.warning("access denied for %r", email)
+        return render_template(
+            "denied.html",
+            message=snap.auth.get("denied_message", "Your account is not authorised."),
+            email=email,
+        ), 403
+    session.clear()  # drop any OIDC state; prevent session fixation
+    session["user"] = {"email": email, "role": role, "name": name}
+    session["csrf"] = secrets.token_urlsafe(32)
+    session.permanent = False  # browser-session only (decision)
+    log.info("login: %s (%s)", email, role)
+    return redirect(target)

+ 71 - 34
config.example.toml

@@ -1,50 +1,87 @@
 # PPS Quarantine Manager — example config.
-# Copy to config.toml and fill in real values. config.toml is gitignored.
+# Copy to config.toml and fill in real values. config.toml is gitignored and is written
+# back by the admin panel, so keep comments meaningful — they are preserved on save.
+#
+# Zones:  (S) server-only, edit this file directly, never shown/written by the panel.
+#         (A) admin-editable in the panel.   (W) write-only secret (never returned).
 
-[pps]
+[pps]                                          # (A) PPS connection
 # Base URL of the PPS admin service (REST APIs live on the admin port, default 10000).
 base_url = "https://pps.example.com:10000"
 # API user (admin account with an API Role that has the Quarantine module enabled).
-username = "apiuser"
-password = "secret"            # PoC only — plaintext.
-# PPS admin certs are frequently self-signed. Set false to skip TLS verification (PoC),
-# or set to a path to a CA bundle to verify against it.
+username = "admin"
+password = "secret"                            # (W) plaintext on disk (0600); rotate via panel.
+# PPS admin certs are frequently self-signed. false skips TLS verification, or set a path
+# to a CA bundle to verify against it.
 verify_tls = false
-# Seconds to wait on a single PPS API call. Actions can be slow, so this is generous.
-timeout = 120
-# Mutual TLS (client certificate). Required if PPS/nginx returns
-# "400 No required SSL certificate was sent". A ready-made self-signed PoC cert lives
-# in certs/ (upload certs/client.crt to PPS: System > Certificates > Client Certificates).
-# Either a combined cert+key PEM (leave client_key empty), or a cert PEM plus its key.
-# Leave both empty if client-cert auth is not used.
-client_cert = "certs/client.pem"
+timeout = 120                                  # seconds per PPS call (5..600)
+# Mutual TLS client cert, if PPS/nginx requires one ("400 No required SSL certificate").
+client_cert = "certs/client.pem"               # combined cert+key PEM, or a cert with client_key
 client_key = ""
 
-[quarantine]
-default_folder = "Quarantine"              # folder shown on first load
-# Full folder list — the API cannot enumerate folders, so this is the source of truth
-# for the folder switcher, the Move dropdown, and the delete/report targets below.
-# Names MUST match PPS exactly (case- and space-sensitive).
+[quarantine]                                   # (A)
+default_folder = "Quarantine"                  # folder shown on first load (per-user overridable)
+# Full folder list — the API cannot enumerate folders, so this is the source of truth for
+# the switcher, the Move dropdown, and the delete/report targets. Names MUST match PPS
+# exactly (case- and space-sensitive) and may not contain commas.
 folders = ["Quarantine", "Attachment Defense", "Debugging - Josef", "Deleted"]
-deleted_folder = "Deleted"                 # where Delete (and Release) send messages
-report_release_folder = "Debugging - Josef"  # Report & Release moves a copy here
-default_limit = 200                        # UI default row count (max 1000)
-# The search API requires a from/rcpt/subject filter — a bare wildcard means
-# "everything in the folder". If your PPS doesn't treat this as match-all, change it
-# (e.g. "rcpt=@yourdomain.com").
+deleted_folder = "Deleted"                     # where Delete sends messages
+default_limit = 200                            # UI default row count, 1..1000 (per-user overridable)
+# The search API requires a from/rcpt/subject filter — a bare wildcard means "everything
+# in the folder". Change if your PPS doesn't treat this as match-all (e.g. "rcpt=@you.com").
 list_query = "from=*"
-default_days_back = 7                      # startdate window (API alone only returns last 24h)
-chunk_size = 25                            # localguids per PPS POST when the worker batches
+default_days_back = 7                          # startdate window (API alone only returns last 24h)
+chunk_size = 25                                # localguids per PPS POST when the worker batches
+default_sort_field = "subject"                 # subject|date|from|rcpt (per-user overridable)
+default_sort_dir = "asc"                       # asc|desc (per-user overridable)
+
+[quarantine.report_release]                    # (A) "Report & Release" pipeline
+# Steps run in the FIXED order release -> move -> delete; pick any subsequence.
+# release delivers the mail in place; move relocates a copy to move_target; delete removes.
+steps = ["release", "move"]
+move_target = "Debugging - Josef"              # must be one of `folders`
+# Seconds to wait between steps on the same message so PPS's eventually-consistent
+# backend settles (e.g. before moving a just-released message). 0..3600; 0 disables.
+# The wait blocks the background worker, not the HTTP request.
+step_delay_seconds = 60
 
-[app]
-secret_key = "change-me-to-a-random-string"  # Flask session signing key
+[app]                                          # (S) restart required for changes here
+# Session signing key: >= 32 chars. Generate with:
+#   python -c "import secrets; print(secrets.token_urlsafe(48))"
+secret_key = "change-me-to-a-random-string"
 listen = "127.0.0.1"
 port = 8080
-db_path = "jobs.db"                        # SQLite job queue file
-log_level = "INFO"                         # DEBUG for full request/response tracing
-worker_log = "worker.log"                  # audit log: one line per message acted on (rotated)
+db_path = "jobs.db"                            # SQLite: job queue + per-user prefs
+log_level = "INFO"                             # (A, live) DEBUG for full request tracing
+worker_log = "worker.log"                      # audit log: one line per message acted on
+app_log = "app.log"                            # application log (errors visible in the panel)
+cookie_secure = true                           # false only for plain-http localhost dev
+
+[auth]                                         # authentication
+# (S) "oidc" for production Okta, "static" for local dev. NOT editable in the panel.
+mode = "oidc"
+# (A) shown to users who authenticate but aren't on the allowed list.
+denied_message = "Your account is not authorised to use the PPS Quarantine Manager. Contact IT."
 
-[auth]
-# Static PoC login. Replaced by SAML/OIDC later.
+# (A) allowed users. role is "admin" or "user".
+[[auth.users]]
+email = "alice@example.com"
+role = "admin"
+
+[[auth.users]]
+email = "bob@example.com"
+role = "user"
+
+# (S) DEV ONLY — the shared account used when mode = "static". Ignored under oidc.
+[auth.static]
 username = "admin"
 password = "admin"
+email = "dev-admin@example.invalid"
+role = "admin"
+
+[okta]                                         # (S) entire section, restart-only, never in panel
+issuer = "https://example.okta.com/oauth2/default"
+client_id = "0oaEXAMPLE"
+client_secret = "EXAMPLE-SECRET"               # (W)
+# Must EXACTLY match the redirect URI registered in the Okta app.
+redirect_uri = "https://ppsq.internal.example.com/authorize"

+ 505 - 0
config_store.py

@@ -0,0 +1,505 @@
+"""Configuration store: load, validate, atomically write, and hot-reload config.toml.
+
+Design notes (see AGENTS.md):
+
+* **tomlkit, not tomllib/tomli-w.** The admin panel writes this file back, and the file
+  is heavily commented. tomlkit round-trips comments/ordering; a plain dict dump would
+  destroy them on the first save.
+* **Immutable snapshots.** Config is never mutated in place. `apply()` builds a new
+  frozen `Snapshot` and rebinds one attribute — atomic under the GIL, so readers need
+  no lock. Views take one snapshot per request; the worker takes one per job. A job
+  therefore runs under a single consistent config and an admin edit affects the *next*
+  job, which removes every mid-job race without locking.
+* **Write-only secrets.** `redacted()` never emits a secret value; `apply()` treats a
+  blank/absent secret as "leave unchanged".
+* **PPSQ_CONFIG** selects an alternate file. Tests rely on this to never touch the real
+  config.toml.
+"""
+
+from __future__ import annotations
+
+import copy
+import logging
+import os
+import shutil
+import tempfile
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+from types import MappingProxyType
+from typing import Any, Mapping
+from urllib.parse import urlparse
+
+import tomlkit
+
+from pps_client import PPSClient
+
+log = logging.getLogger("pps.config")
+
+# Dotted keys the admin panel may write. An explicit ALLOWLIST — never a denylist.
+ADMIN_EDITABLE: frozenset[str] = frozenset(
+    {
+        "pps.base_url", "pps.username", "pps.password", "pps.verify_tls",
+        "pps.timeout", "pps.client_cert", "pps.client_key",
+        "quarantine.default_folder", "quarantine.folders", "quarantine.deleted_folder",
+        "quarantine.default_limit", "quarantine.list_query",
+        "quarantine.default_days_back", "quarantine.chunk_size",
+        "quarantine.default_sort_field", "quarantine.default_sort_dir",
+        "quarantine.report_release.steps", "quarantine.report_release.move_target",
+        "quarantine.report_release.step_delay_seconds",
+        "auth.denied_message", "auth.users",
+        "app.log_level",
+    }
+)
+
+# Written to disk but only picked up on restart; reported back so the UI can say so.
+RESTART_KEYS: frozenset[str] = frozenset(
+    {
+        "app.secret_key", "app.listen", "app.port", "app.db_path",
+        "app.worker_log", "app.app_log", "app.cookie_secure",
+        "auth.mode",
+    }
+)
+
+# Never leave the process. `redacted()` emits a `<name>_set` boolean instead.
+SECRET_KEYS: frozenset[str] = frozenset(
+    {"pps.password", "okta.client_secret", "auth.static.password", "app.secret_key"}
+)
+
+# Connection identity — a change here means the PPSClient must be rebuilt.
+_PPS_FINGERPRINT = (
+    "base_url", "username", "password", "verify_tls", "timeout", "client_cert", "client_key",
+)
+
+_SORT_FIELDS = frozenset({"subject", "date", "from", "rcpt"})
+_SORT_DIRS = frozenset({"asc", "desc"})
+_ROLES = frozenset({"admin", "user"})
+
+
+class ConfigError(Exception):
+    """Validation failure. Carries per-field messages for a 400 response."""
+
+    def __init__(self, errors: list[dict[str, str]]):
+        self.errors = errors
+        super().__init__("; ".join(f"{e['field']}: {e['message']}" for e in errors))
+
+
+@dataclass(frozen=True)
+class Snapshot:
+    version: int
+    pps: Mapping[str, Any]
+    quarantine: Mapping[str, Any]
+    app: Mapping[str, Any]
+    auth: Mapping[str, Any]
+    okta: Mapping[str, Any]
+    path: Path
+
+
+@dataclass(frozen=True)
+class ApplyResult:
+    version: int
+    applied: list[str]
+    restart_required: list[str]
+
+
+def _freeze(d: Any) -> Any:
+    """Deep-copy plain data and wrap mappings read-only, so a snapshot can't be mutated."""
+    if isinstance(d, Mapping):
+        return MappingProxyType({k: _freeze(v) for k, v in d.items()})
+    if isinstance(d, (list, tuple)):
+        return tuple(_freeze(v) for v in d)
+    return d
+
+
+def _plain(doc: Any) -> Any:
+    """tomlkit containers -> plain python (dict/list/scalars)."""
+    if isinstance(doc, Mapping):
+        return {k: _plain(v) for k, v in doc.items()}
+    if isinstance(doc, (list, tuple)):
+        return [_plain(v) for v in doc]
+    return doc
+
+
+def _dig(d: Mapping, dotted: str, default=None):
+    cur: Any = d
+    for part in dotted.split("."):
+        if not isinstance(cur, Mapping) or part not in cur:
+            return default
+        cur = cur[part]
+    return cur
+
+
+def _flatten(d: Mapping, prefix: str = "") -> dict[str, Any]:
+    """Flatten nested dicts to dotted keys. Lists are leaves (e.g. quarantine.folders)."""
+    out: dict[str, Any] = {}
+    for k, v in d.items():
+        key = f"{prefix}{k}"
+        if isinstance(v, Mapping):
+            out.update(_flatten(v, f"{key}."))
+        else:
+            out[key] = v
+    return out
+
+
+def resolve_config_path(explicit: str | Path | None = None) -> Path:
+    """Explicit arg -> $PPSQ_CONFIG -> ./config.toml (next to this module)."""
+    if explicit:
+        return Path(explicit)
+    env = os.environ.get("PPSQ_CONFIG")
+    if env:
+        return Path(env)
+    return Path(__file__).with_name("config.toml")
+
+
+class ConfigStore:
+    def __init__(self, path: str | Path | None = None):
+        # Guard: a test that forgets PPSQ_CONFIG must never touch the real config.toml.
+        if os.environ.get("PYTEST_CURRENT_TEST") and not (path or os.environ.get("PPSQ_CONFIG")):
+            raise RuntimeError(
+                "Refusing to open the default config.toml under pytest. "
+                "Set PPSQ_CONFIG to a temp copy (see tests/conftest.py)."
+            )
+        self.path = resolve_config_path(path)
+        if not self.path.exists():
+            raise SystemExit(
+                f"Missing {self.path}. Copy config.example.toml to config.toml and edit it."
+            )
+        self._lock = threading.RLock()
+        self._version = 0
+        doc = self._read_doc()
+        data = _migrate(_plain(doc))
+        _validate(data)
+        self._snapshot = self._build_snapshot(data)
+        self._pps_fp: tuple | None = None
+        self._pps: PPSClient | None = None
+        self._rebuild_pps(data)
+
+    # ------------------------------------------------------------------ reading
+
+    def snapshot(self) -> Snapshot:
+        """Current config. Lock-free: attribute reads are atomic under the GIL."""
+        return self._snapshot
+
+    def pps(self) -> PPSClient:
+        return self._pps  # type: ignore[return-value]
+
+    def redacted(self) -> dict:
+        """Admin-panel payload. Secrets are replaced by a `<name>_set` boolean."""
+        snap = self._snapshot
+        data = {
+            "pps": dict(_plain(snap.pps)),
+            "quarantine": dict(_plain(snap.quarantine)),
+            "app": dict(_plain(snap.app)),
+            "auth": dict(_plain(snap.auth)),
+        }
+        for dotted in SECRET_KEYS:
+            section, _, leaf = dotted.rpartition(".")
+            parent = _dig(data, section) if section else data
+            if isinstance(parent, dict) and leaf in parent:
+                parent[f"{leaf}_set"] = bool(parent.pop(leaf))
+        # okta is server-only: expose nothing but whether it is configured.
+        data["okta"] = {"configured": bool(_dig(snap.okta, "client_id"))}
+        return data
+
+    # ------------------------------------------------------------------ writing
+
+    def apply(self, patch: dict, *, actor: str = "system") -> ApplyResult:
+        """Validate + atomically write a nested patch, then swap in a new snapshot."""
+        with self._lock:
+            flat = _flatten(patch)
+            rejected = [k for k in flat if k not in ADMIN_EDITABLE]
+            if rejected:
+                raise ConfigError(
+                    [{"field": k, "message": "unknown or non-editable key"} for k in rejected]
+                )
+
+            doc = self._read_doc()
+            current = _migrate(_plain(doc))
+            merged = copy.deepcopy(current)
+
+            applied: list[str] = []
+            for dotted, value in flat.items():
+                # Blank secret = leave unchanged (write-only fields).
+                if dotted in SECRET_KEYS and (value is None or value == ""):
+                    continue
+                if _dig(merged, dotted) == value:
+                    continue
+                _set_dotted(merged, dotted, value)
+                applied.append(dotted)
+
+            if not applied:
+                return ApplyResult(self._version, [], [])
+
+            _validate(merged)
+
+            for dotted in applied:
+                _set_dotted_doc(doc, dotted, _dig(merged, dotted))
+            self._write_atomic(doc)
+
+            self._rebuild_pps(merged)
+            self._snapshot = self._build_snapshot(merged)
+            log.info("config updated by %s: %s", actor, ", ".join(sorted(applied)))
+            return ApplyResult(
+                version=self._snapshot.version,
+                applied=sorted(applied),
+                restart_required=sorted(k for k in applied if k in RESTART_KEYS),
+            )
+
+    def reload_from_disk(self) -> Snapshot:
+        with self._lock:
+            data = _migrate(_plain(self._read_doc()))
+            _validate(data)
+            self._rebuild_pps(data)
+            self._snapshot = self._build_snapshot(data)
+            log.info("config reloaded from %s (version %d)", self.path, self._snapshot.version)
+            return self._snapshot
+
+    # ------------------------------------------------------------------ internals
+
+    def _read_doc(self):
+        with self.path.open("r", encoding="utf-8") as fh:
+            return tomlkit.parse(fh.read())
+
+    def _build_snapshot(self, data: dict) -> Snapshot:
+        self._version += 1
+        return Snapshot(
+            version=self._version,
+            pps=_freeze(data.get("pps", {})),
+            quarantine=_freeze(data.get("quarantine", {})),
+            app=_freeze(data.get("app", {})),
+            auth=_freeze(data.get("auth", {})),
+            okta=_freeze(data.get("okta", {})),
+            path=self.path,
+        )
+
+    def _rebuild_pps(self, data: dict) -> None:
+        """Rebuild the PPS client only when connection identity changed."""
+        p = data.get("pps", {})
+        fp = tuple(p.get(k) for k in _PPS_FINGERPRINT)
+        if fp == self._pps_fp and self._pps is not None:
+            return
+        # Rebind the client before the snapshot; in-flight requests keep their own ref.
+        self._pps = PPSClient(
+            base_url=p["base_url"],
+            username=p["username"],
+            password=p["password"],
+            verify_tls=p.get("verify_tls", False),
+            timeout=int(p.get("timeout", 120)),
+            client_cert=p.get("client_cert") or None,
+            client_key=p.get("client_key") or None,
+        )
+        self._pps_fp = fp
+
+    def _write_atomic(self, doc) -> None:
+        """Temp file in the same dir -> fsync -> backup -> atomic rename -> fsync dir."""
+        parent = self.path.parent
+        fd, tmp = tempfile.mkstemp(dir=parent, prefix=".config.", suffix=".toml.tmp")
+        try:
+            os.fchmod(fd, 0o600)
+            with os.fdopen(fd, "w", encoding="utf-8") as fh:
+                fh.write(tomlkit.dumps(doc))
+                fh.flush()
+                os.fsync(fh.fileno())
+            if self.path.exists():
+                shutil.copy2(self.path, self.path.with_name(self.path.name + ".bak"))
+            os.replace(tmp, self.path)  # same filesystem by construction
+            tmp = None
+            dfd = os.open(parent, os.O_DIRECTORY)
+            try:
+                os.fsync(dfd)
+            finally:
+                os.close(dfd)
+        finally:
+            if tmp:
+                Path(tmp).unlink(missing_ok=True)
+
+
+def _set_dotted(d: dict, dotted: str, value) -> None:
+    parts = dotted.split(".")
+    cur = d
+    for part in parts[:-1]:
+        cur = cur.setdefault(part, {})
+    cur[parts[-1]] = value
+
+
+def _set_dotted_doc(doc, dotted: str, value) -> None:
+    """Set a key in a tomlkit document, creating intermediate tables as needed."""
+    parts = dotted.split(".")
+    cur = doc
+    for part in parts[:-1]:
+        if part not in cur:
+            cur[part] = tomlkit.table()
+        cur = cur[part]
+    cur[parts[-1]] = value
+
+
+def _migrate(data: dict) -> dict:
+    """In-memory back-compat. Never rewrites the user's file on load.
+
+    `quarantine.report_release_folder` (PoC) -> `[quarantine.report_release]` with
+    steps=["release","move"], which reproduces the old hardcoded behaviour exactly.
+    """
+    q = data.setdefault("quarantine", {})
+    if "report_release" not in q:
+        legacy = q.get("report_release_folder")
+        q["report_release"] = {
+            "steps": ["release", "move"] if legacy else ["release"],
+            "move_target": legacy or "",
+        }
+    rr = q["report_release"]
+    rr.setdefault("steps", ["release", "move"])
+    rr.setdefault("move_target", q.get("report_release_folder", "") or "")
+    rr.setdefault("step_delay_seconds", 60)
+    q.setdefault("default_sort_field", "subject")
+    q.setdefault("default_sort_dir", "asc")
+    a = data.setdefault("auth", {})
+    # Fail closed: a config with no explicit mode defaults to oidc (which then requires
+    # [okta] and fails loudly if missing), never to shared-password static admin.
+    a.setdefault("mode", "oidc")
+    a.setdefault("users", [])
+    a.setdefault(
+        "denied_message",
+        "Your account is not authorised to use the PPS Quarantine Manager.",
+    )
+    return data
+
+
+def _validate(data: dict) -> None:
+    """Validate the merged config. Raises ConfigError with per-field messages."""
+    import pipeline  # local import: pipeline imports nothing from us
+
+    errors: list[dict[str, str]] = []
+
+    def bad(field: str, msg: str) -> None:
+        errors.append({"field": field, "message": msg})
+
+    p = data.get("pps", {})
+    url = str(p.get("base_url", ""))
+    parsed = urlparse(url)
+    if parsed.scheme not in ("http", "https") or not parsed.netloc:
+        bad("pps.base_url", "must be an http(s) URL with a host")
+    elif parsed.scheme == "http" and not os.environ.get("PPSQ_ALLOW_INSECURE"):
+        bad("pps.base_url", "must use https (set PPSQ_ALLOW_INSECURE=1 to override)")
+    try:
+        t = int(p.get("timeout", 120))
+        if not 5 <= t <= 600:
+            bad("pps.timeout", "must be between 5 and 600 seconds")
+    except (TypeError, ValueError):
+        bad("pps.timeout", "must be an integer")
+    for key in ("client_cert", "client_key"):
+        val = p.get(key)
+        if val and not Path(val).exists():
+            bad(f"pps.{key}", f"file not found: {val}")
+
+    q = data.get("quarantine", {})
+    folders = q.get("folders", [])
+    if not isinstance(folders, (list, tuple)) or not folders:
+        bad("quarantine.folders", "at least one folder is required")
+        folders = []
+    else:
+        seen = set()
+        for f in folders:
+            if not isinstance(f, str) or not f.strip():
+                bad("quarantine.folders", "folder names must be non-empty strings")
+            elif "," in f:
+                # localguids are joined with "," in the POST payload (pps_client.act).
+                bad("quarantine.folders", f"folder name may not contain a comma: {f!r}")
+            elif f != f.strip():
+                bad("quarantine.folders", f"folder name has leading/trailing space: {f!r}")
+            elif len(f) > 128:
+                bad("quarantine.folders", f"folder name too long: {f[:20]!r}…")
+            elif f in seen:
+                bad("quarantine.folders", f"duplicate folder: {f!r}")
+            seen.add(f)
+
+    for field in ("default_folder", "deleted_folder"):
+        val = q.get(field)
+        if folders and val and val not in folders:
+            bad(f"quarantine.{field}", f"{val!r} is not in the folder list")
+
+    try:
+        lim = int(q.get("default_limit", 200))
+        if not 1 <= lim <= 1000:
+            bad("quarantine.default_limit", "must be between 1 and 1000")
+    except (TypeError, ValueError):
+        bad("quarantine.default_limit", "must be an integer")
+    try:
+        cs = int(q.get("chunk_size", 25))
+        if not 1 <= cs <= 500:
+            bad("quarantine.chunk_size", "must be between 1 and 500")
+    except (TypeError, ValueError):
+        bad("quarantine.chunk_size", "must be an integer")
+    try:
+        db = int(q.get("default_days_back", 7))
+        if db < 1:
+            bad("quarantine.default_days_back", "must be at least 1")
+    except (TypeError, ValueError):
+        bad("quarantine.default_days_back", "must be an integer")
+    if not str(q.get("list_query", "")).strip():
+        bad("quarantine.list_query", "required (the PPS API rejects folder-only searches)")
+    if q.get("default_sort_field") not in _SORT_FIELDS:
+        bad("quarantine.default_sort_field", f"must be one of {sorted(_SORT_FIELDS)}")
+    if q.get("default_sort_dir") not in _SORT_DIRS:
+        bad("quarantine.default_sort_dir", f"must be one of {sorted(_SORT_DIRS)}")
+
+    rr = q.get("report_release", {})
+    steps = rr.get("steps", [])
+    if not isinstance(steps, (list, tuple)) or not steps:
+        bad("quarantine.report_release.steps", "select at least one action")
+    else:
+        unknown = [s for s in steps if s not in pipeline.ALLOWED_STEPS]
+        if unknown:
+            bad("quarantine.report_release.steps", f"unknown step(s): {unknown}")
+        elif len(set(steps)) != len(steps):
+            bad("quarantine.report_release.steps", "duplicate steps")
+        elif not _is_subsequence(steps, pipeline.ALLOWED_STEPS):
+            bad(
+                "quarantine.report_release.steps",
+                f"must follow the fixed order {list(pipeline.ALLOWED_STEPS)}",
+            )
+        if "move" in steps:
+            target = rr.get("move_target")
+            if not target:
+                bad("quarantine.report_release.move_target", "required when 'move' is selected")
+            elif folders and target not in folders:
+                bad(
+                    "quarantine.report_release.move_target",
+                    f"{target!r} is not in the folder list",
+                )
+        if "delete" in steps and not q.get("deleted_folder"):
+            bad("quarantine.deleted_folder", "required when 'delete' is selected")
+    try:
+        d = int(rr.get("step_delay_seconds", 60))
+        if not 0 <= d <= 3600:
+            bad("quarantine.report_release.step_delay_seconds", "must be between 0 and 3600")
+    except (TypeError, ValueError):
+        bad("quarantine.report_release.step_delay_seconds", "must be an integer")
+
+    a = data.get("auth", {})
+    if a.get("mode") not in ("oidc", "static"):
+        bad("auth.mode", "must be 'oidc' or 'static'")
+    users = a.get("users", [])
+    if not isinstance(users, (list, tuple)):
+        bad("auth.users", "must be a list")
+    else:
+        seen_emails = set()
+        for i, u in enumerate(users):
+            if not isinstance(u, Mapping):
+                bad(f"auth.users[{i}]", "must be a table with email and role")
+                continue
+            email = str(u.get("email", "")).strip()
+            if "@" not in email or " " in email or len(email) < 3:
+                bad(f"auth.users[{i}].email", f"invalid email: {email!r}")
+            elif email.casefold() in seen_emails:
+                bad(f"auth.users[{i}].email", f"duplicate user: {email!r}")
+            seen_emails.add(email.casefold())
+            if u.get("role") not in _ROLES:
+                bad(f"auth.users[{i}].role", f"must be one of {sorted(_ROLES)}")
+
+    if errors:
+        raise ConfigError(errors)
+
+
+def _is_subsequence(seq, universe) -> bool:
+    it = iter(universe)
+    return all(any(x == u for u in it) for x in seq)

+ 57 - 0
docs/api.md

@@ -0,0 +1,57 @@
+# HTTP API reference
+
+All `/api/*` routes require an authenticated session. State-changing methods (non-GET)
+require an `X-CSRF-Token` header whose value comes from `GET /api/config` → `csrf_token`.
+Unauthorised API calls return `401`; non-admin calls to admin routes return `403`.
+
+## Auth (`auth.py`)
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| GET/POST | `/login` | OIDC: redirect to Okta. Static: render/submit the dev form. |
+| GET | `/authorize` | OIDC callback; validates token, checks allowed-users, sets session. |
+| GET | `/logout` | Clear session. |
+
+## Quarantine (`app.py`)
+
+| Method | Path | Returns |
+|--------|------|---------|
+| GET | `/` | The single-page UI. |
+| GET | `/api/config` | `{folders, deleted_folder, report_release, prefs, csrf_token, config_version, is_admin}` |
+| GET | `/api/messages?folder=&limit=&before=` | `{folder, messages[], page:{raw_count, oldest, has_more}}` |
+| GET | `/api/message/<guid>` | `{headers, text, html, attachments[], raw_headers}` |
+| POST | `/api/actions` | `202 {job_id, queued}` — body `{action, folder, messages[], targetfolder?}` |
+| GET | `/api/jobs` | `{jobs[], config_version}` — scoped to the caller (admins see all) |
+| GET/PUT/DELETE | `/api/prefs` | Per-user preferences (get / set patch / reset) |
+
+`action` ∈ `release | report_release | delete | move`. `messages[]` items carry
+`{localguid, guid, subject, sender, recipient}`. For `move`, `targetfolder` must be a
+configured folder.
+
+### Paging `/api/messages`
+
+The PPS API caps a search at 1000 rows and has no offset paging. The client pages the whole
+folder backwards by date: fetch a page, then pass the oldest `date` seen as `before` for the
+next page, until `page.has_more` is false. `page.oldest` is the oldest date in the raw batch
+(before hidden/acted messages are filtered), so paging never terminates early.
+
+## Admin (`admin.py`, all `@admin_required`)
+
+| Method | Path | Returns |
+|--------|------|---------|
+| GET | `/admin` | The admin panel page. |
+| GET | `/api/admin/config` | `{config: redacted, schema, version}` — secrets shown as `*_set` booleans |
+| PUT | `/api/admin/config` | `{version, applied[], restart_required[], config}` / `400 {errors:[{field,message}]}` |
+| POST | `/api/admin/pps-test` | `{ok, detail}` — probes PPS with pending form values, never persists |
+| GET | `/api/admin/jobs?limit=` | `{jobs[], active}` — all users |
+| POST | `/api/admin/jobs/<id>/retry` | `{job_id, status}` — failed → pending |
+| GET | `/api/admin/logs?source=ops\|app&lines=&q=` | `{source, lines:[{text, level}], truncated}` |
+
+Users and the denied message are ordinary config keys under `[auth]`, saved through
+`PUT /api/admin/config` (one atomic write). `source` is an enum, never a file path.
+
+## Error shape
+
+PPS failures return `502 {error, detail, status, reqid}` (`reqid` = the PPS `x-pps-reqid`
+header, for cross-referencing PPS logs). Validation failures return `400 {errors:[…]}` or
+`400 {error}`.

+ 63 - 0
docs/architecture.md

@@ -0,0 +1,63 @@
+# Architecture
+
+## Overview
+
+```
+                    ┌─────────────────────────── single process ───────────────────────────┐
+   browser ──HTTP──▶│  Flask (app.py, waitress)                                              │
+                    │    │                                                                    │
+                    │    ├─ auth.py ......... Okta OIDC / static; login_required/admin_required
+                    │    ├─ ConfigStore ..... immutable snapshots of config.toml (tomlkit)   │
+                    │    ├─ PrefStore ....... per-user prefs (jobs.db)                        │
+                    │    └─ /api/actions ──▶ JobQueue.enqueue() ──▶ SQLite jobs table         │
+                    │                              ▲                      │                    │
+                    │                    worker thread (daemon) ◀─────────┘                    │
+                    │                              │                                           │
+                    │                              └─ pipeline.py ──▶ PPSClient ──HTTPS──▶ PPS │
+                    └────────────────────────────────────────────────────────────────────────┘
+```
+
+Everything runs in **one process** (see AGENTS.md invariant #1). The browser fires an
+action and gets an immediate `202`; the daemon worker thread drains the SQLite queue and
+calls PPS. Job progress is polled via `/api/jobs`; it is cosmetic — closing the page does
+not affect processing, and `JobQueue._recover()` requeues interrupted jobs after a restart.
+
+## Request lifecycle
+
+1. `wsgi.py` builds `ConfigStore`, `JobQueue`, `PrefStore`, calls `create_app`, starts the
+   worker, serves.
+2. Each request: `auth.login_required`/`admin_required` gate, then the view takes one
+   `store.snapshot()` and reads all config from it.
+3. State-changing `/api/*` requests must carry `X-CSRF-Token` (checked in `before_request`).
+
+## Configuration hot-reload
+
+`config.toml` is read once into an immutable `Snapshot`. The admin panel's `PUT
+/api/admin/config` validates a patch, atomically rewrites the file with tomlkit (comments
+preserved), rebuilds the `PPSClient` only if a connection key changed, and swaps in a new
+snapshot. Readers are lock-free (attribute rebind is atomic under the GIL). See
+`docs/configuration.md` for which keys apply live vs. need a restart.
+
+## Background jobs
+
+- Tables: `jobs(id, action, folder, user, extra, status, processed, total, error, …)` and
+  `job_items(job_id, localguid, guid, folder, subject, sender, recipient)`.
+- The worker claims the lowest `pending` job, snapshots config once, chunks the items
+  (`chunk_size`), calls `pipeline.run_pipeline` (Report & Release) or a single PPS action,
+  writes one **audit line per message** to `worker.log`, and marks the job done/failed.
+- `retry(job_id)` requeues a failed job.
+
+## Data stores
+
+| Store | Where | Contents |
+|-------|-------|----------|
+| `config.toml` | file (tomlkit) | all configuration; 0600; `.bak` on each write |
+| `jobs.db` | SQLite (WAL) | `jobs`, `job_items`, `user_prefs` |
+| `worker.log` | rotating file | per-message audit trail |
+| `app.log` | rotating file | application log (errors visible in admin panel) |
+
+## Why single-process
+
+The worker thread and both SQLite connections share process memory, and the config lock is
+an in-process `RLock`. Multiple processes would run competing queue workers and uncoordinated
+config writes. Scale vertically (threads), never horizontally. This is a hard constraint.

+ 90 - 0
docs/configuration.md

@@ -0,0 +1,90 @@
+# Configuration reference
+
+All configuration lives in `config.toml` (copy from `config.example.toml`). The file is
+**round-tripped by the admin panel** — comments and formatting are preserved on save, so
+keep comments meaningful. The file is written `0600`; a `config.toml.bak` is kept from the
+previous save.
+
+**Zones:** `(S)` server-only — edit the file directly, never shown/written by the panel.
+`(A)` admin-editable in the panel. `(W)` write-only secret — never returned by any API.
+`live` — applies immediately; otherwise a restart is required.
+
+## `[pps]` — connection to Proofpoint (A, live)
+
+| Key | Type | Notes |
+|-----|------|-------|
+| `base_url` | str | `https://host:10000`. `http://` requires `PPSQ_ALLOW_INSECURE=1`. |
+| `username` | str | API user with the Quarantine module role. |
+| `password` | str (W) | Never returned; blank on save = unchanged. |
+| `verify_tls` | bool \| path | `false` skips verification; or a CA-bundle path. |
+| `timeout` | int | Seconds per call, 5..600. |
+| `client_cert` | path | Mutual-TLS client cert (combined PEM, or cert with `client_key`). |
+| `client_key` | path | Private key, if `client_cert` is cert-only. |
+
+## `[quarantine]` (A, live)
+
+| Key | Type | Notes |
+|-----|------|-------|
+| `default_folder` | str | Must be in `folders`. Per-user overridable. |
+| `folders` | list[str] | Source of truth (API can't enumerate). Exact names, no commas. |
+| `deleted_folder` | str | Target of Delete. Must be in `folders`. |
+| `default_limit` | int | UI row cap 1..1000. Per-user overridable. |
+| `list_query` | str | Search filter; `from=*` = match-all (API rejects folder-only). |
+| `default_days_back` | int | How far back sync reaches (API alone gives 24h). |
+| `chunk_size` | int | localguids per PPS POST, 1..500. |
+| `default_sort_field` | str | `subject`\|`date`\|`from`\|`rcpt`. Per-user overridable. |
+| `default_sort_dir` | str | `asc`\|`desc`. Per-user overridable. |
+
+### `[quarantine.report_release]` (A, live)
+
+| Key | Type | Notes |
+|-----|------|-------|
+| `steps` | list[str] | Subsequence of `["release","move","delete"]`, run in that fixed order. |
+| `move_target` | str | Required if `move` in steps; must be in `folders`. |
+
+Outcomes: `["release"]` delivers in place; `["release","move"]` delivers + copies to
+`move_target`; `["release","delete"]` delivers + removes to `deleted_folder`;
+`["release","move","delete"]` delivers, moves, then removes.
+
+## `[app]` (S, restart — except `log_level`)
+
+| Key | Notes |
+|-----|-------|
+| `secret_key` | Session signing key, ≥32 chars. Rotating it logs everyone out. |
+| `listen`, `port` | Bind address. Keep loopback unless behind a reverse proxy. |
+| `db_path` | SQLite file (jobs + prefs). |
+| `log_level` | `(A, live)` INFO/DEBUG/… |
+| `worker_log`, `app_log` | Rotating log file paths (5 MB × 5). |
+| `cookie_secure` | `true` in production; `false` only for plain-http localhost. |
+
+## `[auth]`
+
+| Key | Zone | Notes |
+|-----|------|-------|
+| `mode` | (S) | `oidc` (production) or `static` (dev). Not admin-editable. |
+| `denied_message` | (A, live) | Shown to authenticated-but-unlisted users. |
+| `users` | (A, live) | `[[auth.users]]` entries `{email, role}`; role `admin`\|`user`. |
+| `[auth.static]` | (S) | Dev shared account (`username`/`password`/`email`/`role`). Ignored under oidc. |
+
+## `[okta]` (S, restart — never admin-editable)
+
+| Key | Notes |
+|-----|-------|
+| `issuer` | e.g. `https://TENANT.okta.com/oauth2/default`. |
+| `client_id` | Okta app client id. |
+| `client_secret` | (W) Okta app secret. |
+| `redirect_uri` | Must EXACTLY match the Okta app registration. Ends in `/authorize`. |
+
+## Environment variables
+
+| Var | Purpose |
+|-----|---------|
+| `PPSQ_CONFIG` | Path to the config file (default: `./config.toml`). |
+| `PPSQ_ALLOW_INSECURE` | Allow an `http://` PPS base_url (dev only). |
+| `PPSQ_ALLOW_INSECURE_AUTH` | Allow static auth on a non-loopback interface (dev only). |
+
+## Live vs restart
+
+Live: `pps.*`, `quarantine.*`, `auth.users`, `auth.denied_message`, `app.log_level`.
+Restart: `app.secret_key/listen/port/db_path/worker_log/app_log/cookie_secure`,
+`auth.mode`, all `okta.*`. The panel reports `restart_required` after a save.

+ 75 - 0
docs/development.md

@@ -0,0 +1,75 @@
+# Development
+
+## Setup
+
+```bash
+python3 -m venv venv
+venv/bin/pip install -r requirements-dev.txt
+```
+
+## Running locally without Okta or a real PPS
+
+Use a dev config (never edit the real `config.toml`) with static auth:
+
+```toml
+[auth]
+mode = "static"
+[auth.static]
+username = "admin"
+password = "admin"
+email = "dev@example.invalid"
+role = "admin"
+```
+
+```bash
+PPSQ_CONFIG=/tmp/dev-config.toml PPSQ_ALLOW_INSECURE=1 venv/bin/python wsgi.py
+```
+
+- `PPSQ_CONFIG` points at your dev file so nothing touches the real config.
+- `PPSQ_ALLOW_INSECURE=1` allows an `http://` PPS base_url.
+- Static mode logs a loud warning and refuses non-loopback binds (override with
+  `PPSQ_ALLOW_INSECURE_AUTH=1`).
+- Point `[pps] base_url` at a mock server, or a throwaway PPS folder.
+
+## Tests
+
+```bash
+venv/bin/python -m pytest        # ~57 tests, < 1s
+```
+
+`tests/conftest.py` guarantees isolation: a session-scoped autouse fixture sets
+`PPSQ_CONFIG` to a temp copy of `config.example.toml` before any app import, and
+`ConfigStore` refuses to open the default `config.toml` under pytest. No test starts a
+worker thread — they call `queue._process(job)` synchronously with a `FakePPS` that records
+every call.
+
+Suites:
+
+| File | Covers |
+|------|--------|
+| `test_config_store.py` | round-trip (comments preserved), atomic write/perms/backup, redaction, validation, restart-vs-live, client rebuild, migration, isolation guard |
+| `test_auth.py` | `is_allowed`/`safe_next` (pure), static login, CSRF, admin gate |
+| `test_oidc.py` | OIDC callback allow/deny/role/demotion (monkeypatched token) |
+| `test_report_release.py` | pipeline: no-`deletedfolder` rule, lazy refind, fallback, destinations |
+| `test_paging.py` | cursor from raw batch, `has_more` boundary, `_clamp_limit` |
+| `test_prefs.py` | override/default/fallback/isolation |
+| `test_jobs_visibility.py` | user attribution, `targetfolder` validation, per-user scoping |
+
+## Project layout
+
+See `AGENTS.md` for the module map and the invariants you must not break. In short:
+
+- `wsgi.py` constructs everything; `app.py` is a factory with no import-time side effects.
+- Config flows through immutable `ConfigStore` snapshots (one per request, one per job).
+- Report & Release is data-driven in `pipeline.py` — add a step there, not in `worker.py`.
+- Frontend is vanilla JS (`static/app.js`, `static/admin.js`); no build step.
+
+## Adding things
+
+- **A pipeline step:** `@step("name")` in `pipeline.py` + add to `ALLOWED_STEPS`.
+- **A config key:** `config.example.toml` (with a comment) → read from snapshot → if
+  admin-editable, add to `ADMIN_EDITABLE` + validation in `config_store.py`.
+- **A preference:** a `PrefSpec` in `prefs.py` `PREF_SPECS`.
+
+Match surrounding style: `from __future__ import annotations`, `pps.*` logger names,
+`%`-style logging, `_`-prefixed privates.

+ 63 - 0
docs/okta-setup.md

@@ -0,0 +1,63 @@
+# Okta OIDC setup
+
+The app authenticates via Okta using OpenID Connect (Authlib). Okta settings live only in
+`config.toml` `[okta]` — they are never editable from the admin panel.
+
+## 1. Create the Okta app
+
+In the Okta admin console: **Applications → Create App Integration → OIDC → Web Application**.
+
+- **Sign-in redirect URI:** `https://YOUR-HOST/authorize` (must match `okta.redirect_uri`
+  exactly, including scheme and trailing path).
+- **Sign-out redirect URI:** `https://YOUR-HOST/login` (optional).
+- **Grant type:** Authorization Code.
+- **Assignments:** grant the app to the users/groups who should be able to *reach* the login
+  (fine-grained allow/deny is still enforced by this app's own users list).
+
+Note the **Client ID**, **Client secret**, and your **issuer** (usually
+`https://TENANT.okta.com/oauth2/default` — verify at `{issuer}/.well-known/openid-configuration`).
+
+## 2. Configure the app
+
+```toml
+[auth]
+mode = "oidc"
+denied_message = "Your account is not authorised. Contact IT."
+
+[[auth.users]]
+email = "you@company.com"
+role  = "admin"
+
+[okta]
+issuer        = "https://TENANT.okta.com/oauth2/default"
+client_id     = "0oaXXXXXXXX"
+client_secret = "XXXXXXXX"
+redirect_uri  = "https://YOUR-HOST/authorize"
+```
+
+The app requests scopes `openid email profile` and identifies users by the **email** claim.
+
+## 3. Access model
+
+Okta decides *who can authenticate*; this app's `[[auth.users]]` list decides *who is
+allowed in and with what role*. An authenticated user not on the list sees `denied_message`
+(HTTP 403). Roles (`admin`/`user`) come from this list and are **re-checked on every
+request**, so removing or demoting someone takes effect immediately without them
+re-logging-in.
+
+## Gotchas
+
+- **`redirect_uri` mismatch** is the #1 failure. It must match Okta exactly. Behind a
+  TLS-terminating proxy, do not rely on auto-detection — set the explicit `https://` value.
+- **`SameSite=Lax`** is required (the app sets it). `Strict` would drop the session cookie on
+  the cross-site callback and every login would fail with `mismatching_state`.
+- **`cookie_secure = true`** in production (HTTPS). Only set `false` for plain-http localhost.
+- Changing any `[okta]` key or `auth.mode` requires a **restart** (the OIDC client is
+  registered at boot).
+
+## Testing without a tenant
+
+Use `[auth] mode = "static"` with `[auth.static]` credentials for local development. The app
+logs a loud warning and refuses to bind a non-loopback interface in this mode (override with
+`PPSQ_ALLOW_INSECURE_AUTH=1` for dev only). The OIDC callback logic is covered by
+`tests/test_oidc.py` with a monkeypatched token exchange — no tenant needed.

+ 81 - 0
docs/operations.md

@@ -0,0 +1,81 @@
+# Operations runbook
+
+## Deploy
+
+Single process (mandatory — see AGENTS.md #1). On the VM, as a dedicated service user:
+
+```bash
+python3 -m venv venv
+venv/bin/pip install -r requirements.lock     # pinned; or requirements.txt for ranges
+cp config.example.toml config.toml
+# edit config.toml: [okta], [pps] creds, [[auth.users]], and a real secret_key:
+python -c "import secrets; print(secrets.token_urlsafe(48))"
+chmod 600 config.toml
+venv/bin/python wsgi.py
+```
+
+The config file must be **writable by the service user** (the admin panel rewrites it via
+temp-file + atomic rename). A read-only mount breaks admin saves. `wsgi.py` refuses to boot
+with the placeholder or a `<32` char `secret_key`.
+
+Run under a supervisor (systemd) that restarts on exit and keeps it a single instance:
+
+```ini
+[Service]
+User=ppsq
+WorkingDirectory=/opt/ppsq
+ExecStart=/opt/ppsq/venv/bin/python wsgi.py
+Restart=on-failure
+# do NOT add multiple instances / workers
+```
+
+Put a TLS-terminating reverse proxy (nginx) in front; set `[app] cookie_secure = true` and
+`listen = "127.0.0.1"`.
+
+## Logs
+
+- **Application log** (`app.log`): requests, job lifecycle, PPS API calls, errors. Viewable
+  in the admin panel (Logs → Application) with errors in red, or `tail -f app.log`.
+- **Operations/audit log** (`worker.log`): one line per message acted on, e.g.
+  `job=#12 user=alice@x action=report_release result=ok src='Quarantine' dst='Debugging - Josef' localguid=… guid=… from=… rcpt=… subject=…`.
+  This is the "who released/deleted what" audit trail. Grep it: `grep result=FAILED worker.log`.
+
+Both rotate at 5 MB × 5 files. The PPS `x-pps-reqid` appears in error lines for
+cross-referencing PPS's own webservices log.
+
+## Background jobs
+
+- View in the admin panel (Jobs) — all users, with status and progress. Failed jobs have a
+  **Retry** button (`POST /api/admin/jobs/<id>/retry`).
+- Jobs persist in `jobs.db` and survive a restart; a job interrupted mid-run is requeued
+  automatically on boot.
+
+## Backup
+
+Back up `config.toml` and `jobs.db`. For a consistent `jobs.db` copy while running (WAL mode):
+
+```bash
+sqlite3 jobs.db ".backup '/backup/jobs.db'"
+```
+
+`config.toml.bak` holds the previous config after each admin save (one-step rollback).
+
+## Rotating credentials
+
+- **PPS password / Okta secret:** enter the new value in the admin panel (PPS section) — it's
+  write-only and applies live (PPS) or after restart (Okta, edit the file). Use the **Test
+  connection** button before relying on it.
+- **`secret_key`:** edit `config.toml` and restart. This invalidates all sessions (everyone
+  re-logs-in).
+- **Client TLS cert:** replace the file at `client_cert`; see `certs/README.md`.
+
+## Common issues
+
+| Symptom | Cause / fix |
+|---------|-------------|
+| `400 No required SSL certificate was sent` | PPS/proxy wants mutual TLS. Set `[pps] client_cert`. |
+| Every login fails `mismatching_state` | Cookie/SameSite or a proxy dropping cookies; ensure `cookie_secure`/HTTPS and `redirect_uri` match. |
+| `database is locked` | WAL/busy_timeout missing — should not happen; verify no second process opened `jobs.db`. |
+| Admin save fails to persist | `config.toml`/its directory not writable by the service user. |
+| Folder shows few/zero messages | `default_days_back` window too small, or `list_query` not match-all for this PPS. |
+| Messages older than N days missing | Bounded by `default_days_back` — raise it. |

+ 49 - 0
docs/security.md

@@ -0,0 +1,49 @@
+# Security notes
+
+## Authentication & authorisation
+
+- **Okta OIDC** (Authlib) validates state, nonce, and the id_token signature (JWKS from
+  discovery). The app trusts the `email` claim and checks it against its own allowed-users
+  list. Roles are re-resolved per request, so removal/demotion is immediate.
+- **Static mode** is development-only: loud startup warning, `hmac.compare_digest` credential
+  check, and it refuses to bind a non-loopback interface without `PPSQ_ALLOW_INSECURE_AUTH=1`.
+
+## Sessions & CSRF
+
+- Cookies: `HttpOnly`, `SameSite=Lax` (required for the OIDC callback), `Secure` when
+  `cookie_secure=true`. **Browser-session only** — no persistent cookie, no idle-timeout
+  cookie; closing the browser ends the session.
+- CSRF: a per-session token from `/api/config` must be echoed as `X-CSRF-Token` on every
+  state-changing `/api/*` request. Enforced in `before_request`.
+- The post-login `next` target is sanitised to a local path (`safe_next`) — no open redirect.
+
+## Secrets
+
+- `pps.password`, `okta.client_secret`, `auth.static.password`, `app.secret_key` are
+  **write-only**: never returned by any endpoint (`redacted()` emits a `*_set` boolean). A
+  blank value on save means "unchanged".
+- `config.toml` is written `0600`. It is plaintext on disk — see "Known limitations".
+
+## Input validation
+
+- Config writes are validated before the atomic write (URLs, ranges, folder membership,
+  pipeline step order, emails/roles). Folder names may not contain commas (they'd corrupt
+  the `localguid` join in the PPS POST payload).
+- `POST /api/actions` validates `targetfolder` against the configured folder list — a user
+  cannot move mail to an arbitrary folder.
+- The log viewer's `source` is an enum key resolved to a path server-side; there is no
+  client-supplied path (no arbitrary file read).
+
+## Transport
+
+- Put a TLS-terminating reverse proxy in front; bind the app to loopback.
+- `verify_tls` controls whether the app verifies the PPS certificate. `false` (self-signed)
+  is common for PPS but should be a CA-bundle path where possible; the client logs a loud
+  warning when verification is disabled.
+
+## Known limitations (accepted for internal use)
+
+- `config.toml` holds secrets in plaintext (0600, never readable via the app). Real secret
+  management (Vault, env injection) is a future step.
+- Roles come from the app's own list, not Okta groups.
+- Single-process by design; no HA/failover.

+ 71 - 0
logs.py

@@ -0,0 +1,71 @@
+"""Log tailing for the admin log viewer.
+
+Reads the tail of a rotating log file backwards in blocks — never loads the whole file.
+The log source is an ENUM KEY resolved to a path server-side; the client can never name
+a file path (a `?path=` parameter here would be an arbitrary-file-read hole).
+
+Level classification is done here, server-side, so the ops-log format stays owned by the
+backend and the browser just colours by the returned `level`.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+MAX_LINES = 2000
+MAX_BYTES = 2_000_000
+
+# name -> function that returns the file path from the app config section.
+LOG_SOURCES = {
+    "ops": lambda app_cfg: app_cfg.get("worker_log", "worker.log"),
+    "app": lambda app_cfg: app_cfg.get("app_log", "app.log"),
+}
+
+
+def resolve_path(source: str, app_cfg) -> Path:
+    if source not in LOG_SOURCES:
+        raise KeyError(source)
+    return Path(LOG_SOURCES[source](app_cfg)).resolve()
+
+
+def tail(path: Path, lines: int = 200, max_bytes: int = MAX_BYTES) -> tuple[list[str], bool]:
+    """Return (last `lines` lines, truncated?). Reads backwards; bounded memory."""
+    lines = max(1, min(lines, MAX_LINES))
+    if not path.exists():
+        return [], False
+    with path.open("rb") as fh:
+        fh.seek(0, os.SEEK_END)
+        pos = fh.tell()
+        data = b""
+        while pos > 0 and data.count(b"\n") <= lines and len(data) < max_bytes:
+            step = min(65536, pos)
+            pos -= step
+            fh.seek(pos)
+            data = fh.read(step) + data
+    text = data.decode("utf-8", errors="replace").splitlines()
+    return text[-lines:], pos > 0
+
+
+def classify(source: str, line: str) -> str:
+    """Map a log line to a severity for colouring."""
+    if source == "ops":
+        return "error" if ("result=FAILED" in line or "error=" in line) else "info"
+    if " ERROR " in line or " CRITICAL " in line:
+        return "error"
+    if " WARNING " in line:
+        return "warn"
+    return "info"
+
+
+def read_log(source: str, app_cfg, lines: int = 200, query: str | None = None) -> dict:
+    path = resolve_path(source, app_cfg)
+    raw, truncated = tail(path, lines)
+    if query:
+        q = query.casefold()
+        raw = [ln for ln in raw if q in ln.casefold()]
+    return {
+        "source": source,
+        "truncated": truncated,
+        "lines": [{"text": ln, "level": classify(source, ln)} for ln in raw],
+    }

+ 180 - 0
pipeline.py

@@ -0,0 +1,180 @@
+"""Configurable Report & Release pipeline.
+
+"Report & Release" is a sequence of steps run per message chunk, chosen in the admin
+panel by checkboxes. The order is FIXED: release → move → delete. Config only toggles
+which steps are included (a subsequence of ALLOWED_STEPS).
+
+Why the order is load-bearing (see AGENTS.md):
+
+* `release` MUST run first and MUST NOT pass `deletedfolder`. Releasing in place leaves
+  the message in its folder so its `localguid` stays valid for a following `move`.
+  Passing `deletedfolder` would relocate the message and invalidate every localguid —
+  the exact bug the guid-refind fallback exists to paper over.
+* `move`/`delete` relocate the message, so afterwards the localguids are stale. A step
+  returns `localguids=None` to signal that; the runner re-finds them by the stable
+  `guid` — but LAZILY, only when another step actually follows. So the common
+  `["release", "move"]` still issues exactly two PPS calls, identical to the PoC.
+
+Each step acts on the message's CURRENT location, threading (folder, localguids)
+forward. Adding a future action = one `@step(...)` function + an entry in ALLOWED_STEPS;
+no worker or frontend change.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass, replace
+from typing import Any, Callable, Mapping
+
+from pps_client import PPSError
+
+log = logging.getLogger("pps.pipeline")
+
+# Canonical order. Also the validation whitelist (config_store imports this).
+ALLOWED_STEPS: tuple[str, ...] = ("release", "move", "delete")
+
+# Seconds to wait between two steps on the same message when config omits the setting.
+# PPS's backend is eventually-consistent: acting again too soon (e.g. move right after
+# release) can hit a stale view. The delay lets the backend catch up.
+DEFAULT_STEP_DELAY = 60
+
+
+@dataclass(frozen=True)
+class StepContext:
+    pps: Any
+    cfg: Mapping[str, Any]           # the quarantine config section
+    folder: str                      # where the messages are RIGHT NOW
+    localguids: list[str] | None     # None => stale; runner must re-find by guid
+    chunk: list[dict]                # job_items rows: localguid, guid, subject, ...
+
+
+@dataclass(frozen=True)
+class StepResult:
+    folder: str
+    localguids: list[str] | None
+    dst: str | None                  # folder this step lands messages in (for ops log)
+
+
+StepFn = Callable[[StepContext], StepResult]
+STEPS: dict[str, StepFn] = {}
+
+
+def step(name: str):
+    def register(fn: StepFn) -> StepFn:
+        STEPS[name] = fn
+        return fn
+    return register
+
+
+@step("release")
+def _release(ctx: StepContext) -> StepResult:
+    # NO deletedfolder — this is the localguid-stability rule. Never add it here.
+    ctx.pps.act("release", ctx.folder, ctx.localguids)
+    return StepResult(folder=ctx.folder, localguids=ctx.localguids, dst=None)
+
+
+@step("move")
+def _move(ctx: StepContext) -> StepResult:
+    target = ctx.cfg["report_release"]["move_target"]
+    if not target:
+        raise ValueError("report_release 'move' step requires move_target")
+    try:
+        ctx.pps.act("move", ctx.folder, ctx.localguids, targetfolder=target)
+    except PPSError:
+        # Some deployments' release relocates the message to the deleted folder (like the
+        # admin GUI). The move from `folder` then fails; re-find by stable guid and move
+        # from the deleted folder instead.
+        deleted = ctx.cfg.get("deleted_folder")
+        if not deleted:
+            raise PPSError("release relocated messages but no deleted_folder configured")
+        found = refind_localguids(ctx.pps, ctx.cfg, deleted, ctx.chunk)
+        ctx.pps.act("move", deleted, found, targetfolder=target)
+    return StepResult(folder=target, localguids=None, dst=target)
+
+
+@step("delete")
+def _delete(ctx: StepContext) -> StepResult:
+    deleted = ctx.cfg.get("deleted_folder")
+    ctx.pps.act("delete", ctx.folder, ctx.localguids, deletedfolder=deleted)
+    return StepResult(folder=deleted, localguids=None, dst=deleted)
+
+
+def run_pipeline(pps, cfg: Mapping[str, Any], folder: str, chunk: list[dict],
+                 sleep: Callable[[float], None] = time.sleep) -> None:
+    """Execute the configured steps in order for one chunk of messages.
+
+    Between two steps we wait `report_release.step_delay_seconds` (default 60) so PPS's
+    eventually-consistent backend settles before the next action on the same message —
+    without this, e.g. a move right after a release can act on a stale view. The wait
+    blocks the worker thread; it does NOT block the HTTP request (actions are queued and
+    return 202 immediately). `sleep` is injectable for tests.
+    """
+    steps = cfg["report_release"]["steps"]
+    delay = int(cfg["report_release"].get("step_delay_seconds", DEFAULT_STEP_DELAY))
+    ctx = StepContext(
+        pps=pps,
+        cfg=cfg,
+        folder=folder,
+        localguids=[it["localguid"] for it in chunk],
+        chunk=chunk,
+    )
+    for i, name in enumerate(steps):
+        # Delay before every step except the first — also lets the backend settle before
+        # the refind search below finds a just-relocated message.
+        if i > 0 and delay > 0:
+            log.info("waiting %ds before %r step (PPS backend consistency)", delay, name)
+            sleep(delay)
+        if ctx.localguids is None:  # a prior relocating step left them stale
+            ctx = replace(ctx, localguids=refind_localguids(pps, cfg, ctx.folder, chunk))
+        result = STEPS[name](ctx)
+        ctx = replace(ctx, folder=result.folder, localguids=result.localguids)
+
+
+def refind_localguids(pps, cfg: Mapping[str, Any], folder: str, chunk: list[dict]) -> list[str]:
+    """Recover current localguids in `folder` by matching the stable guid.
+
+    Generalises the PoC's _move_from_deleted_by_guid. Raises PPSError if nothing matches.
+    """
+    wanted = {it["guid"]: it for it in chunk if it.get("guid")}
+    if not wanted:
+        raise PPSError("cannot recover messages: no guids stored for this chunk")
+    records = pps.search(
+        folder,
+        cfg.get("list_query", "from=*"),
+        limit=1000,
+        days_back=int(cfg.get("default_days_back", 7)),
+    )
+    found = [
+        r["localguid"] for r in records
+        if r.get("guid") in wanted and r.get("localguid")
+    ]
+    if not found:
+        raise PPSError(f"messages not found in {folder!r} to continue the pipeline")
+    return found
+
+
+def pipeline_destination(cfg: Mapping[str, Any]) -> str | None:
+    """Final folder after all steps, computed WITHOUT touching PPS (for the ops log)."""
+    folder = None
+    for name in cfg["report_release"]["steps"]:
+        if name == "move":
+            folder = cfg["report_release"]["move_target"]
+        elif name == "delete":
+            folder = cfg.get("deleted_folder")
+    return folder
+
+
+def describe_pipeline(cfg: Mapping[str, Any]) -> str:
+    """Human sentence for the admin panel, e.g.
+    'released in place → moved to "Debugging - Josef" → deleted to "Deleted"'."""
+    rr = cfg.get("report_release", {})
+    phrases = []
+    for name in rr.get("steps", []):
+        if name == "release":
+            phrases.append("released in place")
+        elif name == "move":
+            phrases.append(f'moved to "{rr.get("move_target", "?")}"')
+        elif name == "delete":
+            phrases.append(f'deleted to "{cfg.get("deleted_folder", "?")}"')
+    return " → ".join(phrases) if phrases else "(no steps configured)"

+ 159 - 0
prefs.py

@@ -0,0 +1,159 @@
+"""Per-user preferences.
+
+Admin config sets the global default for each pref; a user may override it, and the
+override persists across logins. Overrides live in the existing jobs.db (a separate
+connection — WAL + busy_timeout are set by JobQueue), NOT in the shared config.toml.
+
+Key-value schema so adding a new pref needs no migration. The effective value is the
+admin default overridden by the user's stored value ONLY IF it still validates against
+the live config — so if an admin deletes a folder a user had pinned, that user quietly
+falls back to the default instead of hitting an unknown-folder error.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+import threading
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Callable, Mapping
+
+log = logging.getLogger("pps.prefs")
+
+_SORT_FIELDS = {"subject", "date", "from", "rcpt"}
+_SORT_DIRS = {"asc", "desc"}
+
+
+@dataclass(frozen=True)
+class PrefSpec:
+    default_path: tuple[str, ...]                     # where the admin default lives
+    coerce: Callable[[Any], Any]
+    validate: Callable[[Any, Mapping], bool]          # (value, live quarantine cfg) -> ok
+
+
+def _in_folders(v, q) -> bool:
+    return v in q.get("folders", [])
+
+
+PREF_SPECS: dict[str, PrefSpec] = {
+    "default_folder": PrefSpec(("default_folder",), str, _in_folders),
+    "default_limit": PrefSpec(("default_limit",), int, lambda v, q: 1 <= v <= 1000),
+    "sort_field": PrefSpec(("default_sort_field",), str, lambda v, q: v in _SORT_FIELDS),
+    "sort_dir": PrefSpec(("default_sort_dir",), str, lambda v, q: v in _SORT_DIRS),
+}
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+
+
+class PrefStore:
+    def __init__(self, db_path: str):
+        self._lock = threading.Lock()
+        self._conn = sqlite3.connect(db_path, check_same_thread=False)
+        self._conn.row_factory = sqlite3.Row
+        with self._lock:
+            self._conn.execute("PRAGMA busy_timeout=5000")
+            self._conn.execute(
+                """CREATE TABLE IF NOT EXISTS user_prefs (
+                    email      TEXT NOT NULL,
+                    key        TEXT NOT NULL,
+                    value      TEXT NOT NULL,
+                    updated_at TEXT NOT NULL,
+                    PRIMARY KEY (email, key)
+                )"""
+            )
+            self._conn.commit()
+
+    # ------------------------------------------------------------------ reads
+
+    def raw(self, email: str) -> dict:
+        """The user's stored overrides (decoded), unvalidated."""
+        with self._lock:
+            rows = self._conn.execute(
+                "SELECT key, value FROM user_prefs WHERE email=?", (email,)
+            ).fetchall()
+        out = {}
+        for r in rows:
+            try:
+                out[r["key"]] = json.loads(r["value"])
+            except json.JSONDecodeError:
+                continue
+        return out
+
+    def effective(self, email: str, snap) -> dict:
+        """Admin defaults overridden by valid user values."""
+        q = snap.quarantine
+        overrides = self.raw(email)
+        out = {}
+        for key, spec in PREF_SPECS.items():
+            default = _dig(q, spec.default_path)
+            value = default
+            if key in overrides:
+                try:
+                    cand = spec.coerce(overrides[key])
+                    if spec.validate(cand, q):
+                        value = cand
+                except (TypeError, ValueError):
+                    pass
+            out[key] = value
+        return out
+
+    def describe(self, email: str, snap) -> dict:
+        q = snap.quarantine
+        defaults = {k: _dig(q, s.default_path) for k, s in PREF_SPECS.items()}
+        overrides = self.raw(email)
+        return {
+            "prefs": self.effective(email, snap),
+            "defaults": defaults,
+            "overridden": sorted(k for k in overrides if k in PREF_SPECS),
+        }
+
+    # ------------------------------------------------------------------ writes
+
+    def set_many(self, email: str, patch: dict, snap) -> dict:
+        """Validate + persist a user's overrides. Returns the new effective prefs."""
+        q = snap.quarantine
+        to_write = []
+        for key, value in patch.items():
+            spec = PREF_SPECS.get(key)
+            if spec is None:
+                raise ValueError(f"unknown preference: {key}")
+            try:
+                coerced = spec.coerce(value)
+            except (TypeError, ValueError):
+                raise ValueError(f"invalid value for {key}: {value!r}")
+            if not spec.validate(coerced, q):
+                raise ValueError(f"invalid value for {key}: {value!r}")
+            to_write.append((email, key, json.dumps(coerced), _now()))
+        with self._lock:
+            self._conn.executemany(
+                "INSERT INTO user_prefs (email, key, value, updated_at) VALUES (?,?,?,?)"
+                " ON CONFLICT(email, key) DO UPDATE SET value=excluded.value,"
+                " updated_at=excluded.updated_at",
+                to_write,
+            )
+            self._conn.commit()
+        return self.effective(email, snap)
+
+    def clear(self, email: str, keys: list[str] | None = None) -> None:
+        with self._lock:
+            if keys:
+                self._conn.executemany(
+                    "DELETE FROM user_prefs WHERE email=? AND key=?",
+                    [(email, k) for k in keys],
+                )
+            else:
+                self._conn.execute("DELETE FROM user_prefs WHERE email=?", (email,))
+            self._conn.commit()
+
+
+def _dig(m: Mapping, path: tuple[str, ...]):
+    cur: Any = m
+    for p in path:
+        if not isinstance(cur, Mapping) or p not in cur:
+            return None
+        cur = cur[p]
+    return cur

+ 5 - 0
pytest.ini

@@ -0,0 +1,5 @@
+[pytest]
+# Project root (app, config_store, …) and tests/ (conftest helpers) on the import path.
+pythonpath = . tests
+testpaths = tests
+addopts = -q

+ 2 - 0
requirements-dev.txt

@@ -0,0 +1,2 @@
+-r requirements.txt
+pytest>=8

+ 16 - 0
requirements.lock

@@ -0,0 +1,16 @@
+Authlib==1.7.2
+blinker==1.9.0
+certifi==2026.6.17
+charset-normalizer==3.4.9
+click==8.4.2
+cryptography==49.0.0
+Flask==3.1.3
+idna==3.18
+itsdangerous==2.2.0
+Jinja2==3.1.6
+MarkupSafe==3.0.3
+requests==2.34.2
+tomlkit==0.15.1
+urllib3==2.7.0
+waitress==3.0.2
+Werkzeug==3.1.8

+ 5 - 3
requirements.txt

@@ -1,3 +1,5 @@
-Flask>=3.0
-requests>=2.31
-waitress>=3.0
+Flask>=3.0,<4
+requests>=2.31,<3
+waitress>=3.0,<4
+tomlkit>=0.13,<1      # comment/format-preserving TOML round-trip (admin panel writes config.toml)
+Authlib>=1.3,<2       # Okta OIDC: discovery, PKCE, JWKS, id_token validation

+ 72 - 0
static/admin.css

@@ -0,0 +1,72 @@
+/* Admin panel — reuses :root vars from style.css */
+
+.admin-page { background: var(--bg); }
+.admin-nav { display: flex; gap: 14px; flex: 1; flex-wrap: wrap; }
+.admin-nav a { color: var(--muted); text-decoration: none; font-size: 13px; }
+.admin-nav a:hover { color: var(--accent); }
+.topbar-link { color: var(--accent); text-decoration: none; font-size: 13px; }
+
+.admin-main {
+  max-width: 860px; margin: 0 auto; padding: 24px 20px 80px;
+  display: flex; flex-direction: column; gap: 20px;
+}
+.admin-section {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
+  padding: 20px; box-shadow: var(--shadow); scroll-margin-top: 64px;
+}
+.admin-section h2 { margin: 0 0 14px; font-size: 16px; }
+
+.field-block { display: flex; flex-direction: column; gap: 4px; font-size: 13px;
+  color: var(--muted); margin-bottom: 12px; }
+.field-block input, .field-block select, .field-block textarea {
+  padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px;
+  font-size: 14px; color: var(--text); background: var(--panel);
+}
+.field-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 14px;
+  margin-right: 16px; }
+.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; }
+.row input[type="text"], .row input[type="email"] { flex: 1; min-width: 180px;
+  padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; }
+
+.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.btn.primary:hover { opacity: 0.92; }
+
+.admin-table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
+.admin-table th, .admin-table td {
+  text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 13px;
+}
+.admin-table th { color: var(--muted); font-weight: 600; }
+
+.chip-list { list-style: none; padding: 0; margin: 0 0 12px; display: flex;
+  flex-wrap: wrap; gap: 8px; }
+.chip { display: inline-flex; align-items: center; gap: 8px; padding: 5px 10px;
+  border: 1px solid var(--border); border-radius: 999px; font-size: 13px; background: var(--panel); }
+.chip button { border: 0; background: none; cursor: pointer; color: var(--muted); font-size: 13px; }
+.chip button:hover { color: var(--danger); }
+
+.rr-steps { display: flex; gap: 18px; margin-bottom: 12px; }
+.rr-sentence { font-size: 14px; color: var(--text); background: var(--selected);
+  padding: 10px 12px; border-radius: 8px; margin: 12px 0; }
+
+.hint { font-size: 12px; color: var(--muted); }
+.hint.ok { color: #15803d; }
+.hint.err { color: var(--danger); }
+.banner { background: #fdf6e3; border: 1px solid #e0c97f; color: #8a6d00;
+  padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; }
+
+.st-done { color: #15803d; }
+.st-failed { color: var(--danger); }
+.st-running, .st-pending { color: var(--accent); }
+
+.log-view { background: #0f172a; color: #e2e8f0; border-radius: 8px; padding: 12px;
+  max-height: 420px; overflow: auto; font: 12px/1.5 ui-monospace, Menlo, monospace; }
+.log-line { white-space: pre-wrap; word-break: break-word; }
+.log-error { color: #fca5a5; }
+.log-warn { color: #fcd34d; }
+.log-info { color: #cbd5e1; }
+
+.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
+  padding: 12px 20px; border-radius: 8px; box-shadow: var(--shadow); font-size: 14px;
+  z-index: 100; max-width: 80vw; }
+.toast.ok { background: #052e16; color: #bbf7d0; }
+.toast.err { background: #450a0a; color: #fecaca; }

+ 312 - 0
static/admin.js

@@ -0,0 +1,312 @@
+"use strict";
+
+// Admin panel. Loads redacted config + schema, renders each section, and saves per
+// section via PUT /api/admin/config. Secrets are write-only: the password field is only
+// sent when the admin types something.
+
+let CONFIG = null;    // redacted config
+let SCHEMA = null;
+let CSRF = null;
+let logTimer = null;
+
+const $ = (id) => document.getElementById(id);
+
+async function api(path, opts = {}) {
+  const res = await fetch(path, {
+    headers: { "Content-Type": "application/json", ...(CSRF ? { "X-CSRF-Token": CSRF } : {}) },
+    ...opts,
+  });
+  if (res.status === 401) { window.location = "/login"; throw new Error("Not signed in"); }
+  if (res.status === 403) { throw new Error("Admin access required"); }
+  const data = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    const msg = data.errors ? data.errors.map((e) => `${e.field}: ${e.message}`).join("; ")
+                            : (data.error || `HTTP ${res.status}`);
+    throw new Error(msg);
+  }
+  return data;
+}
+
+async function init() {
+  // Grab CSRF from the quarantine config endpoint (shared session token).
+  try { CSRF = (await (await fetch("/api/config")).json()).csrf_token; } catch { /* ignore */ }
+  await load();
+  wire();
+  refreshJobs();
+  refreshLogs();
+}
+
+async function load() {
+  const data = await api("/api/admin/config");
+  CONFIG = data.config;
+  SCHEMA = data.schema;
+  renderAccess();
+  renderPps();
+  renderFolders();
+  renderDefaults();
+  renderReportRelease();
+  renderNav();
+}
+
+// ---------- Access ----------
+function renderAccess() {
+  const body = $("users-body");
+  body.innerHTML = "";
+  (CONFIG.auth.users || []).forEach((u, i) => {
+    const tr = document.createElement("tr");
+    tr.innerHTML =
+      `<td>${esc(u.email)}</td>` +
+      `<td><select data-uidx="${i}">${SCHEMA.roles.map((r) =>
+        `<option value="${r}" ${r === u.role ? "selected" : ""}>${r}</option>`).join("")}</select></td>` +
+      `<td><button class="btn small" data-remove="${i}">Remove</button></td>`;
+    body.append(tr);
+  });
+  body.querySelectorAll("[data-remove]").forEach((b) =>
+    b.onclick = () => { CONFIG.auth.users.splice(+b.dataset.remove, 1); renderAccess(); });
+  body.querySelectorAll("[data-uidx]").forEach((s) =>
+    s.onchange = () => { CONFIG.auth.users[+s.dataset.uidx].role = s.value; });
+  $("denied-message").value = CONFIG.auth.denied_message || "";
+  const banner = $("auth-mode-banner");
+  if (SCHEMA.auth_mode === "static") {
+    banner.textContent = "Auth mode is STATIC (development). Set [auth] mode = \"oidc\" in the server config for production.";
+    banner.classList.remove("hidden");
+  } else banner.classList.add("hidden");
+}
+
+function addUser() {
+  const email = $("new-user-email").value.trim();
+  if (!email) return;
+  CONFIG.auth.users = CONFIG.auth.users || [];
+  CONFIG.auth.users.push({ email, role: $("new-user-role").value });
+  $("new-user-email").value = "";
+  renderAccess();
+}
+
+// ---------- PPS ----------
+function renderPps() {
+  const p = CONFIG.pps;
+  $("pps-base_url").value = p.base_url || "";
+  $("pps-username").value = p.username || "";
+  $("pps-verify_tls").checked = !!p.verify_tls;
+  $("pps-timeout").value = p.timeout ?? 120;
+  $("pps-client_cert").value = p.client_cert || "";
+  $("pps-client_key").value = p.client_key || "";
+  $("pps-password").value = "";
+  $("pps-password-state").textContent = p.password_set ? "Password is set" : "No password set";
+}
+
+// ---------- Folders ----------
+function renderFolders() {
+  const list = $("folders-list");
+  list.innerHTML = "";
+  (CONFIG.quarantine.folders || []).forEach((f, i) => {
+    const li = document.createElement("li");
+    li.className = "chip";
+    li.innerHTML = `<span>${esc(f)}</span><button data-fi="${i}" title="remove">✕</button>`;
+    list.append(li);
+  });
+  list.querySelectorAll("[data-fi]").forEach((b) =>
+    b.onclick = () => { CONFIG.quarantine.folders.splice(+b.dataset.fi, 1); renderFolders(); });
+  fillFolderSelect($("q-default_folder"), CONFIG.quarantine.default_folder);
+  fillFolderSelect($("q-deleted_folder"), CONFIG.quarantine.deleted_folder);
+}
+
+function addFolder() {
+  const v = $("new-folder").value.trim();
+  if (!v) return;
+  CONFIG.quarantine.folders = CONFIG.quarantine.folders || [];
+  CONFIG.quarantine.folders.push(v);
+  $("new-folder").value = "";
+  renderFolders();
+  renderReportRelease();
+}
+
+function fillFolderSelect(sel, current) {
+  sel.innerHTML = (CONFIG.quarantine.folders || [])
+    .map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>${esc(f)}</option>`).join("");
+}
+
+// ---------- Defaults ----------
+function renderDefaults() {
+  const q = CONFIG.quarantine;
+  $("q-default_limit").value = q.default_limit ?? 200;
+  $("q-default_days_back").value = q.default_days_back ?? 7;
+  $("q-list_query").value = q.list_query || "";
+  $("q-chunk_size").value = q.chunk_size ?? 25;
+  fillOptions($("q-default_sort_field"), SCHEMA.sort_fields, q.default_sort_field);
+  fillOptions($("q-default_sort_dir"), SCHEMA.sort_dirs, q.default_sort_dir);
+}
+
+// ---------- Report & Release ----------
+function renderReportRelease() {
+  const rr = CONFIG.quarantine.report_release || { steps: [], move_target: "" };
+  const box = $("rr-steps");
+  box.innerHTML = SCHEMA.steps.map((s) =>
+    `<label class="field-inline"><input type="checkbox" data-step="${s}" ${rr.steps.includes(s) ? "checked" : ""}> ${s}</label>`
+  ).join("");
+  box.querySelectorAll("[data-step]").forEach((c) => c.onchange = updateRrSentence);
+  fillFolderSelect($("rr-move_target"), rr.move_target);
+  $("rr-step_delay_seconds").value = rr.step_delay_seconds ?? 60;
+  updateRrSentence();
+}
+
+function currentSteps() {
+  // Preserve canonical order from the schema.
+  return SCHEMA.steps.filter((s) => document.querySelector(`[data-step="${s}"]`)?.checked);
+}
+
+function updateRrSentence() {
+  const steps = currentSteps();
+  const move = $("rr-move_target").value;
+  const deleted = CONFIG.quarantine.deleted_folder;
+  const parts = steps.map((s) =>
+    s === "release" ? "released in place" :
+    s === "move" ? `moved to "${move}"` :
+    s === "delete" ? `deleted to "${deleted}"` : s);
+  $("rr-sentence").textContent = parts.length ? parts.join(" → ") : "(no steps selected)";
+}
+
+// ---------- Saving ----------
+async function save(section) {
+  let patch = {};
+  if (section === "access") {
+    patch = { auth: { users: CONFIG.auth.users || [], denied_message: $("denied-message").value } };
+  } else if (section === "pps") {
+    const pps = {
+      base_url: $("pps-base_url").value.trim(),
+      username: $("pps-username").value.trim(),
+      verify_tls: $("pps-verify_tls").checked,
+      timeout: +$("pps-timeout").value,
+      client_cert: $("pps-client_cert").value.trim(),
+      client_key: $("pps-client_key").value.trim(),
+    };
+    if ($("pps-password").value) pps.password = $("pps-password").value;  // write-only
+    patch = { pps };
+  } else if (section === "folders") {
+    patch = { quarantine: {
+      folders: CONFIG.quarantine.folders || [],
+      default_folder: $("q-default_folder").value,
+      deleted_folder: $("q-deleted_folder").value,
+    } };
+  } else if (section === "defaults") {
+    patch = { quarantine: {
+      default_limit: +$("q-default_limit").value,
+      default_days_back: +$("q-default_days_back").value,
+      list_query: $("q-list_query").value.trim(),
+      chunk_size: +$("q-chunk_size").value,
+      default_sort_field: $("q-default_sort_field").value,
+      default_sort_dir: $("q-default_sort_dir").value,
+    } };
+  } else if (section === "rr") {
+    patch = { quarantine: { report_release: {
+      steps: currentSteps(),
+      move_target: $("rr-move_target").value,
+      step_delay_seconds: +$("rr-step_delay_seconds").value,
+    } } };
+  }
+  try {
+    const res = await api("/api/admin/config", { method: "PUT", body: JSON.stringify(patch) });
+    CONFIG = res.config;
+    let msg = "Saved.";
+    if (res.restart_required && res.restart_required.length)
+      msg += ` Restart required for: ${res.restart_required.join(", ")}.`;
+    toast(msg, false);
+    await load();
+  } catch (err) {
+    toast(err.message, true);
+  }
+}
+
+// ---------- PPS test ----------
+async function testPps() {
+  $("pps-test-result").textContent = "Testing…";
+  const body = {
+    base_url: $("pps-base_url").value.trim(),
+    username: $("pps-username").value.trim(),
+    verify_tls: $("pps-verify_tls").checked,
+    timeout: +$("pps-timeout").value,
+    client_cert: $("pps-client_cert").value.trim(),
+    client_key: $("pps-client_key").value.trim(),
+  };
+  if ($("pps-password").value) body.password = $("pps-password").value;
+  try {
+    const res = await api("/api/admin/pps-test", { method: "POST", body: JSON.stringify(body) });
+    const el = $("pps-test-result");
+    el.textContent = res.detail;
+    el.className = "hint " + (res.ok ? "ok" : "err");
+  } catch (err) { $("pps-test-result").textContent = err.message; }
+}
+
+// ---------- Jobs ----------
+async function refreshJobs() {
+  try {
+    const data = await api("/api/admin/jobs");
+    const body = $("jobs-body");
+    body.innerHTML = "";
+    (data.jobs || []).forEach((j) => {
+      const tr = document.createElement("tr");
+      tr.innerHTML =
+        `<td>${j.id}</td><td>${esc(j.user || "-")}</td><td>${esc(j.action)}</td>` +
+        `<td>${esc(j.folder)}</td><td class="st-${j.status}">${j.status}</td>` +
+        `<td>${j.processed}/${j.total}</td>` +
+        `<td>${j.status === "failed" ? `<button class="btn small" data-retry="${j.id}">Retry</button>` : ""}</td>`;
+      body.append(tr);
+    });
+    body.querySelectorAll("[data-retry]").forEach((b) =>
+      b.onclick = async () => { await api(`/api/admin/jobs/${b.dataset.retry}/retry`, { method: "POST" }); refreshJobs(); });
+  } catch (err) { toast(err.message, true); }
+}
+
+// ---------- Logs ----------
+async function refreshLogs() {
+  clearTimeout(logTimer);
+  try {
+    const source = $("log-source").value;
+    const q = encodeURIComponent($("log-filter").value || "");
+    const data = await api(`/api/admin/logs?source=${source}&lines=300&q=${q}`);
+    $("log-view").innerHTML = (data.lines || [])
+      .map((l) => `<div class="log-line log-${l.level}">${esc(l.text)}</div>`).join("")
+      || '<div class="log-line">(empty)</div>';
+  } catch (err) { $("log-view").textContent = err.message; }
+  if ($("log-auto").checked && document.visibilityState === "visible")
+    logTimer = setTimeout(refreshLogs, 5000);
+}
+
+// ---------- misc ----------
+function renderNav() {
+  const secs = [["sec-access","Access"],["sec-pps","PPS"],["sec-folders","Folders"],
+    ["sec-defaults","Defaults"],["sec-rr","Report & Release"],["sec-jobs","Jobs"],["sec-logs","Logs"]];
+  $("admin-nav").innerHTML = secs.map(([id,label]) =>
+    `<a href="#${id}">${label}</a>`).join("");
+}
+
+function fillOptions(sel, opts, current) {
+  sel.innerHTML = opts.map((o) => `<option value="${o}" ${o === current ? "selected" : ""}>${o}</option>`).join("");
+}
+
+function wire() {
+  document.querySelectorAll("[data-save]").forEach((b) => b.onclick = () => save(b.dataset.save));
+  $("add-user-btn").onclick = addUser;
+  $("add-folder-btn").onclick = addFolder;
+  $("pps-test-btn").onclick = testPps;
+  $("jobs-refresh").onclick = refreshJobs;
+  $("log-refresh").onclick = refreshLogs;
+  $("log-source").onchange = refreshLogs;
+  $("q-deleted_folder")?.addEventListener("change", updateRrSentence);
+}
+
+let toastTimer = null;
+function toast(msg, isError) {
+  const t = $("toast");
+  t.textContent = msg;
+  t.className = "toast " + (isError ? "err" : "ok");
+  clearTimeout(toastTimer);
+  toastTimer = setTimeout(() => t.classList.add("hidden"), 6000);
+}
+
+function esc(s) {
+  return String(s ?? "").replace(/[&<>"']/g, (c) =>
+    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
+}
+
+init();

+ 52 - 9
static/app.js

@@ -9,6 +9,7 @@ let jobPollTimer = null;
 let sortField = "subject";      // subject | date | from | rcpt
 let sortDir = 1;                // 1 = ascending, -1 = descending
 let syncing = false;            // a full folder load is in progress
+let syncCancelled = false;      // set by the Cancel button to stop paging early
 
 // --- element refs ----------------------------------------------------------
 const $ = (id) => document.getElementById(id);
@@ -25,11 +26,16 @@ const overlay = $("overlay");
 // --- init ------------------------------------------------------------------
 async function init() {
   CONFIG = await fetchJSON("/api/config");
+  const prefs = CONFIG.prefs || {};
   for (const f of CONFIG.folders) {
     folderSelect.append(new Option(f, f));
   }
-  folderSelect.value = CONFIG.default_folder;
-  limitInput.value = CONFIG.default_limit;
+  folderSelect.value = prefs.default_folder || CONFIG.folders[0];
+  limitInput.value = prefs.default_limit ?? 200;
+  sortField = prefs.sort_field || "subject";
+  sortDir = prefs.sort_dir === "desc" ? -1 : 1;
+  $("sort-field").value = sortField;
+  $("sort-dir").textContent = sortDir === 1 ? "▲" : "▼";
 
   // Move dropdown targets
   moveMenu.innerHTML = "";
@@ -50,15 +56,24 @@ function wireEvents() {
   // Refresh only re-renders from the cache; Reload re-fetches the folder.
   $("refresh-btn").onclick = () => renderRows();
   $("reload-btn").onclick = () => fullSync();
-  limitInput.onchange = () => renderRows();
-  folderSelect.onchange = () => fullSync();
+  $("cancel-sync-btn").onclick = () => {
+    syncCancelled = true;
+    const btn = $("cancel-sync-btn");
+    btn.textContent = "Cancelling…";
+    btn.disabled = true;
+  };
+  limitInput.onchange = () => { renderRows(); savePref("default_limit", +limitInput.value); };
+  folderSelect.onchange = () => { savePref("default_folder", folderSelect.value); fullSync(); };
   selectAll.onchange = () => toggleSelectAll(selectAll.checked);
 
-  $("sort-field").onchange = (e) => { sortField = e.target.value; renderRows(); };
+  $("sort-field").onchange = (e) => {
+    sortField = e.target.value; renderRows(); savePref("sort_field", sortField);
+  };
   $("sort-dir").onclick = () => {
     sortDir = -sortDir;
     $("sort-dir").textContent = sortDir === 1 ? "▲" : "▼";
     renderRows();
+    savePref("sort_dir", sortDir === 1 ? "asc" : "desc");
   };
 
   document.querySelectorAll(".btn.action[data-action]").forEach((btn) => {
@@ -84,6 +99,7 @@ function wireEvents() {
 async function fullSync() {
   if (syncing) return;
   syncing = true;
+  syncCancelled = false;
   clearSelection();
   const folder = folderSelect.value;
   showProgress(`Loading messages from “${folder}”…`);
@@ -92,6 +108,7 @@ async function fullSync() {
   const seen = new Set();
   const acc = [];
   let cursor = null;
+  let cancelled = false;
   try {
     while (true) {
       const url = `/api/messages?folder=${encodeURIComponent(folder)}&limit=${PAGE_SIZE}` +
@@ -104,6 +121,10 @@ async function fullSync() {
       }
       updateProgress(acc.length);
 
+      // Cancel takes effect after the in-flight page finishes; that page's messages
+      // are kept, and we stop paging (the folder may still hold more).
+      if (syncCancelled) { cancelled = true; break; }
+
       const page = data.page || {};
       // Stop when the folder is exhausted, or the cursor can't advance (guards
       // against a boundary where >PAGE_SIZE messages share the same timestamp).
@@ -113,6 +134,13 @@ async function fullSync() {
     }
     messages = acc;
     renderRows();
+    if (cancelled) {
+      flashStatus(
+        `Loading cancelled — showing ${acc.length.toLocaleString()} loaded message(s). ` +
+        `The folder may contain more; use “Reload from server” for the full list.`,
+        false,
+      );
+    }
   } catch (err) {
     if (acc.length) {
       // Keep whatever we managed to page in; surface the error non-fatally.
@@ -132,6 +160,9 @@ async function fullSync() {
 function showProgress(title) {
   $("progress-title").textContent = title;
   $("progress-count").textContent = "0 messages loaded";
+  const btn = $("cancel-sync-btn");
+  btn.textContent = "Cancel";
+  btn.disabled = false;
   $("progress").classList.remove("hidden");
 }
 function updateProgress(n) {
@@ -420,16 +451,28 @@ function formatDate(d) {
 }
 
 async function fetchJSON(url, opts = {}) {
-  const res = await fetch(url, {
-    headers: { "Content-Type": "application/json" },
-    ...opts,
-  });
+  const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
+  // CSRF token on state-changing requests (the server requires it on non-GET /api/*).
+  const method = (opts.method || "GET").toUpperCase();
+  if (method !== "GET" && CONFIG && CONFIG.csrf_token) headers["X-CSRF-Token"] = CONFIG.csrf_token;
+  const res = await fetch(url, { ...opts, headers });
   if (res.status === 401) { window.location = "/login"; throw new Error("Not signed in"); }
   const data = await res.json().catch(() => ({}));
   if (!res.ok) throw new Error(data.error || data.detail || `HTTP ${res.status}`);
   return data;
 }
 
+// Persist a single user preference, debounced and fire-and-forget: a failed pref save
+// must never block the UI.
+const _prefTimers = {};
+function savePref(key, value) {
+  clearTimeout(_prefTimers[key]);
+  _prefTimers[key] = setTimeout(() => {
+    fetchJSON("/api/prefs", { method: "PUT", body: JSON.stringify({ [key]: value }) })
+      .catch(() => { /* ignore */ });
+  }, 500);
+}
+
 function escapeHTML(s) {
   return String(s).replace(/[&<>"']/g, (c) =>
     ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

+ 12 - 3
static/style.css

@@ -8,6 +8,7 @@
   --danger: #dc2626;
   --selected: #eef4ff;
   --shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
+  --topbar-h: 53px;
 }
 
 * { box-sizing: border-box; }
@@ -46,14 +47,21 @@ body {
   background: var(--accent); color: #fff; font-size: 15px; cursor: pointer;
 }
 .error { color: var(--danger); margin: 0; font-size: 13px; }
+.login-mode { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
+.denied-message { font-size: 15px; color: var(--text); margin: 8px 0 16px; }
+.denied-email { font-size: 13px; color: var(--muted); }
 
 /* ---------- topbar ---------- */
 .topbar {
   display: flex; align-items: center; gap: 16px;
-  padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
+  height: var(--topbar-h); box-sizing: border-box;
+  padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border);
   position: sticky; top: 0; z-index: 20;
 }
 .brand { font-weight: 700; font-size: 15px; }
+.topbar-link { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 600; }
+.topbar-link:hover { text-decoration: underline; }
+.topbar-user { color: var(--muted); font-size: 12px; }
 .toolbar { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
 .field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
 .field select, .field input {
@@ -87,6 +95,7 @@ body {
 }
 .progress-box #progress-title { font-weight: 600; margin-bottom: 6px; }
 .progress-count { color: var(--muted); font-size: 14px; }
+.progress-box #cancel-sync-btn { margin-top: 16px; }
 .spinner {
   width: 32px; height: 32px; margin: 0 auto 14px;
   border: 3px solid var(--border); border-top-color: var(--accent);
@@ -122,7 +131,7 @@ body {
   overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
 }
 .msg-table thead th {
-  position: sticky; top: 53px; background: #fafbfc; z-index: 10;
+  position: sticky; top: var(--topbar-h); background: #fafbfc; z-index: 10;
   font-size: 12px; color: var(--muted); font-weight: 600;
 }
 .col-check { width: 48px; text-align: center; cursor: pointer; }
@@ -140,7 +149,7 @@ body {
 
 /* ---------- overlay ---------- */
 .overlay {
-  position: fixed; inset: 53px 0 0 0; z-index: 40;
+  position: fixed; inset: var(--topbar-h) 0 0 0; z-index: 40;
   background: rgba(15, 23, 42, 0.45);
   display: flex; justify-content: center; align-items: center;
   padding: 24px;

+ 119 - 0
templates/admin.html

@@ -0,0 +1,119 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Admin — PPS Quarantine</title>
+  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+  <link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
+</head>
+<body class="admin-page">
+  <header class="topbar">
+    <span class="brand">PPS Quarantine — Admin</span>
+    <nav class="admin-nav" id="admin-nav"></nav>
+    <span class="spacer"></span>
+    <a class="topbar-link" href="/">← Back to quarantine</a>
+    <a class="logout" href="{{ url_for('auth.logout') }}">Sign out</a>
+  </header>
+
+  <div id="toast" class="toast hidden"></div>
+
+  <main class="admin-main">
+    <section id="sec-access" class="admin-section">
+      <h2>Access</h2>
+      <div id="auth-mode-banner" class="banner hidden"></div>
+      <table class="admin-table" id="users-table">
+        <thead><tr><th>Email</th><th>Role</th><th></th></tr></thead>
+        <tbody id="users-body"></tbody>
+      </table>
+      <div class="row">
+        <input id="new-user-email" type="email" placeholder="new.user@example.com">
+        <select id="new-user-role"><option value="user">user</option><option value="admin">admin</option></select>
+        <button class="btn" id="add-user-btn">Add user</button>
+      </div>
+      <label class="field-block">Denied message
+        <textarea id="denied-message" rows="2"></textarea>
+      </label>
+      <button class="btn primary" data-save="access">Save access</button>
+    </section>
+
+    <section id="sec-pps" class="admin-section">
+      <h2>PPS connection</h2>
+      <label class="field-block">Base URL <input id="pps-base_url" type="text"></label>
+      <label class="field-block">Username <input id="pps-username" type="text"></label>
+      <label class="field-block">Password
+        <input id="pps-password" type="password" placeholder="••••••">
+        <span id="pps-password-state" class="hint"></span>
+      </label>
+      <label class="field-inline"><input id="pps-verify_tls" type="checkbox"> Verify TLS</label>
+      <label class="field-block">Timeout (s) <input id="pps-timeout" type="number" min="5" max="600"></label>
+      <label class="field-block">Client cert <input id="pps-client_cert" type="text"></label>
+      <label class="field-block">Client key <input id="pps-client_key" type="text"></label>
+      <div class="row">
+        <button class="btn" id="pps-test-btn">Test connection</button>
+        <span id="pps-test-result" class="hint"></span>
+      </div>
+      <button class="btn primary" data-save="pps">Save connection</button>
+    </section>
+
+    <section id="sec-folders" class="admin-section">
+      <h2>Folders</h2>
+      <p class="hint">Names must match PPS exactly (case- and space-sensitive).</p>
+      <ul id="folders-list" class="chip-list"></ul>
+      <div class="row">
+        <input id="new-folder" type="text" placeholder="Exact folder name">
+        <button class="btn" id="add-folder-btn">Add folder</button>
+      </div>
+      <label class="field-block">Default folder <select id="q-default_folder"></select></label>
+      <label class="field-block">Deleted folder <select id="q-deleted_folder"></select></label>
+      <button class="btn primary" data-save="folders">Save folders</button>
+    </section>
+
+    <section id="sec-defaults" class="admin-section">
+      <h2>Defaults</h2>
+      <label class="field-block">Default show limit <input id="q-default_limit" type="number" min="1" max="1000"></label>
+      <label class="field-block">Days back <input id="q-default_days_back" type="number" min="1"></label>
+      <label class="field-block">List query <input id="q-list_query" type="text"></label>
+      <label class="field-block">Chunk size <input id="q-chunk_size" type="number" min="1" max="500"></label>
+      <label class="field-block">Default sort field <select id="q-default_sort_field"></select></label>
+      <label class="field-block">Default sort dir <select id="q-default_sort_dir"></select></label>
+      <p class="hint">Show limit and sort are user-overridable.</p>
+      <button class="btn primary" data-save="defaults">Save defaults</button>
+    </section>
+
+    <section id="sec-rr" class="admin-section">
+      <h2>Report &amp; Release</h2>
+      <div id="rr-steps" class="rr-steps"></div>
+      <label class="field-block">Move target <select id="rr-move_target"></select></label>
+      <label class="field-block">Delay between steps (seconds)
+        <input id="rr-step_delay_seconds" type="number" min="0" max="3600" step="1">
+      </label>
+      <p class="hint">Waits this long between actions on the same message so PPS's backend settles (e.g. before moving a just-released message). 0 disables.</p>
+      <p class="rr-sentence" id="rr-sentence"></p>
+      <button class="btn primary" data-save="rr">Save Report &amp; Release</button>
+    </section>
+
+    <section id="sec-jobs" class="admin-section">
+      <h2>Background jobs</h2>
+      <button class="btn" id="jobs-refresh">Refresh</button>
+      <table class="admin-table" id="jobs-table">
+        <thead><tr><th>#</th><th>User</th><th>Action</th><th>Folder</th><th>Status</th><th>Progress</th><th></th></tr></thead>
+        <tbody id="jobs-body"></tbody>
+      </table>
+    </section>
+
+    <section id="sec-logs" class="admin-section">
+      <h2>Logs</h2>
+      <div class="row">
+        <select id="log-source"><option value="ops">Operations (worker)</option><option value="app">Application</option></select>
+        <input id="log-filter" type="text" placeholder="filter…">
+        <label class="field-inline"><input id="log-auto" type="checkbox" checked> Auto-refresh</label>
+        <button class="btn" id="log-refresh">Refresh</button>
+      </div>
+      <pre id="log-view" class="log-view"></pre>
+    </section>
+  </main>
+
+  <script src="{{ url_for('static', filename='admin.js') }}"></script>
+</body>
+</html>

+ 17 - 0
templates/denied.html

@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Access denied — PPS Quarantine</title>
+  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+<body class="login-page">
+  <div class="login-card">
+    <h1>Access denied</h1>
+    <p class="denied-message">{{ message }}</p>
+    {% if email %}<p class="denied-email">Signed in as {{ email }}</p>{% endif %}
+    <a class="btn" href="{{ url_for('auth.logout') }}">Sign out</a>
+  </div>
+</body>
+</html>

+ 4 - 1
templates/index.html

@@ -9,6 +9,7 @@
 <body>
   <header class="topbar">
     <span class="brand">PPS Quarantine</span>
+    {% if user and user.role == 'admin' %}<a class="topbar-link" href="/admin">Admin</a>{% endif %}
     <div class="toolbar">
       <label class="field">Folder
         <select id="folder-select"></select>
@@ -37,7 +38,8 @@
         <div id="move-menu" class="move-menu hidden"></div>
       </div>
     </div>
-    <a class="logout" href="{{ url_for('logout') }}">Sign out</a>
+    {% if user %}<span class="topbar-user">{{ user.email }}</span>{% endif %}
+    <a class="logout" href="{{ url_for('auth.logout') }}">Sign out</a>
   </header>
 
   <div id="status-bar" class="status-bar hidden"></div>
@@ -82,6 +84,7 @@
         <div class="spinner"></div>
         <div id="progress-title">Loading messages…</div>
         <div id="progress-count" class="progress-count">0 messages loaded</div>
+        <button id="cancel-sync-btn" class="btn">Cancel</button>
       </div>
     </div>
   </main>

+ 2 - 1
templates/login.html

@@ -7,8 +7,9 @@
   <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
 </head>
 <body class="login-page">
-  <form class="login-card" method="post" action="{{ url_for('login') }}{% if request.args.get('next') %}?next={{ request.args.get('next') }}{% endif %}">
+  <form class="login-card" method="post" action="{{ url_for('auth.login', next=next_url) }}">
     <h1>PPS Quarantine</h1>
+    <p class="login-mode">Development sign-in</p>
     {% if error %}<p class="error">{{ error }}</p>{% endif %}
     <label>Username
       <input type="text" name="username" autocomplete="username" autofocus required>

+ 145 - 0
tests/conftest.py

@@ -0,0 +1,145 @@
+"""Shared test fixtures.
+
+Isolation is the top priority: no test may touch the real config.toml or start a worker
+thread. A session-scoped autouse fixture points PPSQ_CONFIG at a temp copy of
+config.example.toml BEFORE any app module is imported, and ConfigStore itself refuses to
+open the default path under pytest.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+EXAMPLE = ROOT / "config.example.toml"
+
+# http base_url in the example would fail https validation; allow it for tests.
+os.environ.setdefault("PPSQ_ALLOW_INSECURE", "1")
+
+
+def _write_config(dest: Path) -> Path:
+    """Copy the example config and make it bootable for tests: strong secret, dev URL,
+    static auth with a known dev account."""
+    text = EXAMPLE.read_text()
+    text = text.replace(
+        'secret_key = "change-me-to-a-random-string"',
+        'secret_key = "test-secret-key-that-is-definitely-long-enough-xx"',
+    )
+    text = text.replace(
+        'base_url = "https://pps.example.com:10000"',
+        'base_url = "http://pps.example.invalid:10000"',
+    )
+    text = text.replace('mode = "oidc"', 'mode = "static"')
+    # Known dev credentials for the static-login tests.
+    text = text.replace('username = "admin"\npassword = "admin"',
+                        'username = "dev"\npassword = "devpass"')
+    text = text.replace('email = "dev-admin@example.invalid"',
+                        'email = "dev@example.invalid"')
+    dest.write_text(text)
+    return dest
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _isolate_config(tmp_path_factory):
+    cfg = tmp_path_factory.mktemp("cfg") / "config.toml"
+    _write_config(cfg)
+    os.environ["PPSQ_CONFIG"] = str(cfg)
+    yield
+
+
+@pytest.fixture
+def config_path(tmp_path):
+    """A fresh, writable config file per test."""
+    return _write_config(tmp_path / "config.toml")
+
+
+@pytest.fixture
+def store(config_path):
+    from config_store import ConfigStore
+    return ConfigStore(config_path)
+
+
+class FakePPS:
+    """Records every call; returns canned data for search/get_raw."""
+
+    def __init__(self, records=None):
+        self.calls = []
+        self._records = records or []
+
+    def act(self, action, folder, localguids, *, targetfolder=None, deletedfolder=None, scan=False):
+        self.calls.append(
+            {"m": "act", "action": action, "folder": folder,
+             "localguids": list(localguids), "targetfolder": targetfolder,
+             "deletedfolder": deletedfolder, "scan": scan}
+        )
+        return {"status": "ok"}
+
+    def search(self, folder, query, limit=200, days_back=7, enddate=None):
+        self.calls.append({"m": "search", "folder": folder, "enddate": enddate})
+        return list(self._records)
+
+    def get_raw(self, guid):
+        self.calls.append({"m": "get_raw", "guid": guid})
+        return b"From: x@y\r\nSubject: t\r\n\r\nbody"
+
+    def searches(self):
+        return [c for c in self.calls if c["m"] == "search"]
+
+    def acts(self):
+        return [c for c in self.calls if c["m"] == "act"]
+
+
+@pytest.fixture
+def fake_pps():
+    return FakePPS()
+
+
+@pytest.fixture
+def make_queue(tmp_path):
+    """Factory: a JobQueue wired to a FakePPS and a live cfg provider."""
+    from worker import JobQueue
+
+    def _make(pps, cfg):
+        return JobQueue(
+            str(tmp_path / "jobs.db"),
+            pps_provider=lambda: pps,
+            cfg_provider=lambda: cfg,
+            ops_log_path=str(tmp_path / "worker.log"),
+        )
+
+    return _make
+
+
+@pytest.fixture
+def app_client(store, fake_pps, tmp_path, monkeypatch):
+    """A Flask test client. No worker thread — tests drive queue._process directly."""
+    from app import create_app
+    from prefs import PrefStore
+    from worker import JobQueue
+
+    # Point the store's PPS client at the fake.
+    monkeypatch.setattr(store, "pps", lambda: fake_pps)
+    queue = JobQueue(
+        str(tmp_path / "jobs.db"),
+        pps_provider=lambda: fake_pps,
+        cfg_provider=lambda: store.snapshot().quarantine,
+        ops_log_path=str(tmp_path / "worker.log"),
+    )
+    prefs = PrefStore(str(tmp_path / "jobs.db"))
+    app = create_app(store, queue, prefs)
+    app.config["TESTING"] = True
+    client = app.test_client()
+    client._store = store
+    client._queue = queue
+    client._pps = fake_pps
+    return client
+
+
+def login_static(client, username="dev", password="devpass"):
+    """Log in via static mode and return the CSRF token."""
+    client.post("/login", data={"username": username, "password": password})
+    return client.get("/api/config").get_json()["csrf_token"]

+ 74 - 0
tests/test_auth.py

@@ -0,0 +1,74 @@
+import pytest
+
+from auth import is_allowed, safe_next
+from conftest import login_static
+
+
+# ---------- pure helpers ----------
+
+def test_is_allowed_exact_and_casefold():
+    users = [{"email": "Alice@Example.com", "role": "admin"}]
+    assert is_allowed("alice@example.com", users) == (True, "admin")
+    assert is_allowed("ALICE@EXAMPLE.COM", users) == (True, "admin")
+
+
+def test_is_allowed_unknown():
+    assert is_allowed("nobody@x.com", [{"email": "a@b.c", "role": "user"}]) == (False, None)
+    assert is_allowed("", []) == (False, None)
+
+
+@pytest.mark.parametrize("target,expected", [
+    ("//evil.com", "/"),
+    ("https://evil.com", "/"),
+    ("/\\evil.com", "/"),
+    ("http:/evil", "/"),
+    (None, "/"),
+    ("", "/"),
+    ("/", "/"),
+    ("/admin", "/admin"),
+    ("/api/messages?folder=x", "/api/messages?folder=x"),
+])
+def test_safe_next(target, expected):
+    assert safe_next(target) == expected
+
+
+# ---------- integration (static mode) ----------
+
+def test_static_login_and_session(app_client):
+    csrf = login_static(app_client)
+    assert csrf
+    cfg = app_client.get("/api/config").get_json()
+    assert cfg["is_admin"] is True
+
+
+def test_api_requires_auth(app_client):
+    assert app_client.get("/api/config").status_code == 401
+    assert app_client.get("/api/messages").status_code == 401
+
+
+def test_admin_gate(app_client):
+    # not logged in -> 401 for api admin
+    assert app_client.get("/api/admin/config").status_code == 401
+    login_static(app_client)   # dev is admin
+    assert app_client.get("/api/admin/config").status_code == 200
+
+
+def test_csrf_required_on_post(app_client):
+    csrf = login_static(app_client)
+    # no token -> 400
+    r = app_client.put("/api/prefs", json={"default_limit": 50})
+    assert r.status_code == 400
+    # with token -> ok
+    r = app_client.put("/api/prefs", json={"default_limit": 50}, headers={"X-CSRF-Token": csrf})
+    assert r.status_code == 200
+
+
+def test_denied_user_static_forced_admin(app_client):
+    # static mode forces admin regardless of users list, so login always succeeds.
+    r = app_client.post("/login", data={"username": "dev", "password": "devpass"})
+    assert r.status_code == 302  # redirect, not denied
+
+
+def test_bad_static_credentials(app_client):
+    r = app_client.post("/login", data={"username": "dev", "password": "wrong"})
+    assert b"Invalid credentials" in r.data

+ 110 - 0
tests/test_config_store.py

@@ -0,0 +1,110 @@
+import json
+import os
+import stat
+
+import pytest
+
+from config_store import ConfigError, ConfigStore
+
+
+def test_round_trip_preserves_comments_and_applies(store):
+    store.apply({"quarantine": {"default_limit": 500}}, actor="t")
+    txt = store.path.read_text()
+    assert "# UI default row count" in txt      # comment survived
+    assert "default_limit = 500" in txt         # value written
+    assert store.snapshot().quarantine["default_limit"] == 500
+
+
+def test_atomic_write_perms_and_backup(store):
+    store.apply({"quarantine": {"chunk_size": 40}}, actor="t")
+    mode = stat.S_IMODE(os.stat(store.path).st_mode)
+    assert mode == 0o600
+    bak = store.path.with_name(store.path.name + ".bak")
+    assert bak.exists()
+    # no temp files left behind
+    leftovers = list(store.path.parent.glob(".config.*.tmp"))
+    assert not leftovers
+
+
+def test_redacted_never_contains_secrets(store):
+    # Set a distinctive password value that isn't a substring of any key name.
+    store.apply({"pps": {"password": "ZZTOPSECRET42"}}, actor="t")
+    red = store.redacted()
+    assert "ZZTOPSECRET42" not in json.dumps(red)
+    assert "password" not in red["pps"]
+    assert red["pps"]["password_set"] is True
+
+
+def test_blank_password_leaves_value_unchanged(store):
+    store.apply({"pps": {"password": "keepme"}}, actor="t")
+    store.apply({"pps": {"password": ""}}, actor="t")
+    assert ConfigStore(store.path).snapshot().pps["password"] == "keepme"
+
+
+@pytest.mark.parametrize("patch,field", [
+    ({"quarantine": {"default_folder": "Nope"}}, "quarantine.default_folder"),
+    ({"quarantine": {"folders": ["a,b"]}}, "quarantine.folders"),
+    ({"pps": {"base_url": "ftp://x"}}, "pps.base_url"),
+    ({"quarantine": {"report_release": {"steps": ["move", "release"]}}},
+     "quarantine.report_release.steps"),
+    ({"quarantine": {"report_release": {"steps": ["bogus"]}}},
+     "quarantine.report_release.steps"),
+    ({"pps": {"timeout": 0}}, "pps.timeout"),
+])
+def test_validation_rejects(store, patch, field):
+    with pytest.raises(ConfigError) as exc:
+        store.apply(patch, actor="t")
+    assert any(e["field"] == field for e in exc.value.errors)
+
+
+def test_restart_vs_live_classification(store):
+    r = store.apply({"app": {"log_level": "DEBUG"}}, actor="t")
+    assert r.restart_required == []            # log_level is live
+    r = store.apply({"quarantine": {"chunk_size": 30}}, actor="t")
+    assert r.restart_required == []
+
+
+def test_non_editable_key_rejected(store):
+    with pytest.raises(ConfigError) as exc:
+        store.apply({"app": {"port": 9999}}, actor="t")   # port not admin-editable
+    assert exc.value.errors[0]["field"] == "app.port"
+
+
+def test_version_increments_and_snapshot_immutable(store):
+    v1 = store.snapshot().version
+    snap1 = store.snapshot()
+    store.apply({"quarantine": {"default_limit": 300}}, actor="t")
+    assert store.snapshot().version > v1
+    # old snapshot unchanged
+    assert snap1.quarantine["default_limit"] != 300 or v1 != store.snapshot().version
+
+
+def test_pps_client_rebuilt_only_on_connection_change(store):
+    c0 = store.pps()
+    store.apply({"quarantine": {"default_limit": 250}}, actor="t")   # not a conn key
+    assert store.pps() is c0
+    store.apply({"pps": {"timeout": 45}}, actor="t")                 # conn key
+    assert store.pps() is not c0
+
+
+def test_migration_report_release_folder(tmp_path):
+    cfg = tmp_path / "config.toml"
+    cfg.write_text(
+        '[pps]\nbase_url="http://x:10000"\nusername="u"\npassword="p"\n'
+        '[quarantine]\nfolders=["Quarantine","Rep"]\ndefault_folder="Quarantine"\n'
+        'deleted_folder="Quarantine"\nlist_query="from=*"\nreport_release_folder="Rep"\n'
+        'default_sort_field="subject"\ndefault_sort_dir="asc"\n'
+        '[app]\nsecret_key="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n'
+        '[auth]\nmode="static"\n'
+    )
+    snap = ConfigStore(cfg).snapshot()
+    rr = snap.quarantine["report_release"]
+    assert list(rr["steps"]) == ["release", "move"]
+    assert rr["move_target"] == "Rep"
+
+
+def test_ppsq_config_guard_under_pytest(monkeypatch):
+    monkeypatch.delenv("PPSQ_CONFIG", raising=False)
+    monkeypatch.setenv("PYTEST_CURRENT_TEST", "x")
+    with pytest.raises(RuntimeError):
+        ConfigStore()   # no explicit path, no env -> refuse

+ 42 - 0
tests/test_jobs_visibility.py

@@ -0,0 +1,42 @@
+from conftest import login_static
+
+
+def test_action_records_user_and_scoped_jobs(app_client):
+    csrf = login_static(app_client)
+    app_client._pps._records = [{"date": "2026-07-14 09:00:00", "from": "s@x",
+                                 "rcpts": ["r@y"], "subject": "m", "guid": "g1",
+                                 "localguid": "6:6:1", "size": "10"}]
+    r = app_client.post("/api/actions", json={
+        "action": "delete", "folder": "Quarantine",
+        "messages": [{"localguid": "6:6:1", "guid": "g1"}],
+    }, headers={"X-CSRF-Token": csrf})
+    assert r.status_code == 202
+
+    # /api/jobs is scoped to the caller (admin sees all, but the job IS the caller's).
+    jobs = app_client.get("/api/jobs").get_json()["jobs"]
+    assert jobs and jobs[0]["user"] == "dev@example.invalid"
+
+
+def test_move_targetfolder_validated(app_client):
+    csrf = login_static(app_client)
+    r = app_client.post("/api/actions", json={
+        "action": "move", "folder": "Quarantine", "targetfolder": "Nonexistent",
+        "messages": [{"localguid": "6:6:1"}],
+    }, headers={"X-CSRF-Token": csrf})
+    assert r.status_code == 400
+    assert b"unknown target folder" in r.data
+
+
+def test_non_admin_sees_only_own_jobs(app_client, monkeypatch):
+    # Two jobs by different users; a non-admin caller sees only theirs.
+    q = app_client._queue
+    q.enqueue("delete", "Quarantine", [{"localguid": "a"}], {}, user="alice@x.com")
+    q.enqueue("delete", "Quarantine", [{"localguid": "b"}], {}, user="bob@x.com")
+
+    login_static(app_client)
+    # Force the current user to be a non-admin "bob" for the visibility check.
+    import auth
+    monkeypatch.setattr(auth, "current_user",
+                        lambda: {"email": "bob@x.com", "role": "user", "name": "bob"})
+    jobs = app_client.get("/api/jobs").get_json()["jobs"]
+    assert {j["user"] for j in jobs} == {"bob@x.com"}

+ 90 - 0
tests/test_oidc.py

@@ -0,0 +1,90 @@
+"""OIDC callback logic without an Okta tenant.
+
+We monkeypatch Authlib's token exchange to return a userinfo dict, which exercises the
+whole /authorize view (allow / deny / role / session / redirect). Authlib's own state and
+id_token validation is Authlib's responsibility, not ours, so we don't re-test it.
+"""
+
+from pathlib import Path
+
+import pytest
+
+import auth
+from config_store import ConfigStore
+
+
+def _oidc_config(tmp_path) -> Path:
+    cfg = tmp_path / "config.toml"
+    cfg.write_text(
+        '[pps]\nbase_url="http://x:10000"\nusername="u"\npassword="p"\n'
+        '[quarantine]\nfolders=["Quarantine"]\ndefault_folder="Quarantine"\n'
+        'deleted_folder="Quarantine"\nlist_query="from=*"\n'
+        'default_sort_field="subject"\ndefault_sort_dir="asc"\n'
+        '[quarantine.report_release]\nsteps=["release"]\nmove_target=""\n'
+        '[app]\nsecret_key="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n'
+        '[auth]\nmode="oidc"\ndenied_message="Nope."\n'
+        '[[auth.users]]\nemail="alice@example.com"\nrole="admin"\n'
+        '[[auth.users]]\nemail="bob@example.com"\nrole="user"\n'
+        '[okta]\nissuer="https://ex.okta.com"\nclient_id="cid"\n'
+        'client_secret="csec"\nredirect_uri="https://app.example.com/authorize"\n'
+    )
+    return cfg
+
+
+@pytest.fixture
+def oidc_client(tmp_path, monkeypatch):
+    from app import create_app
+    from prefs import PrefStore
+    from worker import JobQueue
+
+    store = ConfigStore(_oidc_config(tmp_path))
+    queue = JobQueue(str(tmp_path / "jobs.db"), lambda: None,
+                     lambda: store.snapshot().quarantine, ops_log_path=str(tmp_path / "w.log"))
+    prefs = PrefStore(str(tmp_path / "jobs.db"))
+    app = create_app(store, queue, prefs)
+    app.config["TESTING"] = True
+    return app.test_client()
+
+
+def _patch_token(monkeypatch, email):
+    monkeypatch.setattr(
+        auth._oauth.okta, "authorize_access_token",
+        lambda: {"userinfo": {"email": email, "name": email.split("@")[0]}},
+        raising=False,
+    )
+
+
+def test_authorize_allows_known_user(oidc_client, monkeypatch):
+    _patch_token(monkeypatch, "alice@example.com")
+    r = oidc_client.get("/authorize")
+    assert r.status_code == 302   # redirected into the app
+    cfg = oidc_client.get("/api/config").get_json()
+    assert cfg["is_admin"] is True
+
+
+def test_authorize_denies_unknown_user(oidc_client, monkeypatch):
+    _patch_token(monkeypatch, "stranger@evil.com")
+    r = oidc_client.get("/authorize")
+    assert r.status_code == 403
+    assert b"Nope." in r.data
+
+
+def test_authorize_role_from_list(oidc_client, monkeypatch):
+    _patch_token(monkeypatch, "bob@example.com")
+    oidc_client.get("/authorize")
+    cfg = oidc_client.get("/api/config").get_json()
+    assert cfg["is_admin"] is False
+
+
+def test_role_reresolved_on_demotion(oidc_client, monkeypatch):
+    _patch_token(monkeypatch, "alice@example.com")
+    oidc_client.get("/authorize")
+    assert oidc_client.get("/api/admin/config").status_code == 200
+    # Demote alice in the live config -> next request loses admin without re-login.
+    from flask import current_app
+    store = oidc_client.application.config["STORE"]
+    store.apply({"auth": {"users": [
+        {"email": "alice@example.com", "role": "user"},
+        {"email": "bob@example.com", "role": "user"},
+    ]}}, actor="t")
+    assert oidc_client.get("/api/admin/config").status_code == 403

+ 55 - 0
tests/test_paging.py

@@ -0,0 +1,55 @@
+"""Paging cursor correctness.
+
+The subtle invariant: `page.oldest` must be computed over the RAW batch, before the
+acted-on filter — otherwise a page that is mostly hidden would report a too-recent cursor
+(or none) and the client would stop early, silently truncating the folder with no error.
+"""
+
+from conftest import login_static
+
+
+def _records(n, start_hour=9):
+    return [
+        {"date": f"2026-07-14 {start_hour - i:02d}:00:00", "from": "s@x",
+         "rcpts": ["r@y"], "subject": f"m{i}", "guid": f"g{i}", "localguid": f"6:6:{i}",
+         "size": "10"}
+        for i in range(n)
+    ]
+
+
+def test_oldest_from_raw_batch_not_filtered(app_client):
+    login_static(app_client)
+    pps = app_client._pps
+    pps._records = _records(3)   # 09,08,07
+    # Hide the two newest by enqueuing an action on them.
+    q = app_client._queue
+    q.enqueue("delete", "Quarantine",
+              [{"localguid": "6:6:0"}, {"localguid": "6:6:1"}], {}, user="dev@example.invalid")
+
+    data = app_client.get("/api/messages?folder=Quarantine&limit=1000").get_json()
+    # Only the un-acted message is shown...
+    assert len(data["messages"]) == 1
+    # ...but the cursor still reflects the oldest RAW record (07:00), not the shown one.
+    assert data["page"]["oldest"] == "2026-07-14 07:00:00"
+    assert data["page"]["raw_count"] == 3
+
+
+def test_has_more_boundary(app_client):
+    login_static(app_client)
+    pps = app_client._pps
+    pps._records = _records(5)
+    data = app_client.get("/api/messages?folder=Quarantine&limit=5").get_json()
+    assert data["page"]["has_more"] is True     # raw_count == page_size
+    data = app_client.get("/api/messages?folder=Quarantine&limit=6").get_json()
+    assert data["page"]["has_more"] is False    # raw_count < page_size
+
+
+def test_clamp_limit_edges(app_client):
+    from app import _clamp_limit
+    cfg = {"default_limit": 200}
+    assert _clamp_limit(None, cfg) == 200
+    assert _clamp_limit("abc", cfg) == 200
+    assert _clamp_limit("0", cfg) == 1
+    assert _clamp_limit("-5", cfg) == 1
+    assert _clamp_limit("99999", cfg) == 1000
+    assert _clamp_limit("500", cfg) == 500

+ 51 - 0
tests/test_prefs.py

@@ -0,0 +1,51 @@
+from conftest import login_static
+
+
+def test_default_when_no_override(app_client):
+    login_static(app_client)
+    data = app_client.get("/api/config").get_json()
+    # example default_limit is 200
+    assert data["prefs"]["default_limit"] == 200
+
+
+def test_override_persists(app_client):
+    csrf = login_static(app_client)
+    app_client.put("/api/prefs", json={"default_limit": 42}, headers={"X-CSRF-Token": csrf})
+    data = app_client.get("/api/config").get_json()
+    assert data["prefs"]["default_limit"] == 42
+
+
+def test_invalid_override_rejected(app_client):
+    csrf = login_static(app_client)
+    r = app_client.put("/api/prefs", json={"default_limit": 99999}, headers={"X-CSRF-Token": csrf})
+    assert r.status_code == 400
+
+
+def test_deleted_pinned_folder_falls_back(app_client):
+    csrf = login_static(app_client)
+    # Pin a folder that exists, then remove it from config; effective value must fall back.
+    app_client.put("/api/prefs", json={"default_folder": "Deleted"}, headers={"X-CSRF-Token": csrf})
+    store = app_client._store
+    store.apply({"quarantine": {"folders": ["Quarantine", "Attachment Defense", "Debugging - Josef"],
+                                "deleted_folder": "Quarantine"}}, actor="t")
+    data = app_client.get("/api/config").get_json()
+    # "Deleted" no longer in folders -> falls back to admin default, not an error
+    assert data["prefs"]["default_folder"] != "Deleted"
+
+
+def test_per_user_isolation(app_client):
+    from prefs import PrefStore
+    prefs: PrefStore = app_client.application.config["PREFS"]
+    snap = app_client._store.snapshot()
+    prefs.set_many("alice@x.com", {"default_limit": 10}, snap)
+    prefs.set_many("bob@x.com", {"default_limit": 20}, snap)
+    assert prefs.effective("alice@x.com", snap)["default_limit"] == 10
+    assert prefs.effective("bob@x.com", snap)["default_limit"] == 20
+
+
+def test_clear_resets_to_default(app_client):
+    csrf = login_static(app_client)
+    app_client.put("/api/prefs", json={"default_limit": 15}, headers={"X-CSRF-Token": csrf})
+    app_client.delete("/api/prefs", json={}, headers={"X-CSRF-Token": csrf})
+    data = app_client.get("/api/config").get_json()
+    assert data["prefs"]["default_limit"] == 200

+ 147 - 0
tests/test_report_release.py

@@ -0,0 +1,147 @@
+"""The Report & Release pipeline — the highest-risk behaviour.
+
+Asserts on exact PPS call kwargs, not just counts: the localguid-stability rule (release
+must NOT pass deletedfolder) and the lazy guid-refind are both invisible to a call-count
+check but critical to correctness.
+"""
+
+import pytest
+
+from conftest import FakePPS
+from pipeline import (
+    ALLOWED_STEPS,
+    describe_pipeline,
+    pipeline_destination,
+    run_pipeline,
+)
+from pps_client import PPSError
+
+
+def _cfg(steps, move_target="Rep", deleted="Deleted", step_delay=0):
+    return {
+        "deleted_folder": deleted,
+        "list_query": "from=*",
+        "default_days_back": 7,
+        "report_release": {
+            "steps": steps,
+            "move_target": move_target,
+            "step_delay_seconds": step_delay,
+        },
+    }
+
+
+def _chunk():
+    return [
+        {"localguid": "6:6:1", "guid": "g1"},
+        {"localguid": "6:6:2", "guid": "g2"},
+    ]
+
+
+def test_release_only_has_no_deletedfolder():
+    pps = FakePPS()
+    run_pipeline(pps, _cfg(["release"]), "Quarantine", _chunk())
+    acts = pps.acts()
+    assert len(acts) == 1
+    assert acts[0]["action"] == "release"
+    assert acts[0]["deletedfolder"] is None    # THE localguid-stability rule
+    assert acts[0]["folder"] == "Quarantine"
+
+
+def test_release_move_is_two_calls_zero_searches():
+    pps = FakePPS()
+    run_pipeline(pps, _cfg(["release", "move"]), "Quarantine", _chunk())
+    assert [a["action"] for a in pps.acts()] == ["release", "move"]
+    assert pps.acts()[0]["deletedfolder"] is None
+    assert pps.acts()[1]["targetfolder"] == "Rep"
+    assert pps.acts()[1]["folder"] == "Quarantine"
+    assert pps.searches() == []                 # lazy refind: no search needed
+
+
+def test_move_fallback_refinds_in_deleted_folder():
+    class MovingPPS(FakePPS):
+        def act(self, action, folder, localguids, *, targetfolder=None, deletedfolder=None, scan=False):
+            if action == "move" and folder == "Quarantine":
+                self.calls.append({"m": "act", "action": action, "folder": folder,
+                                   "localguids": list(localguids), "targetfolder": targetfolder,
+                                   "deletedfolder": deletedfolder, "scan": scan})
+                raise PPSError("release relocated it")
+            return super().act(action, folder, localguids, targetfolder=targetfolder,
+                               deletedfolder=deletedfolder, scan=scan)
+
+    pps = MovingPPS(records=[{"guid": "g1", "localguid": "D:1"}, {"guid": "g2", "localguid": "D:2"}])
+    run_pipeline(pps, _cfg(["release", "move"]), "Quarantine", _chunk())
+    # release, failed move on Quarantine, search Deleted, move from Deleted
+    assert [s["folder"] for s in pps.searches()] == ["Deleted"]
+    moves = [a for a in pps.acts() if a["action"] == "move"]
+    assert moves[-1]["folder"] == "Deleted"
+    assert sorted(moves[-1]["localguids"]) == ["D:1", "D:2"]
+
+
+def test_release_move_delete_refinds_between_move_and_delete():
+    pps = FakePPS(records=[{"guid": "g1", "localguid": "R:1"}, {"guid": "g2", "localguid": "R:2"}])
+    run_pipeline(pps, _cfg(["release", "move", "delete"]), "Quarantine", _chunk())
+    actions = [a["action"] for a in pps.acts()]
+    assert actions == ["release", "move", "delete"]
+    # delete happens in the move_target after a refind there
+    assert len(pps.searches()) == 1 and pps.searches()[0]["folder"] == "Rep"
+    delete = [a for a in pps.acts() if a["action"] == "delete"][0]
+    assert delete["folder"] == "Rep"
+    assert delete["deletedfolder"] == "Deleted"
+
+
+def test_refind_no_guids_raises():
+    pps = FakePPS()
+    chunk = [{"localguid": "6:6:1"}]   # no guid
+    with pytest.raises(PPSError):
+        # force a refind by making move fail
+        class P(FakePPS):
+            def act(self, *a, **k):
+                if a[0] == "move":
+                    raise PPSError("relocated")
+                return super().act(*a, **k)
+        run_pipeline(P(), _cfg(["release", "move"]), "Quarantine", chunk)
+
+
+def test_pipeline_destination():
+    assert pipeline_destination(_cfg(["release"])) is None
+    assert pipeline_destination(_cfg(["release", "move"])) == "Rep"
+    assert pipeline_destination(_cfg(["release", "delete"])) == "Deleted"
+    assert pipeline_destination(_cfg(["release", "move", "delete"])) == "Deleted"
+
+
+def test_describe_pipeline():
+    text = describe_pipeline(_cfg(["release", "move", "delete"]))
+    assert "released in place" in text and "Rep" in text and "Deleted" in text
+    assert "→" in text
+
+
+def test_allowed_steps_is_canonical_order():
+    assert ALLOWED_STEPS == ("release", "move", "delete")
+
+
+def test_step_delay_waits_between_steps():
+    pps = FakePPS()
+    waits = []
+    run_pipeline(pps, _cfg(["release", "move"], step_delay=60), "Quarantine", _chunk(),
+                 sleep=waits.append)
+    # One wait, of the configured length, between the two steps.
+    assert waits == [60]
+
+
+def test_no_delay_before_first_or_when_zero():
+    pps = FakePPS()
+    waits = []
+    run_pipeline(pps, _cfg(["release"], step_delay=60), "Quarantine", _chunk(), sleep=waits.append)
+    assert waits == []                      # single step -> no wait
+    waits.clear()
+    run_pipeline(pps, _cfg(["release", "move"], step_delay=0), "Quarantine", _chunk(),
+                 sleep=waits.append)
+    assert waits == []                      # delay 0 -> disabled
+
+
+def test_delay_scales_with_step_count():
+    pps = FakePPS(records=[{"guid": "g1", "localguid": "R:1"}, {"guid": "g2", "localguid": "R:2"}])
+    waits = []
+    run_pipeline(pps, _cfg(["release", "move", "delete"], step_delay=30), "Quarantine",
+                 _chunk(), sleep=waits.append)
+    assert waits == [30, 30]                # before move, before delete

+ 111 - 90
worker.py

@@ -18,7 +18,9 @@ import time
 import traceback
 from datetime import datetime, timezone
 from logging.handlers import RotatingFileHandler
+from typing import Callable, Mapping
 
+import pipeline
 from pps_client import PPSClient, PPSError
 
 log = logging.getLogger("pps.worker")
@@ -50,11 +52,15 @@ def _now() -> str:
 
 
 class JobQueue:
-    def __init__(self, db_path: str, pps: PPSClient, qconfig: dict,
+    def __init__(self, db_path: str,
+                 pps_provider: Callable[[], PPSClient],
+                 cfg_provider: Callable[[], Mapping],
                  ops_log_path: str = "worker.log"):
-        self.pps = pps
-        self.cfg = qconfig
-        self.chunk_size = int(qconfig.get("chunk_size", 25))
+        # Providers, not frozen values: each job reads a fresh, consistent snapshot at
+        # start (see AGENTS.md "snapshot-per-job"), so a live admin edit affects the
+        # NEXT job, never a job mid-flight.
+        self._pps = pps_provider
+        self._cfg = cfg_provider
         self._lock = threading.Lock()
         self._conn = sqlite3.connect(db_path, check_same_thread=False)
         self._conn.row_factory = sqlite3.Row
@@ -68,12 +74,18 @@ class JobQueue:
 
     def _init_db(self) -> None:
         with self._lock:
+            # WAL + busy_timeout so the prefs store (a second connection to this same
+            # file) never collides with per-chunk progress writes. WAL is a persistent
+            # property of the file; busy_timeout must be set on every connection.
+            self._conn.execute("PRAGMA journal_mode=WAL")
+            self._conn.execute("PRAGMA busy_timeout=5000")
             self._conn.executescript(
                 """
                 CREATE TABLE IF NOT EXISTS jobs (
                     id         INTEGER PRIMARY KEY AUTOINCREMENT,
                     action     TEXT NOT NULL,
                     folder     TEXT NOT NULL,
+                    user       TEXT,
                     extra      TEXT NOT NULL DEFAULT '{}',
                     status     TEXT NOT NULL DEFAULT 'pending',
                     processed  INTEGER NOT NULL DEFAULT 0,
@@ -94,13 +106,17 @@ class JobQueue:
                 );
                 CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
                 CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
+                CREATE INDEX IF NOT EXISTS idx_jobs_user ON jobs(user);
                 """
             )
-            # Migrate older DBs that lack the display columns.
-            existing = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
+            # Migrate older DBs that lack later-added columns.
+            item_cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
             for col in ("subject", "sender", "recipient"):
-                if col not in existing:
+                if col not in item_cols:
                     self._conn.execute(f"ALTER TABLE job_items ADD COLUMN {col} TEXT")
+            job_cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(jobs)")}
+            if "user" not in job_cols:
+                self._conn.execute("ALTER TABLE jobs ADD COLUMN user TEXT")
             self._conn.commit()
 
     def _recover(self) -> None:
@@ -116,17 +132,19 @@ class JobQueue:
 
     # -------------------------------------------------------------- public API
 
-    def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
+    def enqueue(self, action: str, folder: str, items: list[dict], extra: dict,
+                user: str | None = None) -> int:
         """Insert a job. `items` is a list of dicts with keys:
-        localguid (required), guid, subject, sender, recipient (all optional)."""
+        localguid (required), guid, subject, sender, recipient (all optional).
+        `user` is the acting user's email, recorded for attribution/visibility."""
         if action not in VALID_ACTIONS:
             raise ValueError(f"unknown action: {action}")
         now = _now()
         with self._lock:
             cur = self._conn.execute(
-                "INSERT INTO jobs (action, folder, extra, status, total, created_at, updated_at)"
-                " VALUES (?,?,?,?,?,?,?)",
-                (action, folder, json.dumps(extra or {}), "pending", len(items), now, now),
+                "INSERT INTO jobs (action, folder, user, extra, status, total, created_at, updated_at)"
+                " VALUES (?,?,?,?,?,?,?,?)",
+                (action, folder, user, json.dumps(extra or {}), "pending", len(items), now, now),
             )
             job_id = cur.lastrowid
             self._conn.executemany(
@@ -139,7 +157,8 @@ class JobQueue:
                 ],
             )
             self._conn.commit()
-        log.info("enqueued job #%d: action=%s folder=%r items=%d", job_id, action, folder, len(items))
+        log.info("enqueued job #%d: action=%s folder=%r user=%s items=%d",
+                 job_id, action, folder, user, len(items))
         return job_id
 
     def acted_localguids(self, folder: str) -> set[str]:
@@ -152,13 +171,20 @@ class JobQueue:
             ).fetchall()
         return {r["localguid"] for r in rows}
 
-    def recent_jobs(self, limit: int = 25) -> list[dict]:
+    def recent_jobs(self, limit: int = 25, user: str | None = None) -> list[dict]:
+        """Recent jobs, newest first. Pass `user` to scope to one owner (None = all)."""
+        cols = ("id, action, folder, user, status, processed, total, error,"
+                " created_at, updated_at")
         with self._lock:
-            rows = self._conn.execute(
-                "SELECT id, action, folder, status, processed, total, error, created_at, updated_at"
-                " FROM jobs ORDER BY id DESC LIMIT ?",
-                (limit,),
-            ).fetchall()
+            if user is None:
+                rows = self._conn.execute(
+                    f"SELECT {cols} FROM jobs ORDER BY id DESC LIMIT ?", (limit,)
+                ).fetchall()
+            else:
+                rows = self._conn.execute(
+                    f"SELECT {cols} FROM jobs WHERE user = ? ORDER BY id DESC LIMIT ?",
+                    (user, limit),
+                ).fetchall()
         return [dict(r) for r in rows]
 
     def has_active_jobs(self) -> bool:
@@ -168,6 +194,17 @@ class JobQueue:
             ).fetchone()
         return row is not None
 
+    def retry(self, job_id: int) -> bool:
+        """Requeue a failed job. Returns False if it isn't a failed job."""
+        with self._lock:
+            cur = self._conn.execute(
+                "UPDATE jobs SET status='pending', processed=0, error=NULL, updated_at=?"
+                " WHERE id=? AND status='failed'",
+                (_now(), job_id),
+            )
+            self._conn.commit()
+        return cur.rowcount > 0
+
     # -------------------------------------------------------------- worker loop
 
     def start(self) -> None:
@@ -206,54 +243,57 @@ class JobQueue:
         job_id = job["id"]
         action = job["action"]
         folder = job["folder"]
+        user = job["user"] if "user" in job.keys() else None
         extra = json.loads(job["extra"] or "{}")
         items = self._items_for(job_id)
+
+        # Snapshot config + client ONCE at job start — a mid-job admin edit affects the
+        # next job, not this one (see AGENTS.md "snapshot-per-job").
+        cfg = self._cfg()
+        pps = self._pps()
+        chunk_size = int(cfg.get("chunk_size", 25))
         log.info(
-            "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
+            "job #%d start: action=%s folder=%r user=%s total=%d",
+            job_id, action, folder, user, len(items),
         )
 
-        dst = self._destination(action, extra)
+        dst = _destination(action, extra, cfg)
         errors: list[str] = []
         processed = 0
-        for chunk in _chunks(items, self.chunk_size):
+        started = time.monotonic()
+        for chunk in _chunks(items, chunk_size):
             try:
-                self._run_action(action, folder, chunk, extra)
+                self._run_action(pps, cfg, action, folder, chunk, extra)
                 processed += len(chunk)
                 self._set_processed(job_id, processed)
-                self._log_ops(job_id, action, folder, dst, chunk, "ok", None)
+                self._log_ops(job_id, action, folder, dst, chunk, user, "ok", None)
             except PPSError as exc:
                 log.error("job #%d chunk failed: %s", job_id, exc)
                 errors.append(f"chunk failed: {exc}")
-                self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
+                self._log_ops(job_id, action, folder, dst, chunk, user, "FAILED", str(exc))
             except Exception as exc:  # noqa: BLE001
                 log.exception("job #%d chunk raised", job_id)
                 errors.append(f"chunk failed: {exc}")
-                self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
+                self._log_ops(job_id, action, folder, dst, chunk, user, "FAILED", str(exc))
 
+        took = _fmt_duration(time.monotonic() - started)
         if errors:
-            log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
-                      job_id, processed, len(items), len(errors))
+            log.error("job #%d FAILED: %d/%d processed, %d chunk error(s), took %s",
+                      job_id, processed, len(items), len(errors), took)
             self._mark_failed(job_id, "\n".join(errors))
         else:
-            log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
+            log.info("job #%d done: %d/%d processed, took %s",
+                     job_id, processed, len(items), took)
             self._mark_done(job_id)
 
-    def _destination(self, action: str, extra: dict) -> str | None:
-        """The folder messages end up in, for the ops log."""
-        if action == "move":
-            return extra.get("targetfolder")
-        if action == "report_release":
-            return self.cfg.get("report_release_folder")
-        if action in ("release", "delete"):
-            return self.cfg.get("deleted_folder")
-        return None
-
     def _log_ops(self, job_id: int, action: str, src: str, dst: str | None,
-                 chunk: list[dict], result: str, error: str | None) -> None:
+                 chunk: list[dict], user: str | None,
+                 result: str, error: str | None) -> None:
         """Write one audit line per message in the chunk to the operations log."""
         for it in chunk:
             fields = [
                 f"job=#{job_id}",
+                f"user={user or '-'}",
                 f"action={action}",
                 f"result={result}",
                 f"src={src!r}",
@@ -270,72 +310,30 @@ class JobQueue:
 
     # -------------------------------------------------------------- action logic
 
-    def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
+    def _run_action(self, pps: PPSClient, cfg: Mapping, action: str,
+                    folder: str, chunk: list[dict], extra: dict) -> None:
         localguids = [it["localguid"] for it in chunk]
-        deleted_folder = self.cfg.get("deleted_folder")
+        deleted_folder = cfg.get("deleted_folder")
 
         if action == "release":
-            self.pps.act("release", folder, localguids, deletedfolder=deleted_folder)
+            pps.act("release", folder, localguids, deletedfolder=deleted_folder)
 
         elif action == "delete":
-            self.pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
+            pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
 
         elif action == "move":
             target = extra.get("targetfolder")
             if not target:
                 raise ValueError("move action requires targetfolder")
-            self.pps.act("move", folder, localguids, targetfolder=target)
+            pps.act("move", folder, localguids, targetfolder=target)
 
         elif action == "report_release":
-            self._report_release(folder, chunk)
+            # Data-driven pipeline (release -> move -> delete, configurable subset).
+            pipeline.run_pipeline(pps, cfg, folder, chunk)
 
         else:
             raise ValueError(f"unknown action: {action}")
 
-    def _report_release(self, folder: str, chunk: list[dict]) -> None:
-        """Release in place (delivers, keeps message), then move a copy to the report folder.
-
-        Fallback: if release relocated the message to the deleted folder (GUI-like
-        behavior), the move from `folder` fails; re-find each message in the deleted
-        folder by its stable guid and move it from there instead.
-        """
-        target = self.cfg.get("report_release_folder")
-        localguids = [it["localguid"] for it in chunk]
-
-        # Step 1: release without deletedfolder -> message stays put, localguid stable.
-        self.pps.act("release", folder, localguids)
-
-        # Step 2: move the (now released) copy into the report folder.
-        try:
-            self.pps.act("move", folder, localguids, targetfolder=target)
-        except PPSError:
-            self._move_from_deleted_by_guid(chunk, target)
-
-    def _move_from_deleted_by_guid(self, chunk: list[dict], target: str) -> None:
-        deleted_folder = self.cfg.get("deleted_folder")
-        if not deleted_folder:
-            raise PPSError("release relocated messages but no deleted_folder configured")
-        wanted = {it["guid"]: it for it in chunk if it.get("guid")}
-        if not wanted:
-            raise PPSError("cannot recover messages: no guids stored for report_release")
-
-        records = self.pps.search(
-            deleted_folder,
-            self.cfg.get("list_query", "from=*"),
-            limit=1000,
-            days_back=int(self.cfg.get("default_days_back", 7)),
-        )
-        found = [
-            r["localguid"]
-            for r in records
-            if r.get("guid") in wanted and r.get("localguid")
-        ]
-        if not found:
-            raise PPSError(
-                "released messages not found in deleted folder to move to report folder"
-            )
-        self.pps.act("move", deleted_folder, found, targetfolder=target)
-
     # -------------------------------------------------------------- db helpers
 
     def _items_for(self, job_id: int) -> list[dict]:
@@ -371,6 +369,17 @@ class JobQueue:
             self._conn.commit()
 
 
+def _destination(action: str, extra: dict, cfg: Mapping) -> str | None:
+    """The folder messages end up in, for the ops log."""
+    if action == "move":
+        return extra.get("targetfolder")
+    if action == "report_release":
+        return pipeline.pipeline_destination(cfg)
+    if action in ("release", "delete"):
+        return cfg.get("deleted_folder")
+    return None
+
+
 def _chunks(seq: list, size: int):
     for i in range(0, len(seq), size):
         yield seq[i : i + size]
@@ -380,3 +389,15 @@ def _clip(value, length: int = 120) -> str:
     """Collapse newlines and truncate a value for a single-line log field."""
     s = " ".join(str(value or "").split())
     return s[:length] + ("…" if len(s) > length else "")
+
+
+def _fmt_duration(seconds: float) -> str:
+    """Human-readable elapsed time, e.g. '0.42s', '12.3s', '3m 07s', '1h 04m'."""
+    if seconds < 60:
+        return f"{seconds:.2f}s"
+    if seconds < 3600:
+        m, s = divmod(int(seconds), 60)
+        return f"{m}m {s:02d}s"
+    h, rem = divmod(int(seconds), 3600)
+    m = rem // 60
+    return f"{h}h {m:02d}m"

+ 100 - 0
wsgi.py

@@ -0,0 +1,100 @@
+"""Composition root and server entrypoint.
+
+This is the ONLY module that constructs the store/queue/prefs and starts the worker
+thread. `python wsgi.py` runs the app. Tests import `app.create_app` directly and never
+touch this file, so importing the app never reads the real config or starts a thread.
+
+Deployment invariant: run as a SINGLE process. The worker thread and the SQLite
+connections live in-process; multiple processes would run competing queue workers and an
+in-process config lock that doesn't coordinate file writes. Do not add web-server workers.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from logging.handlers import RotatingFileHandler
+
+from app import create_app
+from config_store import ConfigStore
+from prefs import PrefStore
+from worker import JobQueue
+
+_PLACEHOLDER_SECRET = "change-me-to-a-random-string"
+
+
+def _configure_logging(app_cfg) -> None:
+    level = getattr(logging, str(app_cfg.get("log_level", "INFO")).upper(), logging.INFO)
+    root = logging.getLogger()
+    root.setLevel(level)
+    fmt = logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
+    sh = logging.StreamHandler()
+    sh.setFormatter(fmt)
+    root.addHandler(sh)
+    # App log to a rotating file too, so admin errors are viewable in the panel.
+    fh = RotatingFileHandler(
+        app_cfg.get("app_log", "app.log"), maxBytes=5_000_000, backupCount=5, encoding="utf-8"
+    )
+    fh.setFormatter(fmt)
+    root.addHandler(fh)
+
+
+def build():
+    store = ConfigStore()
+    snap = store.snapshot()
+    _configure_logging(snap.app)
+    log = logging.getLogger("pps.wsgi")
+
+    secret = snap.app.get("secret_key", "")
+    if secret == _PLACEHOLDER_SECRET or len(secret) < 32:
+        raise SystemExit(
+            "app.secret_key is missing/weak. Generate one with:\n"
+            "  python -c \"import secrets; print(secrets.token_urlsafe(48))\""
+        )
+
+    mode = snap.auth.get("mode", "static")
+    if mode == "static":
+        log.warning("=" * 72)
+        log.warning("AUTH MODE = static — anyone with the shared password is an ADMIN.")
+        log.warning('This is a DEVELOPMENT mode. Set [auth] mode = "oidc" for production.')
+        log.warning("=" * 72)
+        listen = snap.app.get("listen", "127.0.0.1")
+        if listen not in ("127.0.0.1", "localhost", "::1") and not os.environ.get(
+            "PPSQ_ALLOW_INSECURE_AUTH"
+        ):
+            raise SystemExit(
+                f"Refusing to serve static auth on a non-loopback interface ({listen}). "
+                'Use [auth] mode = "oidc", or set PPSQ_ALLOW_INSECURE_AUTH=1 for dev.'
+            )
+
+    db_path = snap.app.get("db_path", "jobs.db")
+    queue = JobQueue(
+        db_path,
+        pps_provider=store.pps,
+        cfg_provider=lambda: store.snapshot().quarantine,
+        ops_log_path=snap.app.get("worker_log", "worker.log"),
+    )
+    prefs = PrefStore(db_path)
+    app = create_app(store, queue, prefs)
+    return app, queue
+
+
+def main() -> int:
+    from waitress import serve
+
+    app, queue = build()
+    queue.start()
+    store = app.config["STORE"]
+    snap = store.snapshot()
+    host = snap.app.get("listen", "127.0.0.1")
+    port = int(snap.app.get("port", 8080))
+    logging.getLogger("pps.wsgi").info(
+        "PPS Quarantine Manager listening on http://%s:%s", host, port
+    )
+    serve(app, host=host, port=port, threads=8)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())