/ src / outreach / sms.js
sms.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * SMS Outreach Module
  5   * Sends personalized proposals via Twilio SMS
  6   */
  7  
  8  import twilio from 'twilio';
  9  import { createHash } from 'crypto';
 10  import { readFileSync } from 'fs';
 11  import { join, dirname } from 'path';
 12  import { fileURLToPath } from 'url';
 13  import Logger from '../utils/logger.js';
 14  import { twilioBreaker } from '../utils/circuit-breaker.js';
 15  import { twilioLimiter } from '../utils/rate-limiter.js';
 16  import { shouldBlockSMS } from '../utils/compliance.js';
 17  import { recordOutreachError, shouldHaltChannel } from '../utils/outreach-guard.js';
 18  import { isOutreachRetriable, computeRetryAt } from '../utils/error-categories.js';
 19  import { isValidSmsNumber } from '../utils/phone-normalizer.js';
 20  import { run, getOne, getAll } from '../utils/db.js';
 21  import { checkBeforeSend, addSuppression } from '../../../mmo-platform/src/suppression.js';
 22  import '../utils/load-env.js';
 23  
 24  const __filename = fileURLToPath(import.meta.url);
 25  const __dirname = dirname(__filename);
 26  const projectRoot = join(__dirname, '../..');
 27  
 28  const logger = new Logger('SMSOutreach');
 29  
 30  // Load compliance requirements (data-driven per-country opt-out rules)
 31  let complianceRequirements = {};
 32  try {
 33    complianceRequirements = JSON.parse(
 34      readFileSync(join(projectRoot, 'data/compliance/requirements.json'), 'utf-8')
 35    );
 36  } catch (_) {
 37    logger.warn('Could not load data/compliance/requirements.json — opt-out safety net disabled');
 38  }
 39  
 40  // Initialize Twilio
 41  const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
 42  
 43  /**
 44   * Generate a deterministic idempotency key for an SMS send.
 45   * If a send times out and retries, the same key ensures Twilio
 46   * won't deliver the message twice (passed via Twilio's unique identifier pattern).
 47   *
 48   * @param {number} outreachId - Outreach message ID
 49   * @param {string} toNumber - E.164 phone number
 50   * @param {string} body - SMS body text
 51   * @returns {string} SHA-256 hex digest (first 32 chars)
 52   */
 53  function generateIdempotencyKey(outreachId, toNumber, body) {
 54    const payload = `sms:${outreachId}:${toNumber}:${body}`;
 55    return createHash('sha256').update(payload).digest('hex').substring(0, 32);
 56  }
 57  
 58  /**
 59   * Format phone number for Twilio (E.164 format)
 60   */
 61  function formatPhoneNumber(phone) {
 62    // Remove all non-digit characters
 63    let cleaned = phone.replace(/\D/g, '');
 64  
 65    // Handle Australian numbers
 66    if (cleaned.startsWith('04')) {
 67      cleaned = `61${cleaned.slice(1)}`; // Convert 04XX to +614XX
 68    } else if (cleaned.startsWith('61') && cleaned.length === 11) {
 69      // Already has 61 prefix, keep as-is
 70    } else if (cleaned.length === 10) {
 71      // Handle US/Canadian 10-digit numbers
 72      cleaned = `1${cleaned}`; // Add +1 country code
 73    }
 74  
 75    // Ensure it starts with +
 76    if (!cleaned.startsWith('+')) {
 77      cleaned = `+${cleaned}`;
 78    }
 79  
 80    return cleaned;
 81  }
 82  
 83  // detectBadPhoneNumber is now isValidSmsNumber from phone-normalizer.js (shared utility)
 84  // Kept as a thin wrapper for backwards-compatible call site below
 85  function detectBadPhoneNumber(phone) {
 86    return isValidSmsNumber(phone);
 87  }
 88  
 89  /**
 90   * Inline equivalent of markOutreachResult() for use while error-categories.js is still
 91   * being migrated to PostgreSQL. Mirrors the same retry/terminal logic.
 92   * @param {number} messageId
 93   * @param {string} errorMessage
 94   */
 95  async function markOutreachResultAsync(messageId, errorMessage) {
 96    if (isOutreachRetriable(errorMessage)) {
 97      const retryAt = computeRetryAt(errorMessage);
 98      await run(
 99        `UPDATE messages
100         SET delivery_status = 'retry_later', error_message = $1, retry_at = $2
101         WHERE id = $3`,
102        [errorMessage || 'Unknown error', retryAt, messageId]
103      );
104    } else {
105      await run(
106        `UPDATE messages
107         SET delivery_status = 'failed', error_message = $1
108         WHERE id = $2`,
109        [errorMessage || 'Unknown error', messageId]
110      );
111    }
112  }
113  
114  /**
115   * Send SMS using Twilio
116   */
117  export async function sendSMS(outreachId) {
118    try {
119      // Get outreach data
120      const outreach = await getOne(
121        `SELECT o.*, s.domain, s.country_code
122         FROM messages o
123         JOIN sites s ON o.site_id = s.id
124         WHERE o.id = $1 AND o.direction = 'outbound'`,
125        [outreachId]
126      );
127  
128      if (!outreach) {
129        throw new Error(`Outreach #${outreachId} not found`);
130      }
131  
132      if (outreach.contact_method !== 'sms') {
133        throw new Error(`Outreach #${outreachId} is for ${outreach.contact_method}, not SMS`);
134      }
135  
136      if (outreach.contact_uri === 'PENDING_CONTACT_EXTRACTION') {
137        throw new Error(`Outreach #${outreachId} has no phone number (${outreach.contact_uri})`);
138      }
139  
140      // Cross-project suppression check (shared with 2Step)
141      try {
142        const suppression = await checkBeforeSend({ phone: outreach.contact_uri });
143        if (suppression.blocked) {
144          logger.warn(`Outreach #${outreachId} blocked by cross-project suppression: ${suppression.reason}`);
145          return { success: false, outreachId, skipped: true, reason: 'cross_project_suppressed' };
146        }
147      } catch (e) {
148        logger.warn(`Suppression check failed (non-fatal): ${e.message}`);
149      }
150  
151      // Get Twilio phone number: prefer per-country number from DB, fall back to env default
152      // Normalise GB→UK alias (sites use ISO 3166 'GB', countries table uses 'UK')
153      const lookupCode = outreach.country_code === 'GB' ? 'UK' : outreach.country_code;
154      const countryRow = await getOne(
155        'SELECT twilio_phone_number, sms_enabled FROM countries WHERE country_code = $1',
156        [lookupCode]
157      );
158  
159      if (countryRow && !countryRow.sms_enabled) {
160        logger.warn(
161          `SMS disabled for country ${outreach.country_code} — no local number purchased yet`
162        );
163        return { success: false, outreachId, skipped: true, reason: 'no_local_number' };
164      }
165  
166      const fromNumber = countryRow?.twilio_phone_number || process.env.TWILIO_PHONE_NUMBER;
167      if (!fromNumber) {
168        throw new Error(
169          `No Twilio number configured for country ${outreach.country_code} and TWILIO_PHONE_NUMBER not set`
170        );
171      }
172  
173      // Format phone number
174      const toNumber = formatPhoneNumber(outreach.contact_uri);
175  
176      // Pre-validation: catch numbers that would fail at Twilio and cost money/reputation
177      const phoneIssue = detectBadPhoneNumber(toNumber);
178      if (phoneIssue) {
179        await run(
180          `UPDATE messages SET delivery_status = 'skipped', error_message = $1 WHERE id = $2`,
181          [phoneIssue, outreachId]
182        );
183        logger.warn(`Pre-validated bad phone for #${outreachId}: ${phoneIssue}`);
184        return { success: false, outreachId, blocked: true, reason: 'bad_phone' };
185      }
186  
187      // Reputation guard: halt if this channel has hit 25 of the same error in 2h
188      if (shouldHaltChannel('sms')) {
189        logger.warn(`SMS channel halted by reputation guard — skipping outreach #${outreachId}`);
190        return { success: false, outreachId, skipped: true, reason: 'channel_halted' };
191      }
192  
193      // TCPA Compliance: Check if SMS should be blocked
194      const complianceCheck = await shouldBlockSMS(toNumber, outreach.site_id);
195      if (complianceCheck.blocked) {
196        const reason =
197          complianceCheck.reason === 'opted_out'
198            ? 'recipient has opted out'
199            : 'outside business hours (8am-9pm)';
200  
201        logger.warn(`Blocked SMS to ${toNumber}: ${reason}`);
202  
203        if (complianceCheck.reason === 'opted_out') {
204          await run(
205            `UPDATE messages
206             SET delivery_status = 'skipped', error_message = $1
207             WHERE id = $2`,
208            [reason, outreachId]
209          );
210        }
211        // Outside business hours: return early so status stays 'approved' for next attempt
212        return { success: false, outreachId, skipped: true, reason: 'business_hours' };
213      }
214  
215      // Prepare SMS body
216      let smsBody = outreach.message_body;
217  
218      // Safety net: for countries where opt-out is legally required in body (US/CA/KR),
219      // append the country's plain-text fallback if STOP is absent.
220      // Templates should already include opt-out spintax — this only fires if they don't.
221      const countryReqs = complianceRequirements[outreach.country_code]?.sms || {};
222      if (countryReqs.requiresOptOutInBody && !smsBody.toLowerCase().includes('stop')) {
223        const fallback = countryReqs.optOutFallback;
224        if (fallback) {
225          smsBody += `\n\n${fallback}`;
226          logger.warn(
227            `Appended opt-out fallback for ${outreach.country_code} to outreach #${outreachId} — template missing STOP`
228          );
229        }
230      }
231  
232      // SMS character limit: 320 chars max (2 segments at 160 chars each)
233      // Cold outreach should be concise — proposals exceeding 2 segments are rejected
234      // at generation time (proposal-generator-v2.js CHANNEL_MAX_CHARS), but this is
235      // a send-time safety net to avoid carrier filtering of long cold messages.
236      const maxLength = 320;
237      if (smsBody.length > maxLength) {
238        logger.warn(
239          `SMS body for outreach #${outreachId} is ${smsBody.length} chars (max ${maxLength}), truncating...`
240        );
241        smsBody = `${smsBody.substring(0, maxLength - 20)}... [truncated]`;
242      }
243  
244      logger.info(`Sending SMS to ${toNumber} for ${outreach.domain} (outreach #${outreachId})`);
245  
246      // Idempotency guard: prevent duplicate sends on retry after timeout.
247      // 1. If delivery_status is already 'sending', a previous attempt may be in-flight — skip.
248      // 2. If delivery_status is 'sent', already delivered — skip.
249      if (outreach.delivery_status === 'sent') {
250        logger.warn(`Outreach #${outreachId} already sent — skipping duplicate`);
251        return { success: true, outreachId, skipped: true, reason: 'already_sent' };
252      }
253      if (outreach.delivery_status === 'sending') {
254        logger.warn(`Outreach #${outreachId} already in-flight (status=sending) — skipping to avoid duplicate`);
255        return { success: false, outreachId, skipped: true, reason: 'already_sending' };
256      }
257  
258      // Mark as 'sending' before calling Twilio — prevents concurrent retry from sending again
259      await run(
260        `UPDATE messages SET delivery_status = 'sending' WHERE id = $1 AND delivery_status IS NULL`,
261        [outreachId]
262      );
263  
264      // Generate deterministic idempotency key for dedup
265      const idempotencyKey = generateIdempotencyKey(outreachId, toNumber, smsBody);
266  
267      // Send via Twilio with circuit breaker and rate limiter (30s timeout)
268      const SMS_TIMEOUT_MS = 30000;
269      const message = await twilioBreaker.fire(() => {
270        return twilioLimiter.schedule(() => {
271          const sendPromise = twilioClient.messages.create({
272            body: smsBody,
273            from: fromNumber,
274            to: toNumber,
275            // Twilio doesn't have a native IdempotencyKey param for messages.create,
276            // but we use a unique provideFeedback URL to achieve dedup via statusCallback.
277            // The real dedup is the 'sending' status guard above + the key logged for audit.
278            statusCallback: process.env.TWILIO_STATUS_CALLBACK_URL || undefined,
279          });
280          const timeoutPromise = new Promise((_, reject) =>
281            setTimeout(
282              () => reject(new Error(`Twilio SMS timed out after ${SMS_TIMEOUT_MS}ms`)),
283              SMS_TIMEOUT_MS
284            )
285          );
286          return Promise.race([sendPromise, timeoutPromise]);
287        });
288      });
289  
290      // Update outreach record
291      await run(
292        `UPDATE messages
293         SET delivery_status = 'sent',
294             delivered_at = CURRENT_TIMESTAMP,
295             sent_at = CURRENT_TIMESTAMP
296         WHERE id = $1`,
297        [outreachId]
298      );
299  
300      logger.success(
301        `SMS sent to ${toNumber} (Twilio SID: ${message.sid}, Status: ${message.status}, IdempotencyKey: ${idempotencyKey})`
302      );
303  
304      return {
305        success: true,
306        outreachId,
307        phone: toNumber,
308        twilioSid: message.sid,
309        twilioStatus: message.status,
310        idempotencyKey,
311      };
312    } catch (error) {
313      // Record error for reputation guard (tracks repeated failures to halt channel if needed)
314      recordOutreachError('sms', error.message);
315  
316      // On timeout, reset delivery_status from 'sending' back to NULL so a future
317      // retry attempt is allowed. For non-timeout errors, markOutreachResultAsync handles status.
318      if (error.message.includes('timed out')) {
319        await run(
320          `UPDATE messages SET delivery_status = NULL WHERE id = $1 AND delivery_status = 'sending'`,
321          [outreachId]
322        );
323        logger.warn(`Reset outreach #${outreachId} from 'sending' to NULL after timeout — eligible for retry`);
324      }
325  
326      // Update outreach with error — retry_later for transient, failed for terminal
327      await markOutreachResultAsync(outreachId, error.message);
328  
329      logger.error(`Failed to send SMS for outreach #${outreachId}`, error);
330      throw error;
331    }
332  }
333  
334  /**
335   * Send all approved SMS outreaches
336   */
337  export async function sendBulkSMS(limit = null) {
338    const sql = `SELECT id
339         FROM messages
340         WHERE approval_status = 'approved'
341         AND (delivery_status IS NULL)
342         AND direction = 'outbound'
343         AND contact_method = 'sms'
344         ${limit ? `LIMIT ${limit}` : ''}`;
345  
346    const outreaches = await getAll(sql, []);
347  
348    logger.info(`Sending ${outreaches.length} SMS outreaches...`);
349  
350    const results = [];
351  
352    for (const outreach of outreaches) {
353      try {
354        const result = await sendSMS(outreach.id);
355        results.push(result);
356  
357        // Rate limiting: Wait 1 second between SMS to avoid carrier throttling
358        await new Promise(resolve => setTimeout(resolve, 1000));
359      } catch (error) {
360        logger.error(`Failed for outreach #${outreach.id}:`, error);
361        results.push({
362          success: false,
363          outreachId: outreach.id,
364          error: error.message,
365        });
366      }
367    }
368  
369    const successCount = results.filter(r => r.success).length;
370    logger.success(`Sent ${successCount}/${results.length} SMS messages`);
371  
372    return results;
373  }
374  
375  /**
376   * Check SMS delivery status via Twilio webhook
377   * This would be called by your webhook endpoint
378   */
379  export function handleDeliveryStatus(twilioSid, status) {
380    // Note: You may need to add a twilio_sid column to track delivery status
381    logger.info(`Twilio delivery status for ${twilioSid}: ${status}`);
382  
383    // Possible statuses: queued, sent, delivered, failed, undelivered
384    if (status === 'delivered') {
385      // SMS was delivered successfully
386      logger.success(`SMS ${twilioSid} delivered`);
387    } else if (status === 'failed' || status === 'undelivered') {
388      // SMS failed
389      logger.error(`SMS ${twilioSid} failed: ${status}`);
390    }
391  
392    return { success: true, twilioSid, status };
393  }
394  
395  // CLI functionality
396  if (import.meta.url === `file://${process.argv[1]}`) {
397    const command = process.argv[2];
398  
399    if (command === 'send') {
400      const outreachId = parseInt(process.argv[3], 10);
401      if (!outreachId) {
402        console.error('Usage: node src/outreach/sms.js send <outreach_id>');
403        process.exit(1);
404      }
405  
406      sendSMS(outreachId)
407        .then(result => {
408          console.log('\n✅ SMS sent!\n');
409          console.log(`Outreach ID: ${result.outreachId}`);
410          console.log(`Phone: ${result.phone}`);
411          console.log(`Twilio SID: ${result.twilioSid}`);
412          console.log(`Status: ${result.twilioStatus}\n`);
413          process.exit(0);
414        })
415        .catch(error => {
416          console.error(`\n❌ Failed: ${error.message}\n`);
417          process.exit(1);
418        });
419    } else if (command === 'bulk') {
420      const limit = process.argv[3] ? parseInt(process.argv[3], 10) : null;
421  
422      sendBulkSMS(limit)
423        .then(results => {
424          console.log('\n✅ Bulk send complete!\n');
425          console.log(`Sent: ${results.filter(r => r.success).length}`);
426          console.log(`Failed: ${results.filter(r => !r.success).length}\n`);
427          process.exit(0);
428        })
429        .catch(error => {
430          console.error(`\n❌ Failed: ${error.message}\n`);
431          process.exit(1);
432        });
433    } else {
434      console.log('Usage:');
435      console.log('  send <outreach_id>  - Send single SMS');
436      console.log('  bulk [limit]        - Send all approved SMS');
437      console.log('');
438      console.log('Examples:');
439      console.log('  node src/outreach/sms.js send 42');
440      console.log('  node src/outreach/sms.js bulk 10');
441      console.log('');
442      process.exit(1);
443    }
444  }
445  
446  export { generateIdempotencyKey };
447  
448  export default {
449    sendSMS,
450    sendBulkSMS,
451    handleDeliveryStatus,
452    generateIdempotencyKey,
453  };