|
|
@@ -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",
|