Josef Straßl 3 týždňov pred
commit
b6a44ab948
11 zmenil súbory, kde vykonal 1495 pridanie a 0 odobranie
  1. 7 0
      .gitignore
  2. 87 0
      README.md
  3. 278 0
      app.py
  4. 48 0
      config.example.toml
  5. 219 0
      pps_client.py
  6. 3 0
      requirements.txt
  7. 312 0
      static/app.js
  8. 145 0
      static/style.css
  9. 68 0
      templates/index.html
  10. 22 0
      templates/login.html
  11. 306 0
      worker.py

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+config.toml
+*.db
+*.db-journal
+venv/
+__pycache__/
+*.pyc
+.DS_Store

+ 87 - 0
README.md

@@ -0,0 +1,87 @@
+# PPS Quarantine Manager (PoC)
+
+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).
+
+> Proof of concept: static login, plaintext secrets in a config file, no permission model.
+> SAML/OIDC and RBAC come later.
+
+## How it works
+
+- **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.
+
+## Action semantics
+
+| 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                                        |
+
+**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.
+
+## Setup
+
+```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
+```
+
+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
+
+```
+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
+```

+ 278 - 0
app.py

@@ -0,0 +1,278 @@
+"""PPS Quarantine Manager — Flask frontend + API.
+
+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.
+"""
+
+from __future__ import annotations
+
+import email
+import logging
+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")
+
+
+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)
+
+
+config = load_config()
+_pps_cfg = config["pps"]
+_q_cfg = config["quarantine"]
+_app_cfg = config["app"]
+_auth_cfg = config["auth"]
+
+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")
+
+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)
+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.route("/api/messages")
+@login_required
+def api_messages():
+    folder = request.args.get("folder") or _q_cfg.get("default_folder", "Quarantine")
+    limit = _clamp_limit(request.args.get("limit"))
+    try:
+        records = pps.search(
+            folder,
+            _q_cfg.get("list_query", "from=*"),
+            limit=limit,
+            days_back=int(_q_cfg.get("default_days_back", 7)),
+        )
+    except PPSError as exc:
+        return _pps_error_response(exc, f"search folder={folder!r}")
+
+    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
+    ]
+    return jsonify({"folder": folder, "count": len(messages), "messages": messages})
+
+
+@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}, ...]
+    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")}
+        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(
+            {
+                "error": str(exc),
+                "detail": exc.body,
+                "status": exc.status,
+                "reqid": exc.reqid,
+            }
+        ),
+        502,
+    )
+
+
+def _clamp_limit(raw: str | None) -> int:
+    default = int(_q_cfg.get("default_limit", 200))
+    try:
+        value = int(raw) if raw is not None else default
+    except (TypeError, ValueError):
+        value = default
+    return max(1, min(value, 1000))
+
+
+def _parse_message(raw: bytes) -> dict:
+    """Parse raw RFC822 bytes into headers + text/html bodies."""
+    msg = email.message_from_bytes(raw, policy=policy.default)
+    headers = {
+        "from": msg.get("From", ""),
+        "to": msg.get("To", ""),
+        "cc": msg.get("Cc", ""),
+        "date": msg.get("Date", ""),
+        "subject": msg.get("Subject", ""),
+    }
+
+    text_body = ""
+    html_body = ""
+    try:
+        text_part = msg.get_body(preferencelist=("plain",))
+        if text_part is not None:
+            text_body = text_part.get_content()
+    except Exception:  # noqa: BLE001 - malformed MIME shouldn't 500 the view
+        text_body = ""
+    try:
+        html_part = msg.get_body(preferencelist=("html",))
+        if html_part is not None:
+            html_body = html_part.get_content()
+    except Exception:  # noqa: BLE001
+        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
+            text_body = raw.decode("utf-8", errors="replace")
+
+    return {"headers": headers, "text": text_body, "html": html_body}
+
+
+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)

+ 48 - 0
config.example.toml

@@ -0,0 +1,48 @@
+# PPS Quarantine Manager — example config.
+# Copy to config.toml and fill in real values. config.toml is gitignored.
+
+[pps]
+# 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.
+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". Point client_cert at a PEM file.
+# Either a combined cert+key PEM (leave client_key empty), or a cert PEM here plus
+# the private key in client_key. Leave both empty if client-cert auth is not used.
+client_cert = ""
+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).
+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").
+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
+
+[app]
+secret_key = "change-me-to-a-random-string"  # Flask session signing key
+listen = "127.0.0.1"
+port = 8080
+db_path = "jobs.db"                        # SQLite job queue file
+log_level = "INFO"                         # DEBUG for full request/response tracing
+
+[auth]
+# Static PoC login. Replaced by SAML/OIDC later.
+username = "admin"
+password = "admin"

+ 219 - 0
pps_client.py

