Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861#!/usr/bin/env node// email-blast.mjs// Send email blast to all Auth0 users about give.aesthetic.computer//// Usage:// node artery/email-blast.mjs --list # List all users (summary)// node artery/email-blast.mjs --export # Export all emails to CSV// node artery/email-blast.mjs --test EMAIL # Send test email// node artery/email-blast.mjs --preview # Preview email content// node artery/email-blast.mjs --send # Send to VERIFIED users (default)// node artery/email-blast.mjs --send --all # Send to ALL users// node artery/email-blast.mjs --send --resume # Resume from last sent//// Gmail SMTP limits:// ~500 emails/day with app passwords.// Verified users (~5.3k) = ~11 days. All users (~18k) = ~36 days.// Use --resume to continue across multiple days.
import { config } from 'dotenv';import { fileURLToPath } from 'url';import { dirname, join } from 'path';import { createTransport } from 'nodemailer';import { createInterface } from 'readline';import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from 'fs';import { createHmac } from 'crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load env from at/.env (has Auth0 M2M creds) and at/deploy.env (has SMTP creds)config({ path: join(__dirname, '../at/.env') });config({ path: join(__dirname, '../at/deploy.env') });
let unsubscribeSecret = process.env.UNSUBSCRIBE_SECRET || null;
async function ensureUnsubscribeSecret() { if (unsubscribeSecret) return unsubscribeSecret;
const { connect } = await import('../system/backend/database.mjs'); const database = await connect();
try { const secrets = await database.db .collection('secrets') .findOne({ _id: 'email-blast' });
if (!secrets?.unsubscribeSecret) { throw new Error('email-blast unsubscribe secret not found'); }
unsubscribeSecret = secrets.unsubscribeSecret; return unsubscribeSecret; } finally { await database.disconnect(); }}
// HMAC token generation for unsubscribe linksfunction generateUnsubscribeToken(email) { if (!unsubscribeSecret) { throw new Error('unsubscribe secret not loaded'); } return createHmac('sha256', unsubscribeSecret) .update(email.toLowerCase().trim()) .digest('hex');}
function getUnsubscribeUrl(email) { const token = generateUnsubscribeToken(email); return `https://aesthetic.computer/api/unsubscribe?email=${encodeURIComponent(email)}&token=${token}`;}
// File paths for tracking (user data goes to vault, logs stay in scratch)const VAULT_DIR = join(__dirname, '../aesthetic-computer-vault/user-reports');const SENT_LOG_FILE = join(__dirname, '../scratch/email-blast-sent.log');const FAILED_LOG_FILE = join(__dirname, '../scratch/email-blast-failed.log');const EXPORT_FILE = join(VAULT_DIR, 'email-blast-users.csv');const USERS_CACHE_FILE = join(VAULT_DIR, 'email-blast-users.json');const FETCH_CHECKPOINT_FILE = join(__dirname, '../scratch/email-blast-checkpoint.json');
// Save fetch checkpointfunction saveFetchCheckpoint(from, users) { writeFileSync(FETCH_CHECKPOINT_FILE, JSON.stringify({ from, count: users.length, timestamp: new Date().toISOString() })); // Also save all users fetched so far writeFileSync(USERS_CACHE_FILE, JSON.stringify(users, null, 2));}
// Load fetch checkpointfunction loadFetchCheckpoint() { if (!existsSync(FETCH_CHECKPOINT_FILE)) return null; try { return JSON.parse(readFileSync(FETCH_CHECKPOINT_FILE, 'utf-8')); } catch { return null; }}
// Load cached usersfunction loadCachedUsers() { if (!existsSync(USERS_CACHE_FILE)) return []; try { return JSON.parse(readFileSync(USERS_CACHE_FILE, 'utf-8')); } catch { return []; }}
// Clear fetch checkpointfunction clearFetchCheckpoint() { if (existsSync(FETCH_CHECKPOINT_FILE)) unlinkSync(FETCH_CHECKPOINT_FILE); if (existsSync(USERS_CACHE_FILE)) unlinkSync(USERS_CACHE_FILE);}
// Create an Auth0 export job to get ALL users (for 16k+)async function createExportJob() { const { got } = await import('got'); const { token, baseURI } = await getAuth0Token(); console.log('📤 Creating Auth0 user export job...\n'); const response = await got.post(`${baseURI}/api/v2/jobs/users-exports`, { json: { format: 'json', fields: [ { name: 'user_id' }, { name: 'email' }, { name: 'email_verified' }, { name: 'created_at' }, { name: 'last_login' }, { name: 'logins_count' }, ], }, headers: { Authorization: `Bearer ${token}` }, responseType: 'json', }); return response.body;}
// Check export job statusasync function checkExportJob(jobId) { const { got } = await import('got'); const { token, baseURI } = await getAuth0Token(); const response = await got(`${baseURI}/api/v2/jobs/${jobId}`, { headers: { Authorization: `Bearer ${token}` }, responseType: 'json', }); return response.body;}
// Download and parse exportasync function downloadExport(url) { const { got } = await import('got'); const { createGunzip } = await import('zlib'); const { pipeline } = await import('stream/promises'); const { Readable } = await import('stream'); console.log('📥 Downloading export...'); const response = await got(url, { responseType: 'buffer' }); // Auth0 exports are gzipped NDJSON const gunzip = createGunzip(); const chunks = []; await new Promise((resolve, reject) => { const input = Readable.from(response.body); input.pipe(gunzip); gunzip.on('data', chunk => chunks.push(chunk)); gunzip.on('end', resolve); gunzip.on('error', reject); }); const text = Buffer.concat(chunks).toString('utf-8'); const users = text.trim().split('\n').map(line => JSON.parse(line)); return users;}
// Full export flow for 16k+ usersasync function exportAllUsers() { console.log('\n🚀 AUTH0 BULK EXPORT (for 16k+ users)\n'); // Create export job const job = await createExportJob(); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); // Poll for completion let status = job.status; let result = job; let dots = 0; while (status === 'pending' || status === 'processing') { await new Promise(r => setTimeout(r, 2000)); result = await checkExportJob(job.id); status = result.status; dots++; process.stdout.write(`\r Waiting${'.'.repeat(dots % 4).padEnd(3)} (${status})`); } console.log(`\n Final status: ${status}`); if (status !== 'completed') { console.log(`\n❌ Export failed: ${result.error || 'Unknown error'}`); return []; } // Download the export const users = await downloadExport(result.location); // Save to cache saveFetchCheckpoint('export', users); const verified = users.filter(u => u.email_verified).length; console.log(`\n${'═'.repeat(50)}`); console.log(`📊 EXPORT COMPLETE`); console.log(` Total users: ${users.length}`); console.log(` Verified: ${verified}`); console.log(` Saved to: aesthetic-computer-vault/user-reports/`); console.log(`${'═'.repeat(50)}\n`); return users;}
// SMTP config from at/deploy.envconst SMTP_CONFIG = { host: 'smtp.gmail.com', port: 465, secure: true, auth: { user: process.env.SMTP_USER || 'mail@aesthetic.computer', pass: process.env.SMTP_PASS, // Required: set in environment or at/.env },};
if (!SMTP_CONFIG.auth.pass) { console.error('❌ SMTP_PASS environment variable is required!'); console.error(' Set it in at/.env or export it before running.'); process.exit(1);}
const EMAIL_SUBJECT = 'a little note from aesthetic computer';
function getEmailText(recipientEmail) { const unsubUrl = getUnsubscribeUrl(recipientEmail); return `Hi,
Aesthetic Computer has had a sweet year so far.
The tiny weird internet computer keeps filling up with life: thousands of paintings, more than 17,000 KidLisp programs, nearly 19,000 chat messages, and hundreds of published pages. The little orbit around it has been growing too: prompt.ac, news.aesthetic.computer, papers.aesthetic.computer, ATProto pages, and the ongoing Blank / AC Native work.
If you want to help keep it alive and growing, the simplest way is:
https://give.aesthetic.computer
If you want to see what support goes toward:
https://bills.aesthetic.computer
You can also help by replying to this email or sharing a favorite AC thing with a friend.
— @jeffrey
---Unsubscribe: ${unsubUrl}`;}
function getEmailHtml(recipientEmail) { const unsubUrl = getUnsubscribeUrl(recipientEmail); return `<p>Hi,</p>
<p> Aesthetic Computer has had a sweet year so far.</p>
<p> The tiny weird internet computer keeps filling up with life: thousands of paintings, more than 17,000 KidLisp programs, nearly 19,000 chat messages, and hundreds of published pages. The little orbit around it has been growing too: <a href="https://prompt.ac">prompt.ac</a>, <a href="https://news.aesthetic.computer">news.aesthetic.computer</a>, <a href="https://papers.aesthetic.computer">papers.aesthetic.computer</a>, ATProto pages, and the ongoing Blank / AC Native work.</p>
<p> If you want to help keep it alive and growing, the simplest way is:</p>
<p><a href="https://give.aesthetic.computer">give.aesthetic.computer</a></p>
<p> If you want to see what support goes toward:</p>
<p><a href="https://bills.aesthetic.computer">bills.aesthetic.computer</a></p>
<p>You can also help by replying to this email or sharing a favorite AC thing with a friend.</p>
<p>— @jeffrey</p>
<hr><p><a href="${unsubUrl}">Unsubscribe</a> from Aesthetic.Computer emails</p>`;}
// Get Auth0 access tokenasync function getAuth0Token() { const { got } = await import('got'); const clientId = process.env.AUTH0_M2M_CLIENT_ID; const clientSecret = process.env.AUTH0_M2M_SECRET; const baseURI = 'https://aesthetic.us.auth0.com';
if (!clientId || !clientSecret) { throw new Error('Missing AUTH0_M2M_CLIENT_ID or AUTH0_M2M_SECRET in env'); }
const response = await got.post(`${baseURI}/oauth/token`, { json: { client_id: clientId, client_secret: clientSecret, audience: `${baseURI}/api/v2/`, grant_type: 'client_credentials', }, responseType: 'json', });
return { token: response.body.access_token, baseURI };}
// Get ALL Auth0 users - use page-based for first 1000, then checkpoint for restasync function getAllAuth0Users(resume = false, showUsers = true) { const { got } = await import('got'); const { token, baseURI } = await getAuth0Token();
let allUsers = []; const perPage = 100; // Track existing user IDs to avoid duplicates const existingIds = new Set();
// Check for resume if (resume) { const cached = loadCachedUsers(); if (cached.length > 0) { for (const u of cached) { if (!existingIds.has(u.user_id)) { existingIds.add(u.user_id); allUsers.push(u); } } console.log(`\n🔄 RESUMING: Loaded ${allUsers.length} unique users from cache\n`); } }
if (allUsers.length === 0) { console.log('📥 Fetching ALL Auth0 users...\n'); }
// Phase 1: Page-based pagination (works up to ~1000 users reliably) let page = 0; let pageBasedDone = false; while (!pageBasedDone && page < 100) { // Max 10,000 via pages try { const response = await got(`${baseURI}/api/v2/users`, { searchParams: { per_page: perPage, page: page, include_totals: true, fields: 'user_id,email,email_verified,created_at', include_fields: true, }, headers: { Authorization: `Bearer ${token}` }, responseType: 'json', });
const data = response.body; const users = data.users || data; const total = data.total || 0; if (users.length === 0) { pageBasedDone = true; break; } let newCount = 0; for (const user of users) { if (existingIds.has(user.user_id)) continue; existingIds.add(user.user_id); allUsers.push(user); newCount++; if (showUsers) { const email = (user.email || '(no email)').slice(0, 35).padEnd(37); const date = new Date(user.created_at).toISOString().slice(0, 10); const v = user.email_verified ? '✓' : '✗'; console.log(`${v} ${date} ${email} [${allUsers.length}]`); } } page++; // Save checkpoint every 500 users if (allUsers.length % 500 < perPage && allUsers.length >= 500) { const verifiedCount = allUsers.filter(u => u.email_verified).length; saveFetchCheckpoint('page:' + page, allUsers); console.log(`\n 💾 Checkpoint: ${allUsers.length} users (${verifiedCount} verified) [page ${page}]\n`); } // Check if we've gotten all users if (allUsers.length >= total || users.length < perPage) { pageBasedDone = true; }
await new Promise(r => setTimeout(r, 100)); } catch (error) { if (error.response?.statusCode === 429) { console.log(`\n ⏳ Rate limited, waiting 5s...`); saveFetchCheckpoint('page:' + page, allUsers); await new Promise(r => setTimeout(r, 5000)); continue; } if (error.response?.statusCode === 400) { // Hit the 1000 user limit for page-based console.log(`\n ℹ️ Page-based limit reached at page ${page}`); pageBasedDone = true; break; } saveFetchCheckpoint('page:' + page, allUsers); throw error; } }
// Final save const verifiedCount = allUsers.filter(u => u.email_verified).length; saveFetchCheckpoint('done', allUsers); console.log(`\n${'═'.repeat(50)}`); console.log(`📊 FETCH COMPLETE`); console.log(` Total users: ${allUsers.length}`); console.log(` Verified: ${verifiedCount}`); console.log(` Saved to: aesthetic-computer-vault/user-reports/`); console.log(`${'═'.repeat(50)}\n`); return allUsers;}
// Quick y/n promptfunction confirmContinue(question) { const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => { rl.question(question, answer => { rl.close(); resolve(answer.toLowerCase() !== 'n' && answer.toLowerCase() !== 'no'); }); });}
// Get handles from MongoDBasync function getHandles() { const { connect } = await import('../system/backend/database.mjs'); const database = await connect(); const handles = database.db.collection('@handles'); const allHandles = await handles.find({}).toArray(); const handleMap = new Map(); for (const h of allHandles) { handleMap.set(h._id, h.handle); } await database.disconnect(); return handleMap;}
// List all users (summary only for large counts)async function listUsers(resume = false) { console.log('\n📋 FETCHING ALL AUTH0 USERS\n');
const users = await getAllAuth0Users(resume, true); const handles = await getHandles();
let verifiedCount = 0; let withHandleCount = 0;
for (const user of users) { if (user.email_verified) verifiedCount++; if (handles.get(user.user_id)) withHandleCount++; }
console.log(`\n📊 FINAL SUMMARY:`); console.log(` Total users: ${users.length}`); console.log(` Email verified: ${verifiedCount}`); console.log(` With handles: ${withHandleCount}`); console.log(` Local cache: vault/user-reports/`);}
// Just load cached users (no fetch)async function showCachedUsers() { const users = loadCachedUsers(); if (users.length === 0) { console.log('\n❌ No cached users. Run --fetch first.\n'); return; } const handles = await getHandles(); let verifiedCount = 0; let withHandleCount = 0;
console.log('\n📋 CACHED USERS (from local file):\n'); console.log('─'.repeat(70)); for (const user of users) { const email = (user.email || '(no email)').slice(0, 35).padEnd(37); const date = new Date(user.created_at).toISOString().slice(0, 10); const v = user.email_verified ? '✓' : '✗'; const handle = handles.get(user.user_id); if (user.email_verified) verifiedCount++; if (handle) withHandleCount++; console.log(`${v} ${date} ${email} ${handle ? '@' + handle : ''}`); } console.log('─'.repeat(70)); console.log(`\n📊 SUMMARY:`); console.log(` Total: ${users.length}`); console.log(` Verified: ${verifiedCount}`); console.log(` With handles: ${withHandleCount}`);}
// Export all users to CSVasync function exportUsers() { console.log('\n📤 EXPORTING ALL USERS TO CSV\n');
const users = await getAllAuth0Users(); const handles = await getHandles();
let verifiedCount = 0; const lines = ['email,created_at,verified,handle'];
for (const user of users) { const email = user.email || ''; const created = user.created_at || ''; const verified = user.email_verified ? 'yes' : 'no'; const handle = handles.get(user.user_id) || ''; if (user.email_verified) verifiedCount++; // Escape commas in email const safeEmail = email.includes(',') ? `"${email}"` : email; lines.push(`${safeEmail},${created},${verified},${handle}`); }
writeFileSync(EXPORT_FILE, lines.join('\n')); console.log(`✅ Exported ${users.length} users to:`); console.log(` ${EXPORT_FILE}`); console.log(`\n📊 Summary:`); console.log(` Total: ${users.length}`); console.log(` Verified: ${verifiedCount}`);}
// Preview email contentasync function previewEmail() { await ensureUnsubscribeSecret(); console.log('\n📧 EMAIL PREVIEW\n'); console.log('─'.repeat(60)); console.log(`From: mail@aesthetic.computer`); console.log(`Subject: ${EMAIL_SUBJECT}`); console.log('─'.repeat(60)); console.log(getEmailText('preview@example.com')); console.log('─'.repeat(60));}
// Send a single emailasync function sendEmail(transporter, to) { await ensureUnsubscribeSecret(); const unsubUrl = getUnsubscribeUrl(to); const result = await transporter.sendMail({ from: '"Aesthetic Computer" <mail@aesthetic.computer>', to, subject: EMAIL_SUBJECT, text: getEmailText(to), html: getEmailHtml(to), headers: { 'List-Unsubscribe': `<${unsubUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', }, }); return result;}
// Send test emailasync function sendTestEmail(testEmail) { console.log(`\n📧 Sending test email to: ${testEmail}\n`);
const transporter = createTransport(SMTP_CONFIG);
try { const result = await sendEmail(transporter, testEmail); console.log(`✅ Test email sent! Message ID: ${result.messageId}`); } catch (error) { console.error(`❌ Failed to send: ${error.message}`); }}
// Prompt for confirmationfunction confirm(question) { const rl = createInterface({ input: process.stdin, output: process.stdout, });
return new Promise(resolve => { rl.question(question, answer => { rl.close(); resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); }); });}
// Get already-sent emails from logfunction getSentEmails() { if (!existsSync(SENT_LOG_FILE)) return new Set(); const content = readFileSync(SENT_LOG_FILE, 'utf-8'); return new Set(content.split('\n').filter(Boolean));}
// Log a sent emailfunction logSentEmail(email) { appendFileSync(SENT_LOG_FILE, email + '\n');}
// Log a failed emailfunction logFailedEmail(email, error) { appendFileSync(FAILED_LOG_FILE, `${email}|${error}\n`);}
// Get muted users from MongoDBasync function getMutedUsers() { const { connect } = await import('../system/backend/database.mjs'); const db = await connect();
const mutesCollections = ['chat-sotce-mutes', 'chat-clock-mutes', 'chat-system-mutes']; const mutedUsers = new Set();
for (const col of mutesCollections) { const mutes = await db.db.collection(col).find({}).toArray(); mutes.forEach(m => mutedUsers.add(m.user)); }
await db.disconnect(); return mutedUsers;}
// Get unsubscribed emails from MongoDBasync function getUnsubscribedEmails() { const { connect } = await import('../system/backend/database.mjs'); const db = await connect();
const docs = await db.db.collection('email-blast-unsubscribes').find({}).toArray(); const emails = new Set(docs.map(d => d.email.toLowerCase()));
await db.disconnect(); return emails;}
// Send blast to verified users by default, excluding muted + unsubscribedasync function sendBlast(resume = false, verifiedOnly = true) { console.log('\n🚀 EMAIL BLAST MODE\n');
// Load cached users let users = loadCachedUsers(); if (users.length === 0) { console.log('⚠️ No cached users. Fetching...'); users = await getAllAuth0Users(false, false); }
// Get muted users and unsubscribed emails console.log('📋 Checking muted users...'); const mutedUsers = await getMutedUsers(); console.log('📋 Checking unsubscribed emails...'); const unsubscribedEmails = await getUnsubscribedEmails();
// Filter users let eligibleUsers = users.filter(u => u.email && !mutedUsers.has(u.user_id) && !unsubscribedEmails.has(u.email.toLowerCase()) );
if (verifiedOnly) { eligibleUsers = eligibleUsers.filter(u => u.email_verified); }
const verified = eligibleUsers.filter(u => u.email_verified).length; const unverified = eligibleUsers.length - verified; // Get already sent if resuming const alreadySent = resume ? getSentEmails() : new Set(); const toSend = eligibleUsers.filter(u => !alreadySent.has(u.email));
console.log(`\n📊 Stats:`); console.log(` Total eligible: ${eligibleUsers.length} (${verified} verified, ${unverified} unverified)`); console.log(` Muted/excluded: ${mutedUsers.size}`); console.log(` Unsubscribed: ${unsubscribedEmails.size}`); if (resume) { console.log(` Already sent: ${alreadySent.size}`); } console.log(` To send now: ${toSend.length}`);
if (toSend.length === 0) { console.log('\n✅ All emails already sent!'); return; }
// Estimate time const estimatedMinutes = Math.ceil(toSend.length * 1.2 / 60); // 1.2s per email avg const estimatedDays = Math.ceil(toSend.length / 450); console.log(` Estimated time: ~${estimatedMinutes} minutes per day`); console.log(` Estimated days: ~${estimatedDays} (Gmail ~500/day limit)`);
console.log(`\n⚠️ WARNING: This will send ${toSend.length} emails!`); console.log(` Use --resume to continue across multiple days.`); const confirmed = await confirm('\nType "yes" to confirm: '); if (!confirmed) { console.log('❌ Aborted.'); return; }
console.log('\n📤 Starting email blast...\n'); console.log(` Progress saved to: ${SENT_LOG_FILE}`); console.log(` Use --send --resume to continue if interrupted\n`);
const transporter = createTransport(SMTP_CONFIG); let sent = 0; let failed = 0; const startTime = Date.now();
for (let i = 0; i < toSend.length; i++) { const user = toSend[i]; const email = user.email;
try { await sendEmail(transporter, email); sent++; logSentEmail(email); const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1); const rate = (sent / (elapsed || 0.1)).toFixed(1); console.log(`✅ [${sent + failed}/${toSend.length}] ${email} (${elapsed}m, ${rate}/min)`); } catch (error) { failed++; logFailedEmail(email, error.message); console.log(`❌ [${sent + failed}/${toSend.length}] ${email} - ${error.message}`); // If too many failures, pause longer if (failed > 10 && failed / (sent + failed) > 0.2) { console.log(`\n⚠️ High failure rate! Pausing 30s...`); await new Promise(r => setTimeout(r, 30000)); } }
// Rate limit: 1 email per second if (i < toSend.length - 1) { await new Promise(r => setTimeout(r, 1000)); }
// Longer pause every 50 emails (Gmail likes this) if ((i + 1) % 50 === 0) { console.log(`\n⏸️ Batch ${Math.floor((i + 1) / 50)} complete. Pausing 10s...\n`); await new Promise(r => setTimeout(r, 10000)); } // Even longer pause every 400 emails (approaching daily limit) if ((i + 1) % 400 === 0) { console.log(`\n🛑 Approaching Gmail daily limit. Pausing 5 minutes...`); console.log(` (Safe to Ctrl+C and resume later with --resume)\n`); await new Promise(r => setTimeout(r, 5 * 60 * 1000)); } }
const totalTime = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
console.log('\n' + '═'.repeat(60)); console.log(`📊 BLAST COMPLETE`); console.log(` Sent: ${sent}`); console.log(` Failed: ${failed}`); console.log(` Time: ${totalTime} minutes`); console.log(` Total sent (all time): ${getSentEmails().size}`); console.log('═'.repeat(60));}
// Mainconst args = process.argv.slice(2);const hasResume = args.includes('--resume') || args.includes('-r');
if (args.includes('--fetch') || args.includes('-f')) { // Fetch all users from Auth0, show them flying by, save locally await getAllAuth0Users(hasResume, true);} else if (args.includes('--fetch-all') || args.includes('--bulk')) { // Use Auth0 export job for 16k+ users await exportAllUsers();} else if (args.includes('--list') || args.includes('-l')) { // Show cached users (or fetch if none) const users = loadCachedUsers(); if (users.length === 0 || hasResume) { await listUsers(hasResume); } else { await showCachedUsers(); }} else if (args.includes('--export') || args.includes('-e')) { await exportUsers();} else if (args.includes('--preview') || args.includes('-p')) { previewEmail();} else if (args.includes('--test') || args.includes('-t')) { const testIdx = args.indexOf('--test') !== -1 ? args.indexOf('--test') : args.indexOf('-t'); const testEmail = args[testIdx + 1]; if (!testEmail) { console.log('Usage: node email-blast.mjs --test EMAIL'); process.exit(1); } await sendTestEmail(testEmail);} else if (args.includes('--send') || args.includes('-s')) { const includeUnverified = args.includes('--all') || args.includes('--include-unverified'); await sendBlast(hasResume, !includeUnverified);} else if (args.includes('--clear')) { clearFetchCheckpoint(); console.log('✅ Cleared all cached data and checkpoints');} else { console.log(`📧 Email Blast Tool — give.aesthetic.computer
Usage: node artery/email-blast.mjs --fetch Fetch users (page-based, max 1000) node artery/email-blast.mjs --fetch-all Bulk export ALL users (16k+) node artery/email-blast.mjs --list Show cached users node artery/email-blast.mjs --export Export to CSV node artery/email-blast.mjs --preview Preview email content node artery/email-blast.mjs --test EMAIL Send test email node artery/email-blast.mjs --send Send to VERIFIED users only (default) node artery/email-blast.mjs --send --all Send to ALL users (verified + unverified) node artery/email-blast.mjs --send --resume Resume interrupted send node artery/email-blast.mjs --clear Clear all cached data
Files: vault/user-reports/email-blast-users.json - Cached user list scratch/email-blast-sent.log - Emails successfully sent scratch/email-blast-failed.log - Failed emails
Notes: - Muted + unsubscribed users automatically excluded - Use --fetch-all for 16k+ users (Auth0 bulk export) - Gmail limit: ~500 emails/day (~11 days for verified, ~36 days for all) - Use --resume to continue across multiple days - Each email includes a personalized unsubscribe link`);}