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