"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} (${j.status})`)
.join(" • ");
statusBar.classList.remove("hidden");
}
// --- 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();