MON.es.addEventListener("connected", () => $("#liveInd").classList.remove("hidden")); MON.es.addEventListener("progress", ev => { try { paintProgress(JSON.parse(ev.data)); } catch {} }); MON.es.addEventListener("done", ev => { try { paintProgress(JSON.parse(ev.data)); } catch {} stopStream(); toast(`Campaign finished — status: ${(JSON.parse(ev.data) || {}).status || "?"}`, "info", 6000); drawControls(); }); MON.es.onerror = () => { $("#liveInd").classList.add("hidden"); }; } async function drawProgress() { try { const p = await GET(`/campaigns/${MON.id}/progress`); paintProgress(p); } catch (e) { $("#monBody").innerHTML = `
✕
${esc(e.message)}
`; } } function paintProgress(p) { let box = $("#progBox"); if (!box) { box = el("div", { id: "progBox" }); $("#monBody").prepend(box); } const meterSegs = Math.min(p.total || 0, 400); const seg = (cls, n) => ``; const sum = p.sent + p.perm_failed + p.suppressed + p.skipped; box.innerHTML = `
#${p.campaign_id} ${stBadge(p.status)} ${p.started_at ? "started " + fmtDT(p.started_at) : "not started"} ${p.finished_at ? `· finished ${fmtDT(p.finished_at)}` : ""}
${p.progress_pct}% complete
${meterSegs("sent", p.sent)}${meterSegs("fail", p.perm_failed)}${meterSegs("supp", p.suppressed)}${meterSegs("skip", p.skipped)}${meterSegs("proc", (p.total || 0) - sum)}
${p.sent}
Accepted & sent
${p.temp_failed}
Temp failed
${p.perm_failed}
Perm failed
${p.suppressed}
Suppressed
${p.skipped}
Skipped
${p.pending}
Pending
${p.queued}
Queued
${p.total}
Total
${p.jobs_total} send jobs · delivery attempts are retried with backoff; temporary failures never masquerade as success.
`; drawControls(); } function drawControls() { const ctl = $("#monCtl"); if (!ctl) return; fetch(`/campaigns/${MON.id}`).then(r => r.json()).then(c => { ctl.innerHTML = ""; const mk = (label, cls, fn, confirmMsg) => { const b = el("button", { class: "btn sm " + cls, onclick: () => confirmMsg ? confirmedAction(confirmMsg, fn, { title: label }) : fn() }, label); ctl.append(b); }; if (c.status === "draft") mk("Start", "", async () => { await POST(`/campaigns/${MON.id}/start`); toast("Campaign started", "ok"); startMonitor(); }); if (c.status === "running") mk("Pause", "secondary", async () => { await POST(`/campaigns/${MON.id}/pause`); toast("Campaign paused", "ok"); startMonitor(); }); if (c.status === "paused") mk("Resume", "", async () => { await POST(`/campaigns/${MON.id}/resume`); toast("Campaign resumed", "ok"); startMonitor(); }); if (["running", "paused", "queued", "draft"].includes(c.status)) mk("Cancel", "danger-o", async () => { await POST(`/campaigns/${MON.id}/cancel`); toast("Campaign cancelled", "warn"); startMonitor(); }, `Cancel campaign #${MON.id}? In-flight messages already accepted by Gmail cannot be recalled.`); const ex = el("button", { class: "btn sm secondary", onclick: () => { window.location = `/campaigns/${MON.id}/export`; } }, "Export CSV"); const pv = el("button", { class: "btn sm secondary", onclick: () => campaignPreview(MON.id) }, "Preview"); ctl.append(ex, pv); }).catch(() => {}); } async function drawRecipients() { let box = $("#recBox"); if (!box) { box = el("div", { id: "recBox" }); $("#monBody").append(box); } await loadRecipients(box); } async function loadRecipients(box) { const p = new URLSearchParams({ status: MON.statusF, search: MON.q, page: MON.page, page_size: 50 }); let d; try { d = await GET(`/campaigns/${MON.id}/recipients?` + p); } catch (e) { box.innerHTML = `
✕
${esc(e.message)}
`; return; } const rows = d.recipients || []; const RS_BADGE = { pending: "b-gray", queued: "b-blue", processing: "b-amber", sent: "b-green", temp_failed: "b-amber", perm_failed: "b-red", suppressed: "b-violet", skipped: "b-gray", bounced: "b-red" }; box.innerHTML = `

Per-recipient monitor

${d.total} recipients
${rows.length ? `
${rows.map(r => ``).join("")}
SeqEmailStatusAccountSubjectInvoiceAmountAttemptsSent atMessage IDLast error
${r.seq} ${esc(r.email)} ${esc(r.status)} ${esc(r.account || "—")} ${esc((r.subject || "—").slice(0, 60))} ${esc(r.invoice_number || "—")} ${r.amount != null ? fmtMoney(r.amount) : "—"} ${r.attempts ?? 0} ${fmtDT(r.sent_at)} ${r.message_id ? esc(r.message_id.slice(0, 14)) + "…" : "—"} ${esc((r.last_error || "—").slice(0, 80))}
` : `
◷

No recipients materialized yet — they are created when the campaign starts.

`}
`; $("#recF").onchange = () => { MON.statusF = $("#recF").value; MON.page = 1; loadRecipients(box); }; $("#recQ").onkeydown = e => { if (e.key === "Enter") { MON.q = $("#recQ").value.trim(); MON.page = 1; loadRecipients(box); } }; $("#recGo").onclick = () => { MON.statusF = $("#recF").value; MON.q = $("#recQ").value.trim(); MON.page = 1; loadRecipients(box); }; const pages = Math.max(1, Math.ceil(d.total / 50)); $("#recPager").innerHTML = pages > 1 ? ` Page ${MON.page} of ${pages} ` : ""; $$("#recPager [data-pg]").forEach(b => b.onclick = () => { MON.page = +b.dataset.pg; loadRecipients(box); }); } /* ==================== CAMPAIGN PREVIEW MODAL ==================== */ async function campaignPreview(cid, size) { let d; try { d = await POST(`/campaigns/${cid}/preview${size ? "?sample_size=" + size : ""}`, {}); } catch (e) { toast(e.message, "err", 6000); return; } const msgs = d.messages || []; const node = el("div", {}, el("p", { class: "muted small" }, `Real rendered messages for ${d.sample_size} sample recipient(s) — variables substituted, attachments planned. Nothing is sent.`), msgs.length ? msgs.map(m => el("details", { class: "pv-acc" }, el("summary", {}, `${m.recipient.email} — ${m.subject}`, m.warnings && m.warnings.length ? el("span", { class: "badge b-amber", style: "margin-left:8px" }, `${m.warnings.length} warning(s)`) : null), el("div", {}, el("div", { class: "kv-inline" }, el("span", { class: "badge b-blue" }, "from " + (m.from_account || "?")), m.raw_variables && m.raw_variables.invoice_number ? el("span", { class: "badge" }, esc(m.raw_variables.invoice_number)) : null,