#!/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 links function 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 checkpoint function 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 checkpoint function loadFetchCheckpoint() { if (!existsSync(FETCH_CHECKPOINT_FILE)) return null; try { return JSON.parse(readFileSync(FETCH_CHECKPOINT_FILE, 'utf-8')); } catch { return null; } } // Load cached users function loadCachedUsers() { if (!existsSync(USERS_CACHE_FILE)) return []; try { return JSON.parse(readFileSync(USERS_CACHE_FILE, 'utf-8')); } catch { return []; } } // Clear fetch checkpoint function 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 status async 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 export async 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+ users async 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.env const 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 `
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:
If you want to see what support goes toward:
You can also help by replying to this email or sharing a favorite AC thing with a friend.
β @jeffrey
Unsubscribe from Aesthetic.Computer emails
`; } // Get Auth0 access token async 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 rest async 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 prompt function 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 MongoDB async 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 CSV async 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 content async 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 email async function sendEmail(transporter, to) { await ensureUnsubscribeSecret(); const unsubUrl = getUnsubscribeUrl(to); const result = await transporter.sendMail({ from: '"Aesthetic Computer"