"use strict"; // --- state ----------------------------------------------------------------- const PAGE_SIZE = 1000; // PPS API caps a single search at 1000 rows let CONFIG = null; 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 let syncCancelled = false; // set by the Cancel button to stop paging early // --- 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"); const prefs = CONFIG.prefs || {}; for (const f of CONFIG.folders) { folderSelect.append(new Option(f, f)); } folderSelect.value = prefs.default_folder || CONFIG.folders[0]; limitInput.value = prefs.default_limit ?? 200; sortField = prefs.sort_field || "subject"; sortDir = prefs.sort_dir === "desc" ? -1 : 1; $("sort-field").value = sortField; $("sort-dir").textContent = sortDir === 1 ? "▲" : "▼"; // 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 fullSync(); // cache the whole default folder on login startJobPolling(); } function wireEvents() { // Refresh only re-renders from the cache; Reload re-fetches the folder. $("refresh-btn").onclick = () => renderRows(); $("reload-btn").onclick = () => fullSync(); $("cancel-sync-btn").onclick = () => { syncCancelled = true; const btn = $("cancel-sync-btn"); btn.textContent = "Cancelling…"; btn.disabled = true; }; limitInput.onchange = () => { renderRows(); savePref("default_limit", +limitInput.value); }; folderSelect.onchange = () => { savePref("default_folder", folderSelect.value); fullSync(); }; selectAll.onchange = () => toggleSelectAll(selectAll.checked); $("sort-field").onchange = (e) => { sortField = e.target.value; renderRows(); savePref("sort_field", sortField); }; $("sort-dir").onclick = () => { sortDir = -sortDir; $("sort-dir").textContent = sortDir === 1 ? "▲" : "▼"; renderRows(); savePref("sort_dir", sortDir === 1 ? "asc" : "desc"); }; 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 = () => setView(overlayView === "html" ? "text" : "html"); $("ov-raw-toggle").onclick = () => setView(overlayView === "raw" ? "text" : "raw"); } // --- 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; syncCancelled = false; clearSelection(); const folder = folderSelect.value; showProgress(`Loading messages from “${folder}”…`); msgBody.innerHTML = `Loading…`; const seen = new Set(); const acc = []; let cursor = null; let cancelled = false; try { 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); // Cancel takes effect after the in-flight page finishes; that page's messages // are kept, and we stop paging (the folder may still hold more). if (syncCancelled) { cancelled = true; break; } 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(); if (cancelled) { flashStatus( `Loading cancelled — showing ${acc.length.toLocaleString()} loaded message(s). ` + `The folder may contain more; use “Reload from server” for the full list.`, false, ); } } catch (err) { 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 = `${escapeHTML(err.message)}`; } } finally { hideProgress(); syncing = false; } } // --- progress window ------------------------------------------------------- function showProgress(title) { $("progress-title").textContent = title; $("progress-count").textContent = "0 messages loaded"; const btn = $("cancel-sync-btn"); btn.textContent = "Cancel"; btn.disabled = false; $("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 } function sortMessages() { messages.sort((a, b) => { const va = String(sortValue(a)).trim(); const vb = String(sortValue(b)).trim(); // Empty values always sort to the bottom, regardless of direction. if (!va && vb) return 1; if (va && !vb) return -1; if (!va && !vb) return 0; return va.localeCompare(vb, undefined, { sensitivity: "base", numeric: true }) * sortDir; }); } 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 visible) { const tr = document.createElement("tr"); tr.dataset.localguid = m.localguid; const check = document.createElement("input"); check.type = "checkbox"; 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")); 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, subject: m.subject, sender: m.from, recipient: (m.rcpts || []).join(", "), })), }; 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 --------------------------------------------------------------- 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-toggle").classList.add("hidden"); setView("text"); 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]) => `
${k}: ${escapeHTML(v)}
`) .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; $("ov-toggle").classList.toggle("hidden", !html); // only offer HTML if present setView("text"); } function renderAttachments(list) { const bar = $("ov-attach"); if (!list.length) { bar.classList.add("hidden"); bar.innerHTML = ""; return; } bar.innerHTML = `📎 ${list.length} attachment${list.length > 1 ? "s" : ""}:` + list.map((a) => `` + `${escapeHTML(a.filename)}` + (a.size != null ? `${formatBytes(a.size)}` : "") + `` ).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() { overlay.classList.add("hidden"); $("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(); } 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} (${jobState(j)})`) .join(" • "); statusBar.classList.remove("hidden"); } // A pending job with run_after set is waiting out a Report & Release step delay — // it isn't stuck behind the queue, it's the queue waiting for PPS to settle. function jobState(j) { return j.status === "pending" && j.run_after ? "waiting" : j.status; } // --- 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", 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 headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; // CSRF token on state-changing requests (the server requires it on non-GET /api/*). const method = (opts.method || "GET").toUpperCase(); if (method !== "GET" && CONFIG && CONFIG.csrf_token) headers["X-CSRF-Token"] = CONFIG.csrf_token; const res = await fetch(url, { ...opts, headers }); 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; } // Persist a single user preference, debounced and fire-and-forget: a failed pref save // must never block the UI. const _prefTimers = {}; function savePref(key, value) { clearTimeout(_prefTimers[key]); _prefTimers[key] = setTimeout(() => { fetchJSON("/api/prefs", { method: "PUT", body: JSON.stringify({ [key]: value }) }) .catch(() => { /* ignore */ }); }, 500); } function escapeHTML(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } init();