@@ -0,0 +1,219 @@
+"""Thin client for the Proofpoint Protection Server Quarantine Search REST API.
+
+Covers the three things the PoC needs:
+  - search(): GET /rest/v1/quarantine   -> list of message records (JSON)
+  - get_raw(): GET .../quarantine?guid= -> raw RFC822 bytes of one message
+  - act():     POST /rest/v1/quarantine -> release / move / delete actions
+
+All requests use HTTP Basic auth against the admin port (default 10000).
+See "Proofpoint Public APIs rev W", "Proofpoint Quarantine Search REST API".
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urljoin
+
+import requests
+import urllib3
+
+log = logging.getLogger("pps.client")
+
+# PPS accepts "YYYY-MM-DD HH:MM:SS" UTC time strings for startdate/enddate.
+_DATE_FMT = "%Y-%m-%d %H:%M:%S"
+
+_HEADERS = {
+    "Accept": "application/json",
+    "User-Agent": "pps-quarantine-manager/0.1",
+}
+
+# How much of a response body to keep in an error message / log line.
+_BODY_SNIPPET = 2000
+
+
+class PPSError(RuntimeError):
+    """Raised when a PPS request fails. Carries status, response body and request-id."""
+
+    def __init__(
+        self,
+        message: str,
+        status: int | None = None,
+        body: str = "",
+        reqid: str | None = None,
+    ):
+        super().__init__(message)
+        self.status = status
+        self.body = body
+        self.reqid = reqid
+
+
+class PPSClient:
+    def __init__(
+        self,
+        base_url: str,
+        username: str,
+        password: str,
+        verify_tls: bool | str = False,
+        timeout: int = 120,
+        client_cert: str | None = None,
+        client_key: str | None = None,
+    ):
+        # Endpoint is a fixed path on the admin service.
+        self.endpoint = urljoin(base_url.rstrip("/") + "/", "rest/v1/quarantine")
+        self.timeout = timeout
+        self.session = requests.Session()
+        self.session.auth = (username, password)
+        self.session.headers.update(_HEADERS)
+        self.session.verify = verify_tls
+
+        # Client-certificate (mutual TLS) auth. Some PPS deployments sit behind nginx
+        # configured to require a client cert ("400 No required SSL certificate was sent").
+        # `client_cert` may be a combined cert+key PEM, or a cert paired with `client_key`.
+        if client_cert:
+            self.session.cert = (client_cert, client_key) if client_key else client_cert
+            log.info("Using client certificate for mutual TLS: %s", client_cert)
+
+        if verify_tls is False:
+            # Intentionally skipping verification (PoC / self-signed PPS certs). Silence
+            # the per-request InsecureRequestWarning but say so once, loudly, at startup.
+            urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+            log.warning(
+                "TLS verification is DISABLED for %s — do not use in production.",
+                self.endpoint,
+            )
+
+    # ------------------------------------------------------------------ search
+
+    def search(
+        self,
+        folder: str,
+        query: str,
+        limit: int = 200,
+        days_back: int = 7,
+    ) -> list[dict]:
+        """Search a quarantine folder and return up to `limit` message records.
+
+        `query` is a raw filter fragment the API requires, e.g. "from=*" (match all)
+        or "rcpt=@example.com". The API needs at least one of from/rcpt/subject.
+        Results are sorted newest-first by the server; we truncate client-side.
+        """
+        params = self._query_to_params(query)
+        params["folder"] = folder
+        startdate = datetime.now(timezone.utc) - timedelta(days=days_back)
+        params["startdate"] = startdate.strftime(_DATE_FMT)
+
+        resp = self._request("GET", params=params)
+        data = resp.json()
+        records = data.get("records", []) or []
+        log.info(
+            "search folder=%r query=%r -> %d record(s)", folder, query, len(records)
+        )
+        if limit and len(records) > limit:
+            records = records[:limit]
+        return records
+
+    def get_raw(self, guid: str) -> bytes:
+        """Fetch the raw RFC822 bytes for a single message by its stable guid."""
+        resp = self._request("GET", params={"guid": guid})
+        return resp.content
+
+    # ------------------------------------------------------------------ actions
+
+    def act(
+        self,
+        action: str,
+        folder: str,
+        localguids: list[str],
+        *,
+        targetfolder: str | None = None,
+        deletedfolder: str | None = None,
+        scan: bool = False,
+    ) -> dict:
+        """POST an action for one or more messages in `folder`.
+
+        `localguids` are the folder-local handles (not the stable guid). One POST can
+        carry many, comma-separated. Returns the parsed JSON status body.
+        """
+        if not localguids:
+            return {"status": "no messages"}
+        payload: dict[str, str] = {
+            "action": action,
+            "folder": folder,
+            "localguid": ",".join(localguids),
+        }
+        if targetfolder:
+            payload["targetfolder"] = targetfolder
+        if deletedfolder:
+            payload["deletedfolder"] = deletedfolder
+        if scan:
+            payload["scan"] = "t"
+
+        log.info(
+            "action=%s folder=%r count=%d targetfolder=%r deletedfolder=%r",
+            action, folder, len(localguids), targetfolder, deletedfolder,
+        )
+        resp = self._request("POST", json=payload)
+        # Success is 200 or 204 with a small JSON body; 204 has no body.
+        if resp.status_code == 204 or not resp.content:
+            result = {"status": "ok"}
+        else:
+            try:
+                result = resp.json()
+            except ValueError:
+                result = {"status": resp.text.strip()}
+        log.info("action=%s folder=%r -> %s", action, folder, result)
+        return result
+
+    # ------------------------------------------------------------------ transport
+
+    def _request(self, method: str, *, params: dict | None = None,
+                 json: dict | None = None) -> requests.Response:
+        """Issue one request, logging it and converting any failure into PPSError."""
+        try:
+            resp = self.session.request(
+                method, self.endpoint, params=params, json=json, timeout=self.timeout
+            )
+        except requests.RequestException as exc:
+            log.error("%s %s failed at transport: %s", method, self.endpoint, exc)
+            raise PPSError(f"PPS request failed ({method}): {exc}") from exc
+
+        reqid = resp.headers.get("x-pps-reqid")
+        log.debug(
+            "%s %s -> HTTP %d (x-pps-reqid=%s)",
+            method, resp.url, resp.status_code, reqid,
+        )
+        self._raise_for_status(resp, method, reqid)
+        return resp
+
+    @staticmethod
+    def _raise_for_status(resp: requests.Response, method: str, reqid: str | None) -> None:
+        if resp.status_code < 400:
+            return
+        body = (resp.text or "").strip()
+        snippet = body[:_BODY_SNIPPET] + ("…" if len(body) > _BODY_SNIPPET else "")
+        # The full picture in one place: status, PPS request-id (to grep PPS logs),
+        # the resolved request URL, and whatever body PPS returned (often the reason).
+        detail = (
+            f"PPS returned HTTP {resp.status_code} for {method} {resp.url} "
+            f"(x-pps-reqid={reqid or 'n/a'}): {snippet or '<empty body>'}"
+        )
+        log.error(detail)
+        raise PPSError(detail, status=resp.status_code, body=body, reqid=reqid)
+
+    # ------------------------------------------------------------------ helpers
+
+    @staticmethod
+    def _query_to_params(query: str) -> dict[str, str]:
+        """Parse a raw filter fragment like 'from=*' or 'rcpt=@x.com&subject=*hi*'."""
+        params: dict[str, str] = {}
+        for part in (query or "").split("&"):
+            part = part.strip()
+            if not part or "=" not in part:
+                continue
+            key, value = part.split("=", 1)
+            params[key.strip()] = value.strip()
+        if not params:
+            # API demands at least one of from/rcpt/subject; default to match-all.
+            params["from"] = "*"
+        return params

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+Flask>=3.0
+requests>=2.31
+waitress>=3.0

+ 312 - 0
static/app.js

@@ -0,0 +1,312 @@
+"use strict";
+
+// --- state -----------------------------------------------------------------
+let CONFIG = null;
+let messages = [];              // current folder's messages
+const selected = new Set();     // selected localguids
+let jobPollTimer = null;
+
+// --- element refs ----------------------------------------------------------
+const $ = (id) => document.getElementById(id);
+const folderSelect = $("folder-select");
+const limitInput = $("limit-input");
+const msgBody = $("msg-body");
+const selectAll = $("select-all");
+const emptyNote = $("empty-note");
+const statusBar = $("status-bar");
+const moveBtn = $("move-btn");
+const moveMenu = $("move-menu");
+const overlay = $("overlay");
+
+// --- init ------------------------------------------------------------------
+async function init() {
+  CONFIG = await fetchJSON("/api/config");
+  for (const f of CONFIG.folders) {
+    folderSelect.append(new Option(f, f));
+  }
+  folderSelect.value = CONFIG.default_folder;
+  limitInput.value = CONFIG.default_limit;
+
+  // Move dropdown targets
+  moveMenu.innerHTML = "";
+  for (const f of CONFIG.folders) {
+    const item = document.createElement("button");
+    item.className = "move-item";
+    item.textContent = f;
+    item.onclick = () => { hideMoveMenu(); runAction("move", f); };
+    moveMenu.append(item);
+  }
+
+  wireEvents();
+  await loadMessages();
+  startJobPolling();
+}
+
+function wireEvents() {
+  $("refresh-btn").onclick = loadMessages;
+  folderSelect.onchange = () => { clearSelection(); loadMessages(); };
+  limitInput.onchange = loadMessages;
+  selectAll.onchange = () => toggleSelectAll(selectAll.checked);
+
+  document.querySelectorAll(".btn.action[data-action]").forEach((btn) => {
+    btn.onclick = () => runAction(btn.dataset.action);
+  });
+
+  moveBtn.onclick = (e) => {
+    e.stopPropagation();
+    moveMenu.classList.toggle("hidden");
+  };
+  document.addEventListener("click", hideMoveMenu);
+
+  $("overlay-close").onclick = closeOverlay;
+  overlay.onclick = (e) => { if (e.target === overlay) closeOverlay(); };
+  $("ov-toggle").onclick = toggleBody;
+}
+
+// --- message list ----------------------------------------------------------
+async function loadMessages() {
+  const folder = folderSelect.value;
+  const limit = limitInput.value || CONFIG.default_limit;
+  msgBody.innerHTML = `<tr><td colspan="5" class="loading">Loading…</td></tr>`;
+  try {
+    const data = await fetchJSON(
+      `/api/messages?folder=${encodeURIComponent(folder)}&limit=${encodeURIComponent(limit)}`
+    );
+    messages = data.messages;
+    renderRows();
+  } catch (err) {
+    msgBody.innerHTML = `<tr><td colspan="5" class="error">${escapeHTML(err.message)}</td></tr>`;
+  }
+}
+
+function renderRows() {
+  clearSelection();
+  msgBody.innerHTML = "";
+  emptyNote.classList.toggle("hidden", messages.length > 0);
+
+  for (const m of messages) {
+    const tr = document.createElement("tr");
+    tr.dataset.localguid = m.localguid;
+
+    const check = document.createElement("input");
+    check.type = "checkbox";
+    check.onclick = (e) => e.stopPropagation();
+    check.onchange = () => toggleRow(m.localguid, check.checked, tr);
+
+    const tdCheck = document.createElement("td");
+    tdCheck.className = "col-check";
+    tdCheck.append(check);
+
+    tr.append(tdCheck);
+    tr.append(cell(formatDate(m.date), "col-date"));
+    tr.append(cell(m.from || "", "col-from"));
+    tr.append(cell((m.rcpts || []).join(", "), "col-rcpt"));
+    tr.append(cell(m.subject || "(no subject)", "col-subj"));
+
+    tr.onclick = () => openOverlay(m);
+    msgBody.append(tr);
+  }
+  updateActionButtons();
+}
+
+function cell(text, cls) {
+  const td = document.createElement("td");
+  td.className = cls;
+  td.textContent = text;
+  td.title = text;
+  return td;
+}
+
+// --- selection -------------------------------------------------------------
+function toggleRow(localguid, on, tr) {
+  if (on) selected.add(localguid); else selected.delete(localguid);
+  tr.classList.toggle("selected", on);
+  updateActionButtons();
+}
+
+function toggleSelectAll(on) {
+  msgBody.querySelectorAll("tr").forEach((tr) => {
+    const cb = tr.querySelector('input[type="checkbox"]');
+    if (cb) { cb.checked = on; toggleRow(tr.dataset.localguid, on, tr); }
+  });
+}
+
+function clearSelection() {
+  selected.clear();
+  selectAll.checked = false;
+  updateActionButtons();
+}
+
+function updateActionButtons() {
+  const has = selected.size > 0;
+  document.querySelectorAll(".btn.action").forEach((b) => { b.disabled = !has; });
+}
+
+// --- actions ---------------------------------------------------------------
+async function runAction(action, targetfolder) {
+  const chosen = messages.filter((m) => selected.has(m.localguid));
+  if (chosen.length === 0) return;
+  if (action === "delete" &&
+      !confirm(`Delete ${chosen.length} message(s)? They move to "${CONFIG.deleted_folder}".`)) {
+    return;
+  }
+
+  const payload = {
+    action,
+    folder: folderSelect.value,
+    messages: chosen.map((m) => ({ localguid: m.localguid, guid: m.guid })),
+  };
+  if (action === "move") payload.targetfolder = targetfolder;
+
+  try {
+    await fetchJSON("/api/actions", { method: "POST", body: JSON.stringify(payload) });
+  } catch (err) {
+    alert("Action failed to queue: " + err.message);
+    return;
+  }
+
+  // Optimistic: remove acted-on rows immediately.
+  const gone = new Set(chosen.map((m) => m.localguid));
+  messages = messages.filter((m) => !gone.has(m.localguid));
+  renderRows();
+  pollJobs();
+}
+
+// --- overlay ---------------------------------------------------------------
+async function openOverlay(m) {
+  $("ov-subject").textContent = m.subject || "(no subject)";
+  $("ov-meta").innerHTML = "";
+  $("ov-text").textContent = "Loading…";
+  $("ov-html").classList.add("hidden");
+  $("ov-text").classList.remove("hidden");
+  $("ov-toggle").classList.add("hidden");
+  overlay.classList.remove("hidden");
+
+  try {
+    const data = await fetchJSON(`/api/message/${encodeURIComponent(m.guid)}`);
+    renderOverlay(data);
+  } catch (err) {
+    $("ov-text").textContent = "Failed to load message: " + err.message;
+  }
+}
+
+function renderOverlay(data) {
+  const h = data.headers || {};
+  const meta = [
+    ["From", h.from], ["To", h.to], ["Cc", h.cc], ["Date", h.date],
+  ].filter(([, v]) => v);
+  $("ov-meta").innerHTML = meta
+    .map(([k, v]) => `<div><span class="mk">${k}:</span> ${escapeHTML(v)}</div>`)
+    .join("");
+
+  const text = data.text || "";
+  const html = data.html || "";
+  $("ov-text").textContent = text || (html ? "(HTML message — use “Show HTML”.)" : "(empty body)");
+  overlay.dataset.html = html;
+  overlay.dataset.text = text;
+  const toggle = $("ov-toggle");
+  if (html) {
+    toggle.classList.remove("hidden");
+    toggle.textContent = "Show HTML";
+    $("ov-html").classList.add("hidden");
+    $("ov-text").classList.remove("hidden");
+  } else {
+    toggle.classList.add("hidden");
+  }
+}
+
+function toggleBody() {
+  const showingHtml = !$("ov-html").classList.contains("hidden");
+  const toggle = $("ov-toggle");
+  if (showingHtml) {
+    $("ov-html").classList.add("hidden");
+    $("ov-text").classList.remove("hidden");
+    toggle.textContent = "Show HTML";
+  } else {
+    // srcdoc + sandbox keeps scripts and remote loads from running.
+    $("ov-html").srcdoc = overlay.dataset.html || "";
+    $("ov-html").classList.remove("hidden");
+    $("ov-text").classList.add("hidden");
+    toggle.textContent = "Show text";
+  }
+}
+
+function closeOverlay() {
+  overlay.classList.add("hidden");
+  $("ov-html").srcdoc = "";
+}
+
+// --- job status polling ----------------------------------------------------
+function startJobPolling() { pollJobs(); }
+
+async function pollJobs() {
+  let data;
+  try {
+    data = await fetchJSON("/api/jobs");
+  } catch {
+    return;
+  }
+  const jobs = data.jobs || [];
+  const active = jobs.filter((j) => j.status === "pending" || j.status === "running");
+  renderStatus(jobs, active);
+
+  clearTimeout(jobPollTimer);
+  if (active.length > 0) {
+    jobPollTimer = setTimeout(pollJobs, 3000);
+  }
+}
+
+function renderStatus(jobs, active) {
+  if (active.length === 0) {
+    // Show the most recent finished job briefly, else hide.
+    const last = jobs[0];
+    if (last && (last.status === "failed")) {
+      statusBar.className = "status-bar error";
+      statusBar.textContent = `Job #${last.id} (${labelFor(last.action)}) failed: ${last.error || ""}`;
+      statusBar.classList.remove("hidden");
+    } else {
+      statusBar.classList.add("hidden");
+    }
+    return;
+  }
+  statusBar.className = "status-bar";
+  statusBar.textContent = active
+    .map((j) => `${labelFor(j.action)}: ${j.processed}/${j.total} (${j.status})`)
+    .join("   •   ");
+  statusBar.classList.remove("hidden");
+}
+
+// --- misc helpers ----------------------------------------------------------
+function hideMoveMenu() { moveMenu.classList.add("hidden"); }
+
+function labelFor(action) {
+  return {
+    release: "Release",
+    report_release: "Report & Release",
+    delete: "Delete",
+    move: "Move",
+  }[action] || action;
+}
+
+function formatDate(d) {
+  if (!d) return "";
+  return d.replace("T", " ").slice(0, 19);
+}
+
+async function fetchJSON(url, opts = {}) {
+  const res = await fetch(url, {
+    headers: { "Content-Type": "application/json" },
+    ...opts,
+  });
+  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;
+}
+
+function escapeHTML(s) {
+  return String(s).replace(/[&<>"']/g, (c) =>
+    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
+}
+
+init();

+ 145 - 0
static/style.css

@@ -0,0 +1,145 @@
+:root {
+  --bg: #f4f5f7;
+  --panel: #ffffff;
+  --border: #d9dce1;
+  --text: #1f2329;
+  --muted: #6b7280;
+  --accent: #2563eb;
+  --danger: #dc2626;
+  --selected: #eef4ff;
+  --shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+  color: var(--text);
+  background: var(--bg);
+}
+
+/* ---------- login ---------- */
+.login-page {
+  display: flex;
+  min-height: 100vh;
+  align-items: center;
+  justify-content: center;
+}
+.login-card {
+  background: var(--panel);
+  padding: 32px;
+  border-radius: 12px;
+  box-shadow: var(--shadow);
+  width: 320px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+.login-card h1 { margin: 0 0 8px; font-size: 20px; }
+.login-card label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
+.login-card input {
+  padding: 9px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px;
+}
+.login-card button {
+  margin-top: 6px; padding: 10px; border: 0; border-radius: 6px;
+  background: var(--accent); color: #fff; font-size: 15px; cursor: pointer;
+}
+.error { color: var(--danger); margin: 0; font-size: 13px; }
+
+/* ---------- topbar ---------- */
+.topbar {
+  display: flex; align-items: center; gap: 16px;
+  padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
+  position: sticky; top: 0; z-index: 20;
+}
+.brand { font-weight: 700; font-size: 15px; }
+.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 {
+  padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text);
+}
+.field input[type="number"] { width: 74px; }
+.spacer { flex: 1; }
+.logout { color: var(--muted); text-decoration: none; font-size: 13px; }
+.logout:hover { color: var(--accent); }
+
+.btn {
+  padding: 7px 12px; border: 1px solid var(--border); border-radius: 6px;
+  background: var(--panel); color: var(--text); font-size: 13px; cursor: pointer;
+}
+.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.btn.action { background: #f0f3f9; }
+.btn.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }
+.btn.small { padding: 4px 8px; font-size: 12px; }
+
+/* ---------- move dropdown ---------- */
+.move-wrap { position: relative; }
+.move-menu {
+  position: absolute; right: 0; top: calc(100% + 4px); min-width: 180px;
+  background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
+  box-shadow: var(--shadow); z-index: 30; overflow: hidden;
+}
+.move-item {
+  display: block; width: 100%; text-align: left; padding: 9px 12px;
+  border: 0; background: none; font-size: 13px; cursor: pointer;
+}
+.move-item:hover { background: var(--selected); }
+
+/* ---------- status bar ---------- */
+.status-bar {
+  padding: 8px 16px; background: #eaf1ff; color: #1e40af; font-size: 13px;
+  border-bottom: 1px solid var(--border);
+}
+.status-bar.error { background: #fdecec; color: var(--danger); }
+
+/* ---------- table ---------- */
+.content { position: relative; padding: 0; }
+.msg-table { width: 100%; border-collapse: collapse; background: var(--panel); table-layout: fixed; }
+.msg-table th, .msg-table td {
+  padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border);
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.msg-table thead th {
+  position: sticky; top: 53px; background: #fafbfc; z-index: 10;
+  font-size: 12px; color: var(--muted); font-weight: 600;
+}
+.col-check { width: 38px; text-align: center; }
+.col-date { width: 160px; color: var(--muted); }
+.col-from { width: 22%; }
+.col-rcpt { width: 22%; }
+.col-subj { width: auto; }
+.msg-table tbody tr { cursor: pointer; }
+.msg-table tbody tr:hover { background: #f7f9fc; }
+.msg-table tbody tr.selected { background: var(--selected); }
+.loading, .error { color: var(--muted); text-align: center; padding: 24px; }
+.msg-table td.error { color: var(--danger); }
+.empty-note { text-align: center; color: var(--muted); padding: 30px; }
+
+/* ---------- overlay ---------- */
+.overlay {
+  position: fixed; inset: 53px 0 0 0; z-index: 40;
+  background: rgba(15, 23, 42, 0.45);
+  display: flex; justify-content: center; align-items: flex-start;
+  padding: 24px;
+}
+.overlay-panel {
+  background: var(--panel); width: min(900px, 100%); max-height: 100%;
+  border-radius: 12px; box-shadow: var(--shadow);
+  display: flex; flex-direction: column; overflow: hidden;
+}
+.overlay-head {
+  display: flex; align-items: center; justify-content: space-between; gap: 12px;
+  padding: 12px 16px; border-bottom: 1px solid var(--border);
+}
+.overlay-head strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.overlay-meta { padding: 12px 16px; font-size: 13px; color: var(--text); border-bottom: 1px solid var(--border); }
+.overlay-meta .mk { color: var(--muted); display: inline-block; min-width: 44px; }
+.overlay-bodybar { padding: 8px 16px 0; }
+.overlay-text {
+  margin: 0; padding: 16px; overflow: auto; white-space: pre-wrap; word-break: break-word;
+  font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; flex: 1;
+}
+.overlay-html { border: 0; width: 100%; flex: 1; min-height: 300px; background: #fff; }
+.hidden { display: none !important; }

+ 68 - 0
templates/index.html

@@ -0,0 +1,68 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>PPS Quarantine Manager</title>
+  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+<body>
+  <header class="topbar">
+    <span class="brand">PPS Quarantine</span>
+    <div class="toolbar">
+      <label class="field">Folder
+        <select id="folder-select"></select>
+      </label>
+      <label class="field">Limit
+        <input id="limit-input" type="number" min="1" max="1000" step="1">
+      </label>
+      <button id="refresh-btn" class="btn">Refresh</button>
+      <span class="spacer"></span>
+      <button data-action="release" class="btn action" disabled>Release</button>
+      <button data-action="report_release" class="btn action" disabled>Report &amp; Release</button>
+      <button data-action="delete" class="btn action danger" disabled>Delete</button>
+      <div class="move-wrap">
+        <button id="move-btn" class="btn action" disabled>Move ▾</button>
+        <div id="move-menu" class="move-menu hidden"></div>
+      </div>
+    </div>
+    <a class="logout" href="{{ url_for('logout') }}">Sign out</a>
+  </header>
+
+  <div id="status-bar" class="status-bar hidden"></div>
+
+  <main class="content">
+    <table class="msg-table">
+      <thead>
+        <tr>
+          <th class="col-check"><input type="checkbox" id="select-all"></th>
+          <th class="col-date">Date</th>
+          <th class="col-from">Sender</th>
+          <th class="col-rcpt">Recipient</th>
+          <th class="col-subj">Subject</th>
+        </tr>
+      </thead>
+      <tbody id="msg-body"></tbody>
+    </table>
+    <p id="empty-note" class="empty-note hidden">No messages.</p>
+
+    <!-- Message content overlay, sits over the list -->
+    <div id="overlay" class="overlay hidden">
+      <div class="overlay-panel">
+        <div class="overlay-head">
+          <strong id="ov-subject"></strong>
+          <button id="overlay-close" class="btn">Close ✕</button>
+        </div>
+        <div class="overlay-meta" id="ov-meta"></div>
+        <div class="overlay-bodybar">
+          <button id="ov-toggle" class="btn small hidden">Show HTML</button>
+        </div>
+        <pre id="ov-text" class="overlay-text"></pre>
+        <iframe id="ov-html" class="overlay-html hidden" sandbox referrerpolicy="no-referrer"></iframe>
+      </div>
+    </div>
+  </main>
+
+  <script src="{{ url_for('static', filename='app.js') }}"></script>
+</body>
+</html>

+ 22 - 0
templates/login.html

@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Sign in — PPS Quarantine</title>
+  <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 %}">
+    <h1>PPS Quarantine</h1>
+    {% if error %}<p class="error">{{ error }}</p>{% endif %}
+    <label>Username
+      <input type="text" name="username" autocomplete="username" autofocus required>
+    </label>
+    <label>Password
+      <input type="password" name="password" autocomplete="current-password" required>
+    </label>
+    <button type="submit">Sign in</button>
+  </form>
+</body>
+</html>

+ 306 - 0
worker.py

@@ -0,0 +1,306 @@
+"""Background job queue for slow PPS quarantine actions.
+
+The frontend fires an action and returns immediately; the actual PPS API calls
+(which can be very slow, especially delete) run here on a daemon thread backed by
+SQLite so queued work survives an app restart.
+
+One JobQueue instance owns a single SQLite connection (guarded by a lock) shared
+between Flask request threads and the worker thread.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+import threading
+import time
+import traceback
+from datetime import datetime, timezone
+
+from pps_client import PPSClient, PPSError
+
+log = logging.getLogger("pps.worker")
+
+# Actions understood from the frontend.
+VALID_ACTIONS = {"release", "report_release", "delete", "move"}
+
+_POLL_SECONDS = 1.0
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+
+
+class JobQueue:
+    def __init__(self, db_path: str, pps: PPSClient, qconfig: dict):
+        self.pps = pps
+        self.cfg = qconfig
+        self.chunk_size = int(qconfig.get("chunk_size", 25))
+        self._lock = threading.Lock()
+        self._conn = sqlite3.connect(db_path, check_same_thread=False)
+        self._conn.row_factory = sqlite3.Row
+        self._init_db()
+        self._recover()
+        self._stop = threading.Event()
+
+    # -------------------------------------------------------------- schema/setup
+
+    def _init_db(self) -> None:
+        with self._lock:
+            self._conn.executescript(
+                """
+                CREATE TABLE IF NOT EXISTS jobs (
+                    id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                    action     TEXT NOT NULL,
+                    folder     TEXT NOT NULL,
+                    extra      TEXT NOT NULL DEFAULT '{}',
+                    status     TEXT NOT NULL DEFAULT 'pending',
+                    processed  INTEGER NOT NULL DEFAULT 0,
+                    total      INTEGER NOT NULL DEFAULT 0,
+                    error      TEXT,
+                    created_at TEXT NOT NULL,
+                    updated_at TEXT NOT NULL
+                );
+                CREATE TABLE IF NOT EXISTS job_items (
+                    job_id    INTEGER NOT NULL,
+                    localguid TEXT NOT NULL,
+                    guid      TEXT,
+                    folder    TEXT NOT NULL,
+                    FOREIGN KEY (job_id) REFERENCES jobs(id)
+                );
+                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);
+                """
+            )
+            self._conn.commit()
+
+    def _recover(self) -> None:
+        # Any job left 'running' when the process died is requeued.
+        with self._lock:
+            cur = self._conn.execute(
+                "UPDATE jobs SET status='pending', updated_at=? WHERE status='running'",
+                (_now(),),
+            )
+            self._conn.commit()
+        if cur.rowcount:
+            log.warning("requeued %d job(s) left running from a previous run", cur.rowcount)
+
+    # -------------------------------------------------------------- public API
+
+    def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
+        """Insert a job. `items` is a list of {'localguid':..., 'guid':...} dicts."""
+        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),
+            )
+            job_id = cur.lastrowid
+            self._conn.executemany(
+                "INSERT INTO job_items (job_id, localguid, guid, folder) VALUES (?,?,?,?)",
+                [(job_id, it["localguid"], it.get("guid"), folder) for it in items],
+            )
+            self._conn.commit()
+        return job_id
+
+    def acted_localguids(self, folder: str) -> set[str]:
+        """localguids in `folder` that belong to a non-failed job (hide them from the list)."""
+        with self._lock:
+            rows = self._conn.execute(
+                "SELECT ji.localguid FROM job_items ji JOIN jobs j ON j.id = ji.job_id"
+                " WHERE ji.folder = ? AND j.status != 'failed'",
+                (folder,),
+            ).fetchall()
+        return {r["localguid"] for r in rows}
+
+    def recent_jobs(self, limit: int = 25) -> list[dict]:
+        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()
+        return [dict(r) for r in rows]
+
+    def has_active_jobs(self) -> bool:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT 1 FROM jobs WHERE status IN ('pending','running') LIMIT 1"
+            ).fetchone()
+        return row is not None
+
+    # -------------------------------------------------------------- worker loop
+
+    def start(self) -> None:
+        thread = threading.Thread(target=self._run_loop, name="job-worker", daemon=True)
+        thread.start()
+
+    def stop(self) -> None:
+        self._stop.set()
+
+    def _run_loop(self) -> None:
+        while not self._stop.is_set():
+            job = self._claim_next()
+            if job is None:
+                time.sleep(_POLL_SECONDS)
+                continue
+            try:
+                self._process(job)
+            except Exception:  # noqa: BLE001 - never let the worker thread die
+                self._mark_failed(job["id"], traceback.format_exc())
+
+    def _claim_next(self) -> dict | None:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT * FROM jobs WHERE status='pending' ORDER BY id ASC LIMIT 1"
+            ).fetchone()
+            if row is None:
+                return None
+            self._conn.execute(
+                "UPDATE jobs SET status='running', updated_at=? WHERE id=?",
+                (_now(), row["id"]),
+            )
+            self._conn.commit()
+            return dict(row)
+
+    def _process(self, job: dict) -> None:
+        job_id = job["id"]
+        action = job["action"]
+        folder = job["folder"]
+        extra = json.loads(job["extra"] or "{}")
+        items = self._items_for(job_id)
+        log.info(
+            "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
+        )
+
+        errors: list[str] = []
+        processed = 0
+        for chunk in _chunks(items, self.chunk_size):
+            try:
+                self._run_action(action, folder, chunk, extra)
+                processed += len(chunk)
+                self._set_processed(job_id, processed)
+            except PPSError as exc:
+                log.error("job #%d chunk failed: %s", job_id, exc)
+                errors.append(f"chunk failed: {exc}")
+            except Exception as exc:  # noqa: BLE001
+                log.exception("job #%d chunk raised", job_id)
+                errors.append(f"chunk failed: {exc}")
+
+        if errors:
+            log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
+                      job_id, processed, len(items), len(errors))
+            self._mark_failed(job_id, "\n".join(errors))
+        else:
+            log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
+            self._mark_done(job_id)
+
+    # -------------------------------------------------------------- action logic
+
+    def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
+        localguids = [it["localguid"] for it in chunk]
+        deleted_folder = self.cfg.get("deleted_folder")
+
+        if action == "release":
+            self.pps.act("release", folder, localguids, deletedfolder=deleted_folder)
+
+        elif action == "delete":
+            self.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)
+
+        elif action == "report_release":
+            self._report_release(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]:
+        with self._lock:
+            rows = self._conn.execute(
+                "SELECT localguid, guid FROM job_items WHERE job_id=?", (job_id,)
+            ).fetchall()
+        return [dict(r) for r in rows]
+
+    def _set_processed(self, job_id: int, processed: int) -> None:
+        with self._lock:
+            self._conn.execute(
+                "UPDATE jobs SET processed=?, updated_at=? WHERE id=?",
+                (processed, _now(), job_id),
+            )
+            self._conn.commit()
+
+    def _mark_done(self, job_id: int) -> None:
+        with self._lock:
+            self._conn.execute(
+                "UPDATE jobs SET status='done', updated_at=? WHERE id=?",
+                (_now(), job_id),
+            )
+            self._conn.commit()
+
+    def _mark_failed(self, job_id: int, error: str) -> None:
+        with self._lock:
+            self._conn.execute(
+                "UPDATE jobs SET status='failed', error=?, updated_at=? WHERE id=?",
+                (error, _now(), job_id),
+            )
+            self._conn.commit()
+
+
+def _chunks(seq: list, size: int):
+    for i in range(0, len(seq), size):
+        yield seq[i : i + size]