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.
- package/CHANGELOG.md +4 -0
- package/CLAUDE.md +52 -0
- package/TODO-2.md +16 -0
- package/TODO-email-auth.md +14 -0
- package/package.json +3 -1
- package/scripts/update-disposable-domains.js +44 -0
- package/src/manager/events/auth/before-create.js +12 -3
- package/src/manager/helpers/middleware.js +36 -16
- package/src/manager/helpers/settings.js +2 -0
- package/src/manager/helpers/utilities.js +40 -0
- package/src/manager/libraries/custom-disposable-domains.json +12 -0
- package/src/manager/libraries/disposable-domains.json +4580 -75
- package/src/manager/libraries/email/transactional/index.js +24 -24
- package/src/manager/libraries/email/validation.js +26 -3
- package/src/manager/libraries/infer-contact.js +4 -54
- package/src/manager/routes/user/signup/post.js +9 -3
- package/src/manager/schemas/admin/email/post.js +1 -1
- package/src/test/test-accounts.js +9 -0
- package/test/functions/general/add-marketing-contact.js +2 -1
- package/test/functions/user/sign-up.js +38 -0
- package/test/helpers/email-validation.js +54 -1
- package/test/helpers/infer-contact.js +17 -195
- package/test/helpers/sanitize.js +222 -0
- package/test/routes/marketing/contact.js +2 -1
- package/test/routes/user/signup.js +38 -0
|
@@ -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
|
|
23
|
+
// Load disposable domains: curated vendor list + custom additions
|
|
24
24
|
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', 'disposable-domains.json'));
|
|
25
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
// ───
|
|
65
|
+
// ─── inferContact: returns empty without AI key ───
|
|
67
66
|
|
|
68
67
|
{
|
|
69
|
-
name: '
|
|
68
|
+
name: 'infer-contact-no-ai-returns-none',
|
|
70
69
|
async run({ assert }) {
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|