app.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. """PPS Quarantine Manager — Flask frontend + API.
  2. Serves the single-page UI and proxies to the Proofpoint Quarantine Search REST API.
  3. Slow actions (release/move/delete) are handed to the background JobQueue and return
  4. immediately; the UI polls /api/jobs for progress.
  5. """
  6. from __future__ import annotations
  7. import email
  8. import logging
  9. import re
  10. import tomllib
  11. from email import policy
  12. from functools import wraps
  13. from pathlib import Path
  14. from flask import (
  15. Flask,
  16. abort,
  17. jsonify,
  18. redirect,
  19. render_template,
  20. request,
  21. session,
  22. url_for,
  23. )
  24. from pps_client import PPSClient, PPSError
  25. from worker import VALID_ACTIONS, JobQueue
  26. CONFIG_PATH = Path(__file__).with_name("config.toml")
  27. def load_config() -> dict:
  28. if not CONFIG_PATH.exists():
  29. raise SystemExit(
  30. f"Missing {CONFIG_PATH.name}. Copy config.example.toml to config.toml and edit it."
  31. )
  32. with CONFIG_PATH.open("rb") as fh:
  33. return tomllib.load(fh)
  34. config = load_config()
  35. _pps_cfg = config["pps"]
  36. _q_cfg = config["quarantine"]
  37. _app_cfg = config["app"]
  38. _auth_cfg = config["auth"]
  39. logging.basicConfig(
  40. level=getattr(logging, str(_app_cfg.get("log_level", "INFO")).upper(), logging.INFO),
  41. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  42. )
  43. log = logging.getLogger("pps.app")
  44. pps = PPSClient(
  45. base_url=_pps_cfg["base_url"],
  46. username=_pps_cfg["username"],
  47. password=_pps_cfg["password"],
  48. verify_tls=_pps_cfg.get("verify_tls", False),
  49. timeout=int(_pps_cfg.get("timeout", 120)),
  50. client_cert=_pps_cfg.get("client_cert") or None,
  51. client_key=_pps_cfg.get("client_key") or None,
  52. )
  53. queue = JobQueue(
  54. _app_cfg.get("db_path", "jobs.db"),
  55. pps,
  56. _q_cfg,
  57. ops_log_path=_app_cfg.get("worker_log", "worker.log"),
  58. )
  59. queue.start()
  60. app = Flask(__name__)
  61. app.secret_key = _app_cfg["secret_key"]
  62. # ------------------------------------------------------------------ auth
  63. def login_required(view):
  64. @wraps(view)
  65. def wrapped(*args, **kwargs):
  66. if not session.get("user"):
  67. if request.path.startswith("/api/"):
  68. abort(401)
  69. return redirect(url_for("login", next=request.path))
  70. return view(*args, **kwargs)
  71. return wrapped
  72. @app.route("/login", methods=["GET", "POST"])
  73. def login():
  74. error = None
  75. if request.method == "POST":
  76. username = request.form.get("username", "")
  77. password = request.form.get("password", "")
  78. if username == _auth_cfg["username"] and password == _auth_cfg["password"]:
  79. session["user"] = username
  80. return redirect(request.args.get("next") or url_for("index"))
  81. error = "Invalid credentials"
  82. return render_template("login.html", error=error)
  83. @app.route("/logout")
  84. def logout():
  85. session.clear()
  86. return redirect(url_for("login"))
  87. # ------------------------------------------------------------------ pages
  88. @app.route("/")
  89. @login_required
  90. def index():
  91. return render_template("index.html")
  92. # ------------------------------------------------------------------ JSON API
  93. @app.route("/api/config")
  94. @login_required
  95. def api_config():
  96. return jsonify(
  97. {
  98. "folders": _q_cfg.get("folders", []),
  99. "default_folder": _q_cfg.get("default_folder", "Quarantine"),
  100. "default_limit": int(_q_cfg.get("default_limit", 200)),
  101. "report_release_folder": _q_cfg.get("report_release_folder"),
  102. "deleted_folder": _q_cfg.get("deleted_folder"),
  103. }
  104. )
  105. @app.route("/api/messages")
  106. @login_required
  107. def api_messages():
  108. """Return one page of a folder, newest-first.
  109. The client pages through the whole folder by passing `before` (the oldest date
  110. it has seen) as a cursor, since the PPS API caps each response at 1000 rows and
  111. offers no offset paging. `page.has_more` and `page.oldest` drive that loop.
  112. Messages with a pending/running/done action are filtered out server-side.
  113. """
  114. folder = request.args.get("folder") or _q_cfg.get("default_folder", "Quarantine")
  115. page_size = _clamp_limit(request.args.get("limit"))
  116. before = request.args.get("before") or None
  117. try:
  118. records = pps.search(
  119. folder,
  120. _q_cfg.get("list_query", "from=*"),
  121. limit=page_size,
  122. days_back=int(_q_cfg.get("default_days_back", 7)),
  123. enddate=before,
  124. )
  125. except PPSError as exc:
  126. return _pps_error_response(exc, f"search folder={folder!r} before={before!r}")
  127. raw_count = len(records)
  128. hidden = queue.acted_localguids(folder)
  129. messages = [
  130. {
  131. "date": r.get("date"),
  132. "from": r.get("from"),
  133. "rcpts": r.get("rcpts", []),
  134. "subject": r.get("subject"),
  135. "guid": r.get("guid"),
  136. "localguid": r.get("localguid"),
  137. "size": r.get("size"),
  138. }
  139. for r in records
  140. if r.get("localguid") not in hidden
  141. ]
  142. # Cursor is the oldest date in the RAW batch (before the acted-on filter), so
  143. # paging never terminates early just because a page was mostly filtered out.
  144. dates = [r.get("date") for r in records if r.get("date")]
  145. oldest = min(dates) if dates else None # fixed-width strings sort chronologically
  146. return jsonify(
  147. {
  148. "folder": folder,
  149. "messages": messages,
  150. "page": {
  151. "raw_count": raw_count,
  152. "oldest": oldest,
  153. "has_more": raw_count >= page_size,
  154. },
  155. }
  156. )
  157. @app.route("/api/message/<path:guid>")
  158. @login_required
  159. def api_message(guid: str):
  160. try:
  161. raw = pps.get_raw(guid)
  162. except PPSError as exc:
  163. return _pps_error_response(exc, f"get_raw guid={guid!r}")
  164. return jsonify(_parse_message(raw))
  165. @app.route("/api/actions", methods=["POST"])
  166. @login_required
  167. def api_actions():
  168. body = request.get_json(silent=True) or {}
  169. action = body.get("action")
  170. folder = body.get("folder")
  171. messages = body.get("messages", []) # [{localguid, guid, subject, sender, recipient}, ...]
  172. targetfolder = body.get("targetfolder")
  173. if action not in VALID_ACTIONS:
  174. return jsonify({"error": f"unknown action: {action}"}), 400
  175. if not folder:
  176. return jsonify({"error": "folder is required"}), 400
  177. if not messages:
  178. return jsonify({"error": "no messages selected"}), 400
  179. if action == "move" and not targetfolder:
  180. return jsonify({"error": "move requires a targetfolder"}), 400
  181. items = [
  182. {
  183. "localguid": m.get("localguid"),
  184. "guid": m.get("guid"),
  185. "subject": m.get("subject"),
  186. "sender": m.get("sender"),
  187. "recipient": m.get("recipient"),
  188. }
  189. for m in messages
  190. if m.get("localguid")
  191. ]
  192. if not items:
  193. return jsonify({"error": "no valid messages (missing localguid)"}), 400
  194. extra = {"targetfolder": targetfolder} if action == "move" else {}
  195. job_id = queue.enqueue(action, folder, items, extra)
  196. return jsonify({"job_id": job_id, "queued": len(items)}), 202
  197. @app.route("/api/jobs")
  198. @login_required
  199. def api_jobs():
  200. return jsonify({"jobs": queue.recent_jobs(limit=25)})
  201. # ------------------------------------------------------------------ helpers
  202. def _pps_error_response(exc: PPSError, context: str):
  203. """Log a PPS failure with full context and return a clean 502 JSON body."""
  204. log.error("%s failed: %s", context, exc)
  205. return (
  206. jsonify(
  207. {
  208. "error": str(exc),
  209. "detail": exc.body,
  210. "status": exc.status,
  211. "reqid": exc.reqid,
  212. }
  213. ),
  214. 502,
  215. )
  216. def _clamp_limit(raw: str | None) -> int:
  217. default = int(_q_cfg.get("default_limit", 200))
  218. try:
  219. value = int(raw) if raw is not None else default
  220. except (TypeError, ValueError):
  221. value = default
  222. return max(1, min(value, 1000))
  223. def _parse_message(raw: bytes) -> dict:
  224. """Parse raw RFC822 bytes into headers + text/html bodies."""
  225. msg = email.message_from_bytes(raw, policy=policy.default)
  226. headers = {
  227. "from": msg.get("From", ""),
  228. "to": msg.get("To", ""),
  229. "cc": msg.get("Cc", ""),
  230. "date": msg.get("Date", ""),
  231. "subject": msg.get("Subject", ""),
  232. }
  233. text_body = ""
  234. html_body = ""
  235. try:
  236. text_part = msg.get_body(preferencelist=("plain",))
  237. if text_part is not None:
  238. text_body = text_part.get_content()
  239. except Exception: # noqa: BLE001 - malformed MIME shouldn't 500 the view
  240. text_body = ""
  241. try:
  242. html_part = msg.get_body(preferencelist=("html",))
  243. if html_part is not None:
  244. html_body = html_part.get_content()
  245. except Exception: # noqa: BLE001
  246. html_body = ""
  247. if not text_body and not html_body:
  248. # Non-multipart or odd structure: fall back to the raw payload.
  249. try:
  250. text_body = msg.get_content()
  251. except Exception: # noqa: BLE001
  252. text_body = raw.decode("utf-8", errors="replace")
  253. return {
  254. "headers": headers,
  255. "text": text_body,
  256. "html": html_body,
  257. "attachments": _parse_attachments(msg),
  258. "raw_headers": _raw_headers(raw),
  259. }
  260. def _parse_attachments(msg) -> list[dict]:
  261. """List attachment parts as {filename, content_type, size} (size in bytes)."""
  262. attachments = []
  263. try:
  264. for part in msg.iter_attachments():
  265. payload = part.get_payload(decode=True)
  266. attachments.append(
  267. {
  268. "filename": part.get_filename() or "(unnamed)",
  269. "content_type": part.get_content_type(),
  270. "size": len(payload) if payload is not None else None,
  271. }
  272. )
  273. except Exception: # noqa: BLE001 - malformed MIME shouldn't 500 the view
  274. pass
  275. return attachments
  276. def _raw_headers(raw: bytes) -> str:
  277. """The literal RFC822 header block (everything before the first blank line)."""
  278. head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0]
  279. return head.decode("utf-8", errors="replace")
  280. if __name__ == "__main__":
  281. from waitress import serve
  282. host = _app_cfg.get("listen", "127.0.0.1")
  283. port = int(_app_cfg.get("port", 8080))
  284. log.info("PPS Quarantine Manager listening on http://%s:%s", host, port)
  285. # Single process so the worker thread and Flask share memory + SQLite connection.
  286. serve(app, host=host, port=port, threads=8)