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 };