Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 860 lines 28 kB view raw
1#!/usr/bin/env node 2// email-blast.mjs 3// Send email blast to all Auth0 users about give.aesthetic.computer 4// 5// Usage: 6// node artery/email-blast.mjs --list # List all users (summary) 7// node artery/email-blast.mjs --export # Export all emails to CSV 8// node artery/email-blast.mjs --test EMAIL # Send test email 9// node artery/email-blast.mjs --preview # Preview email content 10// node artery/email-blast.mjs --send # Send to VERIFIED users (default) 11// node artery/email-blast.mjs --send --all # Send to ALL users 12// node artery/email-blast.mjs --send --resume # Resume from last sent 13// 14// Gmail SMTP limits: 15// ~500 emails/day with app passwords. 16// Verified users (~5.3k) = ~11 days. All users (~18k) = ~36 days. 17// Use --resume to continue across multiple days. 18 19import { config } from 'dotenv'; 20import { fileURLToPath } from 'url'; 21import { dirname, join } from 'path'; 22import { createTransport } from 'nodemailer'; 23import { createInterface } from 'readline'; 24import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from 'fs'; 25import { createHmac } from 'crypto'; 26 27const __dirname = dirname(fileURLToPath(import.meta.url)); 28 29// Load env from at/.env (has Auth0 M2M creds) and at/deploy.env (has SMTP creds) 30config({ path: join(__dirname, '../at/.env') }); 31config({ path: join(__dirname, '../at/deploy.env') }); 32 33let unsubscribeSecret = process.env.UNSUBSCRIBE_SECRET || null; 34 35async function ensureUnsubscribeSecret() { 36 if (unsubscribeSecret) return unsubscribeSecret; 37 38 const { connect } = await import('../system/backend/database.mjs'); 39 const database = await connect(); 40 41 try { 42 const secrets = await database.db 43 .collection('secrets') 44 .findOne({ _id: 'email-blast' }); 45 46 if (!secrets?.unsubscribeSecret) { 47 throw new Error('email-blast unsubscribe secret not found'); 48 } 49 50 unsubscribeSecret = secrets.unsubscribeSecret; 51 return unsubscribeSecret; 52 } finally { 53 await database.disconnect(); 54 } 55} 56 57// HMAC token generation for unsubscribe links 58function generateUnsubscribeToken(email) { 59 if (!unsubscribeSecret) { 60 throw new Error('unsubscribe secret not loaded'); 61 } 62 return createHmac('sha256', unsubscribeSecret) 63 .update(email.toLowerCase().trim()) 64 .digest('hex'); 65} 66 67function getUnsubscribeUrl(email) { 68 const token = generateUnsubscribeToken(email); 69 return `https://aesthetic.computer/api/unsubscribe?email=${encodeURIComponent(email)}&token=${token}`; 70} 71 72// File paths for tracking (user data goes to vault, logs stay in scratch) 73const VAULT_DIR = join(__dirname, '../aesthetic-computer-vault/user-reports'); 74const SENT_LOG_FILE = join(__dirname, '../scratch/email-blast-sent.log'); 75const FAILED_LOG_FILE = join(__dirname, '../scratch/email-blast-failed.log'); 76const EXPORT_FILE = join(VAULT_DIR, 'email-blast-users.csv'); 77const USERS_CACHE_FILE = join(VAULT_DIR, 'email-blast-users.json'); 78const FETCH_CHECKPOINT_FILE = join(__dirname, '../scratch/email-blast-checkpoint.json'); 79 80// Save fetch checkpoint 81function saveFetchCheckpoint(from, users) { 82 writeFileSync(FETCH_CHECKPOINT_FILE, JSON.stringify({ 83 from, 84 count: users.length, 85 timestamp: new Date().toISOString() 86 })); 87 // Also save all users fetched so far 88 writeFileSync(USERS_CACHE_FILE, JSON.stringify(users, null, 2)); 89} 90 91// Load fetch checkpoint 92function loadFetchCheckpoint() { 93 if (!existsSync(FETCH_CHECKPOINT_FILE)) return null; 94 try { 95 return JSON.parse(readFileSync(FETCH_CHECKPOINT_FILE, 'utf-8')); 96 } catch { return null; } 97} 98 99// Load cached users 100function loadCachedUsers() { 101 if (!existsSync(USERS_CACHE_FILE)) return []; 102 try { 103 return JSON.parse(readFileSync(USERS_CACHE_FILE, 'utf-8')); 104 } catch { return []; } 105} 106 107// Clear fetch checkpoint 108function clearFetchCheckpoint() { 109 if (existsSync(FETCH_CHECKPOINT_FILE)) unlinkSync(FETCH_CHECKPOINT_FILE); 110 if (existsSync(USERS_CACHE_FILE)) unlinkSync(USERS_CACHE_FILE); 111} 112 113// Create an Auth0 export job to get ALL users (for 16k+) 114async function createExportJob() { 115 const { got } = await import('got'); 116 const { token, baseURI } = await getAuth0Token(); 117 118 console.log('📤 Creating Auth0 user export job...\n'); 119 120 const response = await got.post(`${baseURI}/api/v2/jobs/users-exports`, { 121 json: { 122 format: 'json', 123 fields: [ 124 { name: 'user_id' }, 125 { name: 'email' }, 126 { name: 'email_verified' }, 127 { name: 'created_at' }, 128 { name: 'last_login' }, 129 { name: 'logins_count' }, 130 ], 131 }, 132 headers: { Authorization: `Bearer ${token}` }, 133 responseType: 'json', 134 }); 135 136 return response.body; 137} 138 139// Check export job status 140async function checkExportJob(jobId) { 141 const { got } = await import('got'); 142 const { token, baseURI } = await getAuth0Token(); 143 144 const response = await got(`${baseURI}/api/v2/jobs/${jobId}`, { 145 headers: { Authorization: `Bearer ${token}` }, 146 responseType: 'json', 147 }); 148 149 return response.body; 150} 151 152// Download and parse export 153async function downloadExport(url) { 154 const { got } = await import('got'); 155 const { createGunzip } = await import('zlib'); 156 const { pipeline } = await import('stream/promises'); 157 const { Readable } = await import('stream'); 158 159 console.log('📥 Downloading export...'); 160 161 const response = await got(url, { responseType: 'buffer' }); 162 163 // Auth0 exports are gzipped NDJSON 164 const gunzip = createGunzip(); 165 const chunks = []; 166 167 await new Promise((resolve, reject) => { 168 const input = Readable.from(response.body); 169 input.pipe(gunzip); 170 gunzip.on('data', chunk => chunks.push(chunk)); 171 gunzip.on('end', resolve); 172 gunzip.on('error', reject); 173 }); 174 175 const text = Buffer.concat(chunks).toString('utf-8'); 176 const users = text.trim().split('\n').map(line => JSON.parse(line)); 177 178 return users; 179} 180 181// Full export flow for 16k+ users 182async function exportAllUsers() { 183 console.log('\n🚀 AUTH0 BULK EXPORT (for 16k+ users)\n'); 184 185 // Create export job 186 const job = await createExportJob(); 187 console.log(` Job ID: ${job.id}`); 188 console.log(` Status: ${job.status}`); 189 190 // Poll for completion 191 let status = job.status; 192 let result = job; 193 let dots = 0; 194 195 while (status === 'pending' || status === 'processing') { 196 await new Promise(r => setTimeout(r, 2000)); 197 result = await checkExportJob(job.id); 198 status = result.status; 199 dots++; 200 process.stdout.write(`\r Waiting${'.'.repeat(dots % 4).padEnd(3)} (${status})`); 201 } 202 203 console.log(`\n Final status: ${status}`); 204 205 if (status !== 'completed') { 206 console.log(`\n❌ Export failed: ${result.error || 'Unknown error'}`); 207 return []; 208 } 209 210 // Download the export 211 const users = await downloadExport(result.location); 212 213 // Save to cache 214 saveFetchCheckpoint('export', users); 215 216 const verified = users.filter(u => u.email_verified).length; 217 console.log(`\n${'═'.repeat(50)}`); 218 console.log(`📊 EXPORT COMPLETE`); 219 console.log(` Total users: ${users.length}`); 220 console.log(` Verified: ${verified}`); 221 console.log(` Saved to: aesthetic-computer-vault/user-reports/`); 222 console.log(`${'═'.repeat(50)}\n`); 223 224 return users; 225} 226 227// SMTP config from at/deploy.env 228const SMTP_CONFIG = { 229 host: 'smtp.gmail.com', 230 port: 465, 231 secure: true, 232 auth: { 233 user: process.env.SMTP_USER || 'mail@aesthetic.computer', 234 pass: process.env.SMTP_PASS, // Required: set in environment or at/.env 235 }, 236}; 237 238if (!SMTP_CONFIG.auth.pass) { 239 console.error('❌ SMTP_PASS environment variable is required!'); 240 console.error(' Set it in at/.env or export it before running.'); 241 process.exit(1); 242} 243 244const EMAIL_SUBJECT = 'a little note from aesthetic computer'; 245 246function getEmailText(recipientEmail) { 247 const unsubUrl = getUnsubscribeUrl(recipientEmail); 248 return `Hi, 249 250Aesthetic Computer has had a sweet year so far. 251 252The 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. 253 254If you want to help keep it alive and growing, the simplest way is: 255 256https://give.aesthetic.computer 257 258If you want to see what support goes toward: 259 260https://bills.aesthetic.computer 261 262You can also help by replying to this email or sharing a favorite AC thing with a friend. 263 264— @jeffrey 265 266--- 267Unsubscribe: ${unsubUrl}`; 268} 269 270function getEmailHtml(recipientEmail) { 271 const unsubUrl = getUnsubscribeUrl(recipientEmail); 272 return `<p>Hi,</p> 273 274<p> 275 Aesthetic Computer has had a sweet year so far. 276</p> 277 278<p> 279 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. 280</p> 281 282<p> 283 If you want to help keep it alive and growing, the simplest way is: 284</p> 285 286<p><a href="https://give.aesthetic.computer">give.aesthetic.computer</a></p> 287 288<p> 289 If you want to see what support goes toward: 290</p> 291 292<p><a href="https://bills.aesthetic.computer">bills.aesthetic.computer</a></p> 293 294<p>You can also help by replying to this email or sharing a favorite AC thing with a friend.</p> 295 296<p>— @jeffrey</p> 297 298<hr> 299<p><a href="${unsubUrl}">Unsubscribe</a> from Aesthetic.Computer emails</p>`; 300} 301 302// Get Auth0 access token 303async function getAuth0Token() { 304 const { got } = await import('got'); 305 306 const clientId = process.env.AUTH0_M2M_CLIENT_ID; 307 const clientSecret = process.env.AUTH0_M2M_SECRET; 308 const baseURI = 'https://aesthetic.us.auth0.com'; 309 310 if (!clientId || !clientSecret) { 311 throw new Error('Missing AUTH0_M2M_CLIENT_ID or AUTH0_M2M_SECRET in env'); 312 } 313 314 const response = await got.post(`${baseURI}/oauth/token`, { 315 json: { 316 client_id: clientId, 317 client_secret: clientSecret, 318 audience: `${baseURI}/api/v2/`, 319 grant_type: 'client_credentials', 320 }, 321 responseType: 'json', 322 }); 323 324 return { token: response.body.access_token, baseURI }; 325} 326 327// Get ALL Auth0 users - use page-based for first 1000, then checkpoint for rest 328async function getAllAuth0Users(resume = false, showUsers = true) { 329 const { got } = await import('got'); 330 const { token, baseURI } = await getAuth0Token(); 331 332 let allUsers = []; 333 const perPage = 100; 334 335 // Track existing user IDs to avoid duplicates 336 const existingIds = new Set(); 337 338 // Check for resume 339 if (resume) { 340 const cached = loadCachedUsers(); 341 if (cached.length > 0) { 342 for (const u of cached) { 343 if (!existingIds.has(u.user_id)) { 344 existingIds.add(u.user_id); 345 allUsers.push(u); 346 } 347 } 348 console.log(`\n🔄 RESUMING: Loaded ${allUsers.length} unique users from cache\n`); 349 } 350 } 351 352 if (allUsers.length === 0) { 353 console.log('📥 Fetching ALL Auth0 users...\n'); 354 } 355 356 // Phase 1: Page-based pagination (works up to ~1000 users reliably) 357 let page = 0; 358 let pageBasedDone = false; 359 360 while (!pageBasedDone && page < 100) { // Max 10,000 via pages 361 try { 362 const response = await got(`${baseURI}/api/v2/users`, { 363 searchParams: { 364 per_page: perPage, 365 page: page, 366 include_totals: true, 367 fields: 'user_id,email,email_verified,created_at', 368 include_fields: true, 369 }, 370 headers: { Authorization: `Bearer ${token}` }, 371 responseType: 'json', 372 }); 373 374 const data = response.body; 375 const users = data.users || data; 376 const total = data.total || 0; 377 378 if (users.length === 0) { 379 pageBasedDone = true; 380 break; 381 } 382 383 let newCount = 0; 384 for (const user of users) { 385 if (existingIds.has(user.user_id)) continue; 386 existingIds.add(user.user_id); 387 allUsers.push(user); 388 newCount++; 389 390 if (showUsers) { 391 const email = (user.email || '(no email)').slice(0, 35).padEnd(37); 392 const date = new Date(user.created_at).toISOString().slice(0, 10); 393 const v = user.email_verified ? '✓' : '✗'; 394 console.log(`${v} ${date} ${email} [${allUsers.length}]`); 395 } 396 } 397 398 page++; 399 400 // Save checkpoint every 500 users 401 if (allUsers.length % 500 < perPage && allUsers.length >= 500) { 402 const verifiedCount = allUsers.filter(u => u.email_verified).length; 403 saveFetchCheckpoint('page:' + page, allUsers); 404 console.log(`\n 💾 Checkpoint: ${allUsers.length} users (${verifiedCount} verified) [page ${page}]\n`); 405 } 406 407 // Check if we've gotten all users 408 if (allUsers.length >= total || users.length < perPage) { 409 pageBasedDone = true; 410 } 411 412 await new Promise(r => setTimeout(r, 100)); 413 414 } catch (error) { 415 if (error.response?.statusCode === 429) { 416 console.log(`\n ⏳ Rate limited, waiting 5s...`); 417 saveFetchCheckpoint('page:' + page, allUsers); 418 await new Promise(r => setTimeout(r, 5000)); 419 continue; 420 } 421 if (error.response?.statusCode === 400) { 422 // Hit the 1000 user limit for page-based 423 console.log(`\n ℹ️ Page-based limit reached at page ${page}`); 424 pageBasedDone = true; 425 break; 426 } 427 saveFetchCheckpoint('page:' + page, allUsers); 428 throw error; 429 } 430 } 431 432 // Final save 433 const verifiedCount = allUsers.filter(u => u.email_verified).length; 434 saveFetchCheckpoint('done', allUsers); 435 436 console.log(`\n${'═'.repeat(50)}`); 437 console.log(`📊 FETCH COMPLETE`); 438 console.log(` Total users: ${allUsers.length}`); 439 console.log(` Verified: ${verifiedCount}`); 440 console.log(` Saved to: aesthetic-computer-vault/user-reports/`); 441 console.log(`${'═'.repeat(50)}\n`); 442 443 return allUsers; 444} 445 446// Quick y/n prompt 447function confirmContinue(question) { 448 const rl = createInterface({ input: process.stdin, output: process.stdout }); 449 return new Promise(resolve => { 450 rl.question(question, answer => { 451 rl.close(); 452 resolve(answer.toLowerCase() !== 'n' && answer.toLowerCase() !== 'no'); 453 }); 454 }); 455} 456 457// Get handles from MongoDB 458async function getHandles() { 459 const { connect } = await import('../system/backend/database.mjs'); 460 const database = await connect(); 461 const handles = database.db.collection('@handles'); 462 463 const allHandles = await handles.find({}).toArray(); 464 const handleMap = new Map(); 465 466 for (const h of allHandles) { 467 handleMap.set(h._id, h.handle); 468 } 469 470 await database.disconnect(); 471 return handleMap; 472} 473 474// List all users (summary only for large counts) 475async function listUsers(resume = false) { 476 console.log('\n📋 FETCHING ALL AUTH0 USERS\n'); 477 478 const users = await getAllAuth0Users(resume, true); 479 const handles = await getHandles(); 480 481 let verifiedCount = 0; 482 let withHandleCount = 0; 483 484 for (const user of users) { 485 if (user.email_verified) verifiedCount++; 486 if (handles.get(user.user_id)) withHandleCount++; 487 } 488 489 console.log(`\n📊 FINAL SUMMARY:`); 490 console.log(` Total users: ${users.length}`); 491 console.log(` Email verified: ${verifiedCount}`); 492 console.log(` With handles: ${withHandleCount}`); 493 console.log(` Local cache: vault/user-reports/`); 494} 495 496// Just load cached users (no fetch) 497async function showCachedUsers() { 498 const users = loadCachedUsers(); 499 if (users.length === 0) { 500 console.log('\n❌ No cached users. Run --fetch first.\n'); 501 return; 502 } 503 504 const handles = await getHandles(); 505 let verifiedCount = 0; 506 let withHandleCount = 0; 507 508 console.log('\n📋 CACHED USERS (from local file):\n'); 509 console.log('─'.repeat(70)); 510 511 for (const user of users) { 512 const email = (user.email || '(no email)').slice(0, 35).padEnd(37); 513 const date = new Date(user.created_at).toISOString().slice(0, 10); 514 const v = user.email_verified ? '✓' : '✗'; 515 const handle = handles.get(user.user_id); 516 517 if (user.email_verified) verifiedCount++; 518 if (handle) withHandleCount++; 519 520 console.log(`${v} ${date} ${email} ${handle ? '@' + handle : ''}`); 521 } 522 523 console.log('─'.repeat(70)); 524 console.log(`\n📊 SUMMARY:`); 525 console.log(` Total: ${users.length}`); 526 console.log(` Verified: ${verifiedCount}`); 527 console.log(` With handles: ${withHandleCount}`); 528} 529 530// Export all users to CSV 531async function exportUsers() { 532 console.log('\n📤 EXPORTING ALL USERS TO CSV\n'); 533 534 const users = await getAllAuth0Users(); 535 const handles = await getHandles(); 536 537 let verifiedCount = 0; 538 const lines = ['email,created_at,verified,handle']; 539 540 for (const user of users) { 541 const email = user.email || ''; 542 const created = user.created_at || ''; 543 const verified = user.email_verified ? 'yes' : 'no'; 544 const handle = handles.get(user.user_id) || ''; 545 546 if (user.email_verified) verifiedCount++; 547 548 // Escape commas in email 549 const safeEmail = email.includes(',') ? `"${email}"` : email; 550 lines.push(`${safeEmail},${created},${verified},${handle}`); 551 } 552 553 writeFileSync(EXPORT_FILE, lines.join('\n')); 554 555 console.log(`✅ Exported ${users.length} users to:`); 556 console.log(` ${EXPORT_FILE}`); 557 console.log(`\n📊 Summary:`); 558 console.log(` Total: ${users.length}`); 559 console.log(` Verified: ${verifiedCount}`); 560} 561 562// Preview email content 563async function previewEmail() { 564 await ensureUnsubscribeSecret(); 565 console.log('\n📧 EMAIL PREVIEW\n'); 566 console.log('─'.repeat(60)); 567 console.log(`From: mail@aesthetic.computer`); 568 console.log(`Subject: ${EMAIL_SUBJECT}`); 569 console.log('─'.repeat(60)); 570 console.log(getEmailText('preview@example.com')); 571 console.log('─'.repeat(60)); 572} 573 574// Send a single email 575async function sendEmail(transporter, to) { 576 await ensureUnsubscribeSecret(); 577 const unsubUrl = getUnsubscribeUrl(to); 578 const result = await transporter.sendMail({ 579 from: '"Aesthetic Computer" <mail@aesthetic.computer>', 580 to, 581 subject: EMAIL_SUBJECT, 582 text: getEmailText(to), 583 html: getEmailHtml(to), 584 headers: { 585 'List-Unsubscribe': `<${unsubUrl}>`, 586 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 587 }, 588 }); 589 return result; 590} 591 592// Send test email 593async function sendTestEmail(testEmail) { 594 console.log(`\n📧 Sending test email to: ${testEmail}\n`); 595 596 const transporter = createTransport(SMTP_CONFIG); 597 598 try { 599 const result = await sendEmail(transporter, testEmail); 600 console.log(`✅ Test email sent! Message ID: ${result.messageId}`); 601 } catch (error) { 602 console.error(`❌ Failed to send: ${error.message}`); 603 } 604} 605 606// Prompt for confirmation 607function confirm(question) { 608 const rl = createInterface({ 609 input: process.stdin, 610 output: process.stdout, 611 }); 612 613 return new Promise(resolve => { 614 rl.question(question, answer => { 615 rl.close(); 616 resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y'); 617 }); 618 }); 619} 620 621// Get already-sent emails from log 622function getSentEmails() { 623 if (!existsSync(SENT_LOG_FILE)) return new Set(); 624 const content = readFileSync(SENT_LOG_FILE, 'utf-8'); 625 return new Set(content.split('\n').filter(Boolean)); 626} 627 628// Log a sent email 629function logSentEmail(email) { 630 appendFileSync(SENT_LOG_FILE, email + '\n'); 631} 632 633// Log a failed email 634function logFailedEmail(email, error) { 635 appendFileSync(FAILED_LOG_FILE, `${email}|${error}\n`); 636} 637 638// Get muted users from MongoDB 639async function getMutedUsers() { 640 const { connect } = await import('../system/backend/database.mjs'); 641 const db = await connect(); 642 643 const mutesCollections = ['chat-sotce-mutes', 'chat-clock-mutes', 'chat-system-mutes']; 644 const mutedUsers = new Set(); 645 646 for (const col of mutesCollections) { 647 const mutes = await db.db.collection(col).find({}).toArray(); 648 mutes.forEach(m => mutedUsers.add(m.user)); 649 } 650 651 await db.disconnect(); 652 return mutedUsers; 653} 654 655// Get unsubscribed emails from MongoDB 656async function getUnsubscribedEmails() { 657 const { connect } = await import('../system/backend/database.mjs'); 658 const db = await connect(); 659 660 const docs = await db.db.collection('email-blast-unsubscribes').find({}).toArray(); 661 const emails = new Set(docs.map(d => d.email.toLowerCase())); 662 663 await db.disconnect(); 664 return emails; 665} 666 667// Send blast to verified users by default, excluding muted + unsubscribed 668async function sendBlast(resume = false, verifiedOnly = true) { 669 console.log('\n🚀 EMAIL BLAST MODE\n'); 670 671 // Load cached users 672 let users = loadCachedUsers(); 673 if (users.length === 0) { 674 console.log('⚠️ No cached users. Fetching...'); 675 users = await getAllAuth0Users(false, false); 676 } 677 678 // Get muted users and unsubscribed emails 679 console.log('📋 Checking muted users...'); 680 const mutedUsers = await getMutedUsers(); 681 console.log('📋 Checking unsubscribed emails...'); 682 const unsubscribedEmails = await getUnsubscribedEmails(); 683 684 // Filter users 685 let eligibleUsers = users.filter(u => 686 u.email && 687 !mutedUsers.has(u.user_id) && 688 !unsubscribedEmails.has(u.email.toLowerCase()) 689 ); 690 691 if (verifiedOnly) { 692 eligibleUsers = eligibleUsers.filter(u => u.email_verified); 693 } 694 695 const verified = eligibleUsers.filter(u => u.email_verified).length; 696 const unverified = eligibleUsers.length - verified; 697 698 // Get already sent if resuming 699 const alreadySent = resume ? getSentEmails() : new Set(); 700 const toSend = eligibleUsers.filter(u => !alreadySent.has(u.email)); 701 702 console.log(`\n📊 Stats:`); 703 console.log(` Total eligible: ${eligibleUsers.length} (${verified} verified, ${unverified} unverified)`); 704 console.log(` Muted/excluded: ${mutedUsers.size}`); 705 console.log(` Unsubscribed: ${unsubscribedEmails.size}`); 706 if (resume) { 707 console.log(` Already sent: ${alreadySent.size}`); 708 } 709 console.log(` To send now: ${toSend.length}`); 710 711 if (toSend.length === 0) { 712 console.log('\n✅ All emails already sent!'); 713 return; 714 } 715 716 // Estimate time 717 const estimatedMinutes = Math.ceil(toSend.length * 1.2 / 60); // 1.2s per email avg 718 const estimatedDays = Math.ceil(toSend.length / 450); 719 console.log(` Estimated time: ~${estimatedMinutes} minutes per day`); 720 console.log(` Estimated days: ~${estimatedDays} (Gmail ~500/day limit)`); 721 722 console.log(`\n⚠️ WARNING: This will send ${toSend.length} emails!`); 723 console.log(` Use --resume to continue across multiple days.`); 724 725 const confirmed = await confirm('\nType "yes" to confirm: '); 726 727 if (!confirmed) { 728 console.log('❌ Aborted.'); 729 return; 730 } 731 732 console.log('\n📤 Starting email blast...\n'); 733 console.log(` Progress saved to: ${SENT_LOG_FILE}`); 734 console.log(` Use --send --resume to continue if interrupted\n`); 735 736 const transporter = createTransport(SMTP_CONFIG); 737 738 let sent = 0; 739 let failed = 0; 740 const startTime = Date.now(); 741 742 for (let i = 0; i < toSend.length; i++) { 743 const user = toSend[i]; 744 const email = user.email; 745 746 try { 747 await sendEmail(transporter, email); 748 sent++; 749 logSentEmail(email); 750 751 const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1); 752 const rate = (sent / (elapsed || 0.1)).toFixed(1); 753 console.log(`✅ [${sent + failed}/${toSend.length}] ${email} (${elapsed}m, ${rate}/min)`); 754 } catch (error) { 755 failed++; 756 logFailedEmail(email, error.message); 757 console.log(`❌ [${sent + failed}/${toSend.length}] ${email} - ${error.message}`); 758 759 // If too many failures, pause longer 760 if (failed > 10 && failed / (sent + failed) > 0.2) { 761 console.log(`\n⚠️ High failure rate! Pausing 30s...`); 762 await new Promise(r => setTimeout(r, 30000)); 763 } 764 } 765 766 // Rate limit: 1 email per second 767 if (i < toSend.length - 1) { 768 await new Promise(r => setTimeout(r, 1000)); 769 } 770 771 // Longer pause every 50 emails (Gmail likes this) 772 if ((i + 1) % 50 === 0) { 773 console.log(`\n⏸️ Batch ${Math.floor((i + 1) / 50)} complete. Pausing 10s...\n`); 774 await new Promise(r => setTimeout(r, 10000)); 775 } 776 777 // Even longer pause every 400 emails (approaching daily limit) 778 if ((i + 1) % 400 === 0) { 779 console.log(`\n🛑 Approaching Gmail daily limit. Pausing 5 minutes...`); 780 console.log(` (Safe to Ctrl+C and resume later with --resume)\n`); 781 await new Promise(r => setTimeout(r, 5 * 60 * 1000)); 782 } 783 } 784 785 const totalTime = ((Date.now() - startTime) / 1000 / 60).toFixed(1); 786 787 console.log('\n' + '═'.repeat(60)); 788 console.log(`📊 BLAST COMPLETE`); 789 console.log(` Sent: ${sent}`); 790 console.log(` Failed: ${failed}`); 791 console.log(` Time: ${totalTime} minutes`); 792 console.log(` Total sent (all time): ${getSentEmails().size}`); 793 console.log('═'.repeat(60)); 794} 795 796// Main 797const args = process.argv.slice(2); 798const hasResume = args.includes('--resume') || args.includes('-r'); 799 800if (args.includes('--fetch') || args.includes('-f')) { 801 // Fetch all users from Auth0, show them flying by, save locally 802 await getAllAuth0Users(hasResume, true); 803} else if (args.includes('--fetch-all') || args.includes('--bulk')) { 804 // Use Auth0 export job for 16k+ users 805 await exportAllUsers(); 806} else if (args.includes('--list') || args.includes('-l')) { 807 // Show cached users (or fetch if none) 808 const users = loadCachedUsers(); 809 if (users.length === 0 || hasResume) { 810 await listUsers(hasResume); 811 } else { 812 await showCachedUsers(); 813 } 814} else if (args.includes('--export') || args.includes('-e')) { 815 await exportUsers(); 816} else if (args.includes('--preview') || args.includes('-p')) { 817 previewEmail(); 818} else if (args.includes('--test') || args.includes('-t')) { 819 const testIdx = args.indexOf('--test') !== -1 ? args.indexOf('--test') : args.indexOf('-t'); 820 const testEmail = args[testIdx + 1]; 821 if (!testEmail) { 822 console.log('Usage: node email-blast.mjs --test EMAIL'); 823 process.exit(1); 824 } 825 await sendTestEmail(testEmail); 826} else if (args.includes('--send') || args.includes('-s')) { 827 const includeUnverified = args.includes('--all') || args.includes('--include-unverified'); 828 await sendBlast(hasResume, !includeUnverified); 829} else if (args.includes('--clear')) { 830 clearFetchCheckpoint(); 831 console.log('✅ Cleared all cached data and checkpoints'); 832} else { 833 console.log(` 834📧 Email Blast Tool — give.aesthetic.computer 835 836Usage: 837 node artery/email-blast.mjs --fetch Fetch users (page-based, max 1000) 838 node artery/email-blast.mjs --fetch-all Bulk export ALL users (16k+) 839 node artery/email-blast.mjs --list Show cached users 840 node artery/email-blast.mjs --export Export to CSV 841 node artery/email-blast.mjs --preview Preview email content 842 node artery/email-blast.mjs --test EMAIL Send test email 843 node artery/email-blast.mjs --send Send to VERIFIED users only (default) 844 node artery/email-blast.mjs --send --all Send to ALL users (verified + unverified) 845 node artery/email-blast.mjs --send --resume Resume interrupted send 846 node artery/email-blast.mjs --clear Clear all cached data 847 848Files: 849 vault/user-reports/email-blast-users.json - Cached user list 850 scratch/email-blast-sent.log - Emails successfully sent 851 scratch/email-blast-failed.log - Failed emails 852 853Notes: 854 - Muted + unsubscribed users automatically excluded 855 - Use --fetch-all for 16k+ users (Auth0 bulk export) 856 - Gmail limit: ~500 emails/day (~11 days for verified, ~36 days for all) 857 - Use --resume to continue across multiple days 858 - Each email includes a personalized unsubscribe link 859`); 860}