Monorepo for Aesthetic.Computer
aesthetic.computer
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}