try { d = await GET(`/campaigns/${MON.id}/recipients?` + p); }
catch (e) { box.innerHTML = `
`; 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
All statuses
${["pending", "queued", "processing", "sent", "temp_failed", "perm_failed", "suppressed", "skipped", "bounced"].map(s => `${s} `).join("")}
Filter
${d.total} recipients
${rows.length ? `
Seq Email Status Account Subject Invoice Amount Attempts Sent at Message ID Last error
${rows.map(r => `
${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))}
`).join("")}
` : `
◷
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 ?
`‹ Prev
Page ${MON.page} of ${pages}
= pages ? "disabled" : ""} data-pg="${MON.page + 1}">Next › ` : "";
$$("#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,
m.raw_variables && m.raw_variables.amount != null ? el("span", { class: "badge" }, fmtMoney(m.raw_variables.amount)) : null,
m.raw_variables && m.raw_variables.shipping != null ? el("span", { class: "badge" }, "ship " + fmtMoney(m.raw_variables.shipping)) : null),
m.warnings && m.warnings.length ? el("div", { class: "notice warn", html:
`⚠ ${m.warnings.map(w => "• " + esc(w)).join(" ")}
` }) : null,
el("iframe", { class: "preview-frame", srcdoc: m.body_html })))) : el("p", { class: "muted" }, "No sample messages."),
d.plan_summary ? el("p", { class: "muted small mt-2" }, "Plan summary: " + esc(JSON.stringify(d.plan_summary))) : null);
openModal(`Preview campaign #${cid}`, node, { xw: true, buttons: [
{ label: "Close", kind: "secondary", onClick: (ev, close) => close() },
{ label: "Refresh sample", kind: "primary", onClick: (ev, close) => { close(); campaignPreview(cid, (size || 5)); } },
]});
}
/* ==================== 12-STEP WIZARD ==================== */
async function openWizard(editId) {
try { if (!OPT) await loadOptions(); } catch (e) { toast(e.message, "err", 6000); return; }
editingId = editId || null;
Object.assign(C, DEF());
step = 1;
if (editingId) {
try {
const c = await GET(`/campaigns/${editingId}`);
Object.assign(C, DEF(), {
name: c.name, recipient_list_id: c.recipient_list_id,
subject_set_ids: c.subject_set_ids || [], template_ids: c.template_ids || [],
message_type: c.message_type, attach_mode: c.attach_mode,
attach_pdf: c.attach_pdf, attach_pdf_template_id: c.attach_pdf_template_id,
attachment_id: c.attachment_id, variable_config: c.variable_config || {},
account_ids: c.account_ids || [], rotation_strategy: c.rotation_strategy,
rotation_weights: c.rotation_weights || {}, subject_rotation: c.subject_rotation,
template_sequence_mode: c.template_sequence_mode, phone_selection: c.phone_selection,
max_per_account: c.max_per_account, send_interval_seconds: c.send_interval_seconds,
rate_per_minute: c.rate_per_minute, max_retries: c.max_retries,
retry_delay_seconds: c.retry_delay_seconds, unsubscribe_enabled: c.unsubscribe_enabled,
preview_sample_size: c.preview_sample_size,
});
} catch (e) { toast(e.message, "err"); return; }
}
const wrap = el("div", {},
el("div", { class: "wiz-steps", id: "wSteps" }),
el("div", { class: "step-body", id: "wBody" }),
el("div", { class: "wiz-foot" },
el("button", { class: "btn secondary", id: "wCancel", onclick: () => closeModal() }, "Cancel"),
el("div", { class: "flex", style: "gap:8px" },
el("button", { class: "btn secondary", id: "wBack" }, "‹ Back"),
el("button", { class: "btn", id: "wNext" }, "Next ›"))));
openModal(editingId ? `Edit campaign #${editingId}` : "New campaign — 12-step wizard", wrap, { xw: true, onClose: () => stopWizardWatch() });
$("#wBack").onclick = () => { if (step > 1) { step--; renderStep(); } };
$("#wNext").onclick = next;
renderStep();
}
function closeModal() { const x = $(".modal-back.open .x-btn"); if (x) x.click(); }
function stopWizardWatch() {}
function renderStepsBar() {
$("#wSteps").innerHTML = STEPS.map((s, i) => {
const n = i + 1;
const cls = n === step ? "active" : (n < step ? "done" : "");
return `${n < step ? "✓" : n} ${esc(s)}
`;
}).join("");
$$("#wSteps .wstep.done").forEach(w => w.onclick = () => { step = +w.dataset.step; renderStep(); });
}
function numRow(label, key, attrs = "") {
return `${label}
`;
}
function renderStep() {
renderStepsBar();
const b = $("#wBody");
$("#wBack").style.visibility = step > 1 ? "visible" : "hidden";
$("#wNext").textContent = step === 12 ? (editingId ? "Save changes" : "Create & start later") : "Next ›";
const H = t => `${t} `;
const hint = t => `${t}
`;
if (step === 1) {
b.innerHTML = H("Step 1 — Campaign name") + hint("A clear, unique name for this campaign. It appears in logs and exports.") +
`Campaign name
`;
} else if (step === 2) {
b.innerHTML = H("Step 2 — Recipient list") + hint(`Pick the list to send to. Max ${OPT.limits.max_recipients} recipients per campaign; only valid, non-suppressed rows are used.`) +
pickList("lists", C.recipient_list_id, l => ({ v: l.id, main: l.name, sub: `${l.row_count} rows · ${l.valid_count} valid` }), "recipient_list_id", true);
} else if (step === 3) {
b.innerHTML = H("Step 3 — Subject lines") + hint(`Select subject line variations (up to ${OPT.limits.max_subjects}) and how they rotate across recipients.`) +
pickList("subjects", C.subject_set_ids, s => ({ v: s.id, main: s.subject, sub: "" }), "subject_set_ids") +
`Subject rotation
${["round_robin", "random", "fixed_first"].map(m => `
${m === "round_robin" ? "Round robin" : m === "random" ? "Random shuffle" : "Fixed first subject"} `).join("")}
`;
} else if (step === 4) {
b.innerHTML = H("Step 4 — HTML templates") + hint("Select one or more templates and how they sequence across recipients.") +
pickList("templates", C.template_ids, t => ({ v: t.id, main: t.name, sub: "" }), "template_ids") +
`Template sequence mode
${["repeat", "stop", "random"].map(m => `
${m === "repeat" ? "Repeat from start" : m === "stop" ? "Stop after last" : "Random"} `).join("")}
`;
} else if (step === 5) {
const am = C.attach_mode;
b.innerHTML = H("Step 5 — Attachments") + hint("Choose what to attach. PDF mode renders a template to a unique Invoice PDF per recipient.") +
`
${[["none", "No attachment"], ["pdf", "Generated PDF invoice"], ["file", "Stored file attachment"], ["image", "Image attachment"]].map(([m, t]) => `
${t} `).join("")}
`;
bindAttExtra();
} else if (step === 6) {
const vc = C.variable_config;
b.innerHTML = H("Step 6 — Variables & random amounts") + hint("Configure the invoice number format and random amount ranges used by %RandomInvoiceNumber%, %RandomTotal% and %RandomShippingCharge%.") +
`
${numRow("Amount min ($)", "amount_min", 'type="number" step="0.01" min="0"')}
${numRow("Amount max ($)", "amount_max", 'type="number" step="0.01" min="0"')}
${numRow("Shipping min ($)", "shipping_min", 'type="number" step="0.01" min="0"')}
${numRow("Shipping max ($)", "shipping_max", 'type="number" step="0.01" min="0"')}
`;
} else if (step === 7) {
b.innerHTML = H("Step 7 — Gmail accounts") + hint(`Select up to ${OPT.limits.max_accounts} sending accounts. Only authorized, active accounts are shown.`) +
(OPT.accounts.length ? pickList("accounts", C.account_ids, a => ({ v: a.id, main: a.email, sub: `${a.sends_today}/${a.daily_limit} today · ${a.sending_method}` }), "account_ids")
: `⚠ No authorized Gmail accounts. Connect one on the Gmail Accounts page first.
`);
} else if (step === 8) {
const strat = C.rotation_strategy;
b.innerHTML = H("Step 8 — Account rotation") + hint("How senders rotate. Weighted lets you favor specific accounts; per-account caps and cooldowns are enforced regardless.") +
`
Sequential
Weighted
`;
bindRotExtra();
} else if (step === 9) {
b.innerHTML = H("Step 9 — Limits & pacing") + hint("Conservative pacing protects your accounts. Sends are throttled and retried with exponential backoff.") +
`
${numRow("Max sends per account", "max_per_account", 'type="number" min="1" max="2000"')}
${numRow("Interval between sends (s)", "send_interval_seconds", 'type="number" step="0.5" min="0.5" max="3600"')}
${numRow("Rate cap (per minute)", "rate_per_minute", 'type="number" min="1" max="600"')}
${numRow("Max retries", "max_retries", 'type="number" min="0" max="10"')}
${numRow("Retry delay (s)", "retry_delay_seconds", 'type="number" min="5" max="3600"')}
`;
} else if (step === 10) {
b.innerHTML = H("Step 10 — Company phone") + hint("Which active phone number appears as %CompanyPhone%.") +
(OPT.phones.length ? pickList("phones", C.phone_selection === "fixed" ? (C.variable_config.phone_id ?? null) : null, p => ({ v: p.id, main: p.phone, sub: p.label || "" }), "phone_fixed", true)
: `⚠ No active phone numbers. %CompanyPhone% will be blank unless numbers are added on the Company Phones page.
`) +
`Selection rule
${["round_robin", "random", "fixed"].map(m => `