email.js
1 #!/usr/bin/env node 2 3 /** 4 * Email Outreach Module 5 * Sends personalized proposals via Resend SMTP 6 */ 7 8 import { Resend } from 'resend'; 9 import { readFileSync, existsSync } from 'fs'; 10 import { join, dirname } from 'path'; 11 import { fileURLToPath } from 'url'; 12 import crypto from 'crypto'; 13 import { spin } from '../utils/spintax.js'; 14 import Logger from '../utils/logger.js'; 15 import { syncUnsubscribes, isEmailUnsubscribed } from '../utils/sync-unsubscribes.js'; 16 import { resendBreaker } from '../utils/circuit-breaker.js'; 17 import { resendLimiter } from '../utils/rate-limiter.js'; 18 import { recordOutreachError, shouldHaltChannel } from '../utils/outreach-guard.js'; 19 import { validateEmail as validateEmailZB } from '../utils/zerobounce.js'; 20 import { isOutreachRetriable, computeRetryAt } from '../utils/error-categories.js'; 21 import { run, getOne, getAll } from '../utils/db.js'; 22 import { checkBeforeSend, addSuppression } from '../../../mmo-platform/src/suppression.js'; 23 import '../utils/load-env.js'; 24 25 const __filename = fileURLToPath(import.meta.url); 26 const __dirname = dirname(__filename); 27 const projectRoot = join(__dirname, '../..'); 28 29 const logger = new Logger('EmailOutreach'); 30 31 // Initialize Resend — prefer test key when running tests to avoid prod sends 32 const resend = new Resend(process.env.RESEND_TEST_API_KEY || process.env.RESEND_API_KEY); 33 34 /** 35 * Generate HMAC token for unsubscribe link security 36 */ 37 function generateUnsubscribeToken(outreachId) { 38 const secret = process.env.UNSUBSCRIBE_SECRET || 'default-secret-change-me-in-production'; 39 40 if ( 41 secret === 'default-secret-change-me-in-production' && 42 process.env.NODE_ENV === 'production' 43 ) { 44 throw new Error('UNSUBSCRIBE_SECRET must be set in production'); 45 } 46 47 const hmac = crypto.createHmac('sha256', secret); 48 hmac.update(`${outreachId}`); 49 return hmac.digest('hex').substring(0, 16); 50 } 51 52 /** 53 * Validate unsubscribe token 54 */ 55 export function validateUnsubscribeToken(outreachId, token) { 56 if (!token) return false; 57 58 const expected = generateUnsubscribeToken(outreachId); 59 60 try { 61 // Use timing-safe comparison to prevent timing attacks 62 return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected)); 63 } catch { 64 return false; 65 } 66 } 67 68 /** 69 * Create secure unsubscribe link with HMAC token 70 */ 71 function createUnsubscribeLink(outreachId) { 72 const baseUrl = process.env.UNSUBSCRIBE_BASE_URL || 'https://333method.com/unsubscribe'; 73 const token = generateUnsubscribeToken(outreachId); 74 return `${baseUrl}?id=${outreachId}&token=${token}`; 75 } 76 77 /** 78 * Format email body with minimal HTML 79 * @param {string} proposalText - Email body content 80 * @param {string} signature - Email signature 81 * @param {string} unsubscribeLink - Unsubscribe URL 82 * @param {string} [physicalAddress] - Physical address (for CAN-SPAM compliance) 83 * @param {string} [senderName] - Sender name for disclosure line 84 */ 85 function formatEmailBody( 86 proposalText, 87 signature, 88 unsubscribeLink, 89 physicalAddress = null, 90 senderName = null, 91 subject = '', 92 finePrint = '' 93 ) { 94 // Convert plain text to simple HTML with line breaks 95 const htmlBody = proposalText 96 .split('\n') 97 .map(line => { 98 // Detect URLs and make them clickable 99 const urlRegex = /(https?:\/\/[^\s]+)/g; 100 const lineWithLinks = line.replace(urlRegex, '<a href="$1" style="color: #0066cc;">$1</a>'); 101 return lineWithLinks; 102 }) 103 .join('<br>'); 104 105 const signatureHtml = signature 106 .split('\n') 107 .map(line => { 108 const urlRegex = /(https?:\/\/[^\s]+)/g; 109 return line.replace(urlRegex, '<a href="$1" style="color: #0066cc;">$1</a>'); 110 }) 111 .join('<br>'); 112 113 // Add physical address if provided (CAN-SPAM compliance) 114 const physicalAddressHtml = physicalAddress 115 ? `<div style="margin-top: 10px; font-size: 11px; color: #999;">${physicalAddress}</div>` 116 : ''; 117 118 // Disclosure line — required by Resend AUP for unsolicited outreach 119 const disclosureName = senderName || '333 Method'; 120 const disclosureHtml = `<p>You received this email because ${disclosureName} found your business online and believes our web optimization services may be relevant to you. This is a one-time outreach, not a mailing list.</p>`; 121 122 const finePrintHtml = finePrint 123 ? `<p>${finePrint}</p>` 124 : ''; 125 126 return ` 127 <!DOCTYPE html> 128 <html lang="en"> 129 <head> 130 <meta charset="utf-8"> 131 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 132 <title>${subject ? subject.replace(/</g, '<') : 'Message from Audit&Fix'}</title> 133 </head> 134 <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;"> 135 <div style="white-space: pre-wrap;">${htmlBody}</div> 136 <div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; white-space: pre-wrap;">${signatureHtml}${physicalAddressHtml}</div> 137 <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666;"> 138 ${disclosureHtml} 139 ${finePrintHtml} 140 <p>If you'd prefer not to receive emails from us, you can <a href="${unsubscribeLink}" style="color: #666;">unsubscribe here</a>.</p> 141 </div> 142 </body> 143 </html> 144 `.trim(); 145 } 146 147 /** 148 * Domain warming schedule — limits daily email volume for new sending domains. 149 * Based on Resend's guide: https://resend.com/docs/knowledge-base/warming-up 150 * Day 1: 150, Day 2: 300, Day 3: 500, Day 4: 750, Day 5: 1000, Day 6: 1500, Day 7+: 2000 151 * @returns {Promise<{ allowed: boolean, dailySent: number, dailyLimit: number }>} 152 */ 153 async function checkDomainWarmingLimit() { 154 const warmingSchedule = [150, 300, 500, 750, 1000, 1500, 2000]; 155 156 // Count emails sent today 157 const todayRow = await getOne( 158 `SELECT COUNT(*) AS count FROM messages 159 WHERE contact_method = 'email' 160 AND direction = 'outbound' 161 AND delivery_status IN ('sent', 'delivered', 'opened', 'clicked') 162 AND updated_at > CURRENT_DATE`, 163 [] 164 ); 165 const todaySent = parseInt(todayRow?.count || '0', 10); 166 167 // Get first email sent date to determine warming day 168 const firstRow = await getOne( 169 `SELECT MIN(updated_at) AS first_date FROM messages 170 WHERE contact_method = 'email' 171 AND direction = 'outbound' 172 AND delivery_status IN ('sent', 'delivered', 'opened', 'clicked')`, 173 [] 174 ); 175 const firstSent = firstRow?.first_date; 176 177 let dayIndex = warmingSchedule.length - 1; // Default to max if no history 178 if (firstSent) { 179 const firstDate = new Date(firstSent); 180 const today = new Date(); 181 const daysSinceFirst = Math.floor((today - firstDate) / (1000 * 60 * 60 * 24)); 182 dayIndex = Math.min(daysSinceFirst, warmingSchedule.length - 1); 183 } else { 184 dayIndex = 0; // First day ever 185 } 186 187 const dailyLimit = warmingSchedule[dayIndex]; 188 return { allowed: todaySent < dailyLimit, dailySent: todaySent, dailyLimit }; 189 } 190 191 /** 192 * Inline equivalent of markOutreachResult() for use while error-categories.js is still 193 * being migrated to PostgreSQL. Mirrors the same retry/terminal logic. 194 * @param {number} messageId 195 * @param {string} errorMessage 196 */ 197 async function markOutreachResultAsync(messageId, errorMessage) { 198 if (isOutreachRetriable(errorMessage)) { 199 const retryAt = computeRetryAt(errorMessage); 200 await run( 201 `UPDATE messages 202 SET delivery_status = 'retry_later', error_message = $1, retry_at = $2 203 WHERE id = $3`, 204 [errorMessage || 'Unknown error', retryAt, messageId] 205 ); 206 } else { 207 await run( 208 `UPDATE messages 209 SET delivery_status = 'failed', error_message = $1 210 WHERE id = $2`, 211 [errorMessage || 'Unknown error', messageId] 212 ); 213 } 214 } 215 216 /** 217 * Send email using Resend 218 */ 219 export async function sendEmail(outreachId) { 220 try { 221 // Domain warming check — limit daily sends based on Resend's warming schedule 222 const warming = await checkDomainWarmingLimit(); 223 if (!warming.allowed) { 224 logger.warn( 225 `Domain warming: daily limit reached (${warming.dailySent}/${warming.dailyLimit}). ` + 226 `Deferring outreach #${outreachId} to tomorrow.` 227 ); 228 return { success: false, outreachId, skipped: true, reason: 'domain_warming_limit' }; 229 } 230 231 // Get outreach data (include country_code for compliance) 232 const outreach = await getOne( 233 `SELECT o.*, o.message_body AS proposal_text, s.domain, s.country_code 234 FROM messages o 235 JOIN sites s ON o.site_id = s.id 236 WHERE o.id = $1 AND o.direction = 'outbound'`, 237 [outreachId] 238 ); 239 240 if (!outreach) { 241 throw new Error(`Outreach #${outreachId} not found`); 242 } 243 244 if (outreach.contact_method !== 'email') { 245 throw new Error(`Outreach #${outreachId} is for ${outreach.contact_method}, not email`); 246 } 247 248 // Reputation guard: halt if this channel has hit 25 of the same error in 2h 249 if (shouldHaltChannel('email')) { 250 const logger2 = new Logger('EmailOutreach'); 251 logger2.warn(`Email channel halted by reputation guard — skipping outreach #${outreachId}`); 252 return { success: false, outreachId, skipped: true, reason: 'channel_halted' }; 253 } 254 255 if (outreach.contact_uri === 'PENDING_CONTACT_EXTRACTION') { 256 throw new Error(`Outreach #${outreachId} has no email address (${outreach.contact_uri})`); 257 } 258 259 // Cross-project suppression check (shared with 2Step) 260 try { 261 const suppression = await checkBeforeSend({ email: outreach.contact_uri }); 262 if (suppression.blocked) { 263 logger.warn(`Outreach #${outreachId} blocked by cross-project suppression: ${suppression.reason}`); 264 return { success: false, outreachId, skipped: true, reason: 'cross_project_suppressed' }; 265 } 266 } catch (e) { 267 logger.warn(`Suppression check failed (non-fatal): ${e.message}`); 268 } 269 270 // Check global unsubscribe list 271 if (await isEmailUnsubscribed(outreach.contact_uri)) { 272 throw new Error( 273 `Cannot send to outreach #${outreachId}: ${outreach.contact_uri} is globally unsubscribed` 274 ); 275 } 276 277 // Per-recipient cooldown — skip if same contact_uri received an email in last 72 hours 278 const recentSend = await getOne( 279 `SELECT id FROM messages 280 WHERE contact_uri = $1 AND contact_method = 'email' 281 AND direction = 'outbound' 282 AND delivery_status IN ('sent', 'delivered', 'opened', 'clicked') 283 AND updated_at > NOW() - INTERVAL '3 days' 284 AND id != $2 285 LIMIT 1`, 286 [outreach.contact_uri, outreachId] 287 ); 288 289 if (recentSend) { 290 await run( 291 `UPDATE messages SET delivery_status = 'failed', error_message = $1 WHERE id = $2`, 292 [ 293 `Per-recipient cooldown: email already sent to ${outreach.contact_uri} within 72 hours (outreach #${recentSend.id})`, 294 outreachId, 295 ] 296 ); 297 return { success: false, outreachId, skipped: true, reason: 'per_recipient_cooldown' }; 298 } 299 300 // ZeroBounce email validation — blocks spam traps, invalid, disposable, role-based addresses. 301 // role_based (info@, support@, admin@) was previously allowed through, but real-world data 302 // shows 28.6% bounce rate for role_based do_not_mail — destroys sender reputation. 303 const zbResult = await validateEmailZB(outreach.contact_uri); 304 if (zbResult.blocked) { 305 const reason = `ZeroBounce: ${zbResult.status}${zbResult.sub_status ? ` (${zbResult.sub_status})` : ''}`; 306 await run( 307 `UPDATE messages SET delivery_status = 'failed', error_message = $1, zb_status = $2 WHERE id = $3`, 308 [reason, zbResult.status, outreachId] 309 ); 310 logger.warn(`Blocked email to ${outreach.contact_uri}: ${reason}`); 311 return { success: false, outreachId, blocked: true, reason }; 312 } 313 // Park unknown addresses for secondary verification (MillionVerifier) rather than sending blind. 314 // ZeroBounce 'unknown' = couldn't confirm mailbox existence → ~38% bounce rate if sent. 315 if (zbResult.status === 'unknown') { 316 await run( 317 `UPDATE messages SET delivery_status = 'retry_later', error_message = $1, zb_status = $2, retry_at = NOW() + INTERVAL '30 days' WHERE id = $3`, 318 [ 319 'zb_unknown: parked pending secondary verification (MillionVerifier)', 320 'unknown', 321 outreachId, 322 ] 323 ); 324 logger.warn( 325 `Parked email to ${outreach.contact_uri}: ZeroBounce unknown — needs secondary verification` 326 ); 327 return { success: false, outreachId, blocked: false, parked: true, reason: 'zb_unknown' }; 328 } 329 330 // Store zb_status for all non-blocked addresses (catch-all, valid) 331 if (zbResult.status && zbResult.status !== 'skipped') { 332 await run(`UPDATE messages SET zb_status = $1 WHERE id = $2`, [zbResult.status, outreachId]); 333 } 334 if (zbResult.status === 'catch-all') { 335 logger.warn(`Sending to catch-all domain: ${outreach.contact_uri} — may bounce (proceeding)`); 336 } 337 338 // Get config from environment variables 339 const signature = ( 340 process.env.EMAIL_SIGNATURE || `Best regards,\n${process.env.SENDER_NAME || '333 Method'}` 341 ).replace(/\\n/g, '\n'); 342 const senderEmail = process.env.SENDER_EMAIL || 'outreach@333method.com'; 343 const senderName = process.env.SENDER_NAME || '333 Method'; 344 const unsubscribeLink = createUnsubscribeLink(outreachId); 345 346 // Physical address: required by CAN-SPAM countries; include for all others too 347 // since Resend's AUP for unsolicited email applies globally 348 const physicalAddress = process.env.CAN_SPAM_PHYSICAL_ADDRESS || null; 349 350 // Fine print: load from country template at send time (not stored in proposal) 351 let finePrint = ''; 352 try { 353 const cc = (outreach.country_code || 'AU').toUpperCase(); 354 const templatePath = join(projectRoot, `data/templates/${cc}/email.json`); 355 if (existsSync(templatePath)) { 356 const tmpl = JSON.parse(readFileSync(templatePath, 'utf-8')); 357 if (tmpl.fine_print_spintax) { 358 finePrint = spin(tmpl.fine_print_spintax); 359 } 360 } 361 } catch { 362 // Non-fatal: fine print is supplementary 363 } 364 365 // Format email body 366 const htmlBody = formatEmailBody( 367 outreach.proposal_text, 368 signature, 369 unsubscribeLink, 370 physicalAddress, 371 senderName, 372 outreach.subject_line, 373 finePrint 374 ); 375 376 // Plain text fallback (strip HTML tags) 377 const plainTextBody = 378 `${outreach.proposal_text}\n\n${signature}${physicalAddress ? `\n\n${physicalAddress}` : ''}\n\n---\n` + 379 `You received this email because ${senderName} found your business online and believes our web optimization services may be relevant to you.\n` + 380 `${finePrint ? `${finePrint}\n` : ''}` + 381 `If you'd prefer not to receive emails from us, visit: ${unsubscribeLink}`; 382 383 logger.info( 384 `Sending email to ${outreach.contact_uri} for ${outreach.domain} (outreach #${outreachId})` 385 ); 386 387 // Send via Resend using direct fetch() with AbortSignal.timeout() — bypasses SDK connection 388 // pooling issues that caused stale TCP connections to hang for minutes on the NixOS host. 389 // AbortSignal.timeout() aborts at the TCP level via libuv, unlike setTimeout()-based 390 // Promise.race() which can't fire if the event loop is blocked or the connection stalls 391 // in a half-closed state. Rate limiting and circuit breaker are still applied. 392 const EMAIL_TIMEOUT_MS = 20000; 393 const resendApiKey = process.env.RESEND_TEST_API_KEY || process.env.RESEND_API_KEY; 394 395 const resendSendPromise = resendLimiter.schedule(() => 396 resendBreaker.fire(() => { 397 return fetch('https://api.resend.com/emails', { 398 method: 'POST', 399 headers: { 400 Authorization: `Bearer ${resendApiKey}`, 401 'Content-Type': 'application/json', 402 'User-Agent': '333Method/1.0', 403 }, 404 body: JSON.stringify({ 405 from: `${senderName} <${senderEmail}>`, 406 to: outreach.contact_uri, 407 subject: outreach.subject_line, 408 html: htmlBody, 409 text: plainTextBody, 410 headers: { 411 'List-Unsubscribe': `<${unsubscribeLink}>`, 412 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 413 }, 414 tags: [{ name: 'outreach_id', value: String(outreachId) }], 415 }), 416 signal: AbortSignal.timeout(EMAIL_TIMEOUT_MS), 417 }); 418 }) 419 ); 420 421 const fetchResponse = await resendSendPromise; 422 423 // Parse raw fetch response — direct API returns { id } on success, { name, message, statusCode } on error 424 if (!fetchResponse.ok) { 425 const text = await fetchResponse.text().catch(() => ''); 426 let errMsg = `Resend API HTTP ${fetchResponse.status}`; 427 try { 428 const parsed = JSON.parse(text); 429 errMsg = `Resend API error (status code ${parsed.statusCode ?? fetchResponse.status}): ${parsed.message ?? text}`; 430 } catch { 431 errMsg = `Resend API error (status code ${fetchResponse.status}): ${text.substring(0, 200)}`; 432 } 433 throw new Error(errMsg); 434 } 435 436 const responseData = await fetchResponse.json(); 437 if (!responseData?.id) { 438 throw new Error( 439 `Invalid Resend API response: ${JSON.stringify(responseData).substring(0, 300)}` 440 ); 441 } 442 443 // Update outreach record with email_id for webhook correlation 444 await run( 445 `UPDATE messages 446 SET delivery_status = 'sent', 447 delivered_at = CURRENT_TIMESTAMP, 448 sent_at = CURRENT_TIMESTAMP, 449 email_id = $1 450 WHERE id = $2`, 451 [responseData.id, outreachId] 452 ); 453 454 logger.success(`Email sent to ${outreach.contact_uri} (Resend ID: ${responseData.id})`); 455 456 return { 457 success: true, 458 outreachId, 459 email: outreach.contact_uri, 460 resendId: responseData.id, 461 }; 462 } catch (error) { 463 // Record error for reputation guard 464 recordOutreachError('email', error.message); 465 466 // Update outreach with error — retry_later for transient, failed for terminal 467 await markOutreachResultAsync(outreachId, error.message); 468 469 logger.error(`Failed to send email for outreach #${outreachId}`, error); 470 throw error; 471 } 472 } 473 474 /** 475 * Send all approved email outreaches 476 */ 477 export async function sendBulkEmails(limit = null) { 478 // Sync unsubscribes from Cloudflare Worker before sending 479 logger.info('Syncing unsubscribes from Cloudflare Worker...'); 480 try { 481 await syncUnsubscribes(); 482 } catch (error) { 483 logger.warn('Failed to sync unsubscribes (continuing anyway):', error.message); 484 } 485 486 // Get outreaches, excluding globally unsubscribed emails 487 const sql = `SELECT o.id 488 FROM messages o 489 WHERE o.direction = 'outbound' 490 AND o.approval_status = 'approved' 491 AND o.delivery_status IS NULL 492 AND o.contact_method = 'email' 493 AND o.contact_uri NOT IN (SELECT email FROM unsubscribed_emails) 494 AND (o.zb_status IS NULL OR o.zb_status NOT IN ('do_not_mail','invalid','spamtrap','abuse','unknown')) 495 ${limit ? `LIMIT ${limit}` : ''}`; 496 497 const outreaches = await getAll(sql, []); 498 499 logger.info(`Sending ${outreaches.length} email outreaches...`); 500 501 const results = []; 502 503 for (const outreach of outreaches) { 504 try { 505 const result = await sendEmail(outreach.id); 506 results.push(result); 507 // Note: Rate limiting handled by resendLimiter (see RESEND_REQUESTS_PER_SECOND in .env) 508 } catch (error) { 509 logger.error(`Failed for outreach #${outreach.id}:`, error); 510 results.push({ 511 success: false, 512 outreachId: outreach.id, 513 error: error.message, 514 }); 515 } 516 } 517 518 const successCount = results.filter(r => r.success).length; 519 logger.success(`Sent ${successCount}/${results.length} emails`); 520 521 return results; 522 } 523 524 /** 525 * Mark email as unsubscribed (and add to global unsubscribe list) 526 */ 527 export async function unsubscribeEmail(outreachId) { 528 // Get outreach details 529 const outreach = await getOne( 530 `SELECT contact_uri, contact_method 531 FROM messages 532 WHERE id = $1 AND direction = 'outbound'`, 533 [outreachId] 534 ); 535 536 if (!outreach) { 537 throw new Error(`Outreach #${outreachId} not found`); 538 } 539 540 if (outreach.contact_method !== 'email') { 541 throw new Error(`Outreach #${outreachId} is not an email outreach`); 542 } 543 544 // Add to global unsubscribe list 545 await run( 546 `INSERT INTO unsubscribed_emails (email, message_id, source) 547 VALUES ($1, $2, 'manual') 548 ON CONFLICT DO NOTHING`, 549 [outreach.contact_uri, outreachId] 550 ); 551 552 // Propagate to cross-project suppression list 553 try { 554 const sDb = openDb(); 555 addSuppression({ email: outreach.contact_uri, source: '333method', reason: 'unsubscribe' }, sDb); 556 sDb.close(); 557 } catch (e) { 558 logger.warn(`Suppression sync failed (non-fatal): ${e.message}`); 559 } 560 561 logger.success( 562 `Unsubscribed: ${outreach.contact_uri} (outreach #${outreachId}) - added to global list` 563 ); 564 } 565 566 // CLI functionality 567 if (import.meta.url === `file://${process.argv[1]}`) { 568 const command = process.argv[2]; 569 570 if (command === 'send') { 571 const outreachId = parseInt(process.argv[3], 10); 572 if (!outreachId) { 573 console.error('Usage: node src/outreach/email.js send <outreach_id>'); 574 process.exit(1); 575 } 576 577 sendEmail(outreachId) 578 .then(result => { 579 console.log('\n✅ Email sent!\n'); 580 console.log(`Outreach ID: ${result.outreachId}`); 581 console.log(`Email: ${result.email}`); 582 console.log(`Resend ID: ${result.resendId}\n`); 583 process.exit(0); 584 }) 585 .catch(error => { 586 console.error(`\n❌ Failed: ${error.message}\n`); 587 process.exit(1); 588 }); 589 } else if (command === 'bulk') { 590 const limit = process.argv[3] ? parseInt(process.argv[3], 10) : null; 591 592 sendBulkEmails(limit) 593 .then(results => { 594 console.log('\n✅ Bulk send complete!\n'); 595 console.log(`Sent: ${results.filter(r => r.success).length}`); 596 console.log(`Failed: ${results.filter(r => !r.success).length}\n`); 597 process.exit(0); 598 }) 599 .catch(error => { 600 console.error(`\n❌ Failed: ${error.message}\n`); 601 process.exit(1); 602 }); 603 } else if (command === 'unsubscribe') { 604 const outreachId = parseInt(process.argv[3], 10); 605 if (!outreachId) { 606 console.error('Usage: node src/outreach/email.js unsubscribe <outreach_id>'); 607 process.exit(1); 608 } 609 610 unsubscribeEmail(outreachId) 611 .then(() => { 612 console.log(`\n✅ Marked outreach #${outreachId} as unsubscribed\n`); 613 process.exit(0); 614 }) 615 .catch(error => { 616 console.error(`\n❌ Failed: ${error.message}\n`); 617 process.exit(1); 618 }); 619 } else { 620 console.log('Usage:'); 621 console.log(' send <outreach_id> - Send single email'); 622 console.log(' bulk [limit] - Send all approved emails'); 623 console.log(' unsubscribe <outreach_id> - Mark as unsubscribed'); 624 console.log(''); 625 console.log('Examples:'); 626 console.log(' node src/outreach/email.js send 42'); 627 console.log(' node src/outreach/email.js bulk 10'); 628 console.log(' node src/outreach/email.js unsubscribe 42'); 629 console.log(''); 630 process.exit(1); 631 } 632 } 633 634 export default { 635 sendEmail, 636 sendBulkEmails, 637 unsubscribeEmail, 638 };