"use strict"; // --- state ----------------------------------------------------------------- let CONFIG = null; let messages = []; // current folder's messages const selected = new Set(); // selected localguids let jobPollTimer = null; // --- 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); 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 renderRows() { 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 })), }; 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();