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?.toUpperCase(); 354 if (!cc) { 355 console.warn(`[email] No country_code for outreach #${outreachId} — fine print skipped`); 356 } else { 357 const templatePath = join(projectRoot, `data/templates/${cc}/email.json`); 358 if (existsSync(templatePath)) { 359 const tmpl = JSON.parse(readFileSync(templatePath, 'utf-8')); 360 if (tmpl.fine_print_spintax) { 361 finePrint = spin(tmpl.fine_print_spintax); 362 } 363 } 364 } 365 } catch { 366 // Non-fatal: fine print is supplementary 367 } 368 369 // Format email body 370 const htmlBody = formatEmailBody( 371 outreach.proposal_text, 372 signature, 373 unsubscribeLink, 374 physicalAddress, 375 senderName, 376 outreach.subject_line, 377 finePrint 378 ); 379 380 // Plain text fallback (strip HTML tags) 381 const plainTextBody = 382 `${outreach.proposal_text}\n\n${signature}${physicalAddress ? `\n\n${physicalAddress}` : ''}\n\n---\n` + 383 `You received this email because ${senderName} found your business online and believes our web optimization services may be relevant to you.\n` + 384 `${finePrint ? `${finePrint}\n` : ''}` + 385 `If you'd prefer not to receive emails from us, visit: ${unsubscribeLink}`; 386 387 logger.info( 388 `Sending email to ${outreach.contact_uri} for ${outreach.domain} (outreach #${outreachId})` 389 ); 390 391 // Send via Resend using direct fetch() with AbortSignal.timeout() — bypasses SDK connection 392 // pooling issues that caused stale TCP connections to hang for minutes on the NixOS host. 393 // AbortSignal.timeout() aborts at the TCP level via libuv, unlike setTimeout()-based 394 // Promise.race() which can't fire if the event loop is blocked or the connection stalls 395 // in a half-closed state. Rate limiting and circuit breaker are still applied. 396 const EMAIL_TIMEOUT_MS = 20000; 397 const resendApiKey = process.env.RESEND_TEST_API_KEY || process.env.RESEND_API_KEY; 398 399 const resendSendPromise = resendLimiter.schedule(() => 400 resendBreaker.fire(() => { 401 return fetch('https://api.resend.com/emails', { 402 method: 'POST', 403 headers: { 404 Authorization: `Bearer ${resendApiKey}`, 405 'Content-Type': 'application/json', 406 'User-Agent': '333Method/1.0', 407 }, 408 body: JSON.stringify({ 409 from: `${senderName} <${senderEmail}>`, 410 to: outreach.contact_uri, 411 subject: outreach.subject_line, 412 html: htmlBody, 413 text: plainTextBody, 414 headers: { 415 'List-Unsubscribe': `<${unsubscribeLink}>`, 416 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 417 }, 418 tags: [{ name: 'outreach_id', value: String(outreachId) }], 419 }), 420 signal: AbortSignal.timeout(EMAIL_TIMEOUT_MS), 421 }); 422 }) 423 ); 424 425 const fetchResponse = await resendSendPromise; 426 427 // Parse raw fetch response — direct API returns { id } on success, { name, message, statusCode } on error 428 if (!fetchResponse.ok) { 429 const text = await fetchResponse.text().catch(() => ''); 430 let errMsg = `Resend API HTTP ${fetchResponse.status}`; 431 try { 432 const parsed = JSON.parse(text); 433 errMsg = `Resend API error (status code ${parsed.statusCode ?? fetchResponse.status}): ${parsed.message ?? text}`; 434 } catch { 435 errMsg = `Resend API error (status code ${fetchResponse.status}): ${text.substring(0, 200)}`; 436 } 437 throw new Error(errMsg); 438 } 439 440 const responseData = await fetchResponse.json(); 441 if (!responseData?.id) { 442 throw new Error( 443 `Invalid Resend API response: ${JSON.stringify(responseData).substring(0, 300)}` 444 ); 445 } 446 447 // Update outreach record with email_id for webhook correlation 448 await run( 449 `UPDATE messages 450 SET delivery_status = 'sent', 451 delivered_at = CURRENT_TIMESTAMP, 452 sent_at = CURRENT_TIMESTAMP, 453 email_id = $1 454 WHERE id = $2`, 455 [responseData.id, outreachId] 456 ); 457 458 logger.success(`Email sent to ${outreach.contact_uri} (Resend ID: ${responseData.id})`); 459 460 return { 461 success: true, 462 outreachId, 463 email: outreach.contact_uri, 464 resendId: responseData.id, 465 }; 466 } catch (error) { 467 // Record error for reputation guard 468 recordOutreachError('email', error.message); 469 470 // Update outreach with error — retry_later for transient, failed for terminal 471 await markOutreachResultAsync(outreachId, error.message); 472 473 logger.error(`Failed to send email for outreach #${outreachId}`, error); 474 throw error; 475 } 476 } 477 478 /** 479 * Send all approved email outreaches 480 */ 481 export async function sendBulkEmails(limit = null) { 482 // Sync unsubscribes from Cloudflare Worker before sending 483 logger.info('Syncing unsubscribes from Cloudflare Worker...'); 484 try { 485 await syncUnsubscribes(); 486 } catch (error) { 487 logger.warn('Failed to sync unsubscribes (continuing anyway):', error.message); 488 } 489 490 // Get outreaches, excluding globally unsubscribed emails 491 const sql = `SELECT o.id 492 FROM messages o 493 WHERE o.direction = 'outbound' 494 AND o.approval_status = 'approved' 495 AND o.delivery_status IS NULL 496 AND o.contact_method = 'email' 497 AND o.contact_uri NOT IN (SELECT email FROM unsubscribed_emails) 498 AND (o.zb_status IS NULL OR o.zb_status NOT IN ('do_not_mail','invalid','spamtrap','abuse','unknown')) 499 ${limit ? `LIMIT ${limit}` : ''}`; 500 501 const outreaches = await getAll(sql, []); 502 503 logger.info(`Sending ${outreaches.length} email outreaches...`); 504 505 const results = []; 506 507 for (const outreach of outreaches) { 508 try { 509 const result = await sendEmail(outreach.id); 510 results.push(result); 511 // Note: Rate limiting handled by resendLimiter (see RESEND_REQUESTS_PER_SECOND in .env) 512 } catch (error) { 513 logger.error(`Failed for outreach #${outreach.id}:`, error); 514 results.push({ 515 success: false, 516 outreachId: outreach.id, 517 error: error.message, 518 }); 519 } 520 } 521 522 const successCount = results.filter(r => r.success).length; 523 logger.success(`Sent ${successCount}/${results.length} emails`); 524 525 return results; 526 } 527 528 /** 529 * Mark email as unsubscribed (and add to global unsubscribe list) 530 */ 531 export async function unsubscribeEmail(outreachId) { 532 // Get outreach details 533 const outreach = await getOne( 534 `SELECT contact_uri, contact_method 535 FROM messages 536 WHERE id = $1 AND direction = 'outbound'`, 537 [outreachId] 538 ); 539 540 if (!outreach) { 541 throw new Error(`Outreach #${outreachId} not found`); 542 } 543 544 if (outreach.contact_method !== 'email') { 545 throw new Error(`Outreach #${outreachId} is not an email outreach`); 546 } 547 548 // Add to global unsubscribe list 549 await run( 550 `INSERT INTO unsubscribed_emails (email, message_id, source) 551 VALUES ($1, $2, 'manual') 552 ON CONFLICT DO NOTHING`, 553 [outreach.contact_uri, outreachId] 554 ); 555 556 // Propagate to cross-project suppression list 557 try { 558 await addSuppression({ email: outreach.contact_uri, source: '333method', reason: 'unsubscribe' }); 559 } catch (e) { 560 logger.warn(`Suppression sync failed (non-fatal): ${e.message}`); 561 } 562 563 logger.success( 564 `Unsubscribed: ${outreach.contact_uri} (outreach #${outreachId}) - added to global list` 565 ); 566 } 567 568 // CLI functionality 569 if (import.meta.url === `file://${process.argv[1]}`) { 570 const command = process.argv[2]; 571 572 if (command === 'send') { 573 const outreachId = parseInt(process.argv[3], 10); 574 if (!outreachId) { 575 console.error('Usage: node src/outreach/email.js send <outreach_id>'); 576 process.exit(1); 577 } 578 579 sendEmail(outreachId) 580 .then(result => { 581 console.log('\n✅ Email sent!\n'); 582 console.log(`Outreach ID: ${result.outreachId}`); 583 console.log(`Email: ${result.email}`); 584 console.log(`Resend ID: ${result.resendId}\n`); 585 process.exit(0); 586 }) 587 .catch(error => { 588 console.error(`\n❌ Failed: ${error.message}\n`); 589 process.exit(1); 590 }); 591 } else if (command === 'bulk') { 592 const limit = process.argv[3] ? parseInt(process.argv[3], 10) : null; 593 594 sendBulkEmails(limit) 595 .then(results => { 596 console.log('\n✅ Bulk send complete!\n'); 597 console.log(`Sent: ${results.filter(r => r.success).length}`); 598 console.log(`Failed: ${results.filter(r => !r.success).length}\n`); 599 process.exit(0); 600 }) 601 .catch(error => { 602 console.error(`\n❌ Failed: ${error.message}\n`); 603 process.exit(1); 604 }); 605 } else if (command === 'unsubscribe') { 606 const outreachId = parseInt(process.argv[3], 10); 607 if (!outreachId) { 608 console.error('Usage: node src/outreach/email.js unsubscribe <outreach_id>'); 609 process.exit(1); 610 } 611 612 unsubscribeEmail(outreachId) 613 .then(() => { 614 console.log(`\n✅ Marked outreach #${outreachId} as unsubscribed\n`); 615 process.exit(0); 616 }) 617 .catch(error => { 618 console.error(`\n❌ Failed: ${error.message}\n`); 619 process.exit(1); 620 }); 621 } else { 622 console.log('Usage:'); 623 console.log(' send <outreach_id> - Send single email'); 624 console.log(' bulk [limit] - Send all approved emails'); 625 console.log(' unsubscribe <outreach_id> - Mark as unsubscribed'); 626 console.log(''); 627 console.log('Examples:'); 628 console.log(' node src/outreach/email.js send 42'); 629 console.log(' node src/outreach/email.js bulk 10'); 630 console.log(' node src/outreach/email.js unsubscribe 42'); 631 console.log(''); 632 process.exit(1); 633 } 634 } 635 636 export default { 637 sendEmail, 638 sendBulkEmails, 639 unsubscribeEmail, 640 };