backend-manager 5.0.149 → 5.0.151
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 +13 -0
- package/package.json +1 -1
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +3 -2
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +1 -2
- package/src/manager/libraries/email/constants.js +1 -3
- package/src/manager/libraries/email/index.js +9 -18
- package/src/manager/libraries/email/marketing/index.js +31 -36
- package/src/manager/libraries/email/providers/beehiiv.js +23 -2
- package/src/manager/libraries/email/providers/sendgrid.js +10 -1
- 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/delete.js +1 -2
- package/src/manager/routes/marketing/contact/post.js +4 -4
- package/src/manager/schemas/admin/infer-contact/post.js +12 -0
- package/src/manager/schemas/marketing/contact/delete.js +0 -3
- package/src/manager/schemas/marketing/contact/post.js +0 -3
- package/templates/backend-manager-config.json +9 -0
- package/test/functions/general/add-marketing-contact.js +1 -40
- package/test/routes/admin/infer-contact.js +195 -0
- package/test/routes/marketing/contact.js +1 -38
- /package/src/manager/schemas/{app → brand}/get.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,19 @@ 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.150] - 2026-03-16
|
|
18
|
+
### Added
|
|
19
|
+
- `marketing` config section in `backend-manager-config.json` — per-brand control over SendGrid and Beehiiv provider availability
|
|
20
|
+
- Beehiiv provider reads `publicationId` from config (skips fuzzy-match API call) with in-memory cache
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Provider availability resolved once in Marketing constructor from `config.marketing` + env vars instead of per-request
|
|
24
|
+
- Removed `providers` parameter from `add()`, `sync()`, `remove()` and all route/schema callers
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
- `DEFAULT_PROVIDERS` constant — no longer needed with config-driven provider resolution
|
|
28
|
+
- Provider-selection tests — no longer applicable
|
|
29
|
+
|
|
17
30
|
# [5.0.149] - 2026-03-14
|
|
18
31
|
### Added
|
|
19
32
|
- Modular email library (`libraries/email/`) — replaces monolithic `libraries/email.js` with provider-based architecture
|
package/package.json
CHANGED
|
@@ -29,7 +29,6 @@ Module.prototype.main = function () {
|
|
|
29
29
|
|
|
30
30
|
// Admin-only options
|
|
31
31
|
const tags = isAdmin ? (requestPayload.tags || []) : [];
|
|
32
|
-
const providers = isAdmin ? (requestPayload.providers || ['sendgrid', 'beehiiv']) : ['sendgrid', 'beehiiv'];
|
|
33
32
|
const skipValidation = isAdmin ? (requestPayload.skipValidation || false) : false;
|
|
34
33
|
|
|
35
34
|
// Validate email is provided
|
|
@@ -96,10 +95,12 @@ Module.prototype.main = function () {
|
|
|
96
95
|
|
|
97
96
|
// Infer name if not provided
|
|
98
97
|
let nameInferred = null;
|
|
98
|
+
let company = '';
|
|
99
99
|
if (!firstName && !lastName) {
|
|
100
100
|
nameInferred = await inferContact(email, assistant);
|
|
101
101
|
firstName = nameInferred.firstName;
|
|
102
102
|
lastName = nameInferred.lastName;
|
|
103
|
+
company = nameInferred.company;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// Add to providers
|
|
@@ -113,8 +114,8 @@ Module.prototype.main = function () {
|
|
|
113
114
|
email,
|
|
114
115
|
firstName,
|
|
115
116
|
lastName,
|
|
117
|
+
company,
|
|
116
118
|
source,
|
|
117
|
-
providers,
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -22,7 +22,6 @@ Module.prototype.main = function () {
|
|
|
22
22
|
|
|
23
23
|
// Extract parameters
|
|
24
24
|
const email = (requestPayload.email || '').trim().toLowerCase();
|
|
25
|
-
const providers = requestPayload.providers || ['sendgrid', 'beehiiv'];
|
|
26
25
|
|
|
27
26
|
// Validate email is provided
|
|
28
27
|
if (!email) {
|
|
@@ -31,7 +30,7 @@ Module.prototype.main = function () {
|
|
|
31
30
|
|
|
32
31
|
// Remove from providers
|
|
33
32
|
const mailer = Manager.Email(assistant);
|
|
34
|
-
const providerResults = await mailer.remove(email
|
|
33
|
+
const providerResults = await mailer.remove(email);
|
|
35
34
|
|
|
36
35
|
// Log result
|
|
37
36
|
assistant.log('remove-marketing-contact result:', {
|
|
@@ -84,8 +84,6 @@ const SENDERS = {
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
// Default marketing providers — SSOT for all provider loops
|
|
88
|
-
const DEFAULT_PROVIDERS = ['sendgrid', 'beehiiv'];
|
|
89
87
|
|
|
90
88
|
// SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
|
|
91
89
|
const SEND_AT_LIMIT = 71;
|
|
@@ -146,6 +144,7 @@ const FIELDS = {
|
|
|
146
144
|
|
|
147
145
|
// User identity
|
|
148
146
|
user_auth_uid: { source: 'user', path: 'auth.uid', type: 'text' },
|
|
147
|
+
user_personal_company: { source: 'user', path: 'personal.company.name', type: 'text' },
|
|
149
148
|
user_personal_country: { source: 'user', path: 'personal.location.country', type: 'text' },
|
|
150
149
|
user_metadata_signup_date: { source: 'user', path: 'metadata.created.timestamp', type: 'date' },
|
|
151
150
|
user_metadata_last_activity: { source: 'user', path: 'metadata.updated.timestamp', type: 'date' },
|
|
@@ -234,7 +233,6 @@ module.exports = {
|
|
|
234
233
|
GROUPS,
|
|
235
234
|
SENDERS,
|
|
236
235
|
FIELDS,
|
|
237
|
-
DEFAULT_PROVIDERS,
|
|
238
236
|
SEND_AT_LIMIT,
|
|
239
237
|
sanitizeImagesForEmail,
|
|
240
238
|
encode,
|
|
@@ -74,16 +74,9 @@ Email.prototype.build = function (settings) {
|
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
77
|
+
* Add a new contact to enabled marketing providers (lightweight, no full user doc needed).
|
|
78
78
|
*
|
|
79
|
-
* @param {object}
|
|
80
|
-
* @param {object} [options] - { providers: array of provider names (default: DEFAULT_PROVIDERS) }
|
|
81
|
-
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
82
|
-
*/
|
|
83
|
-
/**
|
|
84
|
-
* Add a new contact to marketing providers (lightweight, no full user doc needed).
|
|
85
|
-
*
|
|
86
|
-
* @param {object} options - { email, firstName, lastName, source, customFields, providers }
|
|
79
|
+
* @param {object} options - { email, firstName, lastName, source, customFields }
|
|
87
80
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
88
81
|
*/
|
|
89
82
|
Email.prototype.add = function (options) {
|
|
@@ -91,25 +84,23 @@ Email.prototype.add = function (options) {
|
|
|
91
84
|
};
|
|
92
85
|
|
|
93
86
|
/**
|
|
94
|
-
* Sync a user's full data to marketing providers
|
|
87
|
+
* Sync a user's full data to enabled marketing providers.
|
|
95
88
|
*
|
|
96
|
-
* @param {object}
|
|
97
|
-
* @param {object} [options] - { providers: array of provider names (default: DEFAULT_PROVIDERS) }
|
|
89
|
+
* @param {string|object} userDocOrUid - UID string or full user document from Firestore
|
|
98
90
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
99
91
|
*/
|
|
100
|
-
Email.prototype.sync = function (
|
|
101
|
-
return this._marketing.sync(
|
|
92
|
+
Email.prototype.sync = function (userDocOrUid) {
|
|
93
|
+
return this._marketing.sync(userDocOrUid);
|
|
102
94
|
};
|
|
103
95
|
|
|
104
96
|
/**
|
|
105
|
-
* Remove a contact from all marketing providers.
|
|
97
|
+
* Remove a contact from all enabled marketing providers.
|
|
106
98
|
*
|
|
107
99
|
* @param {string} email - Email address to remove
|
|
108
|
-
* @param {object} [options] - { providers: array of provider names (default: DEFAULT_PROVIDERS) }
|
|
109
100
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
110
101
|
*/
|
|
111
|
-
Email.prototype.remove = function (email
|
|
112
|
-
return this._marketing.remove(email
|
|
102
|
+
Email.prototype.remove = function (email) {
|
|
103
|
+
return this._marketing.remove(email);
|
|
113
104
|
};
|
|
114
105
|
|
|
115
106
|
/**
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
const _ = require('lodash');
|
|
27
27
|
|
|
28
|
-
const { TEMPLATES, GROUPS, SENDERS
|
|
28
|
+
const { TEMPLATES, GROUPS, SENDERS } = require('../constants.js');
|
|
29
29
|
const sendgridProvider = require('../providers/sendgrid.js');
|
|
30
30
|
const beehiivProvider = require('../providers/beehiiv.js');
|
|
31
31
|
|
|
@@ -36,11 +36,19 @@ function Marketing(assistant) {
|
|
|
36
36
|
self.Manager = assistant.Manager;
|
|
37
37
|
self.admin = self.Manager.libraries.admin;
|
|
38
38
|
|
|
39
|
+
// Resolve provider availability from config + env
|
|
40
|
+
const marketing = self.Manager.config?.marketing || {};
|
|
41
|
+
|
|
42
|
+
self.providers = {
|
|
43
|
+
sendgrid: marketing.sendgrid?.enabled !== false && !!process.env.SENDGRID_API_KEY,
|
|
44
|
+
beehiiv: marketing.beehiiv?.enabled !== false && !!process.env.BEEHIIV_API_KEY,
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
return self;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
43
|
-
* Add a new contact to
|
|
51
|
+
* Add a new contact to enabled providers (lightweight — no full user doc needed).
|
|
44
52
|
* Used by newsletter subscribe and admin bulk import.
|
|
45
53
|
*
|
|
46
54
|
* @param {object} options
|
|
@@ -49,49 +57,47 @@ function Marketing(assistant) {
|
|
|
49
57
|
* @param {string} [options.lastName]
|
|
50
58
|
* @param {string} [options.source] - UTM source
|
|
51
59
|
* @param {object} [options.customFields] - Extra SendGrid custom fields (keyed by field ID)
|
|
52
|
-
* @param {Array<string>} [options.providers] - Which providers (default: all available)
|
|
53
60
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
54
61
|
*/
|
|
55
62
|
Marketing.prototype.add = async function (options) {
|
|
56
63
|
const self = this;
|
|
57
64
|
const assistant = self.assistant;
|
|
58
|
-
const { email, firstName, lastName, source, customFields
|
|
65
|
+
const { email, firstName, lastName, company, source, customFields } = options;
|
|
59
66
|
|
|
60
67
|
if (!email) {
|
|
61
68
|
assistant.warn('Marketing.add(): No email provided, skipping');
|
|
62
69
|
return {};
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
const addProviders = providers || DEFAULT_PROVIDERS;
|
|
67
|
-
const results = {};
|
|
68
|
-
|
|
69
|
-
if (!shouldAdd) {
|
|
72
|
+
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
70
73
|
assistant.log('Marketing.add(): Skipping providers (testing mode)');
|
|
71
|
-
return
|
|
74
|
+
return {};
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
assistant.log('Marketing.add():', { email });
|
|
75
78
|
|
|
79
|
+
const results = {};
|
|
76
80
|
const promises = [];
|
|
77
81
|
|
|
78
|
-
if (
|
|
82
|
+
if (self.providers.sendgrid) {
|
|
79
83
|
promises.push(
|
|
80
84
|
sendgridProvider.addContact({
|
|
81
85
|
email,
|
|
82
86
|
firstName,
|
|
83
87
|
lastName,
|
|
88
|
+
company,
|
|
84
89
|
customFields,
|
|
85
90
|
}).then((r) => { results.sendgrid = r; })
|
|
86
91
|
);
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
if (
|
|
94
|
+
if (self.providers.beehiiv) {
|
|
90
95
|
promises.push(
|
|
91
96
|
beehiivProvider.addContact({
|
|
92
97
|
email,
|
|
93
98
|
firstName,
|
|
94
99
|
lastName,
|
|
100
|
+
company,
|
|
95
101
|
source,
|
|
96
102
|
}).then((r) => { results.beehiiv = r; })
|
|
97
103
|
);
|
|
@@ -109,14 +115,11 @@ Marketing.prototype.add = async function (options) {
|
|
|
109
115
|
* Upserts the contact with all custom fields derived from the user doc.
|
|
110
116
|
*
|
|
111
117
|
* @param {string|object} userDocOrUid - UID string (fetches from Firestore) or full user document object
|
|
112
|
-
* @param {object} [options]
|
|
113
|
-
* @param {Array<string>} [options.providers] - Which providers to sync to (default: all available)
|
|
114
118
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
115
119
|
*/
|
|
116
|
-
Marketing.prototype.sync = async function (userDocOrUid
|
|
120
|
+
Marketing.prototype.sync = async function (userDocOrUid) {
|
|
117
121
|
const self = this;
|
|
118
122
|
const assistant = self.assistant;
|
|
119
|
-
const { providers } = options || {};
|
|
120
123
|
|
|
121
124
|
// Resolve UID to user doc if string
|
|
122
125
|
let userDoc;
|
|
@@ -145,13 +148,9 @@ Marketing.prototype.sync = async function (userDocOrUid, options) {
|
|
|
145
148
|
return {};
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
const syncProviders = providers || DEFAULT_PROVIDERS;
|
|
150
|
-
const results = {};
|
|
151
|
-
|
|
152
|
-
if (!shouldSync) {
|
|
151
|
+
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
153
152
|
assistant.log('Marketing.sync(): Skipping providers (testing mode)');
|
|
154
|
-
return
|
|
153
|
+
return {};
|
|
155
154
|
}
|
|
156
155
|
|
|
157
156
|
assistant.log('Marketing.sync():', { email });
|
|
@@ -159,9 +158,10 @@ Marketing.prototype.sync = async function (userDocOrUid, options) {
|
|
|
159
158
|
const firstName = _.get(userDoc, 'personal.name.first');
|
|
160
159
|
const lastName = _.get(userDoc, 'personal.name.last');
|
|
161
160
|
const source = _.get(userDoc, 'attribution.utm.tags.utm_source');
|
|
161
|
+
const results = {};
|
|
162
162
|
const promises = [];
|
|
163
163
|
|
|
164
|
-
if (
|
|
164
|
+
if (self.providers.sendgrid) {
|
|
165
165
|
promises.push(
|
|
166
166
|
sendgridProvider.buildFields(userDoc).then((customFields) =>
|
|
167
167
|
sendgridProvider.addContact({
|
|
@@ -174,7 +174,7 @@ Marketing.prototype.sync = async function (userDocOrUid, options) {
|
|
|
174
174
|
);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
if (
|
|
177
|
+
if (self.providers.beehiiv) {
|
|
178
178
|
promises.push(
|
|
179
179
|
beehiivProvider.addContact({
|
|
180
180
|
email,
|
|
@@ -194,38 +194,33 @@ Marketing.prototype.sync = async function (userDocOrUid, options) {
|
|
|
194
194
|
};
|
|
195
195
|
|
|
196
196
|
/**
|
|
197
|
-
* Remove a contact from all providers.
|
|
197
|
+
* Remove a contact from all enabled providers.
|
|
198
198
|
*
|
|
199
199
|
* @param {string} email - Email address to remove
|
|
200
|
-
* @param {object} [options]
|
|
201
|
-
* @param {Array<string>} [options.providers] - Which providers to remove from (default: all available)
|
|
202
200
|
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
203
201
|
*/
|
|
204
|
-
Marketing.prototype.remove = async function (email
|
|
202
|
+
Marketing.prototype.remove = async function (email) {
|
|
205
203
|
const self = this;
|
|
206
204
|
const assistant = self.assistant;
|
|
207
|
-
const { providers } = options || {};
|
|
208
205
|
|
|
209
206
|
if (!email) {
|
|
210
207
|
assistant.warn('Marketing.remove(): No email provided, skipping');
|
|
211
208
|
return {};
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
const removeProviders = providers || DEFAULT_PROVIDERS;
|
|
215
|
-
const results = {};
|
|
216
|
-
|
|
217
211
|
assistant.log('Marketing.remove():', { email });
|
|
218
212
|
|
|
213
|
+
const results = {};
|
|
219
214
|
const promises = [];
|
|
220
215
|
|
|
221
|
-
if (
|
|
216
|
+
if (self.providers.sendgrid) {
|
|
222
217
|
promises.push(
|
|
223
218
|
sendgridProvider.removeContact(email)
|
|
224
219
|
.then((r) => { results.sendgrid = r; })
|
|
225
220
|
);
|
|
226
221
|
}
|
|
227
222
|
|
|
228
|
-
if (
|
|
223
|
+
if (self.providers.beehiiv) {
|
|
229
224
|
promises.push(
|
|
230
225
|
beehiivProvider.removeContact(email)
|
|
231
226
|
.then((r) => { results.beehiiv = r; })
|
|
@@ -259,8 +254,8 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
259
254
|
const Manager = self.Manager;
|
|
260
255
|
const assistant = self.assistant;
|
|
261
256
|
|
|
262
|
-
if (!
|
|
263
|
-
return { success: false, error: '
|
|
257
|
+
if (!self.providers.sendgrid) {
|
|
258
|
+
return { success: false, error: 'SendGrid not enabled' };
|
|
264
259
|
}
|
|
265
260
|
|
|
266
261
|
const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
|
|
@@ -136,7 +136,22 @@ async function removeSubscriber(email, publicationId) {
|
|
|
136
136
|
* @param {string} brandName
|
|
137
137
|
* @returns {string|null} Publication ID or null
|
|
138
138
|
*/
|
|
139
|
+
let _publicationIdCache = null;
|
|
140
|
+
|
|
139
141
|
async function getPublicationId() {
|
|
142
|
+
if (_publicationIdCache) {
|
|
143
|
+
return _publicationIdCache;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Use publicationId from config if set (skips API call)
|
|
147
|
+
const configPubId = Manager.config?.marketing?.beehiiv?.publicationId;
|
|
148
|
+
|
|
149
|
+
if (configPubId) {
|
|
150
|
+
_publicationIdCache = configPubId;
|
|
151
|
+
return configPubId;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fuzzy-match by brand name
|
|
140
155
|
const brandName = Manager.config.brand?.name;
|
|
141
156
|
|
|
142
157
|
if (!brandName) {
|
|
@@ -168,6 +183,7 @@ async function getPublicationId() {
|
|
|
168
183
|
);
|
|
169
184
|
|
|
170
185
|
if (matchedPub) {
|
|
186
|
+
_publicationIdCache = matchedPub.id;
|
|
171
187
|
return matchedPub.id;
|
|
172
188
|
}
|
|
173
189
|
|
|
@@ -199,20 +215,25 @@ async function getPublicationId() {
|
|
|
199
215
|
* @param {Array<{name: string, value: string}>} [options.customFields] - Pre-built custom fields
|
|
200
216
|
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
201
217
|
*/
|
|
202
|
-
async function addContact({ email, firstName, lastName, source, customFields }) {
|
|
218
|
+
async function addContact({ email, firstName, lastName, company, source, customFields }) {
|
|
203
219
|
const publicationId = await getPublicationId();
|
|
204
220
|
|
|
205
221
|
if (!publicationId) {
|
|
206
222
|
return { success: false, error: 'Publication not found' };
|
|
207
223
|
}
|
|
208
224
|
|
|
225
|
+
const fields = [...(customFields || [])];
|
|
226
|
+
if (company) {
|
|
227
|
+
fields.push({ name: 'company', value: company });
|
|
228
|
+
}
|
|
229
|
+
|
|
209
230
|
return addSubscriber({
|
|
210
231
|
email,
|
|
211
232
|
firstName,
|
|
212
233
|
lastName,
|
|
213
234
|
source,
|
|
214
235
|
publicationId,
|
|
215
|
-
customFields:
|
|
236
|
+
customFields: fields,
|
|
216
237
|
});
|
|
217
238
|
}
|
|
218
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],
|
|
@@ -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
|
+
};
|
|
@@ -18,7 +18,6 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
18
18
|
|
|
19
19
|
// Extract parameters
|
|
20
20
|
const email = (settings.email || '').trim().toLowerCase();
|
|
21
|
-
const providers = settings.providers;
|
|
22
21
|
|
|
23
22
|
// Validate email is provided
|
|
24
23
|
if (!email) {
|
|
@@ -27,7 +26,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
27
26
|
|
|
28
27
|
// Remove from providers
|
|
29
28
|
const mailer = Manager.Email(assistant);
|
|
30
|
-
const providerResults = await mailer.remove(email
|
|
29
|
+
const providerResults = await mailer.remove(email);
|
|
31
30
|
|
|
32
31
|
// Log result
|
|
33
32
|
assistant.log('marketing/contact delete result:', {
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
const recaptcha = require('../../../libraries/recaptcha.js');
|
|
6
6
|
const { validate: validateEmail, ALL_CHECKS } = require('../../../libraries/email/validation.js');
|
|
7
7
|
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
8
|
-
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
9
8
|
|
|
10
9
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
11
10
|
|
|
@@ -23,7 +22,6 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
23
22
|
|
|
24
23
|
// Admin-only options
|
|
25
24
|
const tags = isAdmin ? settings.tags : [];
|
|
26
|
-
const providers = isAdmin ? settings.providers : DEFAULT_PROVIDERS;
|
|
27
25
|
const skipValidation = isAdmin ? settings.skipValidation : false;
|
|
28
26
|
|
|
29
27
|
// Email validation — run free checks before reCAPTCHA/rate limit
|
|
@@ -85,12 +83,14 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
85
83
|
}
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
// Infer
|
|
86
|
+
// Infer contact info if name not provided
|
|
89
87
|
let nameInferred = null;
|
|
88
|
+
let company = '';
|
|
90
89
|
if (!firstName && !lastName) {
|
|
91
90
|
nameInferred = await inferContact(email, assistant);
|
|
92
91
|
firstName = nameInferred.firstName;
|
|
93
92
|
lastName = nameInferred.lastName;
|
|
93
|
+
company = nameInferred.company;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Add to providers
|
|
@@ -104,8 +104,8 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
104
104
|
email,
|
|
105
105
|
firstName,
|
|
106
106
|
lastName,
|
|
107
|
+
company,
|
|
107
108
|
source,
|
|
108
|
-
providers,
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for DELETE /marketing/contact
|
|
3
3
|
*/
|
|
4
|
-
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
5
|
-
|
|
6
4
|
module.exports = () => ({
|
|
7
5
|
email: { types: ['string'], default: undefined, required: true },
|
|
8
|
-
providers: { types: ['array'], default: DEFAULT_PROVIDERS },
|
|
9
6
|
});
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for POST /marketing/contact
|
|
3
3
|
*/
|
|
4
|
-
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
5
|
-
|
|
6
4
|
module.exports = () => ({
|
|
7
5
|
email: { types: ['string'], default: undefined, required: true },
|
|
8
6
|
firstName: { types: ['string'], default: '' },
|
|
9
7
|
lastName: { types: ['string'], default: '' },
|
|
10
8
|
source: { types: ['string'], default: 'unknown' },
|
|
11
9
|
tags: { types: ['array'], default: [] },
|
|
12
|
-
providers: { types: ['array'], default: DEFAULT_PROVIDERS },
|
|
13
10
|
skipValidation: { types: ['boolean'], default: false },
|
|
14
11
|
'g-recaptcha-response': { types: ['string'], default: undefined },
|
|
15
12
|
});
|
|
@@ -122,6 +122,15 @@
|
|
|
122
122
|
// Add more products/tiers here
|
|
123
123
|
],
|
|
124
124
|
},
|
|
125
|
+
marketing: {
|
|
126
|
+
sendgrid: {
|
|
127
|
+
enabled: true,
|
|
128
|
+
},
|
|
129
|
+
beehiiv: {
|
|
130
|
+
enabled: false,
|
|
131
|
+
// publicationId: 'pub_xxxxx', // Set to skip fuzzy-match API call
|
|
132
|
+
},
|
|
133
|
+
},
|
|
125
134
|
firebaseConfig: {
|
|
126
135
|
apiKey: '123-456',
|
|
127
136
|
authDomain: 'PROJECT-ID.firebaseapp.com',
|
|
@@ -206,46 +206,7 @@ module.exports = {
|
|
|
206
206
|
},
|
|
207
207
|
},
|
|
208
208
|
|
|
209
|
-
// Test 7:
|
|
210
|
-
{
|
|
211
|
-
name: 'admin-specify-providers',
|
|
212
|
-
auth: 'admin',
|
|
213
|
-
timeout: 30000,
|
|
214
|
-
|
|
215
|
-
async run({ http, assert, config, state }) {
|
|
216
|
-
const testEmail = TEST_EMAILS.valid(config.domain);
|
|
217
|
-
state.testEmail = testEmail;
|
|
218
|
-
|
|
219
|
-
const response = await http.command('general:add-marketing-contact', {
|
|
220
|
-
email: testEmail,
|
|
221
|
-
source: 'bem-test',
|
|
222
|
-
providers: ['sendgrid'], // Only SendGrid, not Beehiiv
|
|
223
|
-
// No firstName/lastName - should be inferred as "Rachel Greene"
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
assert.isSuccess(response, 'Add marketing contact with specific providers should succeed');
|
|
227
|
-
|
|
228
|
-
// Only check providers if TEST_EXTENDED_MODE is set (external APIs are called)
|
|
229
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
230
|
-
// Should only have sendgrid result
|
|
231
|
-
if (response.data?.providers) {
|
|
232
|
-
assert.hasProperty(response.data.providers, 'sendgrid', 'Should have SendGrid result');
|
|
233
|
-
}
|
|
234
|
-
state.sendgridAdded = response.data?.providers?.sendgrid?.success;
|
|
235
|
-
// Beehiiv not called since we only specified sendgrid
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
async cleanup({ state, http }) {
|
|
240
|
-
if (!process.env.TEST_EXTENDED_MODE || !state.testEmail) {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
await http.command('general:remove-marketing-contact', { email: state.testEmail });
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
|
|
248
|
-
// Test 8: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
209
|
+
// Test 7: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
249
210
|
{
|
|
250
211
|
name: 'mailbox-validation',
|
|
251
212
|
auth: 'admin',
|
|
@@ -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
|
+
};
|
|
@@ -208,44 +208,7 @@ module.exports = {
|
|
|
208
208
|
},
|
|
209
209
|
},
|
|
210
210
|
|
|
211
|
-
// Test 7:
|
|
212
|
-
{
|
|
213
|
-
name: 'add-admin-specify-providers',
|
|
214
|
-
auth: 'admin',
|
|
215
|
-
timeout: 30000,
|
|
216
|
-
|
|
217
|
-
async run({ http, assert, config, state }) {
|
|
218
|
-
const testEmail = TEST_EMAILS.valid(config.domain);
|
|
219
|
-
state.testEmail = testEmail;
|
|
220
|
-
|
|
221
|
-
const response = await http.post('marketing/contact', {
|
|
222
|
-
email: testEmail,
|
|
223
|
-
source: 'bem-test',
|
|
224
|
-
providers: ['sendgrid'], // Only SendGrid, not Beehiiv
|
|
225
|
-
// No firstName/lastName - should be inferred as "Rachel Greene"
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
assert.isSuccess(response, 'Add marketing contact with specific providers should succeed');
|
|
229
|
-
|
|
230
|
-
// Provider calls only happen in extended mode
|
|
231
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
232
|
-
// Should only have sendgrid result (since we specified providers: ['sendgrid'])
|
|
233
|
-
assert.hasProperty(response.data.providers, 'sendgrid', 'Should have SendGrid result');
|
|
234
|
-
state.sendgridAdded = response.data?.providers?.sendgrid?.success;
|
|
235
|
-
// Beehiiv not called since we only specified sendgrid
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
async cleanup({ state, http }) {
|
|
240
|
-
if (!process.env.TEST_EXTENDED_MODE || !state.testEmail) {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
await http.delete('marketing/contact', { email: state.testEmail });
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
|
|
248
|
-
// Test 8: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
211
|
+
// Test 7: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
249
212
|
{
|
|
250
213
|
name: 'add-mailbox-validation',
|
|
251
214
|
auth: 'admin',
|
|
File without changes
|