/ src / outreach / email.js
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, '&lt;') : 'Message from Audit&amp;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  };