diff --git a/public/assets/admin.js b/public/assets/admin.js index 1ad652e..9adaa32 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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' : ''); $('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '').join(''); $('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '').join('') : 'No release notes yet.'; - $('updLog').innerHTML = 'WhenSubjectAudienceSent' + (d.sends.length ? d.sends.map(x => '' + new Date(x.ts).toLocaleString() + '' + esc(x.subject) + '' + esc(d.audiences[x.audience] || x.audience) + '' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.status === 'running' ? ' · running' : '') + '').join('') : 'None yet.'); + $('updLog').innerHTML = 'WhenSubjectAudienceSent' + (d.sends.length ? d.sends.map(x => '' + new Date(x.ts).toLocaleString() + '' + esc(x.subject) + '' + esc(d.audiences[x.audience] || x.audience) + '' + 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' : '') + '').join('') : 'None yet.'); 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); } catch (e) { $('updSub').textContent = e.message; } diff --git a/server.js b/server.js index ef9fec4..b3e8466 100644 --- a/server.js +++ b/server.js @@ -1347,7 +1347,7 @@ const server = http.createServer(async (req, res) => { if (p === '/api/admin/updates/send' && req.method === 'POST') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); 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); } if (p === '/api/admin/releases' && req.method === 'GET') { diff --git a/updates.js b/updates.js index 17c9f54..6919f57 100644 --- a/updates.js +++ b/updates.js @@ -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' }; // 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 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) { - const c = optinCache.get(email); if (c && Date.now() - c.t < 6 * 3600000) return c.v; - const st = R.sendy ? await R.sendy.status(email) : ''; const v = st === 'Subscribed'; - optinCache.set(email, { t: Date.now(), v }); return v; + const c = optinCache.get(email); if (c && Date.now() - c.t < (c.known ? 6 * 3600000 : 60000)) return c.v; + 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) : ''; } + 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) { 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.' }; 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.' }; - 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; (async () => { for (const acct of list) { try { if (R.drip.isUnsubscribed && await R.drip.isUnsubscribed(acct.email)) { rec.skipped += 1; continue; } 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); } 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));