瀏覽代碼

improvement

Josef Straßl 3 周之前
父節點
當前提交
95c2a431ba
共有 5 個文件被更改,包括 255 次插入55 次删除
  1. 60 7
      app.py
  2. 8 1
      pps_client.py
  3. 135 42
      static/app.js
  4. 35 2
      static/style.css
  5. 17 3
      templates/index.html

+ 60 - 7
app.py

@@ -9,6 +9,7 @@ from __future__ import annotations
 
 import email
 import logging
+import re
 import tomllib
 from email import policy
 from functools import wraps
@@ -134,18 +135,28 @@ def api_config():
 @app.route("/api/messages")
 @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` 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")
-    limit = _clamp_limit(request.args.get("limit"))
+    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=limit,
+            limit=page_size,
             days_back=int(_q_cfg.get("default_days_back", 7)),
+            enddate=before,
         )
     except PPSError as exc:
-        return _pps_error_response(exc, f"search folder={folder!r}")
+        return _pps_error_response(exc, f"search folder={folder!r} before={before!r}")
 
+    raw_count = len(records)
     hidden = queue.acted_localguids(folder)
     messages = [
         {
@@ -160,9 +171,21 @@ def api_messages():
         for r in records
         if r.get("localguid") not in hidden
     ]
-    # Ordering is handled client-side (the toolbar Sort control); the API returns
-    # newest-first by date.
-    return jsonify({"folder": folder, "count": len(messages), "messages": messages})
+    # 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/message/<path:guid>")
@@ -278,7 +301,37 @@ def _parse_message(raw: bytes) -> dict:
         except Exception:  # noqa: BLE001
             text_body = raw.decode("utf-8", errors="replace")
 
-    return {"headers": headers, "text": text_body, "html": html_body}
+    return {
+        "headers": headers,
+        "text": text_body,
+        "html": html_body,
+        "attachments": _parse_attachments(msg),
+        "raw_headers": _raw_headers(raw),
+    }
+
+
+def _parse_attachments(msg) -> list[dict]:
+    """List attachment parts as {filename, content_type, size} (size in bytes)."""
+    attachments = []
+    try:
+        for part in msg.iter_attachments():
+            payload = part.get_payload(decode=True)
+            attachments.append(
+                {
+                    "filename": part.get_filename() or "(unnamed)",
+                    "content_type": part.get_content_type(),
+                    "size": len(payload) if payload is not None else None,
+                }
+            )
+    except Exception:  # noqa: BLE001 - malformed MIME shouldn't 500 the view
+        pass
+    return attachments
+
+
+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__":

+ 8 - 1
pps_client.py

@@ -91,23 +91,30 @@ class PPSClient:
         query: str,
         limit: int = 200,
         days_back: int = 7,
+        enddate: str | None = None,
     ) -> 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.
+
+        `enddate` (a "YYYY-MM-DD HH:MM:SS" string) upper-bounds the date range — used
+        to page backwards through a folder that holds more than the API's 1000-row cap.
         """
         params = self._query_to_params(query)
         params["folder"] = folder
         startdate = datetime.now(timezone.utc) - timedelta(days=days_back)
         params["startdate"] = startdate.strftime(_DATE_FMT)
+        if enddate:
+            params["enddate"] = enddate
 
         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)
+            "search folder=%r query=%r enddate=%r -> %d record(s)",
+            folder, query, enddate, len(records),
         )
         if limit and len(records) > limit:
             records = records[:limit]

+ 135 - 42
static/app.js

@@ -1,12 +1,14 @@
 "use strict";
 
 // --- state -----------------------------------------------------------------
+const PAGE_SIZE = 1000;         // PPS API caps a single search at 1000 rows
 let CONFIG = null;
-let messages = [];              // current folder's messages
+let messages = [];              // cached messages for the current folder (the display source)
 const selected = new Set();     // selected localguids
 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
 
 // --- element refs ----------------------------------------------------------
 const $ = (id) => document.getElementById(id);
@@ -40,14 +42,16 @@ async function init() {
   }
 
   wireEvents();
-  await loadMessages();
+  await fullSync();           // cache the whole default folder on login
   startJobPolling();
 }
 
 function wireEvents() {
-  $("refresh-btn").onclick = loadMessages;
-  folderSelect.onchange = () => { clearSelection(); loadMessages(); };
-  limitInput.onchange = loadMessages;
+  // 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();
   selectAll.onchange = () => toggleSelectAll(selectAll.checked);
 
   $("sort-field").onchange = (e) => { sortField = e.target.value; renderRows(); };
@@ -69,25 +73,72 @@ function wireEvents() {
 
   $("overlay-close").onclick = closeOverlay;
   overlay.onclick = (e) => { if (e.target === overlay) closeOverlay(); };
-  $("ov-toggle").onclick = toggleBody;
+  $("ov-toggle").onclick = () => setView(overlayView === "html" ? "text" : "html");
+  $("ov-raw-toggle").onclick = () => setView(overlayView === "raw" ? "text" : "raw");
 }
 
-// --- message list ----------------------------------------------------------
-async function loadMessages() {
+// --- full folder load (paged) ----------------------------------------------
+// Page backwards through the folder by date until a page returns < PAGE_SIZE rows,
+// caching everything client-side. The PPS API has no offset paging, so the cursor
+// is the oldest date seen so far (passed as `before`).
+async function fullSync() {
+  if (syncing) return;
+  syncing = true;
+  clearSelection();
   const folder = folderSelect.value;
-  const limit = limitInput.value || CONFIG.default_limit;
+  showProgress(`Loading messages from “${folder}”…`);
   msgBody.innerHTML = `<tr><td colspan="5" class="loading">Loading…</td></tr>`;
+
+  const seen = new Set();
+  const acc = [];
+  let cursor = null;
   try {
-    const data = await fetchJSON(
-      `/api/messages?folder=${encodeURIComponent(folder)}&limit=${encodeURIComponent(limit)}`
-    );
-    messages = data.messages;
+    while (true) {
+      const url = `/api/messages?folder=${encodeURIComponent(folder)}&limit=${PAGE_SIZE}` +
+        (cursor ? `&before=${encodeURIComponent(cursor)}` : "");
+      const data = await fetchJSON(url);
+
+      let added = 0;
+      for (const m of data.messages) {
+        if (!seen.has(m.localguid)) { seen.add(m.localguid); acc.push(m); added++; }
+      }
+      updateProgress(acc.length);
+
+      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).
+      if (!page.has_more || !page.oldest) break;
+      if (page.oldest === cursor && added === 0) break;
+      cursor = page.oldest;
+    }
+    messages = acc;
     renderRows();
   } catch (err) {
-    msgBody.innerHTML = `<tr><td colspan="5" class="error">${escapeHTML(err.message)}</td></tr>`;
+    if (acc.length) {
+      // Keep whatever we managed to page in; surface the error non-fatally.
+      messages = acc;
+      renderRows();
+      flashStatus(`Partial load: ${err.message}`, true);
+    } else {
+      msgBody.innerHTML = `<tr><td colspan="5" class="error">${escapeHTML(err.message)}</td></tr>`;
+    }
+  } finally {
+    hideProgress();
+    syncing = false;
   }
 }
 
+// --- progress window -------------------------------------------------------
+function showProgress(title) {
+  $("progress-title").textContent = title;
+  $("progress-count").textContent = "0 messages loaded";
+  $("progress").classList.remove("hidden");
+}
+function updateProgress(n) {
+  $("progress-count").textContent = `${n.toLocaleString()} messages loaded`;
+}
+function hideProgress() { $("progress").classList.add("hidden"); }
+
 function sortValue(m) {
   if (sortField === "rcpt") return (m.rcpts || []).join(", ");
   return m[sortField] || "";  // subject | date | from
@@ -105,24 +156,44 @@ function sortMessages() {
   });
 }
 
+function displayLimit() {
+  const v = parseInt(limitInput.value, 10);
+  return Number.isFinite(v) && v > 0 ? v : Infinity;  // blank/invalid = show all
+}
+
 function renderRows() {
   sortMessages();
   clearSelection();
   msgBody.innerHTML = "";
+
+  // The whole folder stays cached in `messages`; the Show box only caps how many
+  // rows we render, to keep large folders manageable for a human.
+  const visible = messages.slice(0, displayLimit());
   emptyNote.classList.toggle("hidden", messages.length > 0);
+  $("cache-info").textContent =
+    visible.length < messages.length
+      ? `showing ${visible.length.toLocaleString()} of ${messages.length.toLocaleString()} cached`
+      : `${messages.length.toLocaleString()} cached`;
 
-  for (const m of messages) {
+  for (const m of visible) {
     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);
+    // The whole checkbox cell is a click target (bigger than the box itself).
+    tdCheck.onclick = (e) => {
+      e.stopPropagation();               // don't open the message overlay
+      if (e.target !== check) {          // clicks on the box are handled natively
+        check.checked = !check.checked;
+        toggleRow(m.localguid, check.checked, tr);
+      }
+    };
 
     tr.append(tdCheck);
     tr.append(cell(formatDate(m.date), "col-date"));
@@ -206,13 +277,17 @@ async function runAction(action, targetfolder) {
 }
 
 // --- overlay ---------------------------------------------------------------
+let overlayView = "text";   // text | html | raw
+
 async function openOverlay(m) {
   $("ov-subject").textContent = m.subject || "(no subject)";
   $("ov-meta").innerHTML = "";
+  $("ov-attach").classList.add("hidden");
+  $("ov-attach").innerHTML = "";
+  $("ov-raw").textContent = "";
   $("ov-text").textContent = "Loading…";
-  $("ov-html").classList.add("hidden");
-  $("ov-text").classList.remove("hidden");
   $("ov-toggle").classList.add("hidden");
+  setView("text");
   overlay.classList.remove("hidden");
 
   try {
@@ -232,36 +307,42 @@ function renderOverlay(data) {
     .map(([k, v]) => `<div><span class="mk">${k}:</span> ${escapeHTML(v)}</div>`)
     .join("");
 
+  renderAttachments(data.attachments || []);
+
   const text = data.text || "";
   const html = data.html || "";
   $("ov-text").textContent = text || (html ? "(HTML message — use “Show HTML”.)" : "(empty body)");
+  $("ov-raw").textContent = data.raw_headers || "(no headers)";
   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");
-  }
+
+  $("ov-toggle").classList.toggle("hidden", !html);  // only offer HTML if present
+  setView("text");
 }
 
-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 renderAttachments(list) {
+  const bar = $("ov-attach");
+  if (!list.length) { bar.classList.add("hidden"); bar.innerHTML = ""; return; }
+  bar.innerHTML =
+    `<span class="attach-label">📎 ${list.length} attachment${list.length > 1 ? "s" : ""}:</span>` +
+    list.map((a) =>
+      `<span class="attach-chip" title="${escapeHTML(a.content_type || "")}">` +
+      `${escapeHTML(a.filename)}` +
+      (a.size != null ? `<span class="attach-size">${formatBytes(a.size)}</span>` : "") +
+      `</span>`
+    ).join("");
+  bar.classList.remove("hidden");
+}
+
+// Switch among the body views: plain text, sandboxed HTML, raw headers.
+function setView(view) {
+  overlayView = view;
+  $("ov-text").classList.toggle("hidden", view !== "text");
+  $("ov-html").classList.toggle("hidden", view !== "html");
+  $("ov-raw").classList.toggle("hidden", view !== "raw");
+  // srcdoc + sandbox keeps scripts and remote loads from running.
+  $("ov-html").srcdoc = view === "html" ? (overlay.dataset.html || "") : "";
+  $("ov-toggle").textContent = view === "html" ? "Show text" : "Show HTML";
+  $("ov-raw-toggle").textContent = view === "raw" ? "Show text" : "Show raw headers";
 }
 
 function closeOverlay() {
@@ -269,6 +350,12 @@ function closeOverlay() {
   $("ov-html").srcdoc = "";
 }
 
+function formatBytes(n) {
+  if (n < 1024) return `${n} B`;
+  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+  return `${(n / 1024 / 1024).toFixed(1)} MB`;
+}
+
 // --- job status polling ----------------------------------------------------
 function startJobPolling() { pollJobs(); }
 
@@ -312,6 +399,12 @@ function renderStatus(jobs, active) {
 // --- misc helpers ----------------------------------------------------------
 function hideMoveMenu() { moveMenu.classList.add("hidden"); }
 
+function flashStatus(text, isError) {
+  statusBar.className = isError ? "status-bar error" : "status-bar";
+  statusBar.textContent = text;
+  statusBar.classList.remove("hidden");
+}
+
 function labelFor(action) {
   return {
     release: "Release",

+ 35 - 2
static/style.css

@@ -73,6 +73,26 @@ body {
 .btn.action { background: #f0f3f9; }
 .btn.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }
 .btn.small { padding: 4px 8px; font-size: 12px; }
+.cache-info { font-size: 12px; color: var(--muted); white-space: nowrap; }
+
+/* ---------- progress window ---------- */
+.progress-overlay {
+  position: fixed; inset: 0; z-index: 60;
+  background: rgba(15, 23, 42, 0.45);
+  display: flex; align-items: center; justify-content: center;
+}
+.progress-box {
+  background: var(--panel); padding: 28px 36px; border-radius: 12px;
+  box-shadow: var(--shadow); text-align: center; min-width: 260px;
+}
+.progress-box #progress-title { font-weight: 600; margin-bottom: 6px; }
+.progress-count { color: var(--muted); font-size: 14px; }
+.spinner {
+  width: 32px; height: 32px; margin: 0 auto 14px;
+  border: 3px solid var(--border); border-top-color: var(--accent);
+  border-radius: 50%; animation: spin 0.8s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
 
 /* ---------- move dropdown ---------- */
 .move-wrap { position: relative; }
@@ -105,7 +125,8 @@ body {
   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-check { width: 48px; text-align: center; cursor: pointer; }
+.col-check input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; vertical-align: middle; }
 .col-date { width: 160px; color: var(--muted); }
 .col-from { width: 22%; }
 .col-rcpt { width: 22%; }
@@ -136,7 +157,19 @@ body {
 .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-attach {
+  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
+  padding: 10px 16px; border-bottom: 1px solid var(--border); background: #fafbfc;
+}
+.attach-label { font-size: 12px; color: var(--muted); }
+.attach-chip {
+  display: inline-flex; align-items: center; gap: 6px;
+  padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px;
+  font-size: 12px; background: var(--panel); max-width: 320px;
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.attach-chip .attach-size { color: var(--muted); }
+.overlay-bodybar { display: flex; gap: 8px; 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;

+ 17 - 3
templates/index.html

@@ -13,8 +13,8 @@
       <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 class="field">Show
+        <input id="limit-input" type="number" min="1" step="1" title="Max rows to display (the full folder stays cached)">
       </label>
       <label class="field">Sort
         <select id="sort-field">
@@ -25,7 +25,9 @@
         </select>
       </label>
       <button id="sort-dir" class="btn" title="Toggle ascending / descending">▲</button>
-      <button id="refresh-btn" class="btn">Refresh</button>
+      <button id="refresh-btn" class="btn" title="Re-render from the cached messages">Refresh</button>
+      <button id="reload-btn" class="btn" title="Re-fetch the whole folder from the server">Reload from server</button>
+      <span id="cache-info" class="cache-info"></span>
       <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>
@@ -63,11 +65,23 @@
           <button id="overlay-close" class="btn">Close ✕</button>
         </div>
         <div class="overlay-meta" id="ov-meta"></div>
+        <div class="overlay-attach hidden" id="ov-attach"></div>
         <div class="overlay-bodybar">
           <button id="ov-toggle" class="btn small hidden">Show HTML</button>
+          <button id="ov-raw-toggle" class="btn small">Show raw headers</button>
         </div>
         <pre id="ov-text" class="overlay-text"></pre>
         <iframe id="ov-html" class="overlay-html hidden" sandbox referrerpolicy="no-referrer"></iframe>
+        <pre id="ov-raw" class="overlay-text hidden"></pre>
+      </div>
+    </div>
+
+    <!-- Full-load progress window -->
+    <div id="progress" class="progress-overlay hidden">
+      <div class="progress-box">
+        <div class="spinner"></div>
+        <div id="progress-title">Loading messages…</div>
+        <div id="progress-count" class="progress-count">0 messages loaded</div>
       </div>
     </div>
   </main>