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, 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 `
`; } 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.") + ` `; } 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") + `
${["round_robin", "random", "fixed_first"].map(m => ` `).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") + `
${["repeat", "stop", "random"].map(m => ` `).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]) => ` `).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%.") + `

Tokens: {prefix} {R#} upper {r#} lower {d#} digits {seq4} {date} {hash}. Crypto-strong, collision-checked per campaign.

${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.") + `
`; 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.
`) + `
${["round_robin", "random", "fixed"].map(m => `