// Member update emails (Marty, 2026-09-14): the admin picks release notes, writes a short intro, chooses an // audience, previews, sends a test to themselves, then sends. Plain text through the site mailer (SendGrid), // one recipient at a time with a small gap, opt-outs honoured (the same unsubscribe link the drip uses). // Log: DATA_DIR/updates-log.json. Nothing here sends on its own: every send is a click in Admin > Releases. const fs = require('fs'); const path = require('path'); let R = null; // { dataDir, accounts, releases, mailer, drip, sendy, adminEmail } const SITE = 'https://instantadpay.com'; const FILE = () => path.join(R.dataDir, 'updates-log.json'); function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return { sends: [] }; } } function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l)); } catch (e) {} } 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 < (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); const now = Date.now(); const days = kind === 'optin30' ? 30 : 0; const needOptin = kind !== 'all'; const out = []; for (const a of all) { if (!a.email || /@(demo|example)\./i.test(a.email)) continue; if (days && now - Math.max(a.lastSeen || 0, a.created || 0) > days * 86400000) continue; if (needOptin && !(await optedIn(a.email))) continue; out.push(a); } return out; } async function counts() { const o = {}; for (const k of Object.keys(AUDIENCES)) o[k] = (await recipients(k)).length; return o; } function compose({ subject, intro, noteIds, closing }, acct) { const notes = R.releases.notes().filter(n => noteIds.includes(n.id)); const name = acct && acct.username ? '@' + acct.username : 'there'; const parts = ['Hi ' + name + ',']; if (intro && intro.trim()) parts.push(intro.trim()); for (const n of notes) parts.push(n.title.toUpperCase() + (n.date ? ' (' + n.date + ')' : '') + '\n' + String(n.body || '').trim()); if (closing && closing.trim()) parts.push(closing.trim()); // sign-off AFTER the notes (Marty, 2026-09-14) parts.push('Every release note and what is being built next: ' + SITE + '/whats-new'); parts.push('Sign in: ' + SITE + '/my'); parts.push('InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nNo income is guaranteed; results depend on your effort. Cryptocurrency carries risk.'); if (acct && acct.email) parts.push('Stop these update emails: ' + R.drip.unsubUrl(acct.email)); return { subject: String(subject || '').trim().slice(0, 150) || 'What is new on InstantAdPay', text: parts.join('\n\n') }; } let running = null; async function send(input, opts) { const noteIds = (input.noteIds || []).map(String); if (!noteIds.length) return { error: 'Pick at least one release note.' }; if (!R.mailer.hasKey()) return { error: 'The mailer is not configured on this server.' }; if (opts && opts.test) { const to = R.adminEmail; if (!to) return { error: 'No admin email configured.' }; const acct = (await R.accounts.byEmail(to)) || { email: to, username: null }; const m = compose(input, acct); await R.mailer.send(to, '[TEST] ' + m.subject, m.text); return { ok: true, test: true, to }; } 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'; 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', 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; (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)); } rec.status = 'done'; rec.doneAt = Date.now(); const l3 = log(); const k = l3.sends.find(x => x.id === rec.id); if (k) Object.assign(k, rec); saveLog(l3); running = null; console.log('updates sent', rec.subject, rec.sent, 'of', rec.total, 'skipped', rec.skipped, 'failed', rec.failed); })(); return { ok: true, id: rec.id, total: rec.total }; } // one saved draft (subject, intro, noteIds, audience): the admin card loads it, Save draft writes it const DRAFT = () => path.join(R.dataDir, 'updates-draft.json'); function draft() { try { return JSON.parse(fs.readFileSync(DRAFT(), 'utf8')); } catch (e) { return null; } } function saveDraft(d) { const v = { subject: String(d.subject || '').slice(0, 150), intro: String(d.intro || '').slice(0, 8000), closing: String(d.closing || '').slice(0, 2000), noteIds: (d.noteIds || []).map(String).slice(0, 40), audience: AUDIENCES[d.audience] ? d.audience : 'optin', savedAt: Date.now() }; try { fs.writeFileSync(DRAFT(), JSON.stringify(v)); } catch (e) {} return v; } function status() { const l = log(); if (running) { const k = l.sends.find(x => x.id === running.id); if (k) Object.assign(k, running); } return { sends: l.sends.slice(0, 12), running: !!running }; } function lastSentAt() { const l = log(); const d = l.sends.find(x => x.status === 'done'); return d ? d.ts : 0; } module.exports = { init, AUDIENCES, counts, compose, send, status, lastSentAt, draft, saveDraft };