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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.149",
3
+ "version": "5.0.151",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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, { providers });
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
- * Sync a user's data to marketing providers (SendGrid + Beehiiv).
77
+ * Add a new contact to enabled marketing providers (lightweight, no full user doc needed).
78
78
  *
79
- * @param {object} userDoc - Full user document from Firestore
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 (SendGrid + Beehiiv).
87
+ * Sync a user's full data to enabled marketing providers.
95
88
  *
96
- * @param {object} userDoc - Full user document from Firestore
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 (userDoc, options) {
101
- return this._marketing.sync(userDoc, options);
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, options) {
112
- return this._marketing.remove(email, options);
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, DEFAULT_PROVIDERS } = require('../constants.js');
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 all providers (lightweight — no full user doc needed).
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, providers } = options;
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
- const shouldAdd = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
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 results;
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 (addProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
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 (addProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
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, options) {
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
- const shouldSync = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
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 results;
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 (syncProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
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 (syncProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
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, options) {
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 (removeProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
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 (removeProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
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 (!process.env.SENDGRID_API_KEY) {
263
- return { success: false, error: 'SENDGRID_API_KEY not set' };
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: 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 && (aiResult.firstName || aiResult.lastName)) {
34
+ if (aiResult) {
35
35
  return aiResult;
36
36
  }
37
37
  }
38
38
 
39
- return inferContactFromEmail(email);
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
- if (result?.firstName !== undefined) {
68
+ const parsed = result?.content;
69
+ if (parsed?.firstName !== undefined) {
67
70
  return {
68
- firstName: capitalize(result.firstName || ''),
69
- lastName: capitalize(result.lastName || ''),
70
- company: capitalize(result.company || ''),
71
- confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
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 like so:
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.7}</output>
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.6}</output>
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, { providers });
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 name if not provided
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
 
@@ -0,0 +1,12 @@
1
+ module.exports = () => ({
2
+ email: {
3
+ types: ['string'],
4
+ default: '',
5
+ required: false,
6
+ },
7
+ emails: {
8
+ types: ['array'],
9
+ default: [],
10
+ required: false,
11
+ },
12
+ });
@@ -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: Admin can specify providers
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: Admin can specify providers
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