Files
instantadpay/updates.js
T
2026-09-14 14:11:51 -05:00

89 lines
6.2 KiB
JavaScript

// 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 };
}
// 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), 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 };