app.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 tomllib
  10. from email import policy
  11. from functools import wraps
  12. from pathlib import Path
  13. from flask import (
  14. Flask,
  15. abort,
  16. jsonify,
  17. redirect,
  18. render_template,
  19. request,
  20. session,
  21. url_for,
  22. )
  23. from pps_client import PPSClient, PPSError
  24. from worker import VALID_ACTIONS, JobQueue
  25. CONFIG_PATH = Path(__file__).with_name("config.toml")
  26. def load_config() -> dict:
  27. if not CONFIG_PATH.exists():
  28. raise SystemExit(
  29. f"Missing {CONFIG_PATH.name}. Copy config.example.toml to config.toml and edit it."
  30. )
  31. with CONFIG_PATH.open("rb") as fh:
  32. return tomllib.load(fh)
  33. config = load_config()
  34. _pps_cfg = config["pps"]
  35. _q_cfg = config["quarantine"]
  36. _app_cfg = config["app"]
  37. _auth_cfg = config["auth"]
  38. logging.basicConfig(
  39. level=getattr(logging, str(_app_cfg.get("log_level", "INFO")).upper(), logging.INFO),
  40. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  41. )
  42. log = logging.getLogger("pps.app")
  43. pps = PPSClient(
  44. base_url=_pps_cfg["base_url"],
  45. username=_pps_cfg["username"],
  46. password=_pps_cfg["password"],
  47. verify_tls=_pps_cfg.get("verify_tls", False),
  48. timeout=int(_pps_cfg.get("timeout", 120)),
  49. client_cert=_pps_cfg.get("client_cert") or None,
  50. client_key=_pps_cfg.get("client_key") or None,
  51. )
  52. queue = JobQueue(_app_cfg.get("db_path", "jobs.db"), pps, _q_cfg)
  53. queue.start()
  54. app = Flask(__name__)
  55. app.secret_key = _app_cfg["secret_key"]
  56. # ------------------------------------------------------------------ auth
  57. def login_required(view):
  58. @wraps(view)
  59. def wrapped(*args, **kwargs):
  60. if not session.get("user"):
  61. if request.path.startswith("/api/"):
  62. abort(401)
  63. return redirect(url_for("login", next=request.path))
  64. return view(*args, **kwargs)
  65. return wrapped
  66. @app.route("/login", methods=["GET", "POST"])
  67. def login():
  68. error = None
  69. if request.method == "POST":
  70. username = request.form.get("username", "")
  71. password = request.form.get("password", "")
  72. if username == _auth_cfg["username"] and password == _auth_cfg["password"]:
  73. session["user"] = username
  74. return redirect(request.args.get("next") or url_for("index"))
  75. error = "Invalid credentials"
  76. return render_template("login.html", error=error)
  77. @app.route("/logout")
  78. def logout():
  79. session.clear()
  80. return redirect(url_for("login"))
  81. # ------------------------------------------------------------------ pages
  82. @app.route("/")
  83. @login_required
  84. def index():
  85. return render_template("index.html")
  86. # ------------------------------------------------------------------ JSON API
  87. @app.route("/api/config")
  88. @login_required
  89. def api_config():
  90. return jsonify(
  91. {
  92. "folders": _q_cfg.get("folders", []),
  93. "default_folder": _q_cfg.get("default_folder", "Quarantine"),
  94. "default_limit": int(_q_cfg.get("default_limit", 200)),
  95. "report_release_folder": _q_cfg.get("report_release_folder"),
  96. "deleted_folder": _q_cfg.get("deleted_folder"),
  97. }
  98. )
  99. @app.route("/api/messages")
  100. @login_required
  101. def api_messages():
  102. folder = request.args.get("folder") or _q_cfg.get("default_folder", "Quarantine")
  103. limit = _clamp_limit(request.args.get("limit"))
  104. try:
  105. records = pps.search(
  106. folder,
  107. _q_cfg.get("list_query", "from=*"),
  108. limit=limit,
  109. days_back=int(_q_cfg.get("default_days_back", 7)),
  110. )
  111. except PPSError as exc:
  112. return _pps_error_response(exc, f"search folder={folder!r}")
  113. hidden = queue.acted_localguids(folder)
  114. messages = [
  115. {
  116. "date": r.get("date"),
  117. "from": r.get("from"),
  118. "rcpts": r.get("rcpts", []),
  119. "subject": r.get("subject"),
  120. "guid": r.get("guid"),
  121. "localguid": r.get("localguid"),
  122. "size": r.get("size"),
  123. }
  124. for r in records
  125. if r.get("localguid") not in hidden
  126. ]
  127. return jsonify({"folder": folder, "count": len(messages), "messages": messages})
  128. @app.route("/api/message/<path:guid>")
  129. @login_required
  130. def api_message(guid: str):
  131. try:
  132. raw = pps.get_raw(guid)
  133. except PPSError as exc:
  134. return _pps_error_response(exc, f"get_raw guid={guid!r}")
  135. return jsonify(_parse_message(raw))
  136. @app.route("/api/actions", methods=["POST"])
  137. @login_required
  138. def api_actions():
  139. body = request.get_json(silent=True) or {}
  140. action = body.get("action")
  141. folder = body.get("folder")
  142. messages = body.get("messages", []) # [{localguid, guid}, ...]
  143. targetfolder = body.get("targetfolder")
  144. if action not in VALID_ACTIONS:
  145. return jsonify({"error": f"unknown action: {action}"}), 400
  146. if not folder:
  147. return jsonify({"error": "folder is required"}), 400
  148. if not messages:
  149. return jsonify({"error": "no messages selected"}), 400
  150. if action == "move" and not targetfolder:
  151. return jsonify({"error": "move requires a targetfolder"}), 400
  152. items = [
  153. {"localguid": m.get("localguid"), "guid": m.get("guid")}
  154. for m in messages
  155. if m.get("localguid")
  156. ]
  157. if not items:
  158. return jsonify({"error": "no valid messages (missing localguid)"}), 400
  159. extra = {"targetfolder": targetfolder} if action == "move" else {}
  160. job_id = queue.enqueue(action, folder, items, extra)
  161. return jsonify({"job_id": job_id, "queued": len(items)}), 202
  162. @app.route("/api/jobs")
  163. @login_required
  164. def api_jobs():
  165. return jsonify({"jobs": queue.recent_jobs(limit=25)})
  166. # ------------------------------------------------------------------ helpers
  167. def _pps_error_response(exc: PPSError, context: str):
  168. """Log a PPS failure with full context and return a clean 502 JSON body."""
  169. log.error("%s failed: %s", context, exc)
  170. return (
  171. jsonify(
  172. {
  173. "error": str(exc),
  174. "detail": exc.body,
  175. "status": exc.status,
  176. "reqid": exc.reqid,
  177. }
  178. ),
  179. 502,
  180. )
  181. def _clamp_limit(raw: str | None) -> int:
  182. default = int(_q_cfg.get("default_limit", 200))
  183. try:
  184. value = int(raw) if raw is not None else default
  185. except (TypeError, ValueError):
  186. value = default
  187. return max(1, min(value, 1000))
  188. def _parse_message(raw: bytes) -> dict:
  189. """Parse raw RFC822 bytes into headers + text/html bodies."""
  190. msg = email.message_from_bytes(raw, policy=policy.default)
  191. headers = {
  192. "from": msg.get("From", ""),
  193. "to": msg.get("To", ""),
  194. "cc": msg.get("Cc", ""),
  195. "date": msg.get("Date", ""),
  196. "subject": msg.get("Subject", ""),
  197. }
  198. text_body = ""
  199. html_body = ""
  200. try:
  201. text_part = msg.get_body(preferencelist=("plain",))
  202. if text_part is not None:
  203. text_body = text_part.get_content()
  204. except Exception: # noqa: BLE001 - malformed MIME shouldn't 500 the view
  205. text_body = ""
  206. try:
  207. html_part = msg.get_body(preferencelist=("html",))
  208. if html_part is not None:
  209. html_body = html_part.get_content()
  210. except Exception: # noqa: BLE001
  211. html_body = ""
  212. if not text_body and not html_body:
  213. # Non-multipart or odd structure: fall back to the raw payload.
  214. try:
  215. text_body = msg.get_content()
  216. except Exception: # noqa: BLE001
  217. text_body = raw.decode("utf-8", errors="replace")
  218. return {"headers": headers, "text": text_body, "html": html_body}
  219. if __name__ == "__main__":
  220. from waitress import serve
  221. host = _app_cfg.get("listen", "127.0.0.1")
  222. port = int(_app_cfg.get("port", 8080))
  223. log.info("PPS Quarantine Manager listening on http://%s:%s", host, port)
  224. # Single process so the worker thread and Flask share memory + SQLite connection.
  225. serve(app, host=host, port=port, threads=8)