From c78b39427ef872e8cf67d3aba8db3e3d2c9a45d9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:12:14 +0000 Subject: [PATCH] newsletter-abtest: report deliveries on every list, and mail them while tracking is off Since tracking went off on 2026-10-21 the digest logged 'no digest' and mailed nothing, which hid two stalled sends. It now mails sent/remaining per issue across all lists, sends since the last report, the daily cap and the days left at it, and flags stalled sends with the command to resume them. With tracking on the same section follows the A/B digest. Co-Authored-By: Claude Opus 5.5 --- bin/newsletter-abtest | 93 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/bin/newsletter-abtest b/bin/newsletter-abtest index e09d7a5..257a228 100755 --- a/bin/newsletter-abtest +++ b/bin/newsletter-abtest @@ -89,6 +89,77 @@ function trackingOn() { } } +// ---------------------------------------------------------------- deliveries + +const DELIVERIES = join(DATA, 'deliveries.jsonl'); +const DAY_MS = 86_400_000; + +/** The daily cap every send is paced by (outreach.maxEmailsPerDay). */ +function dailyCap() { + try { return Number(myna('config', 'outreach.maxEmailsPerDay').trim()) || null; } catch { return null; } +} + +/** Where every send stands, on every list, whatever its id: a one-off blast + * is mail to the same people, and it spends the same daily cap. This part + * needs no tracking, so it still reports while tracking is off. Each run + * appends a reading so "sent since the last report" is a real number. */ +function deliveries({ record }) { + const now = new Date().toISOString(); + const all = JSON.parse(myna('newsletter', '--json')); + const last = new Map(); + if (existsSync(DELIVERIES)) { + for (const l of readFileSync(DELIVERIES, 'utf8').split('\n').filter(Boolean)) { + try { const r = JSON.parse(l); last.set(r.id, r); } catch {} + } + } + const rows = []; + for (const n of all) { + if (n.status === 'draft') continue; + // A finished send drops out a week after it went, once nothing moves. + if (n.status === 'sent' && n.sentAt && Date.parse(now) - Date.parse(n.sentAt) > 7 * DAY_MS) continue; + let s; + try { s = JSON.parse(myna('newsletter', 'status', n.id, '--json')); } catch { continue; } + const before = last.get(n.id); + const since = before ? Math.max(0, s.sent - before.sent) : null; + const quietFor = s.lastSentAt ? Date.parse(now) - Date.parse(s.lastSentAt) : Infinity; + const state = s.remaining === 0 ? 'done' + : s.running ? 'sending' + : n.status === 'scheduled' ? 'scheduled' + : quietFor > DAY_MS ? 'stalled' : 'waiting'; + rows.push({ + id: n.id, list: n.list, subject: n.subject, status: n.status, state, + audience: s.audience, sent: s.sent, failed: s.failed, remaining: s.remaining, + since, lastSentAt: s.lastSentAt, scheduledFor: n.scheduledFor || null, + etaMs: s.running ? s.etaMs : null, + }); + } + if (record && rows.length) { + appendFileSync(DELIVERIES, rows.map((r) => JSON.stringify({ at: now, id: r.id, sent: r.sent, failed: r.failed })).join('\n') + '\n'); + } + const cap = dailyCap(); + const remaining = rows.reduce((t, r) => t + r.remaining, 0); + const sentSince = rows.reduce((t, r) => t + (r.since || 0), 0); + return { now, cap, rows, remaining, sentSince, days: cap ? Math.ceil(remaining / cap) : null }; +} + +function renderDeliveries(d) { + const lines = []; + lines.push(`Deliveries, every list (daily cap ${d.cap ? num(d.cap) : 'unknown'})`); + lines.push(` ${num(d.sentSince)} sent since the last report; ${num(d.remaining)} still to go` + + (d.remaining && d.days ? `, about ${d.days} day${d.days === 1 ? '' : 's'} at the cap` : '')); + for (const r of d.rows) { + const when = r.state === 'sending' && r.etaMs ? `, done about ${new Date(Date.parse(d.now) + r.etaMs).toISOString().slice(11, 16)} UTC` + : r.state === 'scheduled' && r.scheduledFor ? `, at ${r.scheduledFor.slice(0, 16).replace('T', ' ')} UTC` + : r.state === 'stalled' ? `, nothing since ${r.lastSentAt ? r.lastSentAt.slice(0, 16).replace('T', ' ') : 'the start'}: run myna newsletter blast --go ${r.id}` + : ''; + lines.push(` ${r.id} [${r.state}] ${r.list}`); + lines.push(` ${num(r.sent)} of ${num(r.audience)} sent${r.failed ? `, ${num(r.failed)} failed` : ''}` + + `${r.since != null ? ` (${plus(r.since)} since last)` : ''}${when}`); + } + if (!d.rows.length) lines.push(' Nothing sent or sending in the last week.'); + return lines.join('\n'); +} + // ---------------------------------------------------------------- statistics /** Standard normal tail, Abramowitz and Stegun 26.2.17; good to 7 decimals. */ @@ -569,8 +640,23 @@ async function main() { // Offline renders mail only when --to says where: a re-render of old // readings must never land in the real inbox by accident. const send = !quiet && (!OFFLINE || has('--to')); + // Tracking off (on purpose since 2026-10-21) used to mean no mail at all, + // which hid every send for days. Deliveries still get reported. if (!OFFLINE && !trackingOn()) { - console.log(`${new Date().toISOString()} myna newsletter tracking is off, so there are no opens or clicks to count; no digest. Turn it back on with: myna newsletter track set `); + let text; + try { + const d = deliveries({ record: !has('--dry-run') }); + text = `${renderDeliveries(d)}\n\nNo opens or clicks: myna newsletter tracking is off. Turn it back on with: myna newsletter track set `; + const stalled = d.rows.filter((r) => r.state === 'stalled').length; + const subject = `Newsletter: ${num(d.sentSince)} sent, ${num(d.remaining)} to go${stalled ? `, ${stalled} stalled` : ''}`; + if (send) await mail(subject, text); + } catch (e) { + const report = `newsletter-abtest failed on ${hostname()}:\n\n${e?.stack || e}`; + if (send) await mail('Newsletter: the delivery report failed', report); + console.error(report); + process.exit(1); + } + console.log(text); return; } let m, text, html; @@ -578,6 +664,11 @@ async function main() { m = collect({ record: !has('--dry-run') && !OFFLINE }); text = renderText(m); html = renderHtml(m); + if (!OFFLINE) { + const d = renderDeliveries(deliveries({ record: !has('--dry-run') })); + text += `\n\n${d}`; + html += `
${preHtml(d)}
`; + } } catch (e) { const report = `newsletter-abtest failed on ${hostname()}:\n\n${e?.stack || e}`; if (send) await mail('Newsletter A/B: the digest failed', report);