/ tests / inbound / inbound-email.test.js
inbound-email.test.js
  1  /**
  2   * Tests for Inbound Email Module
  3   */
  4  
  5  import { test, mock } from 'node:test';
  6  import assert from 'node:assert';
  7  import Database from 'better-sqlite3';
  8  import { createPgMock } from '../helpers/pg-mock.js';
  9  
 10  // Shared in-memory database — minimal schema covering what email.js needs
 11  const db = new Database(':memory:');
 12  db.pragma('foreign_keys = ON');
 13  db.exec(`
 14    CREATE TABLE IF NOT EXISTS sites (
 15      id INTEGER PRIMARY KEY,
 16      domain TEXT NOT NULL UNIQUE,
 17      landing_page_url TEXT,
 18      keyword TEXT,
 19      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 20      rescored_at DATETIME
 21    );
 22  
 23    CREATE TABLE IF NOT EXISTS messages (
 24      id INTEGER PRIMARY KEY,
 25      site_id INTEGER NOT NULL REFERENCES sites(id),
 26      direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')),
 27      contact_method TEXT CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')),
 28      contact_uri TEXT,
 29      message_body TEXT,
 30      subject_line TEXT,
 31      approval_status TEXT,
 32      delivery_status TEXT,
 33      sentiment TEXT,
 34      raw_payload TEXT,
 35      message_type TEXT DEFAULT 'outreach',
 36      sent_at DATETIME,
 37      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 38      read_at TEXT
 39    );
 40  
 41    CREATE TABLE IF NOT EXISTS config (
 42      key TEXT PRIMARY KEY,
 43      value TEXT,
 44      description TEXT
 45    );
 46  
 47    CREATE TABLE IF NOT EXISTS countries (
 48      country_code TEXT PRIMARY KEY,
 49      country_name TEXT,
 50      google_domain TEXT,
 51      language_code TEXT,
 52      currency_code TEXT,
 53      is_active INTEGER DEFAULT 1,
 54      sms_enabled INTEGER DEFAULT 1,
 55      requires_gdpr_check INTEGER DEFAULT 0,
 56      twilio_phone_number TEXT
 57    );
 58  `);
 59  
 60  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 61  
 62  const {
 63    findOutreachByEmail,
 64    parseEmailBody,
 65    detectSentiment,
 66    storeInboundEmail,
 67  } = await import('../../src/inbound/email.js');
 68  
 69  test('Inbound Email Module', async t => {
 70    await t.test('Setup test database', () => {
 71      // Clear and seed test data
 72      db.exec('DELETE FROM messages; DELETE FROM sites;');
 73  
 74      db.prepare('INSERT INTO sites (id, domain, landing_page_url, keyword) VALUES (?, ?, ?, ?)')
 75        .run(1, 'testsite.com', 'https://testsite.com', 'test keyword');
 76  
 77      db.prepare(
 78        `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status)
 79         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
 80      ).run(1, 1, 'email', 'owner@testsite.com', 'Test proposal', 'Test subject', 'outbound', 'sent');
 81  
 82      db.prepare(
 83        `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status)
 84         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
 85      ).run(2, 1, 'email', 'CONTACT@EXAMPLE.COM', 'Test proposal 2', 'Test subject 2', 'outbound', 'sent');
 86    });
 87  
 88    await t.test('findOutreachByEmail', async t => {
 89      await t.test('should find outreach by exact email match', async () => {
 90        const outreach = await findOutreachByEmail('owner@testsite.com');
 91        assert.ok(outreach, 'Should find outreach');
 92        assert.strictEqual(outreach.id, 1);
 93        assert.strictEqual(outreach.contact_method, 'email');
 94      });
 95  
 96      await t.test('should find outreach by email (case insensitive)', async () => {
 97        const outreach = await findOutreachByEmail('OWNER@TESTSITE.COM');
 98        assert.ok(outreach, 'Should find outreach');
 99        assert.strictEqual(outreach.id, 1);
100      });
101  
102      await t.test('should find outreach with uppercase email in database', async () => {
103        const outreach = await findOutreachByEmail('contact@example.com');
104        assert.ok(outreach, 'Should find outreach');
105        assert.strictEqual(outreach.id, 2);
106      });
107  
108      await t.test('should return undefined for non-existent email', async () => {
109        const outreach = await findOutreachByEmail('nonexistent@example.com');
110        assert.ok(outreach == null, `expected null or undefined, got ${outreach}`);
111      });
112  
113      await t.test('should return most recent outreach if multiple matches', async () => {
114        db.prepare(
115          `INSERT INTO messages (site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status)
116           VALUES (?, ?, ?, ?, ?, ?, ?)`
117        ).run(1, 'email', 'owner@testsite.com', 'Newer proposal', 'Newer subject', 'outbound', 'sent');
118  
119        const outreach = await findOutreachByEmail('owner@testsite.com');
120        assert.ok(outreach, 'Should find outreach');
121        assert.strictEqual(outreach.contact_uri, 'owner@testsite.com');
122      });
123    });
124  
125    await t.test('parseEmailBody', async t => {
126      await t.test('should return clean body when no quoted text', () => {
127        const result = parseEmailBody('This is a clean email body without quotes.');
128        assert.strictEqual(result.clean, 'This is a clean email body without quotes.');
129        assert.strictEqual(result.quoted, '');
130      });
131  
132      await t.test('should parse quoted text with "On ... wrote:"', () => {
133        const body = `Yes, I'm interested!
134  
135  On Mon, Jan 1, 2024 at 10:00 AM, Sender <sender@example.com> wrote:
136  > Original message here`;
137  
138        const result = parseEmailBody(body);
139        assert.strictEqual(result.clean, "Yes, I'm interested!");
140        assert.ok(result.quoted.includes('On Mon, Jan 1'));
141      });
142  
143      await t.test('should parse quoted text with "> " prefix', () => {
144        const body = `Thanks for reaching out.
145  
146  > Your original message
147  > Line 2`;
148  
149        const result = parseEmailBody(body);
150        assert.strictEqual(result.clean, 'Thanks for reaching out.');
151        assert.ok(result.quoted.includes('> Your original message'));
152      });
153  
154      await t.test('should parse quoted text with "-----Original Message-----"', () => {
155        const body = `I am interested.
156  
157  -----Original Message-----
158  From: sender@example.com
159  Sent: Monday, January 1, 2024
160  Subject: Test`;
161  
162        const result = parseEmailBody(body);
163        assert.strictEqual(result.clean, 'I am interested.');
164        assert.ok(result.quoted.includes('-----Original Message-----'));
165      });
166  
167      await t.test('should handle empty body', () => {
168        const result = parseEmailBody('');
169        assert.strictEqual(result.clean, '');
170        assert.strictEqual(result.quoted, '');
171      });
172  
173      await t.test('should handle object with text property', () => {
174        const result = parseEmailBody({ text: 'Hello world' });
175        assert.strictEqual(result.clean, 'Hello world');
176        assert.strictEqual(result.quoted, '');
177      });
178    });
179  
180    await t.test('detectSentiment', async t => {
181      await t.test('should detect positive sentiment', () => {
182        assert.strictEqual(detectSentiment('Yes, I am interested!'), 'positive');
183        assert.strictEqual(detectSentiment("Sounds good, let's talk"), 'positive');
184        assert.strictEqual(detectSentiment('Please call me when available'), 'positive');
185        assert.strictEqual(detectSentiment('Great! Thank you'), 'positive');
186      });
187  
188      await t.test('should detect objection/negative sentiment', () => {
189        assert.strictEqual(detectSentiment('Not interested'), 'objection');
190        assert.strictEqual(detectSentiment('No thanks, please remove me'), 'objection');
191        assert.strictEqual(detectSentiment('STOP sending me emails'), 'objection');
192        assert.strictEqual(detectSentiment('Already have a solution'), 'objection');
193        assert.strictEqual(detectSentiment('Too expensive for us'), 'objection');
194      });
195  
196      await t.test('should detect neutral sentiment', () => {
197        assert.strictEqual(detectSentiment('I received your email.'), 'neutral');
198        assert.strictEqual(detectSentiment('What are your rates?'), 'neutral');
199        assert.strictEqual(detectSentiment('Tell me more'), 'neutral');
200      });
201  
202      await t.test('should handle empty text', () => {
203        assert.strictEqual(detectSentiment(''), null);
204        assert.strictEqual(detectSentiment(null), null);
205        assert.strictEqual(detectSentiment(undefined), null);
206      });
207  
208      await t.test('should be case insensitive', () => {
209        assert.strictEqual(detectSentiment('YES I AM INTERESTED'), 'positive');
210        assert.strictEqual(detectSentiment('NOT INTERESTED'), 'objection');
211      });
212    });
213  
214    await t.test('storeInboundEmail', async t => {
215      await t.test('should store inbound email message', async () => {
216        const conversationId = await storeInboundEmail(
217          1,
218          'owner@testsite.com',
219          'Re: Your proposal',
220          'Hello, I am interested in learning more!',
221          { test: 'payload' }
222        );
223  
224        const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId);
225  
226        assert.ok(conversation, 'Conversation should exist');
227        assert.strictEqual(conversation.site_id, 1);
228        assert.strictEqual(conversation.direction, 'inbound');
229        assert.strictEqual(conversation.contact_method, 'email');
230        assert.strictEqual(conversation.contact_uri, 'owner@testsite.com');
231        assert.strictEqual(conversation.subject_line, 'Re: Your proposal');
232        assert.strictEqual(conversation.message_body, 'Hello, I am interested in learning more!');
233        assert.strictEqual(conversation.sentiment, 'positive');
234        assert.ok(conversation.created_at, 'Should have created_at timestamp');
235        assert.ok(conversation.raw_payload, 'Should have raw payload');
236      });
237  
238      await t.test('should parse quoted text when storing', async () => {
239        const emailBody = `Yes, please send more info.
240  
241  On Mon, Jan 1, 2024 at 10:00 AM, Sender <sender@example.com> wrote:
242  > Original proposal message`;
243  
244        const conversationId = await storeInboundEmail(
245          1,
246          'owner@testsite.com',
247          'Re: Proposal',
248          emailBody,
249          {}
250        );
251  
252        const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId);
253  
254        assert.ok(conversation, 'Conversation should exist');
255        assert.strictEqual(conversation.message_body, 'Yes, please send more info.');
256        assert.strictEqual(conversation.sentiment, 'positive');
257      });
258  
259      await t.test('should detect objection sentiment', async () => {
260        const conversationId = await storeInboundEmail(
261          1,
262          'owner@testsite.com',
263          'Re: Your email',
264          'Not interested, please remove me from your list',
265          {}
266        );
267  
268        const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId);
269  
270        assert.strictEqual(conversation.sentiment, 'objection');
271      });
272  
273      await t.test('should fail with invalid site_id (foreign key)', async () => {
274        await assert.rejects(
275          () => storeInboundEmail(9999, 'test@example.com', 'Test', 'Test message', {}),
276          {
277            message: /FOREIGN KEY constraint failed/,
278          }
279        );
280      });
281    });
282  });