Member updates: paced + retried Sendy opt-in checks (unknowns never cached as declines), explicit recipient list for repairs, recipients recorded on the send log

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 14:24:27 -05:00
parent 6e8f1ccf9d
commit 1533d377cc
3 changed files with 15 additions and 8 deletions
+1 -1
View File
@@ -445,7 +445,7 @@
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : ''); $('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join(''); $('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>'; $('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>'); $('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; } if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; }
if (d.running) setTimeout(loadUpdates, 4000); if (d.running) setTimeout(loadUpdates, 4000);
} catch (e) { $('updSub').textContent = e.message; } } catch (e) { $('updSub').textContent = e.message; }
+1 -1
View File
@@ -1347,7 +1347,7 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/admin/updates/send' && req.method === 'POST') { if (p === '/api/admin/updates/send' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req); const b = await readBody(req);
const r = await updates.send({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String), audience: b.audience }, { test: !!b.test }); const r = await updates.send({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String), audience: b.audience, to: Array.isArray(b.to) ? b.to.slice(0, 5000) : null }, { test: !!b.test });
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
if (p === '/api/admin/releases' && req.method === 'GET') { if (p === '/api/admin/releases' && req.method === 'GET') {
+13 -6
View File
@@ -14,10 +14,14 @@ function init(refs) { R = refs; }
const AUDIENCES = { optin: 'Newsletter opt-ins (ticked the box at sign-up)', optin30: 'Newsletter opt-ins active in the last 30 days', all: 'Every member, including those who declined the newsletter' }; const AUDIENCES = { optin: 'Newsletter opt-ins (ticked the box at sign-up)', optin30: 'Newsletter opt-ins active in the last 30 days', all: 'Every member, including those who declined the newsletter' };
// the sign-up checkbox subscribes the member to the Sendy newsletter list, so Sendy is the record of who opted in // the sign-up checkbox subscribes the member to the Sendy newsletter list, so Sendy is the record of who opted in
const optinCache = new Map(); // email -> { t, v } const optinCache = new Map(); // email -> { t, v }
const KNOWN = ['Subscribed', 'Unsubscribed', 'Unconfirmed', 'Bounced', 'Soft bounced', 'Complained', 'Email does not exist in list'];
let unknownOptins = 0; // answers Sendy did not give (timeouts, rate limits): counted, never treated as a decline for long
async function optedIn(email) { async function optedIn(email) {
const c = optinCache.get(email); if (c && Date.now() - c.t < 6 * 3600000) return c.v; const c = optinCache.get(email); if (c && Date.now() - c.t < (c.known ? 6 * 3600000 : 60000)) return c.v;
const st = R.sendy ? await R.sendy.status(email) : ''; const v = st === 'Subscribed'; let st = ''; for (let i = 0; i < 3 && !KNOWN.includes(st); i++) { if (i) await new Promise(r => setTimeout(r, 800 * i)); st = R.sendy ? await R.sendy.status(email) : ''; }
optinCache.set(email, { t: Date.now(), v }); return v; await new Promise(r => setTimeout(r, 250)); // Sendy's status endpoint throttles bursts (2026-09-14: an unpaced pass under-counted 126 opt-ins as 92)
const known = KNOWN.includes(st); if (!known) unknownOptins += 1;
const v = st === 'Subscribed'; optinCache.set(email, { t: Date.now(), v, known }); return v;
} }
async function recipients(kind) { async function recipients(kind) {
const all = await R.accounts.listAll(20000); const all = await R.accounts.listAll(20000);
@@ -60,16 +64,19 @@ async function send(input, opts) {
} }
if (running) return { error: 'A send is already running (' + running.sent + ' of ' + running.total + '). Wait for it to finish.' }; if (running) return { error: 'A send is already running (' + running.sent + ' of ' + running.total + '). Wait for it to finish.' };
const kind = AUDIENCES[input.audience] ? input.audience : 'optin'; const kind = AUDIENCES[input.audience] ? input.audience : 'optin';
const list = await recipients(kind); unknownOptins = 0;
let list = await recipients(kind);
if (Array.isArray(input.to) && input.to.length) { const want = new Set(input.to.map(e => String(e).toLowerCase())); const all = await R.accounts.listAll(20000); list = all.filter(a => a.email && want.has(String(a.email).toLowerCase())); } // explicit list (repairs)
if (unknownOptins && !(input.to && input.to.length)) console.log('updates: Sendy gave no answer for', unknownOptins, 'members; they were left out of this send');
if (!list.length) return { error: 'Nobody in that audience.' }; if (!list.length) return { error: 'Nobody in that audience.' };
const rec = { id: Date.now().toString(36), ts: Date.now(), subject: compose(input, null).subject, noteIds, audience: kind, total: list.length, sent: 0, skipped: 0, failed: 0, status: 'running' }; const rec = { id: Date.now().toString(36), ts: Date.now(), subject: compose(input, null).subject, noteIds, audience: kind, total: list.length, sent: 0, skipped: 0, failed: 0, status: 'running', unknown: unknownOptins, recipients: list.map(a => a.email) };
const l = log(); l.sends.unshift(rec); l.sends = l.sends.slice(0, 50); saveLog(l); running = rec; const l = log(); l.sends.unshift(rec); l.sends = l.sends.slice(0, 50); saveLog(l); running = rec;
(async () => { (async () => {
for (const acct of list) { for (const acct of list) {
try { try {
if (R.drip.isUnsubscribed && await R.drip.isUnsubscribed(acct.email)) { rec.skipped += 1; continue; } if (R.drip.isUnsubscribed && await R.drip.isUnsubscribed(acct.email)) { rec.skipped += 1; continue; }
const m = compose(input, acct); const m = compose(input, acct);
await R.mailer.send(acct.email, m.subject, m.text); rec.sent += 1; await R.mailer.send(acct.email, m.subject, m.text); rec.sent += 1; (rec.delivered = rec.delivered || []).push(acct.email);
} catch (e) { rec.failed += 1; console.error('updates send', acct.email, e.message); } } catch (e) { rec.failed += 1; console.error('updates send', acct.email, e.message); }
if ((rec.sent + rec.failed + rec.skipped) % 10 === 0) { const l2 = log(); const k = l2.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l2); } if ((rec.sent + rec.failed + rec.skipped) % 10 === 0) { const l2 = log(); const k = l2.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l2); }
await new Promise(r => setTimeout(r, 150)); await new Promise(r => setTimeout(r, 150));