010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
97 lines
7.5 KiB
JavaScript
97 lines
7.5 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://linkspin-test.saasy.top';
|
|
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('LinkSpin\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 LinkSpin', 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 };
|