outreach-email.integration.test.js
1 /** 2 * Integration Tests for Email Outreach Module with Resend API 3 * 4 * These tests make actual API calls to Resend using test addresses. 5 * Requires RESEND_API_KEY or RESEND_TEST_API_KEY in environment. 6 * 7 * Run with: npm run test:integration 8 */ 9 10 import { test, describe, before, beforeEach, afterEach } from 'node:test'; 11 import assert from 'node:assert'; 12 import Database from 'better-sqlite3'; 13 import { Resend } from 'resend'; 14 import { join } from 'path'; 15 import { existsSync, rmSync } from 'fs'; 16 import dotenv from 'dotenv'; 17 18 dotenv.config(); 19 20 const testDbPath = join(process.cwd(), 'test-email-integration.db'); 21 22 // Set DATABASE_PATH before importing modules that use it 23 process.env.DATABASE_PATH = testDbPath; 24 25 // Now dynamically import the email module after setting env vars 26 const { sendEmail, sendBulkEmails, unsubscribeEmail } = await import('../../src/outreach/email.js'); 27 28 // Use test API key if available, otherwise production key 29 const resendApiKey = process.env.RESEND_TEST_API_KEY || process.env.RESEND_API_KEY; 30 31 // Resend test addresses for different scenarios 32 const TEST_ADDRESSES = { 33 delivered: 'delivered@resend.dev', 34 bounced: 'bounced@resend.dev', 35 complained: 'complained@resend.dev', 36 }; 37 38 // Skip tests if no API key available 39 const skipIfNoApiKey = () => { 40 if (!resendApiKey) { 41 console.log('⚠️ Skipping Resend integration tests (no API key found)'); 42 console.log(' Set RESEND_API_KEY or RESEND_TEST_API_KEY in .env'); 43 process.exit(0); 44 } 45 }; 46 47 skipIfNoApiKey(); 48 49 describe('Resend API Integration Tests', () => { 50 let db; 51 let resend; 52 53 beforeEach(() => { 54 // Clean up test database 55 if (existsSync(testDbPath)) { 56 rmSync(testDbPath); 57 } 58 59 // Initialize test database 60 db = new Database(testDbPath); 61 62 // Create schema 63 db.exec(` 64 CREATE TABLE IF NOT EXISTS sites ( 65 id INTEGER PRIMARY KEY AUTOINCREMENT, 66 domain TEXT NOT NULL, 67 url TEXT NOT NULL, 68 keyword TEXT, 69 country_code TEXT DEFAULT 'US', 70 approval_status TEXT DEFAULT 'pending', delivery_status TEXT, 71 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 72 ); 73 74 CREATE TABLE IF NOT EXISTS messages ( 75 id INTEGER PRIMARY KEY AUTOINCREMENT, 76 site_id INTEGER NOT NULL, 77 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 78 contact_method TEXT NOT NULL CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')), 79 contact_uri TEXT, 80 message_body TEXT, 81 subject_line TEXT, 82 approval_status TEXT DEFAULT 'pending', delivery_status TEXT, 83 delivered_at DATETIME, 84 email_id TEXT, 85 zb_status TEXT, 86 error_message TEXT, 87 retry_at DATETIME, 88 sent_at DATETIME, 89 opened_at DATETIME, 90 tracking_clicked_at DATETIME, 91 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 92 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 93 FOREIGN KEY (site_id) REFERENCES sites(id) 94 ); 95 96 CREATE TABLE IF NOT EXISTS config ( 97 key TEXT PRIMARY KEY, 98 value TEXT NOT NULL 99 ); 100 101 CREATE TABLE IF NOT EXISTS unsubscribed_emails ( 102 id INTEGER PRIMARY KEY AUTOINCREMENT, 103 email TEXT NOT NULL UNIQUE, 104 message_id INTEGER, 105 source TEXT DEFAULT 'manual', 106 unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP 107 ); 108 109 CREATE TABLE IF NOT EXISTS email_validations ( 110 email TEXT PRIMARY KEY, 111 status TEXT NOT NULL, 112 sub_status TEXT, 113 free_email INTEGER, 114 mx_found INTEGER, 115 validated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 116 expires_at DATETIME DEFAULT (datetime('now', '+90 days')) 117 ); 118 119 -- Pre-seed ZeroBounce cache for test addresses so sendEmail() doesn't call ZB API 120 INSERT OR IGNORE INTO email_validations (email, status) VALUES 121 ('delivered@resend.dev', 'valid'), 122 ('bounced@resend.dev', 'invalid'), 123 ('complained@resend.dev', 'valid'); 124 125 -- Insert test config 126 INSERT INTO config (key, value) VALUES 127 ('email_signature', 'Best regards,\\nTest Team\\nhttps://333method.com'), 128 ('sender_email', '${process.env.SENDER_EMAIL || 'test@333method.com'}'), 129 ('sender_name', '${process.env.SENDER_NAME || 'Test Sender'}'); 130 `); 131 132 // Initialize Resend client 133 resend = new Resend(resendApiKey); 134 }); 135 136 afterEach(() => { 137 if (db) { 138 db.close(); 139 } 140 if (existsSync(testDbPath)) { 141 rmSync(testDbPath); 142 } 143 delete process.env.DATABASE_PATH; 144 }); 145 146 describe('Resend API Direct Tests', () => { 147 test('should send email to Resend test address (delivered)', async () => { 148 const result = await resend.emails.send({ 149 from: `${process.env.SENDER_NAME || 'Test'} <${process.env.SENDER_EMAIL || 'test@333method.com'}>`, 150 to: TEST_ADDRESSES.delivered, 151 subject: 'Integration Test - Delivered', 152 html: '<p>This is a test email that should be delivered.</p>', 153 text: 'This is a test email that should be delivered.', 154 }); 155 156 assert.ok(result.data?.id, 'Should return Resend ID'); 157 assert.ok(typeof result.data.id === 'string', 'Resend ID should be a string'); 158 assert.ok(result.data.id.length > 0, 'Resend ID should not be empty'); 159 console.log(`✅ Email sent successfully (ID: ${result.data.id})`); 160 }); 161 162 test('should validate email payload structure', async () => { 163 const result = await resend.emails.send({ 164 from: `Test <${process.env.SENDER_EMAIL || 'test@333method.com'}>`, 165 to: TEST_ADDRESSES.delivered, 166 subject: 'Payload Validation Test', 167 html: '<p>Testing payload structure</p>', 168 headers: { 169 'List-Unsubscribe': '<https://333method.com/unsubscribe?id=123>', 170 }, 171 tags: [ 172 { name: 'site_id', value: '123' }, 173 { name: 'test', value: 'integration' }, 174 ], 175 }); 176 177 assert.ok(result.data.id); 178 console.log(`✅ Payload validation passed (ID: ${result.data.id})`); 179 }); 180 }); 181 182 describe('sendEmail() Integration Tests', () => { 183 // Resend rate limit is 2 req/sec — pause between test suites to avoid 429 184 before(async () => await new Promise(r => setTimeout(r, 1500))); 185 186 test('should send email and update database', async () => { 187 // Insert test data 188 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 189 1, 190 'example.com', 191 'https://example.com', 192 'test keyword' 193 ); 194 195 db.prepare( 196 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) 197 VALUES (?, ?, ?, ?, ?, ?, ?)` 198 ).run( 199 1, 200 1, 201 'email', 202 TEST_ADDRESSES.delivered, 203 'This is a test proposal for example.com', 204 'Improve Your Website Conversion', 205 'pending' 206 ); 207 208 // Send email 209 const result = await sendEmail(1); 210 211 // Verify result 212 assert.ok(result.success, 'Should report success'); 213 assert.strictEqual(result.outreachId, 1); 214 assert.strictEqual(result.email, TEST_ADDRESSES.delivered); 215 assert.ok(result.resendId, 'Should return Resend ID'); 216 assert.ok(typeof result.resendId === 'string', 'Resend ID should be a string'); 217 assert.ok(result.resendId.length > 0, 'Resend ID should not be empty'); 218 219 // Verify database update 220 const outreach = db.prepare('SELECT * FROM messages WHERE id = ?').get(1); 221 assert.strictEqual(outreach.delivery_status, 'sent'); 222 assert.ok(outreach.delivered_at, 'Should set delivered_at timestamp'); 223 assert.strictEqual(outreach.email_id, result.resendId); 224 225 console.log(`✅ Full sendEmail() workflow test passed (ID: ${result.resendId})`); 226 }); 227 228 test('should reject email to unsubscribed recipient', async () => { 229 // Insert test data 230 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 231 2, 232 'example2.com', 233 'https://example2.com', 234 'test keyword' 235 ); 236 237 db.prepare( 238 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) 239 VALUES (?, ?, ?, ?, ?, ?, ?)` 240 ).run( 241 2, 242 2, 243 'email', 244 TEST_ADDRESSES.delivered, 245 'This should not be sent', 246 'Should Not Send', 247 'pending' 248 ); 249 250 // Mark recipient as unsubscribed via unsubscribed_emails table 251 db.prepare( 252 'INSERT OR IGNORE INTO unsubscribed_emails (email, message_id, source) VALUES (?, ?, ?)' 253 ).run(TEST_ADDRESSES.delivered, 2, 'manual'); 254 255 // Attempt to send email 256 await assert.rejects( 257 async () => { 258 await sendEmail(2); 259 }, 260 { 261 message: /unsubscribed/i, 262 }, 263 'Should reject sending to unsubscribed recipient' 264 ); 265 266 console.log('✅ Unsubscribe blocking test passed'); 267 }); 268 269 test('should reject email to globally unsubscribed address', async () => { 270 // Insert test data 271 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 272 3, 273 'example3.com', 274 'https://example3.com', 275 'test keyword' 276 ); 277 278 const testEmail = 'globally-unsubscribed@test.com'; 279 280 // Add to global unsubscribe list 281 db.prepare('INSERT INTO unsubscribed_emails (email, source) VALUES (?, ?)').run( 282 testEmail, 283 'manual' 284 ); 285 286 db.prepare( 287 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) 288 VALUES (?, ?, ?, ?, ?, ?, ?)` 289 ).run(3, 3, 'email', testEmail, 'This should not be sent', 'Should Not Send', 'pending'); 290 291 // Attempt to send email 292 await assert.rejects( 293 async () => { 294 await sendEmail(3); 295 }, 296 { 297 message: /globally unsubscribed/i, 298 }, 299 'Should reject sending to globally unsubscribed address' 300 ); 301 302 console.log('✅ Global unsubscribe blocking test passed'); 303 }); 304 305 test('should handle PENDING_CONTACT_EXTRACTION gracefully', async () => { 306 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 307 4, 308 'example4.com', 309 'https://example4.com', 310 'test keyword' 311 ); 312 313 db.prepare( 314 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) 315 VALUES (?, ?, ?, ?, ?, ?, ?)` 316 ).run( 317 4, 318 4, 319 'email', 320 'PENDING_CONTACT_EXTRACTION', 321 'This should not be sent', 322 'Should Not Send', 323 'pending' 324 ); 325 326 await assert.rejects( 327 async () => { 328 await sendEmail(4); 329 }, 330 { 331 message: /no email address/i, 332 }, 333 'Should reject PENDING_CONTACT_EXTRACTION' 334 ); 335 336 console.log('✅ PENDING_CONTACT_EXTRACTION handling test passed'); 337 }); 338 }); 339 340 describe('sendBulkEmails() Integration Tests', () => { 341 // Resend rate limit is 2 req/sec — pause between test suites to avoid 429 342 before(async () => await new Promise(r => setTimeout(r, 1500))); 343 344 test('should send multiple emails in sequence', async () => { 345 // Insert test data 346 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 347 5, 348 'bulk1.com', 349 'https://bulk1.com', 350 'test' 351 ); 352 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 353 6, 354 'bulk2.com', 355 'https://bulk2.com', 356 'test' 357 ); 358 359 db.prepare( 360 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status) 361 VALUES (?, ?, ?, ?, ?, ?, ?)` 362 ).run(5, 5, 'email', TEST_ADDRESSES.delivered, 'Bulk test 1', 'Bulk Subject 1', 'approved'); 363 364 db.prepare( 365 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status) 366 VALUES (?, ?, ?, ?, ?, ?, ?)` 367 ).run(6, 6, 'email', TEST_ADDRESSES.delivered, 'Bulk test 2', 'Bulk Subject 2', 'approved'); 368 369 // Send bulk emails 370 const results = await sendBulkEmails(2); 371 372 // Verify results 373 assert.strictEqual(results.length, 2); 374 assert.ok( 375 results.every(r => r.success), 376 'All emails should succeed' 377 ); 378 assert.ok( 379 results.every(r => r.resendId), 380 'All should have Resend IDs' 381 ); 382 383 // Verify database updates 384 const sent = db 385 .prepare("SELECT COUNT(*) as count FROM messages WHERE delivery_status = 'sent'") 386 .get(); 387 assert.strictEqual(sent.count, 2); 388 389 console.log(`✅ Bulk send test passed (${results.length} emails)`); 390 }); 391 392 test('should skip unsubscribed emails in bulk send', async () => { 393 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 394 7, 395 'bulk3.com', 396 'https://bulk3.com', 397 'test' 398 ); 399 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 400 8, 401 'bulk4.com', 402 'https://bulk4.com', 403 'test' 404 ); 405 406 db.prepare( 407 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status) 408 VALUES (?, ?, ?, ?, ?, ?, ?)` 409 ).run(7, 7, 'email', TEST_ADDRESSES.delivered, 'Should send', 'Bulk Subject 3', 'approved'); 410 411 // This one is unsubscribed 412 db.prepare( 413 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status) 414 VALUES (?, ?, ?, ?, ?, ?, ?)` 415 ).run( 416 8, 417 8, 418 'email', 419 TEST_ADDRESSES.delivered, 420 'Should NOT send', 421 'Bulk Subject 4', 422 'approved' 423 ); 424 425 // Add to unsubscribed_emails so it gets filtered out 426 db.prepare( 427 'INSERT OR IGNORE INTO unsubscribed_emails (email, message_id, source) VALUES (?, ?, ?)' 428 ).run(TEST_ADDRESSES.delivered, 8, 'manual'); 429 430 // Send bulk emails 431 const results = await sendBulkEmails(); 432 433 // Should only send 1 email (skipping unsubscribed) 434 assert.strictEqual(results.length, 1); 435 assert.ok(results[0].success); 436 437 console.log('✅ Bulk send with unsubscribe filtering test passed'); 438 }); 439 }); 440 441 describe('unsubscribeEmail() Integration Tests', () => { 442 test('should mark email as unsubscribed and add to global list', async () => { 443 db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run( 444 9, 445 'unsub1.com', 446 'https://unsub1.com', 447 'test' 448 ); 449 450 const testEmail = 'unsubscribe-test@test.com'; 451 452 db.prepare( 453 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) 454 VALUES (?, ?, ?, ?, ?, ?, ?)` 455 ).run(9, 9, 'email', testEmail, 'Test', 'Test Subject', 'sent'); 456 457 // Unsubscribe 458 unsubscribeEmail(9); 459 460 // Verify added to global unsubscribe list 461 const globalUnsub = db 462 .prepare('SELECT * FROM unsubscribed_emails WHERE email = ?') 463 .get(testEmail); 464 assert.ok(globalUnsub, 'Should be in global unsubscribe list'); 465 assert.strictEqual(globalUnsub.message_id, 9); 466 assert.strictEqual(globalUnsub.source, 'manual'); 467 468 console.log('✅ Unsubscribe workflow test passed'); 469 }); 470 }); 471 }); 472 473 describe('Email Format and Compliance Tests', () => { 474 test('should include unsubscribe link in HTML body', () => { 475 const unsubLink = 'https://333method.com/unsubscribe?id=123&token=abc'; 476 const html = `<p>Test</p><a href="${unsubLink}">unsubscribe</a>`; 477 478 assert.ok(html.includes(unsubLink)); 479 assert.ok(html.includes('unsubscribe')); 480 console.log('✅ Unsubscribe link inclusion test passed'); 481 }); 482 483 test('should format List-Unsubscribe header correctly', () => { 484 const unsubLink = 'https://333method.com/unsubscribe?id=123'; 485 const header = `<${unsubLink}>`; 486 487 assert.ok(header.startsWith('<')); 488 assert.ok(header.endsWith('>')); 489 assert.ok(header.includes(unsubLink)); 490 console.log('✅ List-Unsubscribe header format test passed'); 491 }); 492 493 test('should validate email address format', () => { 494 const validEmails = [ 495 'test@example.com', 496 'user.name@domain.co.uk', 497 'test+tag@example.com', 498 'delivered@resend.dev', 499 ]; 500 501 const invalidEmails = ['invalid', 'test@', '@example.com', 'test @example.com']; 502 503 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 504 505 validEmails.forEach(email => { 506 assert.ok(emailRegex.test(email), `${email} should be valid`); 507 }); 508 509 invalidEmails.forEach(email => { 510 assert.ok(!emailRegex.test(email), `${email} should be invalid`); 511 }); 512 513 console.log('✅ Email validation test passed'); 514 }); 515 }); 516 517 console.log('\n🧪 Running Resend Integration Tests...'); 518 console.log(`📧 Using API key: ${resendApiKey.substring(0, 8)}...`); 519 console.log(`📬 Test addresses: ${Object.values(TEST_ADDRESSES).join(', ')}\n`);