backend-manager 5.0.185 → 5.0.187

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -188,30 +188,6 @@ Transactional.prototype.build = async function (settings) {
188
188
  signoff.urlText = signoff.urlText || '@ianwieds';
189
189
  }
190
190
 
191
- // Process markdown in body fields
192
- if (settings?.data?.body?.message) {
193
- settings.data.body.message = md.render(settings.data.body.message);
194
- }
195
- if (settings?.data?.email?.body) {
196
- settings.data.email.body = md.render(settings.data.email.body);
197
- }
198
-
199
- // Tag links with UTM params for attribution
200
- const utmOptions = {
201
- brandUrl: brand?.url,
202
- brandId: brand?.id,
203
- campaign: settings.sender || templateId,
204
- type: 'transactional',
205
- utm: settings.utm,
206
- };
207
-
208
- if (settings?.data?.body?.message) {
209
- settings.data.body.message = tagLinks(settings.data.body.message, utmOptions);
210
- }
211
- if (settings?.data?.email?.body) {
212
- settings.data.email.body = tagLinks(settings.data.email.body, utmOptions);
213
- }
214
-
215
191
  // Build dynamic template data defaults
216
192
  const dynamicTemplateData = {
217
193
  email: {
@@ -242,6 +218,30 @@ Transactional.prototype.build = async function (settings) {
242
218
  _.merge(dynamicTemplateData, settings.data);
243
219
  }
244
220
 
221
+ // Process markdown in body fields (after merge so all data paths are resolved)
222
+ if (dynamicTemplateData.data?.body?.message) {
223
+ dynamicTemplateData.data.body.message = md.render(dynamicTemplateData.data.body.message);
224
+ }
225
+ if (dynamicTemplateData.email?.body) {
226
+ dynamicTemplateData.email.body = md.render(dynamicTemplateData.email.body);
227
+ }
228
+
229
+ // Tag links with UTM params for attribution
230
+ const utmOptions = {
231
+ brandUrl: brand?.url,
232
+ brandId: brand?.id,
233
+ campaign: settings.sender || templateId,
234
+ type: 'transactional',
235
+ utm: settings.utm,
236
+ };
237
+
238
+ if (dynamicTemplateData.data?.body?.message) {
239
+ dynamicTemplateData.data.body.message = tagLinks(dynamicTemplateData.data.body.message, utmOptions);
240
+ }
241
+ if (dynamicTemplateData.email?.body) {
242
+ dynamicTemplateData.email.body = tagLinks(dynamicTemplateData.email.body, utmOptions);
243
+ }
244
+
245
245
  // Build the email object
246
246
  const email = {
247
247
  to,
@@ -20,9 +20,13 @@
20
20
  const fetch = require('wonderful-fetch');
21
21
  const path = require('path');
22
22
 
23
- // Load disposable domains list once at module level
23
+ // Load disposable domains: curated vendor list + custom additions
24
24
  const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', 'disposable-domains.json'));
25
- const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
25
+ const CUSTOM_DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', 'custom-disposable-domains.json'));
26
+ const DISPOSABLE_SET = new Set([
27
+ ...DISPOSABLE_DOMAINS.map(d => d.toLowerCase()),
28
+ ...CUSTOM_DISPOSABLE_DOMAINS.map(d => d.toLowerCase()),
29
+ ]);
26
30
 
27
31
  // Spam/junk local parts — exact matches (checked after stripping +suffix)
28
32
  const BLOCKED_LOCAL_PARTS = new Set([
@@ -167,4 +171,23 @@ async function validate(email, options = {}) {
167
171
  return result;
168
172
  }
169
173
 
170
- module.exports = { validate, DEFAULT_CHECKS, ALL_CHECKS };
174
+ /**
175
+ * Quick check: is this email from a disposable domain?
176
+ * Works with a full email address or just a domain.
177
+ *
178
+ * @param {string} emailOrDomain
179
+ * @returns {boolean}
180
+ */
181
+ function isDisposable(emailOrDomain) {
182
+ if (!emailOrDomain) {
183
+ return false;
184
+ }
185
+
186
+ const domain = emailOrDomain.includes('@')
187
+ ? emailOrDomain.split('@')[1]
188
+ : emailOrDomain;
189
+
190
+ return DISPOSABLE_SET.has(domain.toLowerCase());
191
+ }
192
+
193
+ module.exports = { validate, isDisposable, DEFAULT_CHECKS, ALL_CHECKS };
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared contact inference library
3
3
  *
4
- * Infers first/last name and company from an email address.
5
- * Tries AI first (if OPENAI_API_KEY is set), falls back to regex parsing.
4
+ * Infers first/last name and company from an email address using AI.
5
+ * Requires BACKEND_MANAGER_OPENAI_API_KEY to be set.
6
6
  *
7
7
  * Usage:
8
8
  * const { inferContact } = require('./libraries/infer-contact.js');
@@ -13,16 +13,8 @@ const path = require('path');
13
13
 
14
14
  const PROMPT_PATH = path.join(__dirname, 'prompts', 'infer-contact.md');
15
15
 
16
- // Common email providers (don't infer company from these domains)
17
- const GENERIC_DOMAINS = new Set([
18
- 'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com',
19
- 'icloud.com', 'mail.com', 'protonmail.com', 'proton.me', 'zoho.com',
20
- 'yandex.com', 'gmx.com', 'live.com', 'msn.com', 'me.com',
21
- ]);
22
-
23
16
  /**
24
- * Infer contact info from email address
25
- * Tries AI first (if OPENAI_API_KEY is set), falls back to regex
17
+ * Infer contact info from email address using AI
26
18
  *
27
19
  * @param {string} email - Email address
28
20
  * @param {object} assistant - Assistant instance (for AI access)
@@ -36,8 +28,6 @@ async function inferContact(email, assistant) {
36
28
  }
37
29
  }
38
30
 
39
- // TODO: Re-enable regex fallback if needed
40
- // return inferContactFromEmail(email);
41
31
  return { firstName: '', lastName: '', company: '', confidence: 0, method: 'none' };
42
32
  }
43
33
 
@@ -84,46 +74,6 @@ async function inferContactWithAI(email, assistant) {
84
74
  return null;
85
75
  }
86
76
 
87
- /**
88
- * Regex-based contact inference from email
89
- * Extracts name from local part and company from domain
90
- *
91
- * @param {string} email - Email address
92
- * @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
93
- */
94
- function inferContactFromEmail(email) {
95
- const [local, domain] = email.split('@');
96
-
97
- // Infer company from domain (skip generic providers)
98
- let company = '';
99
- if (domain && !GENERIC_DOMAINS.has(domain.toLowerCase())) {
100
- const domainName = domain.split('.')[0];
101
- company = capitalize(domainName.replace(/[-_]/g, ' '));
102
- }
103
-
104
- // Infer name from local part
105
- const cleaned = local.replace(/[0-9]+$/, '');
106
- const parts = cleaned.split(/[._-]/);
107
-
108
- if (parts.length >= 2) {
109
- return {
110
- firstName: capitalize(parts[0]),
111
- lastName: capitalize(parts.slice(1).join(' ')),
112
- company,
113
- confidence: 0.5,
114
- method: 'regex',
115
- };
116
- }
117
-
118
- return {
119
- firstName: capitalize(cleaned),
120
- lastName: '',
121
- company,
122
- confidence: 0.25,
123
- method: 'regex',
124
- };
125
- }
126
-
127
77
  /**
128
78
  * Capitalize first letter of each word
129
79
  *
@@ -140,4 +90,4 @@ function capitalize(str) {
140
90
  .join(' ');
141
91
  }
142
92
 
143
- module.exports = { inferContact, inferContactFromEmail, capitalize };
93
+ module.exports = { inferContact, capitalize };
@@ -1,6 +1,6 @@
1
1
  const moment = require('moment');
2
2
  const { inferContact } = require('../../../libraries/infer-contact.js');
3
- const { validate: validateEmail } = require('../../../libraries/email/validation.js');
3
+ const { validate: validateEmail, isDisposable } = require('../../../libraries/email/validation.js');
4
4
 
5
5
  const MAX_POLL_TIME_MS = 30000;
6
6
  const POLL_INTERVAL_MS = 500;
@@ -73,7 +73,7 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
73
73
  .set(userRecord, { merge: true });
74
74
 
75
75
  // 5. Process affiliate referral (writes to referrer's doc, not this user's)
76
- await processAffiliate(assistant, uid, settings);
76
+ await processAffiliate(assistant, uid, email, settings);
77
77
 
78
78
  // 6. Send emails + marketing (non-blocking, fire-and-forget)
79
79
  syncMarketingContact(assistant, uid, email);
@@ -181,7 +181,7 @@ async function inferUserContact(assistant, email) {
181
181
  * Process affiliate referral if affiliate code provided
182
182
  * Writes to the referrer's doc (not the current user's)
183
183
  */
184
- async function processAffiliate(assistant, uid, settings) {
184
+ async function processAffiliate(assistant, uid, email, settings) {
185
185
  const { admin } = assistant.Manager.libraries;
186
186
  const affiliateCode = settings.attribution?.affiliate?.code
187
187
  || settings.affiliateCode
@@ -191,6 +191,12 @@ async function processAffiliate(assistant, uid, settings) {
191
191
  return;
192
192
  }
193
193
 
194
+ // Skip referral credit for disposable email signups (affiliate fraud prevention)
195
+ if (isDisposable(email)) {
196
+ assistant.log(`processAffiliate(): Skipping referral — disposable email ${email}`);
197
+ return;
198
+ }
199
+
194
200
  assistant.log(`processAffiliate(): Looking for referrer with code ${affiliateCode}`);
195
201
 
196
202
  const snapshot = await admin.firestore().collection('users')
@@ -21,5 +21,5 @@ module.exports = () => ({
21
21
  data: { types: ['object'], default: {} },
22
22
  categories: { types: ['array'], default: [] },
23
23
  copy: { types: ['boolean'], default: undefined },
24
- html: { types: ['string'], default: undefined },
24
+ html: { types: ['string'], default: undefined, sanitize: false },
25
25
  });
@@ -153,6 +153,15 @@ const STATIC_ACCOUNTS = {
153
153
  subscription: { product: { id: 'basic' }, status: 'active' },
154
154
  },
155
155
  },
156
+ 'referred-disposable': {
157
+ id: 'referred-disposable',
158
+ uid: '_test-referred-disposable',
159
+ email: '_test.referred-disposable@mailinator.com',
160
+ properties: {
161
+ roles: {},
162
+ subscription: { product: { id: 'basic' }, status: 'active' },
163
+ },
164
+ },
156
165
  };
157
166
 
158
167
  /**
@@ -128,9 +128,10 @@ module.exports = {
128
128
  },
129
129
  },
130
130
 
131
- // Test 5: Name inferred from email
131
+ // Test 5: Name inferred from email (AI only — requires extended mode)
132
132
  {
133
133
  name: 'name-inferred-from-email',
134
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set (AI inference requires OPENAI_API_KEY)' : false,
134
135
  auth: 'admin',
135
136
  timeout: 30000,
136
137
 
@@ -196,6 +196,44 @@ module.exports = {
196
196
  },
197
197
  },
198
198
 
199
+ // --- Disposable email referral test ---
200
+ {
201
+ name: 'disposable-email-referral-skipped',
202
+ async run({ http, firestore, assert, state, accounts }) {
203
+ // Record current referral count before disposable signup
204
+ const referrerBefore = await firestore.get(`users/${state.referrerUid}`);
205
+ const referralsBefore = referrerBefore?.affiliate?.referrals || [];
206
+ state.referralCountBefore = referralsBefore.length;
207
+
208
+ // Sign up a disposable email account with the referrer's affiliate code
209
+ // The signup itself should succeed (account was created via Admin SDK, bypassing beforeCreate)
210
+ // But the referral credit should be SKIPPED because the email is disposable
211
+ const signupResponse = await http.as('referred-disposable').command('user:sign-up', {
212
+ attribution: {
213
+ affiliate: { code: state.referrerAffiliateCode },
214
+ },
215
+ });
216
+
217
+ assert.isSuccess(signupResponse, 'Disposable email signup should succeed');
218
+
219
+ // Verify NO new referral was added to the referrer
220
+ // Small delay to ensure any async writes would have completed
221
+ await new Promise((resolve) => setTimeout(resolve, 3000));
222
+
223
+ const referrerAfter = await firestore.get(`users/${state.referrerUid}`);
224
+ const referralsAfter = referrerAfter?.affiliate?.referrals || [];
225
+
226
+ assert.equal(
227
+ referralsAfter.length,
228
+ state.referralCountBefore,
229
+ `Referrer should NOT get credit for disposable email referral (before=${state.referralCountBefore}, after=${referralsAfter.length})`
230
+ );
231
+
232
+ const disposableReferral = referralsAfter.find(r => r.uid === accounts['referred-disposable'].uid);
233
+ assert.ok(!disposableReferral, 'Disposable account should NOT appear in referrals');
234
+ },
235
+ },
236
+
199
237
  // --- Auth rejection test (at end per convention) ---
200
238
  {
201
239
  name: 'unauthenticated-rejected',
@@ -5,7 +5,7 @@
5
5
  * Format, local part, and disposable tests always run (free, regex-based).
6
6
  * Mailbox verification tests require TEST_EXTENDED_MODE + ZEROBOUNCE_API_KEY.
7
7
  */
8
- const { validate, DEFAULT_CHECKS, ALL_CHECKS } = require('../../src/manager/libraries/email/validation.js');
8
+ const { validate, isDisposable, DEFAULT_CHECKS, ALL_CHECKS } = require('../../src/manager/libraries/email/validation.js');
9
9
 
10
10
  module.exports = {
11
11
  description: 'Email validation',
@@ -287,6 +287,59 @@ module.exports = {
287
287
  },
288
288
  },
289
289
 
290
+ // --- isDisposable helper ---
291
+
292
+ {
293
+ name: 'isDisposable-vendor-domain-detected',
294
+ timeout: 5000,
295
+
296
+ async run({ assert }) {
297
+ assert.equal(isDisposable('user@mailinator.com'), true, 'mailinator.com should be disposable');
298
+ assert.equal(isDisposable('user@guerrillamail.com'), true, 'guerrillamail.com should be disposable');
299
+ },
300
+ },
301
+
302
+ {
303
+ name: 'isDisposable-custom-domain-detected',
304
+ timeout: 5000,
305
+
306
+ async run({ assert }) {
307
+ assert.equal(isDisposable('user@sharebot.net'), true, 'Custom list domain should be disposable');
308
+ assert.equal(isDisposable('user@pickmemail.com'), true, 'Custom list domain should be disposable');
309
+ },
310
+ },
311
+
312
+ {
313
+ name: 'isDisposable-legitimate-domain-passes',
314
+ timeout: 5000,
315
+
316
+ async run({ assert }) {
317
+ assert.equal(isDisposable('user@gmail.com'), false, 'gmail.com should not be disposable');
318
+ assert.equal(isDisposable('user@somiibo.com'), false, 'Custom domain should not be disposable');
319
+ },
320
+ },
321
+
322
+ {
323
+ name: 'isDisposable-accepts-domain-only',
324
+ timeout: 5000,
325
+
326
+ async run({ assert }) {
327
+ assert.equal(isDisposable('mailinator.com'), true, 'Should work with bare domain');
328
+ assert.equal(isDisposable('gmail.com'), false, 'Should work with bare domain');
329
+ },
330
+ },
331
+
332
+ {
333
+ name: 'isDisposable-handles-edge-cases',
334
+ timeout: 5000,
335
+
336
+ async run({ assert }) {
337
+ assert.equal(isDisposable(''), false, 'Empty string should return false');
338
+ assert.equal(isDisposable(null), false, 'Null should return false');
339
+ assert.equal(isDisposable(undefined), false, 'Undefined should return false');
340
+ },
341
+ },
342
+
290
343
  // --- Selective checks ---
291
344
 
292
345
  {
@@ -2,10 +2,9 @@
2
2
  * Test: libraries/infer-contact.js
3
3
  * Unit tests for contact inference from email addresses
4
4
  *
5
- * Tests capitalize, inferContactFromEmail (regex), and inferContact (AI fallback).
6
5
  * AI tests only run when TEST_EXTENDED_MODE is set.
7
6
  */
8
- const { inferContact, inferContactFromEmail, capitalize } = require('../../src/manager/libraries/infer-contact.js');
7
+ const { inferContact, capitalize } = require('../../src/manager/libraries/infer-contact.js');
9
8
 
10
9
  module.exports = {
11
10
  description: 'Infer contact from email',
@@ -63,205 +62,28 @@ module.exports = {
63
62
  },
64
63
  },
65
64
 
66
- // ─── inferContactFromEmail: name parsing ───
65
+ // ─── inferContact: returns empty without AI key ───
67
66
 
68
67
  {
69
- name: 'regex-first-dot-last',
68
+ name: 'infer-contact-no-ai-returns-none',
70
69
  async run({ assert }) {
71
- const result = inferContactFromEmail('john.doe@gmail.com');
70
+ // Without BACKEND_MANAGER_OPENAI_API_KEY, should return empty result
71
+ const originalKey = process.env.BACKEND_MANAGER_OPENAI_API_KEY;
72
+ delete process.env.BACKEND_MANAGER_OPENAI_API_KEY;
72
73
 
73
- assert.equal(result.firstName, 'John', 'First name from local part before dot');
74
- assert.equal(result.lastName, 'Doe', 'Last name from local part after dot');
75
- assert.equal(result.method, 'regex', 'Method should be regex');
76
- assert.equal(result.confidence, 0.5, 'Two-part name should have 0.5 confidence');
77
- },
78
- },
79
-
80
- {
81
- name: 'regex-first-underscore-last',
82
- async run({ assert }) {
83
- const result = inferContactFromEmail('jane_smith@yahoo.com');
84
-
85
- assert.equal(result.firstName, 'Jane', 'First name from local part before underscore');
86
- assert.equal(result.lastName, 'Smith', 'Last name from local part after underscore');
87
- },
88
- },
89
-
90
- {
91
- name: 'regex-first-hyphen-last',
92
- async run({ assert }) {
93
- const result = inferContactFromEmail('bob-jones@hotmail.com');
94
-
95
- assert.equal(result.firstName, 'Bob', 'First name from local part before hyphen');
96
- assert.equal(result.lastName, 'Jones', 'Last name from local part after hyphen');
97
- },
98
- },
99
-
100
- {
101
- name: 'regex-three-part-name',
102
- async run({ assert }) {
103
- const result = inferContactFromEmail('mary.jane.watson@example.com');
104
-
105
- assert.equal(result.firstName, 'Mary', 'First name is first part');
106
- assert.equal(result.lastName, 'Jane Watson', 'Last name joins remaining parts');
107
- },
108
- },
109
-
110
- {
111
- name: 'regex-single-word-local-part',
112
- async run({ assert }) {
113
- const result = inferContactFromEmail('admin@example.com');
114
-
115
- assert.equal(result.firstName, 'Admin', 'Single word becomes first name');
116
- assert.equal(result.lastName, '', 'No last name for single word');
117
- assert.equal(result.confidence, 0.25, 'Single-part name should have 0.25 confidence');
118
- },
119
- },
120
-
121
- {
122
- name: 'regex-strips-trailing-numbers',
123
- async run({ assert }) {
124
- const result = inferContactFromEmail('john.doe42@gmail.com');
125
-
126
- assert.equal(result.firstName, 'John', 'Trailing numbers stripped before parsing');
127
- assert.equal(result.lastName, 'Doe', 'Name parsed correctly after stripping');
128
- },
129
- },
130
-
131
- {
132
- name: 'regex-only-numbers-after-name',
133
- async run({ assert }) {
134
- const result = inferContactFromEmail('user123@gmail.com');
135
-
136
- assert.equal(result.firstName, 'User', 'Numbers stripped, name capitalized');
137
- assert.equal(result.lastName, '', 'No last name');
138
- },
139
- },
140
-
141
- // ─── inferContactFromEmail: company inference ───
142
-
143
- {
144
- name: 'regex-company-from-custom-domain',
145
- async run({ assert }) {
146
- const result = inferContactFromEmail('john@acme.com');
147
-
148
- assert.equal(result.company, 'Acme', 'Company from custom domain');
149
- },
150
- },
151
-
152
- {
153
- name: 'regex-company-from-hyphenated-domain',
154
- async run({ assert }) {
155
- const result = inferContactFromEmail('john@my-company.com');
156
-
157
- assert.equal(result.company, 'My Company', 'Hyphens replaced with spaces');
158
- },
159
- },
160
-
161
- {
162
- name: 'regex-company-from-underscored-domain',
163
- async run({ assert }) {
164
- const result = inferContactFromEmail('john@my_company.com');
165
-
166
- assert.equal(result.company, 'My Company', 'Underscores replaced with spaces');
167
- },
168
- },
169
-
170
- {
171
- name: 'regex-no-company-from-gmail',
172
- async run({ assert }) {
173
- const result = inferContactFromEmail('john@gmail.com');
174
-
175
- assert.equal(result.company, '', 'Gmail is generic, no company');
176
- },
177
- },
178
-
179
- {
180
- name: 'regex-no-company-from-yahoo',
181
- async run({ assert }) {
182
- const result = inferContactFromEmail('john@yahoo.com');
183
-
184
- assert.equal(result.company, '', 'Yahoo is generic, no company');
185
- },
186
- },
74
+ try {
75
+ const result = await inferContact('alice.wonderland@example.com');
187
76
 
188
- {
189
- name: 'regex-no-company-from-outlook',
190
- async run({ assert }) {
191
- const result = inferContactFromEmail('john@outlook.com');
192
-
193
- assert.equal(result.company, '', 'Outlook is generic, no company');
194
- },
195
- },
196
-
197
- {
198
- name: 'regex-no-company-from-protonmail',
199
- async run({ assert }) {
200
- const result = inferContactFromEmail('john@protonmail.com');
201
-
202
- assert.equal(result.company, '', 'Protonmail is generic, no company');
203
- },
204
- },
205
-
206
- {
207
- name: 'regex-no-company-from-icloud',
208
- async run({ assert }) {
209
- const result = inferContactFromEmail('john@icloud.com');
210
-
211
- assert.equal(result.company, '', 'iCloud is generic, no company');
212
- },
213
- },
214
-
215
- {
216
- name: 'regex-generic-domain-case-insensitive',
217
- async run({ assert }) {
218
- const result = inferContactFromEmail('john@GMAIL.COM');
219
-
220
- assert.equal(result.company, '', 'Generic domain check should be case-insensitive');
221
- },
222
- },
223
-
224
- // ─── inferContactFromEmail: combined name + company ───
225
-
226
- {
227
- name: 'regex-full-result-custom-domain',
228
- async run({ assert }) {
229
- const result = inferContactFromEmail('sarah.connor@skynet.io');
230
-
231
- assert.equal(result.firstName, 'Sarah', 'First name parsed');
232
- assert.equal(result.lastName, 'Connor', 'Last name parsed');
233
- assert.equal(result.company, 'Skynet', 'Company from domain');
234
- assert.equal(result.method, 'regex', 'Method is regex');
235
- assert.equal(result.confidence, 0.5, 'Confidence 0.5 for two-part name');
236
- },
237
- },
238
-
239
- {
240
- name: 'regex-single-name-custom-domain',
241
- async run({ assert }) {
242
- const result = inferContactFromEmail('info@acme.com');
243
-
244
- assert.equal(result.firstName, 'Info', 'Single word capitalized');
245
- assert.equal(result.lastName, '', 'No last name');
246
- assert.equal(result.company, 'Acme', 'Company inferred');
247
- assert.equal(result.confidence, 0.25, 'Lower confidence for single name');
248
- },
249
- },
250
-
251
- // ─── inferContact: regex fallback (no OPENAI_API_KEY) ───
252
-
253
- {
254
- name: 'infer-contact-regex-fallback',
255
- async run({ assert, skip }) {
256
- if (!process.env.TEST_EXTENDED_MODE || !process.env.BACKEND_MANAGER_OPENAI_API_KEY) {
257
- skip('TEST_EXTENDED_MODE or BACKEND_MANAGER_OPENAI_API_KEY not set');
77
+ assert.equal(result.firstName, '', 'No first name without AI');
78
+ assert.equal(result.lastName, '', 'No last name without AI');
79
+ assert.equal(result.company, '', 'No company without AI');
80
+ assert.equal(result.method, 'none', 'Method should be none');
81
+ assert.equal(result.confidence, 0, 'Confidence should be 0');
82
+ } finally {
83
+ if (originalKey) {
84
+ process.env.BACKEND_MANAGER_OPENAI_API_KEY = originalKey;
85
+ }
258
86
  }
259
-
260
- const result = await inferContact('alice.wonderland@example.com');
261
-
262
- assert.equal(result.firstName, 'Alice', 'AI inferred first name');
263
- assert.equal(result.lastName, 'Wonderland', 'AI inferred last name');
264
- assert.ok(result.method === 'ai', 'Should use AI');
265
87
  },
266
88
  },
267
89