#!/usr/bin/env ts-node
/**
* Email Digest Cloud Run Job
* Sends daily or weekly digest emails to opted-in users
*
* Usage:
* ts-node send-email-digests.ts daily
* ts-node send-email-digests.ts weekly
*
* Environment Variables:
* - SUPABASE_URL
* - SUPABASE_SERVICE_ROLE_KEY
* - RESEND_API_KEY
* - RESEND_FROM_EMAIL
* - RESEND_FROM_NAME
* - BATCH_SIZE (optional, default: 100)
*/
import { generateAndSendDigest, getUsersForDigest } from '../packages/frontend/src/actions/email-digest';
const BATCH_SIZE = parseInt(process.env.BATCH_SIZE || '100', 10);
const MAX_CONCURRENT = 10; // Send up to 10 emails concurrently
const DELAY_BETWEEN_BATCHES = 1000; // 1 second delay between batches
interface DigestStats {
total: number;
sent: number;
failed: number;
skipped: number;
errors: Array<{ userId: string; error: string }>;
}
/**
* Sleep for a specified number of milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Send digests to a batch of users with concurrency control
*/
async function sendBatchDigests(
userIds: string[],
digestType: 'daily' | 'weekly',
stats: DigestStats
): Promise<void> {
// Process users in chunks to respect rate limits
for (let i = 0; i < userIds.length; i += MAX_CONCURRENT) {
const chunk = userIds.slice(i, i + MAX_CONCURRENT);
const promises = chunk.map(async (userId) => {
try {
const result = await generateAndSendDigest(userId, digestType);
if (result.success) {
if (result.data?.emailId === 'skipped') {
stats.skipped++;
console.log(`[SKIPPED] User ${userId} - not due for digest`);
} else {
stats.sent++;
console.log(`[SENT] User ${userId} - Email ID: ${result.data?.emailId}`);
}
} else {
stats.failed++;
stats.errors.push({ userId, error: result.error || 'Unknown error' });
console.error(`[FAILED] User ${userId} - ${result.error}`);
}
} catch (error) {
stats.failed++;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
stats.errors.push({ userId, error: errorMsg });
console.error(`[ERROR] User ${userId} - ${errorMsg}`);
}
});
await Promise.all(promises);
// Delay between chunks to respect rate limits
if (i + MAX_CONCURRENT < userIds.length) {
await sleep(DELAY_BETWEEN_BATCHES);
}
}
}
/**
* Main function to send digests
*/
async function main() {
const digestType = process.argv[2] as 'daily' | 'weekly' | undefined;
if (!digestType || !['daily', 'weekly'].includes(digestType)) {
console.error('Usage: ts-node send-email-digests.ts [daily|weekly]');
process.exit(1);
}
console.log('='.repeat(60));
console.log(`📧 Starting ${digestType} email digest job`);
console.log(`Batch size: ${BATCH_SIZE}`);
console.log(`Max concurrent: ${MAX_CONCURRENT}`);
console.log('='.repeat(60));
console.log('');
const stats: DigestStats = {
total: 0,
sent: 0,
failed: 0,
skipped: 0,
errors: [],
};
const startTime = Date.now();
try {
let offset = 0;
let hasMore = true;
while (hasMore) {
console.log(`\nFetching users (offset: ${offset}, limit: ${BATCH_SIZE})...`);
// Get batch of users
const result = await getUsersForDigest(digestType, BATCH_SIZE, offset);
if (!result.success || !result.data) {
console.error(`Failed to fetch users: ${result.error}`);
break;
}
const users = result.data;
stats.total += users.length;
if (users.length === 0) {
console.log('No more users to process.');
hasMore = false;
break;
}
console.log(`Found ${users.length} users to process`);
console.log(`Users: ${users.map((u) => u.email).join(', ')}`);
// Send digests to this batch
const userIds = users.map((u) => u.user_id);
await sendBatchDigests(userIds, digestType, stats);
// Check if there are more users
if (users.length < BATCH_SIZE) {
hasMore = false;
} else {
offset += BATCH_SIZE;
}
}
// Print final statistics
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log('');
console.log('='.repeat(60));
console.log('📊 Digest Job Summary');
console.log('='.repeat(60));
console.log(`Digest Type: ${digestType}`);
console.log(`Total Users: ${stats.total}`);
console.log(`✅ Sent: ${stats.sent}`);
console.log(`⏭️ Skipped: ${stats.skipped}`);
console.log(`❌ Failed: ${stats.failed}`);
console.log(`Duration: ${duration}s`);
console.log('');
if (stats.errors.length > 0) {
console.log('Errors:');
stats.errors.forEach(({ userId, error }) => {
console.log(` - ${userId}: ${error}`);
});
console.log('');
}
if (stats.failed > 0) {
console.error('⚠️ Job completed with errors');
process.exit(1);
} else {
console.log('✅ Job completed successfully');
process.exit(0);
}
} catch (error) {
console.error('');
console.error('='.repeat(60));
console.error('💥 Fatal Error');
console.error('='.repeat(60));
console.error(error);
console.error('');
process.exit(1);
}
}
// Run the job
main();