app.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """PPS Quarantine Manager — Flask app factory.
  2. `create_app(store, queue, prefs)` wires the routes with no import-time side effects
  3. (no config read, no worker thread, no network). The composition root is `wsgi.py`;
  4. tests build an app directly with a temp config and a fake PPS client.
  5. Slow actions (release/move/delete) are queued to the background JobQueue and return
  6. immediately; the browser polls /api/jobs for progress.
  7. """
  8. from __future__ import annotations
  9. import email
  10. import logging
  11. import re
  12. from email import policy
  13. from flask import Flask, jsonify, render_template, request
  14. import auth
  15. from admin import init_admin
  16. from pps_client import PPSError
  17. from worker import VALID_ACTIONS, JobQueue
  18. log = logging.getLogger("pps.app")
  19. def create_app(store, queue: JobQueue, prefs) -> Flask:
  20. app = Flask(__name__)
  21. snap = store.snapshot()
  22. app.secret_key = snap.app["secret_key"]
  23. app.config.update(
  24. STORE=store,
  25. QUEUE=queue,
  26. PREFS=prefs,
  27. SESSION_COOKIE_HTTPONLY=True,
  28. # Lax (not Strict): the Okta callback is a cross-site top-level GET; Strict would
  29. # withhold the cookie and every login would fail with mismatching_state.
  30. SESSION_COOKIE_SAMESITE="Lax",
  31. SESSION_COOKIE_SECURE=bool(snap.app.get("cookie_secure", True)),
  32. SESSION_COOKIE_NAME="ppsq_session",
  33. MAX_CONTENT_LENGTH=4 * 1024 * 1024,
  34. )
  35. app.register_blueprint(auth.init_auth(app, store))
  36. app.register_blueprint(init_admin(store, queue))
  37. app.before_request(auth.check_csrf)
  38. @app.context_processor
  39. def _inject_user():
  40. return {"user": auth.current_user()}
  41. # ------------------------------------------------------------------ pages
  42. @app.route("/")
  43. @auth.login_required
  44. def index():
  45. return render_template("index.html")
  46. # ------------------------------------------------------------------ JSON API
  47. @app.route("/api/config")
  48. @auth.login_required
  49. def api_config():
  50. cfg = store.snapshot().quarantine
  51. user_email = auth.current_user()["email"]
  52. return jsonify(
  53. {
  54. "folders": list(cfg.get("folders", [])),
  55. "deleted_folder": cfg.get("deleted_folder"),
  56. "report_release": {
  57. "steps": list(cfg.get("report_release", {}).get("steps", [])),
  58. "move_target": cfg.get("report_release", {}).get("move_target"),
  59. },
  60. "prefs": prefs.effective(user_email, store.snapshot()),
  61. "csrf_token": auth.csrf_token(),
  62. "config_version": store.snapshot().version,
  63. "is_admin": auth.current_user().get("role") == "admin",
  64. }
  65. )
  66. @app.route("/api/messages")
  67. @auth.login_required
  68. def api_messages():
  69. """Return one page of a folder, newest-first.
  70. The client pages through the whole folder by passing `before` (the oldest date
  71. it has seen) as a cursor, since the PPS API caps each response at 1000 rows and
  72. offers no offset paging. `page.has_more`/`page.oldest` drive that loop.
  73. """
  74. cfg = store.snapshot().quarantine
  75. folder = request.args.get("folder") or cfg.get("default_folder", "Quarantine")
  76. page_size = _clamp_limit(request.args.get("limit"), cfg)
  77. before = request.args.get("before") or None
  78. try:
  79. records = store.pps().search(
  80. folder,
  81. cfg.get("list_query", "from=*"),
  82. limit=page_size,
  83. days_back=int(cfg.get("default_days_back", 7)),
  84. enddate=before,
  85. )
  86. except PPSError as exc:
  87. return _pps_error_response(exc, f"search folder={folder!r} before={before!r}")
  88. raw_count = len(records)
  89. hidden = queue.acted_localguids(folder)
  90. messages = [
  91. {
  92. "date": r.get("date"),
  93. "from": r.get("from"),
  94. "rcpts": r.get("rcpts", []),
  95. "subject": r.get("subject"),
  96. "guid": r.get("guid"),
  97. "localguid": r.get("localguid"),
  98. "size": r.get("size"),
  99. }
  100. for r in records
  101. if r.get("localguid") not in hidden
  102. ]
  103. # Cursor is the oldest date in the RAW batch (before the acted-on filter), so
  104. # paging never terminates early just because a page was mostly filtered out.
  105. dates = [r.get("date") for r in records if r.get("date")]
  106. oldest = min(dates) if dates else None
  107. return jsonify(
  108. {
  109. "folder": folder,
  110. "messages": messages,
  111. "page": {
  112. "raw_count": raw_count,
  113. "oldest": oldest,
  114. "has_more": raw_count >= page_size,
  115. },
  116. }
  117. )
  118. @app.route("/api/message/<path:guid>")
  119. @auth.login_required
  120. def api_message(guid: str):
  121. try:
  122. raw = store.pps().get_raw(guid)
  123. except PPSError as exc:
  124. return _pps_error_response(exc, f"get_raw guid={guid!r}")
  125. return jsonify(_parse_message(raw))
  126. @app.route("/api/actions", methods=["POST"])
  127. @auth.login_required
  128. def api_actions():
  129. cfg = store.snapshot().quarantine
  130. folders = set(cfg.get("folders", []))
  131. body = request.get_json(silent=True) or {}
  132. action = body.get("action")
  133. folder = body.get("folder")
  134. messages = body.get("messages", [])
  135. targetfolder = body.get("targetfolder")
  136. if action not in VALID_ACTIONS:
  137. return jsonify({"error": f"unknown action: {action}"}), 400
  138. if not folder:
  139. return jsonify({"error": "folder is required"}), 400
  140. if not messages:
  141. return jsonify({"error": "no messages selected"}), 400
  142. if action == "move":
  143. if not targetfolder:
  144. return jsonify({"error": "move requires a targetfolder"}), 400
  145. # Validate against the folder list — otherwise any user could move mail to
  146. # an arbitrary folder name.
  147. if targetfolder not in folders:
  148. return jsonify({"error": f"unknown target folder: {targetfolder}"}), 400
  149. items = [
  150. {
  151. "localguid": m.get("localguid"),
  152. "guid": m.get("guid"),
  153. "subject": m.get("subject"),
  154. "sender": m.get("sender"),
  155. "recipient": m.get("recipient"),
  156. }
  157. for m in messages
  158. if m.get("localguid")
  159. ]
  160. if not items:
  161. return jsonify({"error": "no valid messages (missing localguid)"}), 400
  162. extra = {"targetfolder": targetfolder} if action == "move" else {}
  163. user = auth.current_user()["email"]
  164. job_id = queue.enqueue(action, folder, items, extra, user=user)
  165. return jsonify({"job_id": job_id, "queued": len(items)}), 202
  166. @app.route("/api/jobs")
  167. @auth.login_required
  168. def api_jobs():
  169. # Users see only their own jobs; admins see everything.
  170. user = auth.current_user()
  171. scope = None if user.get("role") == "admin" else user["email"]
  172. return jsonify(
  173. {"jobs": queue.recent_jobs(limit=25, user=scope),
  174. "config_version": store.snapshot().version}
  175. )
  176. @app.route("/api/prefs", methods=["GET", "PUT", "DELETE"])
  177. @auth.login_required
  178. def api_prefs():
  179. user_email = auth.current_user()["email"]
  180. snap = store.snapshot()
  181. if request.method == "GET":
  182. return jsonify(prefs.describe(user_email, snap))
  183. if request.method == "PUT":
  184. patch = request.get_json(silent=True) or {}
  185. try:
  186. effective = prefs.set_many(user_email, patch, snap)
  187. except ValueError as exc:
  188. return jsonify({"error": str(exc)}), 400
  189. return jsonify({"prefs": effective})
  190. # DELETE
  191. keys = (request.get_json(silent=True) or {}).get("keys")
  192. prefs.clear(user_email, keys)
  193. return jsonify({"prefs": prefs.effective(user_email, snap)})
  194. # ------------------------------------------------------------------ helpers
  195. def _pps_error_response(exc: PPSError, context: str):
  196. log.error("%s failed: %s", context, exc)
  197. return (
  198. jsonify(
  199. {"error": str(exc), "detail": exc.body, "status": exc.status, "reqid": exc.reqid}
  200. ),
  201. 502,
  202. )
  203. return app
  204. def _clamp_limit(raw: str | None, cfg) -> int:
  205. default = int(cfg.get("default_limit", 200))
  206. try:
  207. value = int(raw) if raw is not None else default
  208. except (TypeError, ValueError):
  209. value = default
  210. return max(1, min(value, 1000))
  211. def _parse_message(raw: bytes) -> dict:
  212. """Parse raw RFC822 bytes into headers + text/html bodies + attachments."""
  213. msg = email.message_from_bytes(raw, policy=policy.default)
  214. headers = {
  215. "from": msg.get("From", ""),
  216. "to": msg.get("To", ""),
  217. "cc": msg.get("Cc", ""),
  218. "date": msg.get("Date", ""),
  219. "subject": msg.get("Subject", ""),
  220. }
  221. text_body = ""
  222. html_body = ""
  223. try:
  224. text_part = msg.get_body(preferencelist=("plain",))
  225. if text_part is not None:
  226. text_body = text_part.get_content()
  227. except Exception: # noqa: BLE001 - malformed MIME shouldn't 500 the view
  228. text_body = ""
  229. try:
  230. html_part = msg.get_body(preferencelist=("html",))
  231. if html_part is not None:
  232. html_body = html_part.get_content()
  233. except Exception: # noqa: BLE001
  234. html_body = ""
  235. if not text_body and not html_body:
  236. try:
  237. text_body = msg.get_content()
  238. except Exception: # noqa: BLE001
  239. text_body = raw.decode("utf-8", errors="replace")
  240. return {
  241. "headers": headers,
  242. "text": text_body,
  243. "html": html_body,
  244. "attachments": _parse_attachments(msg),
  245. "raw_headers": _raw_headers(raw),
  246. }
  247. def _parse_attachments(msg) -> list[dict]:
  248. """List attachment parts as {filename, content_type, size} (size in bytes)."""
  249. attachments = []
  250. try:
  251. for part in msg.iter_attachments():
  252. payload = part.get_payload(decode=True)
  253. attachments.append(
  254. {
  255. "filename": part.get_filename() or "(unnamed)",
  256. "content_type": part.get_content_type(),
  257. "size": len(payload) if payload is not None else None,
  258. }
  259. )
  260. except Exception: # noqa: BLE001 - malformed MIME shouldn't 500 the view
  261. pass
  262. return attachments
  263. def _raw_headers(raw: bytes) -> str:
  264. """The literal RFC822 header block (everything before the first blank line)."""
  265. head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0]
  266. return head.decode("utf-8", errors="replace")