"use strict";
// --- state -----------------------------------------------------------------
let CONFIG = null;
let messages = []; // current folder's messages
const selected = new Set(); // selected localguids
let jobPollTimer = null;
let sortField = "subject"; // subject | date | from | rcpt
let sortDir = 1; // 1 = ascending, -1 = descending
// --- 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");
for (const f of CONFIG.folders) {
folderSelect.append(new Option(f, f));
}
folderSelect.value = CONFIG.default_folder;
limitInput.value = CONFIG.default_limit;
// 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 loadMessages();
startJobPolling();
}
function wireEvents() {
$("refresh-btn").onclick = loadMessages;
folderSelect.onchange = () => { clearSelection(); loadMessages(); };
limitInput.onchange = loadMessages;
selectAll.onchange = () => toggleSelectAll(selectAll.checked);
$("sort-field").onchange = (e) => { sortField = e.target.value; renderRows(); };
$("sort-dir").onclick = () => {
sortDir = -sortDir;
$("sort-dir").textContent = sortDir === 1 ? "▲" : "▼";
renderRows();
};
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 = toggleBody;
}
// --- message list ----------------------------------------------------------
async function loadMessages() {
const folder = folderSelect.value;
const limit = limitInput.value || CONFIG.default_limit;
msgBody.innerHTML = `
| Loading… |
`;
try {
const data = await fetchJSON(
`/api/messages?folder=${encodeURIComponent(folder)}&limit=${encodeURIComponent(limit)}`
);
messages = data.messages;
renderRows();
} catch (err) {
msgBody.innerHTML = `| ${escapeHTML(err.message)} |
`;
}
}
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 renderRows() {
sortMessages();
clearSelection();
msgBody.innerHTML = "";
emptyNote.classList.toggle("hidden", messages.length > 0);
for (const m of messages) {
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);
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 ---------------------------------------------------------------
async function openOverlay(m) {
$("ov-subject").textContent = m.subject || "(no subject)";
$("ov-meta").innerHTML = "";
$("ov-text").textContent = "Loading…";
$("ov-html").classList.add("hidden");
$("ov-text").classList.remove("hidden");
$("ov-toggle").classList.add("hidden");
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("");
const text = data.text || "";
const html = data.html || "";
$("ov-text").textContent = text || (html ? "(HTML message — use “Show HTML”.)" : "(empty body)");
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");
}
}
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 closeOverlay() {
overlay.classList.add("hidden");
$("ov-html").srcdoc = "";
}
// --- 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 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 res = await fetch(url, {
headers: { "Content-Type": "application/json" },
...opts,
});
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;
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
init();