/ tests / inbound / inbound-email-mocked.test.js
inbound-email-mocked.test.js
  1  /**
  2   * Mocked Unit Tests for Inbound Email Module
  3   *
  4   * Tests functions that require external API calls (Resend, Cloudflare Worker)
  5   * by mocking fetch() and using an in-memory database with full schema.
  6   *
  7   * Covers uncovered lines:
  8   * - pollInboundEmails (lines 207-322) - Cloudflare Worker + Resend API polling
  9   * - processPendingReplies (lines 329-388) - Sending operator replies via email
 10   * - fetchReceivedEmail (lines 143-166) - Resend API email fetch
 11   */
 12  
 13  import { test, describe, mock, beforeEach, afterEach } from 'node:test';
 14  import assert from 'node:assert';
 15  import Database from 'better-sqlite3';
 16  import { readFileSync } from 'fs';
 17  import { join, dirname } from 'path';
 18  import { fileURLToPath } from 'url';
 19  import { createPgMock } from '../helpers/pg-mock.js';
 20  
 21  const __filename = fileURLToPath(import.meta.url);
 22  const __dirname = dirname(__filename);
 23  const projectRoot = join(__dirname, '../..');
 24  const schemaPath = join(projectRoot, 'db/schema.sql');
 25  
 26  // Store original fetch
 27  const originalFetch = globalThis.fetch;
 28  
 29  // Mock the sendEmail function used by processPendingReplies
 30  const mockSendEmail = mock.fn(async () => ({ id: 'mock-email-id', status: 'sent' }));
 31  
 32  mock.module('../../src/outreach/email.js', {
 33    namedExports: {
 34      sendEmail: mockSendEmail,
 35    },
 36  });
 37  
 38  // In-memory DB seeded BEFORE mock.module for db.js
 39  const db = new Database(':memory:');
 40  const schema = readFileSync(schemaPath, 'utf-8');
 41  db.exec(schema);
 42  db.pragma('foreign_keys = ON');
 43  
 44  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 45  
 46  // Import module under test AFTER setting mocks
 47  const {
 48    findOutreachByEmail,
 49    parseEmailBody,
 50    detectSentiment,
 51    fetchReceivedEmail,
 52    storeInboundEmail,
 53    pollInboundEmails,
 54    processPendingReplies,
 55  } = await import('../../src/inbound/email.js');
 56  
 57  // ─── Test helpers ─────────────────────────────────────────────────────────────
 58  
 59  function clearTables() {
 60    db.exec('DELETE FROM messages; DELETE FROM sites');
 61  }
 62  
 63  /**
 64   * Helper: insert standard test fixtures (site + outreaches)
 65   */
 66  function insertTestData() {
 67    db.prepare(
 68      `INSERT INTO sites (id, domain, landing_page_url, keyword, status)
 69       VALUES (?, ?, ?, ?, ?)`
 70    ).run(1, 'testsite.com', 'https://testsite.com', 'web design', 'outreach_sent');
 71  
 72    db.prepare(
 73      `INSERT INTO sites (id, domain, landing_page_url, keyword, status)
 74       VALUES (?, ?, ?, ?, ?)`
 75    ).run(2, 'another.com', 'https://another.com', 'seo services', 'outreach_sent');
 76  
 77    db.prepare(
 78      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
 79       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-1 hour'))`
 80    ).run(
 81      1,
 82      1,
 83      'email',
 84      'owner@testsite.com',
 85      'Great proposal',
 86      'Improve your site',
 87      'outbound',
 88      'sent'
 89    );
 90  
 91    db.prepare(
 92      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
 93       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-2 hours'))`
 94    ).run(
 95      2,
 96      2,
 97      'email',
 98      'contact@another.com',
 99      'Another proposal',
100      'SEO for you',
101      'outbound',
102      'sent'
103    );
104  
105    db.prepare(
106      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
107       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-3 hours'))`
108    ).run(3, 1, 'sms', '+1234567890', 'SMS proposal', 'SMS subject', 'outbound', 'sent');
109  }
110  
111  describe('Inbound Email Module - Mocked Tests', () => {
112    beforeEach(() => {
113      clearTables();
114      insertTestData();
115  
116      // Reset mocks
117      mockSendEmail.mock.resetCalls();
118      mockSendEmail.mock.mockImplementation(async () => ({
119        id: 'mock-email-id',
120        status: 'sent',
121      }));
122    });
123  
124    afterEach(() => {
125      // Restore original fetch
126      globalThis.fetch = originalFetch;
127    });
128  
129    describe('fetchReceivedEmail', () => {
130      test('should throw error when RESEND_API_KEY is not configured', async () => {
131        const savedKey = process.env.RESEND_API_KEY;
132        delete process.env.RESEND_API_KEY;
133  
134        await assert.rejects(
135          async () => {
136            await fetchReceivedEmail('test-email-id-123');
137          },
138          {
139            message: 'RESEND_API_KEY not configured',
140          }
141        );
142  
143        // Restore
144        if (savedKey) process.env.RESEND_API_KEY = savedKey;
145      });
146  
147      test('should fetch email details from Resend API successfully', async () => {
148        process.env.RESEND_API_KEY = 'test-api-key-123';
149  
150        const mockEmailData = {
151          id: 'email-id-456',
152          from: 'owner@testsite.com',
153          to: ['sender@example.com'],
154          subject: 'Re: Improve your site',
155          text: 'Yes, I am interested!',
156          html: '<p>Yes, I am interested!</p>',
157          created_at: '2024-01-01T00:00:00.000Z',
158        };
159  
160        globalThis.fetch = mock.fn(async () => ({
161          ok: true,
162          json: async () => mockEmailData,
163        }));
164  
165        const result = await fetchReceivedEmail('email-id-456');
166  
167        assert.deepStrictEqual(result, mockEmailData);
168        assert.strictEqual(globalThis.fetch.mock.calls.length, 1);
169        assert.ok(globalThis.fetch.mock.calls[0].arguments[0].includes('email-id-456'));
170  
171        // Verify auth header
172        const { headers } = globalThis.fetch.mock.calls[0].arguments[1];
173        assert.strictEqual(headers.Authorization, 'Bearer test-api-key-123');
174  
175        delete process.env.RESEND_API_KEY;
176      });
177  
178      test('should return null on 404 response from Resend API', async () => {
179        process.env.RESEND_API_KEY = 'test-api-key-123';
180  
181        globalThis.fetch = mock.fn(async () => ({
182          ok: false,
183          status: 404,
184          statusText: 'Not Found',
185        }));
186  
187        const result = await fetchReceivedEmail('nonexistent-email-id');
188        assert.strictEqual(result, null, 'Should return null for 404');
189  
190        delete process.env.RESEND_API_KEY;
191      });
192  
193      test('should throw error on 500 server error', async () => {
194        process.env.RESEND_API_KEY = 'test-api-key-123';
195  
196        globalThis.fetch = mock.fn(async () => ({
197          ok: false,
198          status: 500,
199          statusText: 'Internal Server Error',
200        }));
201  
202        await assert.rejects(
203          async () => {
204            await fetchReceivedEmail('some-email-id');
205          },
206          {
207            message: /Failed to fetch email: 500 Internal Server Error/,
208          }
209        );
210  
211        delete process.env.RESEND_API_KEY;
212      });
213    });
214  
215    describe('pollInboundEmails', () => {
216      test('should throw error when EMAIL_EVENTS_WORKER_URL is not configured', async () => {
217        const savedUrl = process.env.EMAIL_EVENTS_WORKER_URL;
218        delete process.env.EMAIL_EVENTS_WORKER_URL;
219  
220        await assert.rejects(
221          async () => {
222            await pollInboundEmails();
223          },
224          {
225            message: /EMAIL_EVENTS_WORKER_URL not configured/,
226          }
227        );
228  
229        if (savedUrl) process.env.EMAIL_EVENTS_WORKER_URL = savedUrl;
230      });
231  
232      test('should return zero counts when no received events', async () => {
233        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
234  
235        globalThis.fetch = mock.fn(async () => ({
236          ok: true,
237          json: async () => [],
238        }));
239  
240        const result = await pollInboundEmails();
241  
242        assert.strictEqual(result.processed, 0);
243        assert.strictEqual(result.stored, 0);
244        assert.strictEqual(result.unmatched, 0);
245  
246        delete process.env.EMAIL_EVENTS_WORKER_URL;
247      });
248  
249      test('should return zero counts when events exist but none are email.received type', async () => {
250        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
251  
252        globalThis.fetch = mock.fn(async () => ({
253          ok: true,
254          json: async () => [
255            {
256              type: 'email.sent',
257              created_at: new Date().toISOString(),
258              data: { from: 'test@test.com', email_id: 'id-1' },
259            },
260            {
261              type: 'email.delivered',
262              created_at: new Date().toISOString(),
263              data: { from: 'test@test.com', email_id: 'id-2' },
264            },
265          ],
266        }));
267  
268        const result = await pollInboundEmails();
269  
270        assert.strictEqual(result.processed, 0);
271        assert.strictEqual(result.stored, 0);
272        assert.strictEqual(result.unmatched, 0);
273  
274        delete process.env.EMAIL_EVENTS_WORKER_URL;
275      });
276  
277      test('should throw error when worker URL returns non-OK response', async () => {
278        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
279  
280        globalThis.fetch = mock.fn(async () => ({
281          ok: false,
282          status: 503,
283          statusText: 'Service Unavailable',
284        }));
285  
286        await assert.rejects(
287          async () => {
288            await pollInboundEmails();
289          },
290          {
291            message: /Failed to fetch events: 503 Service Unavailable/,
292          }
293        );
294  
295        delete process.env.EMAIL_EVENTS_WORKER_URL;
296      });
297  
298      test('should process and store matched inbound email events', async () => {
299        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
300        process.env.RESEND_API_KEY = 'test-api-key-123';
301  
302        const now = new Date();
303        const recentDate = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
304  
305        // Mock the worker URL fetch (returns events list)
306        // Mock the Resend API fetch (returns email details)
307        globalThis.fetch = mock.fn(async url => {
308          if (url.includes('email-events.json')) {
309            // Worker URL - return events
310            return {
311              ok: true,
312              json: async () => [
313                {
314                  type: 'email.received',
315                  created_at: recentDate.toISOString(),
316                  data: {
317                    from: 'owner@testsite.com',
318                    subject: 'Re: Improve your site',
319                    email_id: 'received-email-001',
320                  },
321                },
322              ],
323            };
324          }
325          // Resend API - return email details
326          return {
327            ok: true,
328            json: async () => ({
329              id: 'received-email-001',
330              from: 'owner@testsite.com',
331              to: ['sender@example.com'],
332              subject: 'Re: Improve your site',
333              text: 'Yes, I am interested in your services!',
334              html: '<p>Yes, I am interested in your services!</p>',
335            }),
336          };
337        });
338  
339        const result = await pollInboundEmails();
340  
341        assert.strictEqual(result.stored, 1);
342        assert.strictEqual(result.unmatched, 0);
343        assert.strictEqual(result.processed, 1);
344  
345        // Verify conversation was stored in DB
346        const conversations = db.prepare('SELECT * FROM messages WHERE site_id = 1').all();
347  
348        assert.ok(conversations.length >= 1, 'Should have stored at least one conversation');
349  
350        const conv = conversations[conversations.length - 1];
351        assert.strictEqual(conv.direction, 'inbound');
352        assert.strictEqual(conv.contact_method, 'email');
353        assert.strictEqual(conv.contact_uri, 'owner@testsite.com');
354        assert.strictEqual(conv.message_body, 'Yes, I am interested in your services!');
355        assert.strictEqual(conv.sentiment, 'positive');
356  
357        delete process.env.EMAIL_EVENTS_WORKER_URL;
358        delete process.env.RESEND_API_KEY;
359      });
360  
361      test('should count unmatched events for unknown senders', async () => {
362        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
363        process.env.RESEND_API_KEY = 'test-api-key-123';
364  
365        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
366  
367        globalThis.fetch = mock.fn(async url => {
368          if (url.includes('email-events.json')) {
369            return {
370              ok: true,
371              json: async () => [
372                {
373                  type: 'email.received',
374                  created_at: recentDate.toISOString(),
375                  data: {
376                    from: 'unknown@stranger.com',
377                    subject: 'Random email',
378                    email_id: 'unknown-email-001',
379                  },
380                },
381              ],
382            };
383          }
384          return { ok: true, json: async () => ({}) };
385        });
386  
387        const result = await pollInboundEmails();
388  
389        // Unmatched emails without site_id cannot be stored (messages.site_id is NOT NULL),
390        // so stored=0 and unmatched=1.
391        assert.strictEqual(result.stored, 0);
392        assert.strictEqual(result.unmatched, 1);
393        assert.strictEqual(result.processed, 1);
394  
395        delete process.env.EMAIL_EVENTS_WORKER_URL;
396        delete process.env.RESEND_API_KEY;
397      });
398  
399      test('should skip events with missing from or email_id', async () => {
400        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
401  
402        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
403  
404        globalThis.fetch = mock.fn(async () => ({
405          ok: true,
406          json: async () => [
407            {
408              type: 'email.received',
409              created_at: recentDate.toISOString(),
410              data: { from: null, subject: 'No from', email_id: null },
411            },
412            {
413              type: 'email.received',
414              created_at: recentDate.toISOString(),
415              data: {},
416            },
417          ],
418        }));
419  
420        const result = await pollInboundEmails();
421  
422        assert.strictEqual(result.stored, 0);
423        assert.strictEqual(result.unmatched, 2);
424        assert.strictEqual(result.processed, 2);
425  
426        delete process.env.EMAIL_EVENTS_WORKER_URL;
427      });
428  
429      test('should skip already stored emails (deduplication by email_id)', async () => {
430        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
431        process.env.RESEND_API_KEY = 'test-api-key-123';
432  
433        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
434  
435        // Pre-insert a conversation with the same email_id in raw_payload
436        db.prepare(
437          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, raw_payload)
438           VALUES (?, 'inbound', 'email', ?, ?, ?)`
439        ).run(
440          1,
441          'owner@testsite.com',
442          'Already stored',
443          JSON.stringify({ email_id: 'dup-email-001' })
444        );
445  
446        globalThis.fetch = mock.fn(async url => {
447          if (url.includes('email-events.json')) {
448            return {
449              ok: true,
450              json: async () => [
451                {
452                  type: 'email.received',
453                  created_at: recentDate.toISOString(),
454                  data: {
455                    from: 'owner@testsite.com',
456                    subject: 'Re: Duplicate',
457                    email_id: 'dup-email-001',
458                  },
459                },
460              ],
461            };
462          }
463          return {
464            ok: true,
465            json: async () => ({
466              id: 'dup-email-001',
467              text: 'Duplicate message',
468            }),
469          };
470        });
471  
472        const result = await pollInboundEmails();
473  
474        // Should be processed but not stored (duplicate)
475        assert.strictEqual(result.stored, 0);
476        assert.strictEqual(result.unmatched, 0);
477        assert.strictEqual(result.processed, 1);
478  
479        delete process.env.EMAIL_EVENTS_WORKER_URL;
480        delete process.env.RESEND_API_KEY;
481      });
482  
483      test('should handle errors for individual events gracefully', async () => {
484        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
485        process.env.RESEND_API_KEY = 'test-api-key-123';
486  
487        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
488  
489        globalThis.fetch = mock.fn(async url => {
490          if (url.includes('email-events.json')) {
491            return {
492              ok: true,
493              json: async () => [
494                {
495                  type: 'email.received',
496                  created_at: recentDate.toISOString(),
497                  data: {
498                    from: 'owner@testsite.com',
499                    subject: 'Error test',
500                    email_id: 'error-email-001',
501                  },
502                },
503              ],
504            };
505          }
506          // Resend API call fails
507          return {
508            ok: false,
509            status: 500,
510            statusText: 'Internal Server Error',
511          };
512        });
513  
514        const result = await pollInboundEmails();
515  
516        // Event found outreach but Resend API failed, so it counts as unmatched (error)
517        assert.strictEqual(result.stored, 0);
518        assert.strictEqual(result.unmatched, 1);
519        assert.strictEqual(result.processed, 1);
520  
521        delete process.env.EMAIL_EVENTS_WORKER_URL;
522        delete process.env.RESEND_API_KEY;
523      });
524  
525      test('should filter out events older than 24 hours', async () => {
526        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
527  
528        const oldDate = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48 hours ago
529  
530        globalThis.fetch = mock.fn(async () => ({
531          ok: true,
532          json: async () => [
533            {
534              type: 'email.received',
535              created_at: oldDate.toISOString(),
536              data: {
537                from: 'owner@testsite.com',
538                subject: 'Old email',
539                email_id: 'old-email-001',
540              },
541            },
542          ],
543        }));
544  
545        const result = await pollInboundEmails();
546  
547        // Old event should be filtered out
548        assert.strictEqual(result.processed, 0);
549        assert.strictEqual(result.stored, 0);
550        assert.strictEqual(result.unmatched, 0);
551  
552        delete process.env.EMAIL_EVENTS_WORKER_URL;
553      });
554  
555      test('should handle email with no subject gracefully', async () => {
556        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
557        process.env.RESEND_API_KEY = 'test-api-key-123';
558  
559        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
560  
561        globalThis.fetch = mock.fn(async url => {
562          if (url.includes('email-events.json')) {
563            return {
564              ok: true,
565              json: async () => [
566                {
567                  type: 'email.received',
568                  created_at: recentDate.toISOString(),
569                  data: {
570                    from: 'owner@testsite.com',
571                    subject: null,
572                    email_id: 'no-subject-001',
573                  },
574                },
575              ],
576            };
577          }
578          return {
579            ok: true,
580            json: async () => ({
581              id: 'no-subject-001',
582              text: 'Interested!',
583            }),
584          };
585        });
586  
587        const result = await pollInboundEmails();
588  
589        assert.strictEqual(result.stored, 1);
590  
591        // Verify subject was stored as '(no subject)'
592        const conv = db
593          .prepare("SELECT * FROM messages WHERE raw_payload LIKE '%no-subject-001%'")
594          .get();
595        assert.ok(conv);
596        assert.strictEqual(conv.subject_line, '(no subject)');
597  
598        delete process.env.EMAIL_EVENTS_WORKER_URL;
599        delete process.env.RESEND_API_KEY;
600      });
601  
602      test('should use html content when text is not available', async () => {
603        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
604        process.env.RESEND_API_KEY = 'test-api-key-123';
605  
606        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
607  
608        globalThis.fetch = mock.fn(async url => {
609          if (url.includes('email-events.json')) {
610            return {
611              ok: true,
612              json: async () => [
613                {
614                  type: 'email.received',
615                  created_at: recentDate.toISOString(),
616                  data: {
617                    from: 'owner@testsite.com',
618                    subject: 'HTML only',
619                    email_id: 'html-only-001',
620                  },
621                },
622              ],
623            };
624          }
625          return {
626            ok: true,
627            json: async () => ({
628              id: 'html-only-001',
629              text: null,
630              html: '<p>Please call me to discuss</p>',
631            }),
632          };
633        });
634  
635        const result = await pollInboundEmails();
636  
637        assert.strictEqual(result.stored, 1);
638  
639        delete process.env.EMAIL_EVENTS_WORKER_URL;
640        delete process.env.RESEND_API_KEY;
641      });
642  
643      test('should process multiple events in one poll cycle', async () => {
644        process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com';
645        process.env.RESEND_API_KEY = 'test-api-key-123';
646  
647        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
648  
649        globalThis.fetch = mock.fn(async url => {
650          if (url.includes('email-events.json')) {
651            return {
652              ok: true,
653              json: async () => [
654                {
655                  type: 'email.received',
656                  created_at: recentDate.toISOString(),
657                  data: {
658                    from: 'owner@testsite.com',
659                    subject: 'First email',
660                    email_id: 'multi-001',
661                  },
662                },
663                {
664                  type: 'email.received',
665                  created_at: recentDate.toISOString(),
666                  data: {
667                    from: 'contact@another.com',
668                    subject: 'Second email',
669                    email_id: 'multi-002',
670                  },
671                },
672                {
673                  type: 'email.received',
674                  created_at: recentDate.toISOString(),
675                  data: {
676                    from: 'nobody@nowhere.com',
677                    subject: 'Unknown sender',
678                    email_id: 'multi-003',
679                  },
680                },
681              ],
682            };
683          }
684          // Resend API for each email
685          const emailId = url.split('/').pop();
686          return {
687            ok: true,
688            json: async () => ({
689              id: emailId,
690              text: `Reply from ${emailId}`,
691            }),
692          };
693        });
694  
695        const result = await pollInboundEmails();
696  
697        assert.strictEqual(result.processed, 3);
698        assert.strictEqual(result.stored, 2); // 2 matched stored, unmatched without site_id cannot be stored
699        assert.strictEqual(result.unmatched, 1); // 1 unknown sender
700  
701        delete process.env.EMAIL_EVENTS_WORKER_URL;
702        delete process.env.RESEND_API_KEY;
703      });
704    });
705  
706    describe('processPendingReplies', () => {
707      test('should return zero counts when no pending replies', async () => {
708        const result = await processPendingReplies();
709  
710        assert.strictEqual(result.sent, 0);
711        assert.strictEqual(result.failed, 0);
712      });
713  
714      test('should send pending operator email replies', async () => {
715        // Insert a pending outbound conversation
716        db.prepare(
717          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type)
718           VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')`
719        ).run(1, 'owner@testsite.com', 'Thanks for your interest!', 'Re: Improve your site');
720  
721        const result = await processPendingReplies();
722  
723        assert.strictEqual(result.sent, 1);
724        assert.strictEqual(result.failed, 0);
725  
726        // Verify sendEmail was called once with the message id (outreachId)
727        assert.strictEqual(mockSendEmail.mock.calls.length, 1);
728        const callArgs = mockSendEmail.mock.calls[0].arguments;
729        assert.strictEqual(callArgs.length, 1); // sendEmail(outreachId) — single arg
730        assert.ok(typeof callArgs[0] === 'number', 'should be called with numeric message id');
731  
732        // Verify sent_at was set
733        const conv = db
734          .prepare("SELECT * FROM messages WHERE direction = 'outbound' AND contact_method = 'email' AND message_type = 'reply'")
735          .get();
736        assert.ok(conv.sent_at, 'sent_at should be set after sending');
737      });
738  
739      test('should handle multiple pending replies', async () => {
740        // Insert two pending outbound conversations
741        db.prepare(
742          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type)
743           VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')`
744        ).run(1, 'owner@testsite.com', 'Reply 1', 'Re: Subject 1');
745  
746        db.prepare(
747          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type)
748           VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')`
749        ).run(2, 'contact@another.com', 'Reply 2', 'Re: Subject 2');
750  
751        const result = await processPendingReplies();
752  
753        assert.strictEqual(result.sent, 2);
754        assert.strictEqual(result.failed, 0);
755        assert.strictEqual(mockSendEmail.mock.calls.length, 2);
756      });
757  
758      test('should handle send failures gracefully and continue processing', async () => {
759        // Insert two pending outbound conversations
760        db.prepare(
761          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type)
762           VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')`
763        ).run(1, 'owner@testsite.com', 'Reply that fails', 'Re: Fail');
764  
765        db.prepare(
766          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type)
767           VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')`
768        ).run(2, 'contact@another.com', 'Reply that succeeds', 'Re: Success');
769  
770        // First call fails, second succeeds
771        let callNum = 0;
772        mockSendEmail.mock.mockImplementation(async () => {
773          callNum++;
774          if (callNum === 1) {
775            throw new Error('Resend API error: rate limited');
776          }
777          return { id: 'mock-email-id', status: 'sent' };
778        });
779  
780        const result = await processPendingReplies();
781  
782        assert.strictEqual(result.sent, 1);
783        assert.strictEqual(result.failed, 1);
784        assert.strictEqual(mockSendEmail.mock.calls.length, 2);
785      });
786  
787      test('should not process SMS outbound conversations', async () => {
788        // Insert an SMS outbound conversation (should be ignored by email processPendingReplies)
789        db.prepare(
790          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body)
791           VALUES (?, 'outbound', 'sms', ?, ?)`
792        ).run(1, '+1234567890', 'SMS reply');
793  
794        const result = await processPendingReplies();
795  
796        assert.strictEqual(result.sent, 0);
797        assert.strictEqual(result.failed, 0);
798        assert.strictEqual(mockSendEmail.mock.calls.length, 0);
799      });
800  
801      test('should not re-send already sent replies', async () => {
802        // Insert a conversation that already has sent_at set
803        db.prepare(
804          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, sent_at)
805           VALUES (?, 'outbound', 'email', ?, ?, ?, CURRENT_TIMESTAMP)`
806        ).run(1, 'owner@testsite.com', 'Already sent', 'Re: Already done');
807  
808        const result = await processPendingReplies();
809  
810        assert.strictEqual(result.sent, 0);
811        assert.strictEqual(result.failed, 0);
812        assert.strictEqual(mockSendEmail.mock.calls.length, 0);
813      });
814  
815      test('should use default subject when subject_line is null', async () => {
816        // Insert conversation without subject_line
817        db.prepare(
818          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
819           VALUES (?, 'outbound', 'email', ?, ?, 'reply')`
820        ).run(1, 'owner@testsite.com', 'Reply without subject');
821  
822        const result = await processPendingReplies();
823  
824        assert.strictEqual(result.sent, 1);
825  
826        // sendEmail is called with just the message id (outreachId)
827        // subject_line fallback logic lives inside sendEmail, not here
828        assert.strictEqual(mockSendEmail.mock.calls.length, 1);
829        const callArgs = mockSendEmail.mock.calls[0].arguments;
830        assert.ok(typeof callArgs[0] === 'number', 'should be called with numeric message id');
831      });
832    });
833  });