// 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 } 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; } 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 }, 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()); 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'; const list = await recipients(kind); 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 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; } 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 }; } 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 };