backend-manager 5.0.150 → 5.0.152
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 +21 -0
- package/package.json +1 -1
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +3 -0
- package/src/manager/libraries/email/constants.js +1 -0
- package/src/manager/libraries/email/marketing/index.js +3 -1
- package/src/manager/libraries/email/providers/beehiiv.js +7 -2
- package/src/manager/libraries/email/providers/sendgrid.js +10 -1
- package/src/manager/libraries/email/transactional/index.js +2 -2
- package/src/manager/libraries/infer-contact.js +11 -8
- package/src/manager/libraries/prompts/infer-contact.md +39 -9
- package/src/manager/routes/admin/infer-contact/post.js +34 -0
- package/src/manager/routes/marketing/contact/post.js +4 -1
- package/src/manager/schemas/admin/infer-contact/post.js +12 -0
- package/test/routes/admin/infer-contact.js +195 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.152] - 2026-03-16
|
|
18
|
+
### Fixed
|
|
19
|
+
- Email queue documents all stored at `emails-queue/NaN` — `powertools.random()` doesn't support string generation, replaced with `pushid()`
|
|
20
|
+
|
|
21
|
+
# [5.0.151] - 2026-03-16
|
|
22
|
+
### Fixed
|
|
23
|
+
- AI contact inference was silently broken — `ai.request()` returns `{content, tokens, ...}` but code read `result.firstName` instead of `result.content.firstName`, so AI was never used
|
|
24
|
+
- OpenAI API key not passed to AI library — now explicitly passes `BACKEND_MANAGER_OPENAI_API_KEY`
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `POST /admin/infer-contact` route for testing/debugging contact inference (admin-only, supports batch)
|
|
28
|
+
- `user_personal_company` custom field in FIELDS constant for marketing provider sync
|
|
29
|
+
- Company passthrough in `Marketing.add()` → SendGrid and Beehiiv providers
|
|
30
|
+
- Test suite for admin/infer-contact route
|
|
31
|
+
- Standalone test script (`scripts/test-infer-contact.js`)
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Improved AI prompt: rejects placeholders/gibberish, always infers company from domain, preserves hyphenated name capitalization
|
|
35
|
+
- Disabled regex fallback — returns empty when AI can't infer a real name
|
|
36
|
+
- All 3 inferContact callsites (marketing/contact, user/signup, legacy add-marketing-contact) now extract and pass company
|
|
37
|
+
|
|
17
38
|
# [5.0.150] - 2026-03-16
|
|
18
39
|
### Added
|
|
19
40
|
- `marketing` config section in `backend-manager-config.json` — per-brand control over SendGrid and Beehiiv provider availability
|
package/package.json
CHANGED
|
@@ -95,10 +95,12 @@ Module.prototype.main = function () {
|
|
|
95
95
|
|
|
96
96
|
// Infer name if not provided
|
|
97
97
|
let nameInferred = null;
|
|
98
|
+
let company = '';
|
|
98
99
|
if (!firstName && !lastName) {
|
|
99
100
|
nameInferred = await inferContact(email, assistant);
|
|
100
101
|
firstName = nameInferred.firstName;
|
|
101
102
|
lastName = nameInferred.lastName;
|
|
103
|
+
company = nameInferred.company;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// Add to providers
|
|
@@ -112,6 +114,7 @@ Module.prototype.main = function () {
|
|
|
112
114
|
email,
|
|
113
115
|
firstName,
|
|
114
116
|
lastName,
|
|
117
|
+
company,
|
|
115
118
|
source,
|
|
116
119
|
});
|
|
117
120
|
}
|
|
@@ -144,6 +144,7 @@ const FIELDS = {
|
|
|
144
144
|
|
|
145
145
|
// User identity
|
|
146
146
|
user_auth_uid: { source: 'user', path: 'auth.uid', type: 'text' },
|
|
147
|
+
user_personal_company: { source: 'user', path: 'personal.company.name', type: 'text' },
|
|
147
148
|
user_personal_country: { source: 'user', path: 'personal.location.country', type: 'text' },
|
|
148
149
|
user_metadata_signup_date: { source: 'user', path: 'metadata.created.timestamp', type: 'date' },
|
|
149
150
|
user_metadata_last_activity: { source: 'user', path: 'metadata.updated.timestamp', type: 'date' },
|
|
@@ -62,7 +62,7 @@ function Marketing(assistant) {
|
|
|
62
62
|
Marketing.prototype.add = async function (options) {
|
|
63
63
|
const self = this;
|
|
64
64
|
const assistant = self.assistant;
|
|
65
|
-
const { email, firstName, lastName, source, customFields } = options;
|
|
65
|
+
const { email, firstName, lastName, company, source, customFields } = options;
|
|
66
66
|
|
|
67
67
|
if (!email) {
|
|
68
68
|
assistant.warn('Marketing.add(): No email provided, skipping');
|
|
@@ -85,6 +85,7 @@ Marketing.prototype.add = async function (options) {
|
|
|
85
85
|
email,
|
|
86
86
|
firstName,
|
|
87
87
|
lastName,
|
|
88
|
+
company,
|
|
88
89
|
customFields,
|
|
89
90
|
}).then((r) => { results.sendgrid = r; })
|
|
90
91
|
);
|
|
@@ -96,6 +97,7 @@ Marketing.prototype.add = async function (options) {
|
|
|
96
97
|
email,
|
|
97
98
|
firstName,
|
|
98
99
|
lastName,
|
|
100
|
+
company,
|
|
99
101
|
source,
|
|
100
102
|
}).then((r) => { results.beehiiv = r; })
|
|
101
103
|
);
|
|
@@ -215,20 +215,25 @@ async function getPublicationId() {
|
|
|
215
215
|
* @param {Array<{name: string, value: string}>} [options.customFields] - Pre-built custom fields
|
|
216
216
|
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
217
217
|
*/
|
|
218
|
-
async function addContact({ email, firstName, lastName, source, customFields }) {
|
|
218
|
+
async function addContact({ email, firstName, lastName, company, source, customFields }) {
|
|
219
219
|
const publicationId = await getPublicationId();
|
|
220
220
|
|
|
221
221
|
if (!publicationId) {
|
|
222
222
|
return { success: false, error: 'Publication not found' };
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
const fields = [...(customFields || [])];
|
|
226
|
+
if (company) {
|
|
227
|
+
fields.push({ name: 'company', value: company });
|
|
228
|
+
}
|
|
229
|
+
|
|
225
230
|
return addSubscriber({
|
|
226
231
|
email,
|
|
227
232
|
firstName,
|
|
228
233
|
lastName,
|
|
229
234
|
source,
|
|
230
235
|
publicationId,
|
|
231
|
-
customFields:
|
|
236
|
+
customFields: fields,
|
|
232
237
|
});
|
|
233
238
|
}
|
|
234
239
|
|
|
@@ -370,7 +370,7 @@ async function listSingleSends(options) {
|
|
|
370
370
|
* @param {object} [options.customFields] - Pre-built custom_fields object (keyed by SendGrid field IDs)
|
|
371
371
|
* @returns {{ success: boolean, jobId?: string, listId?: string, error?: string }}
|
|
372
372
|
*/
|
|
373
|
-
async function addContact({ email, firstName, lastName, customFields }) {
|
|
373
|
+
async function addContact({ email, firstName, lastName, company, customFields }) {
|
|
374
374
|
const contact = {
|
|
375
375
|
email: email.toLowerCase(),
|
|
376
376
|
first_name: firstName || undefined,
|
|
@@ -378,6 +378,15 @@ async function addContact({ email, firstName, lastName, customFields }) {
|
|
|
378
378
|
custom_fields: customFields || {},
|
|
379
379
|
};
|
|
380
380
|
|
|
381
|
+
// Add company to custom fields if provided (requires field provisioned via OMEGA)
|
|
382
|
+
if (company) {
|
|
383
|
+
const idMap = await resolveFieldIds();
|
|
384
|
+
const companyFieldId = idMap['user_personal_company'];
|
|
385
|
+
if (companyFieldId) {
|
|
386
|
+
contact.custom_fields[companyFieldId] = company;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
381
390
|
const listId = await getListId();
|
|
382
391
|
const result = await upsertContacts({
|
|
383
392
|
contacts: [contact],
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
const _ = require('lodash');
|
|
15
15
|
const moment = require('moment');
|
|
16
|
+
const pushid = require('pushid');
|
|
16
17
|
const MarkdownIt = require('markdown-it');
|
|
17
18
|
const md = new MarkdownIt({
|
|
18
19
|
html: true,
|
|
@@ -482,8 +483,7 @@ function normalizeSendAt(sendAt) {
|
|
|
482
483
|
* build pipeline when the cron picks it up.
|
|
483
484
|
*/
|
|
484
485
|
async function saveToEmailQueue(settings, sendAt, admin, assistant) {
|
|
485
|
-
const
|
|
486
|
-
const emailId = powertools.random(32, { type: 'alphanumeric' });
|
|
486
|
+
const emailId = pushid();
|
|
487
487
|
|
|
488
488
|
// Clone and clean undefined values for Firestore
|
|
489
489
|
const settingsCloned = _.cloneDeepWith(settings, (value) => {
|
|
@@ -31,12 +31,14 @@ const GENERIC_DOMAINS = new Set([
|
|
|
31
31
|
async function inferContact(email, assistant) {
|
|
32
32
|
if (process.env.BACKEND_MANAGER_OPENAI_API_KEY) {
|
|
33
33
|
const aiResult = await inferContactWithAI(email, assistant);
|
|
34
|
-
if (aiResult
|
|
34
|
+
if (aiResult) {
|
|
35
35
|
return aiResult;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// TODO: Re-enable regex fallback if needed
|
|
40
|
+
// return inferContactFromEmail(email);
|
|
41
|
+
return { firstName: '', lastName: '', company: '', confidence: 0, method: 'none' };
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -48,7 +50,7 @@ async function inferContact(email, assistant) {
|
|
|
48
50
|
*/
|
|
49
51
|
async function inferContactWithAI(email, assistant) {
|
|
50
52
|
try {
|
|
51
|
-
const ai = assistant.Manager.AI(assistant);
|
|
53
|
+
const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
|
|
52
54
|
const result = await ai.request({
|
|
53
55
|
model: 'gpt-5-mini',
|
|
54
56
|
timeout: 30000,
|
|
@@ -63,12 +65,13 @@ async function inferContactWithAI(email, assistant) {
|
|
|
63
65
|
},
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
const parsed = result?.content;
|
|
69
|
+
if (parsed?.firstName !== undefined) {
|
|
67
70
|
return {
|
|
68
|
-
firstName: capitalize(
|
|
69
|
-
lastName: capitalize(
|
|
70
|
-
company: capitalize(
|
|
71
|
-
confidence: typeof
|
|
71
|
+
firstName: capitalize(parsed.firstName || ''),
|
|
72
|
+
lastName: capitalize(parsed.lastName || ''),
|
|
73
|
+
company: capitalize(parsed.company || ''),
|
|
74
|
+
confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.5,
|
|
72
75
|
method: 'ai',
|
|
73
76
|
};
|
|
74
77
|
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
<identity>
|
|
2
|
-
You extract names and company from email addresses.
|
|
2
|
+
You extract real human names and company from email addresses.
|
|
3
3
|
</identity>
|
|
4
4
|
|
|
5
|
+
<rules>
|
|
6
|
+
- Only return a name if it is a REAL human name. If the local part contains placeholder text, gibberish, generic words, or fictional/brand names, return empty strings for firstName and lastName.
|
|
7
|
+
- Placeholder/test examples that are NOT real names: firstname.lastname, first.last, asdf.qwerty, test.user, mixed.case, bobs.burgers, john.doe (this is a well-known placeholder)
|
|
8
|
+
- Generic role words are NOT names: admin, info, support, hello, contact, sales, billing, noreply, webmaster, postmaster, dev, ceo
|
|
9
|
+
- Single letters or initials ARE acceptable (e.g., "j" from j@company.com → firstName: "J")
|
|
10
|
+
- ALWAYS infer company from the domain, even when there is no name. Generic email providers (gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com, icloud.com, mail.com, protonmail.com, proton.me, zoho.com, yandex.com, gmx.com, live.com, msn.com, me.com) should return empty company.
|
|
11
|
+
- Hyphenated names should preserve capitalization on both parts (e.g., jean-pierre → Jean-Pierre)
|
|
12
|
+
- confidence should reflect how certain you are that the name is a REAL person's name. Placeholders and gibberish should never have confidence above 0.
|
|
13
|
+
</rules>
|
|
14
|
+
|
|
5
15
|
<format>
|
|
6
|
-
Return ONLY valid JSON
|
|
16
|
+
Return ONLY valid JSON:
|
|
7
17
|
{
|
|
8
18
|
"firstName": "...",
|
|
9
19
|
"lastName": "...",
|
|
@@ -11,12 +21,12 @@ Return ONLY valid JSON like so:
|
|
|
11
21
|
"confidence": "..."
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
- firstName: First name (string), capitalized
|
|
15
|
-
- lastName: Last name (string), capitalized
|
|
16
|
-
- company: Company name (string), capitalized
|
|
17
|
-
- confidence: Confidence level (number), 0-1 scale
|
|
24
|
+
- firstName: First name (string), properly capitalized
|
|
25
|
+
- lastName: Last name (string), properly capitalized
|
|
26
|
+
- company: Company name (string), capitalized. Infer from domain (not generic providers).
|
|
27
|
+
- confidence: Confidence level (number), 0-1 scale. How sure you are the name is real.
|
|
18
28
|
|
|
19
|
-
If you cannot determine a name, use empty strings.
|
|
29
|
+
If you cannot determine a real name, use empty strings for firstName and lastName.
|
|
20
30
|
</format>
|
|
21
31
|
|
|
22
32
|
<examples>
|
|
@@ -30,7 +40,7 @@ If you cannot determine a name, use empty strings.
|
|
|
30
40
|
</example>
|
|
31
41
|
<example>
|
|
32
42
|
<input>support@bigcorp.io</input>
|
|
33
|
-
<output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0
|
|
43
|
+
<output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0}</output>
|
|
34
44
|
</example>
|
|
35
45
|
<example>
|
|
36
46
|
<input>mary_jane_watson@stark-industries.com</input>
|
|
@@ -38,6 +48,26 @@ If you cannot determine a name, use empty strings.
|
|
|
38
48
|
</example>
|
|
39
49
|
<example>
|
|
40
50
|
<input>info@company.org</input>
|
|
41
|
-
<output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0
|
|
51
|
+
<output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0}</output>
|
|
52
|
+
</example>
|
|
53
|
+
<example>
|
|
54
|
+
<input>firstname.lastname@company.com</input>
|
|
55
|
+
<output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0}</output>
|
|
56
|
+
</example>
|
|
57
|
+
<example>
|
|
58
|
+
<input>asdf.qwerty@outlook.com</input>
|
|
59
|
+
<output>{"firstName": "", "lastName": "", "company": "", "confidence": 0}</output>
|
|
60
|
+
</example>
|
|
61
|
+
<example>
|
|
62
|
+
<input>jean-pierre.dupont@orange.fr</input>
|
|
63
|
+
<output>{"firstName": "Jean-Pierre", "lastName": "Dupont", "company": "Orange", "confidence": 0.95}</output>
|
|
64
|
+
</example>
|
|
65
|
+
<example>
|
|
66
|
+
<input>bobs.burgers@example.com</input>
|
|
67
|
+
<output>{"firstName": "", "lastName": "", "company": "Example", "confidence": 0}</output>
|
|
68
|
+
</example>
|
|
69
|
+
<example>
|
|
70
|
+
<input>j@company.com</input>
|
|
71
|
+
<output>{"firstName": "J", "lastName": "", "company": "Company", "confidence": 0.3}</output>
|
|
42
72
|
</example>
|
|
43
73
|
</examples>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /admin/infer-contact - Infer contact info from email addresses
|
|
3
|
+
* Admin-only endpoint for testing/debugging the inferContact pipeline
|
|
4
|
+
*/
|
|
5
|
+
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
8
|
+
|
|
9
|
+
// Require authentication
|
|
10
|
+
if (!user.authenticated) {
|
|
11
|
+
return assistant.respond('Authentication required', { code: 401 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Require admin
|
|
15
|
+
if (!user.roles.admin) {
|
|
16
|
+
return assistant.respond('Admin required.', { code: 403 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Accept single email or array of emails
|
|
20
|
+
const emails = Array.isArray(settings.emails)
|
|
21
|
+
? settings.emails
|
|
22
|
+
: [settings.email];
|
|
23
|
+
|
|
24
|
+
const results = await Promise.all(
|
|
25
|
+
emails
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map(async (email) => {
|
|
28
|
+
const result = await inferContact(email, assistant);
|
|
29
|
+
return { email, ...result };
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return assistant.respond({ results });
|
|
34
|
+
};
|
|
@@ -83,12 +83,14 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// Infer
|
|
86
|
+
// Infer contact info if name not provided
|
|
87
87
|
let nameInferred = null;
|
|
88
|
+
let company = '';
|
|
88
89
|
if (!firstName && !lastName) {
|
|
89
90
|
nameInferred = await inferContact(email, assistant);
|
|
90
91
|
firstName = nameInferred.firstName;
|
|
91
92
|
lastName = nameInferred.lastName;
|
|
93
|
+
company = nameInferred.company;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
// Add to providers
|
|
@@ -102,6 +104,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
102
104
|
email,
|
|
103
105
|
firstName,
|
|
104
106
|
lastName,
|
|
107
|
+
company,
|
|
105
108
|
source,
|
|
106
109
|
});
|
|
107
110
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /admin/infer-contact
|
|
3
|
+
* Tests the admin infer-contact endpoint for inferring names from email addresses
|
|
4
|
+
*
|
|
5
|
+
* AI inference tests only run when TEST_EXTENDED_MODE is set (requires BACKEND_MANAGER_OPENAI_API_KEY)
|
|
6
|
+
*/
|
|
7
|
+
module.exports = {
|
|
8
|
+
description: 'Admin infer contact',
|
|
9
|
+
type: 'group',
|
|
10
|
+
tests: [
|
|
11
|
+
// ─── Auth rejection ───
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
name: 'unauthenticated-rejected',
|
|
15
|
+
auth: 'none',
|
|
16
|
+
timeout: 15000,
|
|
17
|
+
|
|
18
|
+
async run({ http, assert }) {
|
|
19
|
+
const response = await http.post('admin/infer-contact', {
|
|
20
|
+
email: 'john.smith@gmail.com',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.isError(response, 401, 'Should reject unauthenticated requests');
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
name: 'non-admin-rejected',
|
|
29
|
+
auth: 'user',
|
|
30
|
+
timeout: 15000,
|
|
31
|
+
|
|
32
|
+
async run({ http, assert }) {
|
|
33
|
+
const response = await http.post('admin/infer-contact', {
|
|
34
|
+
email: 'john.smith@gmail.com',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.isError(response, 403, 'Should reject non-admin users');
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// ─── Single email ───
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
name: 'single-email-returns-result',
|
|
45
|
+
auth: 'admin',
|
|
46
|
+
timeout: 30000,
|
|
47
|
+
|
|
48
|
+
async run({ http, assert }) {
|
|
49
|
+
const response = await http.post('admin/infer-contact', {
|
|
50
|
+
email: 'john.smith@gmail.com',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.isSuccess(response, 'Should succeed for admin');
|
|
54
|
+
assert.hasProperty(response, 'data.results', 'Should have results array');
|
|
55
|
+
assert.equal(response.data.results.length, 1, 'Should have 1 result');
|
|
56
|
+
|
|
57
|
+
const result = response.data.results[0];
|
|
58
|
+
assert.equal(result.email, 'john.smith@gmail.com', 'Should include email');
|
|
59
|
+
assert.ok(result.firstName, 'Should infer a first name');
|
|
60
|
+
assert.ok(result.lastName, 'Should infer a last name');
|
|
61
|
+
assert.hasProperty(result, 'method', 'Should include method');
|
|
62
|
+
assert.hasProperty(result, 'confidence', 'Should include confidence');
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// ─── Batch emails ───
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
name: 'batch-emails-returns-all-results',
|
|
70
|
+
auth: 'admin',
|
|
71
|
+
timeout: 60000,
|
|
72
|
+
|
|
73
|
+
async run({ http, assert }) {
|
|
74
|
+
const emails = [
|
|
75
|
+
'sarah.connor@skynet.io',
|
|
76
|
+
'bob-jones@hotmail.com',
|
|
77
|
+
'admin@acme.com',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const response = await http.post('admin/infer-contact', { emails });
|
|
81
|
+
|
|
82
|
+
assert.isSuccess(response, 'Should succeed for batch');
|
|
83
|
+
assert.equal(response.data.results.length, 3, 'Should have 3 results');
|
|
84
|
+
|
|
85
|
+
// Verify each email is in results
|
|
86
|
+
for (let i = 0; i < emails.length; i++) {
|
|
87
|
+
assert.equal(response.data.results[i].email, emails[i], `Result ${i} should match input email`);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// ─── Name parsing (regex) ───
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: 'regex-parses-dot-separated-names',
|
|
96
|
+
auth: 'admin',
|
|
97
|
+
timeout: 30000,
|
|
98
|
+
|
|
99
|
+
async run({ http, assert }) {
|
|
100
|
+
const response = await http.post('admin/infer-contact', {
|
|
101
|
+
email: 'alice.wonderland@example.com',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.isSuccess(response);
|
|
105
|
+
const result = response.data.results[0];
|
|
106
|
+
|
|
107
|
+
// AI or regex — either way should get the name right
|
|
108
|
+
assert.equal(result.firstName, 'Alice', 'Should parse first name');
|
|
109
|
+
assert.equal(result.lastName, 'Wonderland', 'Should parse last name');
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
name: 'infers-company-from-custom-domain',
|
|
115
|
+
auth: 'admin',
|
|
116
|
+
timeout: 30000,
|
|
117
|
+
|
|
118
|
+
async run({ http, assert }) {
|
|
119
|
+
const response = await http.post('admin/infer-contact', {
|
|
120
|
+
email: 'ceo@my-startup.com',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
assert.isSuccess(response);
|
|
124
|
+
const result = response.data.results[0];
|
|
125
|
+
assert.ok(result.company, 'Should infer company from custom domain');
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
name: 'no-company-from-generic-domain',
|
|
131
|
+
auth: 'admin',
|
|
132
|
+
timeout: 30000,
|
|
133
|
+
|
|
134
|
+
async run({ http, assert }) {
|
|
135
|
+
const response = await http.post('admin/infer-contact', {
|
|
136
|
+
email: 'someone@gmail.com',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.isSuccess(response);
|
|
140
|
+
const result = response.data.results[0];
|
|
141
|
+
assert.equal(result.company, '', 'Should not infer company from gmail');
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// ─── AI inference (requires TEST_EXTENDED_MODE) ───
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
name: 'ai-inference',
|
|
149
|
+
auth: 'admin',
|
|
150
|
+
timeout: 60000,
|
|
151
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
152
|
+
? 'TEST_EXTENDED_MODE not set (skipping AI inference test)'
|
|
153
|
+
: false,
|
|
154
|
+
|
|
155
|
+
async run({ http, assert }) {
|
|
156
|
+
const response = await http.post('admin/infer-contact', {
|
|
157
|
+
emails: [
|
|
158
|
+
'john.smith@microsoft.com',
|
|
159
|
+
'xkcd42@gmail.com',
|
|
160
|
+
'bobs.burgers@example.com',
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.isSuccess(response, 'AI inference should succeed');
|
|
165
|
+
|
|
166
|
+
const results = response.data.results;
|
|
167
|
+
const aiResults = results.filter(r => r.method === 'ai');
|
|
168
|
+
|
|
169
|
+
assert.ok(aiResults.length > 0, 'At least one result should use AI method');
|
|
170
|
+
|
|
171
|
+
// john.smith should be parsed correctly regardless of method
|
|
172
|
+
const john = results.find(r => r.email === 'john.smith@microsoft.com');
|
|
173
|
+
assert.equal(john.firstName, 'John', 'Should infer John');
|
|
174
|
+
assert.equal(john.lastName, 'Smith', 'Should infer Smith');
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// ─── Edge cases ───
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
name: 'empty-emails-array-returns-empty',
|
|
182
|
+
auth: 'admin',
|
|
183
|
+
timeout: 15000,
|
|
184
|
+
|
|
185
|
+
async run({ http, assert }) {
|
|
186
|
+
const response = await http.post('admin/infer-contact', {
|
|
187
|
+
emails: [],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.isSuccess(response);
|
|
191
|
+
assert.equal(response.data.results.length, 0, 'Empty input should return empty results');
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
};
|