backend-manager 5.2.2 → 5.2.5
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 +33 -0
- package/CLAUDE.md +3 -3
- package/docs/consent.md +5 -10
- package/docs/sanitization.md +32 -24
- package/docs/schemas.md +1 -1
- package/docs/testing.md +8 -7
- package/package.json +1 -1
- package/src/manager/helpers/middleware.js +7 -4
- package/src/manager/helpers/utilities.js +31 -0
- package/src/manager/libraries/email/data/blocked-local-patterns.js +10 -5
- package/src/manager/libraries/email/marketing/index.js +15 -7
- package/src/manager/libraries/email/providers/beehiiv.js +147 -94
- package/src/manager/libraries/email/providers/sendgrid.js +131 -72
- package/src/test/runner.js +0 -31
- package/src/test/test-accounts.js +8 -63
- package/templates/backend-manager-config.json +2 -1
- package/test/marketing/consent-lifecycle.js +255 -0
|
@@ -79,39 +79,55 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
82
|
+
* Look up a Beehiiv subscriber by email. Returns the subscription object
|
|
83
|
+
* (id, email, status, custom_fields, ...) or null if not found.
|
|
84
|
+
*
|
|
85
|
+
* Useful for tests that need to verify whether a subscriber landed in the
|
|
86
|
+
* publication after a marketing sync.
|
|
83
87
|
*
|
|
84
88
|
* @param {string} email
|
|
85
89
|
* @param {string} publicationId
|
|
86
|
-
* @returns {
|
|
90
|
+
* @returns {Promise<object|null>}
|
|
87
91
|
*/
|
|
88
|
-
async function
|
|
92
|
+
async function findSubscriber(email, publicationId) {
|
|
89
93
|
try {
|
|
90
94
|
const encodedEmail = encodeURIComponent(email);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
response: 'json',
|
|
99
|
-
headers: headers(),
|
|
100
|
-
timeout: 10000,
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
} catch (e) {
|
|
104
|
-
if (e.status === 404) {
|
|
105
|
-
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
95
|
+
const searchData = await fetch(
|
|
96
|
+
`${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
|
|
97
|
+
{
|
|
98
|
+
response: 'json',
|
|
99
|
+
headers: headers(),
|
|
100
|
+
timeout: 60000,
|
|
106
101
|
}
|
|
107
|
-
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return searchData.data || null;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.status === 404) {
|
|
107
|
+
return null;
|
|
108
108
|
}
|
|
109
|
+
console.error('Beehiiv findSubscriber error:', e);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a subscriber from a Beehiiv publication by email.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} email
|
|
118
|
+
* @param {string} publicationId
|
|
119
|
+
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
120
|
+
*/
|
|
121
|
+
async function removeSubscriber(email, publicationId) {
|
|
122
|
+
try {
|
|
123
|
+
// Step 1: Look up the subscription
|
|
124
|
+
const subscription = await findSubscriber(email, publicationId);
|
|
109
125
|
|
|
110
|
-
if (!
|
|
111
|
-
return { success: true, skipped: true, reason: '
|
|
126
|
+
if (!subscription?.id) {
|
|
127
|
+
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
const subscriptionId =
|
|
130
|
+
const subscriptionId = subscription.id;
|
|
115
131
|
|
|
116
132
|
// Step 2: Permanently delete the subscription
|
|
117
133
|
await fetch(
|
|
@@ -119,7 +135,7 @@ async function removeSubscriber(email, publicationId) {
|
|
|
119
135
|
{
|
|
120
136
|
method: 'delete',
|
|
121
137
|
headers: headers(),
|
|
122
|
-
timeout:
|
|
138
|
+
timeout: 60000,
|
|
123
139
|
}
|
|
124
140
|
);
|
|
125
141
|
|
|
@@ -131,80 +147,96 @@ async function removeSubscriber(email, publicationId) {
|
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
/**
|
|
134
|
-
* Get
|
|
150
|
+
* Get this brand's Beehiiv publication ID.
|
|
135
151
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
152
|
+
* Reads `Manager.config.marketing.beehiiv.publicationId` — populated by
|
|
153
|
+
* OMEGA's `beehiiv/ensure/publication.js` at brand-onboarding time. No
|
|
154
|
+
* runtime API call, no fuzzy-match fragility.
|
|
155
|
+
*
|
|
156
|
+
* If the brand hasn't been onboarded yet (publicationId missing/empty), logs
|
|
157
|
+
* a warning and returns null — the marketing sync will skip Beehiiv for this
|
|
158
|
+
* brand. Fix: run OMEGA's beehiiv service to populate.
|
|
159
|
+
*
|
|
160
|
+
* @returns {string|null} Publication ID or null if not configured
|
|
138
161
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
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 (guard against uninitialized Manager singleton —
|
|
155
|
-
// happens in test stubs that build their own Manager without init()).
|
|
156
|
-
const brandName = Manager.config?.brand?.name;
|
|
162
|
+
function getPublicationId() {
|
|
163
|
+
const publicationId = Manager.config?.marketing?.beehiiv?.publicationId;
|
|
157
164
|
|
|
158
|
-
if (!
|
|
159
|
-
console.
|
|
165
|
+
if (!publicationId) {
|
|
166
|
+
console.warn(
|
|
167
|
+
'Beehiiv: marketing.beehiiv.publicationId is not set in config. '
|
|
168
|
+
+ 'Subscriber will NOT be added to a publication. '
|
|
169
|
+
+ 'Run OMEGA to populate.',
|
|
170
|
+
);
|
|
160
171
|
return null;
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
|
|
164
|
-
const allPublications = [];
|
|
165
|
-
let page = 1;
|
|
166
|
-
const limit = 100;
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
while (true) {
|
|
170
|
-
const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
171
|
-
response: 'json',
|
|
172
|
-
headers: headers(),
|
|
173
|
-
timeout: 10000,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
if (!data.data || data.data.length === 0) {
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const matchedPub = data.data.find(pub =>
|
|
181
|
-
pub.name.toLowerCase() === brandNameLower
|
|
182
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
183
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
if (matchedPub) {
|
|
187
|
-
_publicationIdCache = matchedPub.id;
|
|
188
|
-
return matchedPub.id;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
allPublications.push(...data.data);
|
|
192
|
-
|
|
193
|
-
if (data.data.length < limit) {
|
|
194
|
-
break;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
page++;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
201
|
-
} catch (e) {
|
|
202
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return null;
|
|
174
|
+
return publicationId;
|
|
206
175
|
}
|
|
207
176
|
|
|
177
|
+
// LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
|
|
178
|
+
// in case the config-based approach has an edge case we haven't seen yet.
|
|
179
|
+
// Delete once we've verified the config-based approach works across all brands.
|
|
180
|
+
//
|
|
181
|
+
// let _publicationIdCache = null;
|
|
182
|
+
//
|
|
183
|
+
// async function getPublicationIdByFuzzyMatch() {
|
|
184
|
+
// if (_publicationIdCache) {
|
|
185
|
+
// return _publicationIdCache;
|
|
186
|
+
// }
|
|
187
|
+
//
|
|
188
|
+
// const brandName = Manager.config?.brand?.name;
|
|
189
|
+
//
|
|
190
|
+
// if (!brandName) {
|
|
191
|
+
// console.error('Beehiiv: Brand name is required to find publication');
|
|
192
|
+
// return null;
|
|
193
|
+
// }
|
|
194
|
+
//
|
|
195
|
+
// const brandNameLower = brandName.toLowerCase();
|
|
196
|
+
// const allPublications = [];
|
|
197
|
+
// let page = 1;
|
|
198
|
+
// const limit = 100;
|
|
199
|
+
//
|
|
200
|
+
// try {
|
|
201
|
+
// while (true) {
|
|
202
|
+
// const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
203
|
+
// response: 'json',
|
|
204
|
+
// headers: headers(),
|
|
205
|
+
// timeout: 60000,
|
|
206
|
+
// });
|
|
207
|
+
//
|
|
208
|
+
// if (!data.data || data.data.length === 0) {
|
|
209
|
+
// break;
|
|
210
|
+
// }
|
|
211
|
+
//
|
|
212
|
+
// const matchedPub = data.data.find(pub =>
|
|
213
|
+
// pub.name.toLowerCase() === brandNameLower
|
|
214
|
+
// || pub.name.toLowerCase().includes(brandNameLower)
|
|
215
|
+
// || brandNameLower.includes(pub.name.toLowerCase())
|
|
216
|
+
// );
|
|
217
|
+
//
|
|
218
|
+
// if (matchedPub) {
|
|
219
|
+
// _publicationIdCache = matchedPub.id;
|
|
220
|
+
// return matchedPub.id;
|
|
221
|
+
// }
|
|
222
|
+
//
|
|
223
|
+
// allPublications.push(...data.data);
|
|
224
|
+
//
|
|
225
|
+
// if (data.data.length < limit) {
|
|
226
|
+
// break;
|
|
227
|
+
// }
|
|
228
|
+
//
|
|
229
|
+
// page++;
|
|
230
|
+
// }
|
|
231
|
+
//
|
|
232
|
+
// console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
233
|
+
// } catch (e) {
|
|
234
|
+
// console.error('Beehiiv publication lookup error:', e);
|
|
235
|
+
// }
|
|
236
|
+
//
|
|
237
|
+
// return null;
|
|
238
|
+
// }
|
|
239
|
+
|
|
208
240
|
/**
|
|
209
241
|
* Add a contact to Beehiiv — resolves publication, adds subscriber with optional custom fields.
|
|
210
242
|
*
|
|
@@ -217,7 +249,7 @@ async function getPublicationId() {
|
|
|
217
249
|
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
218
250
|
*/
|
|
219
251
|
async function addContact({ email, firstName, lastName, company, source, customFields }) {
|
|
220
|
-
const publicationId =
|
|
252
|
+
const publicationId = getPublicationId();
|
|
221
253
|
|
|
222
254
|
if (!publicationId) {
|
|
223
255
|
return { success: false, error: 'Publication not found' };
|
|
@@ -245,7 +277,7 @@ async function addContact({ email, firstName, lastName, company, source, customF
|
|
|
245
277
|
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
246
278
|
*/
|
|
247
279
|
async function removeContact(email) {
|
|
248
|
-
const publicationId =
|
|
280
|
+
const publicationId = getPublicationId();
|
|
249
281
|
|
|
250
282
|
if (!publicationId) {
|
|
251
283
|
return { success: false, error: 'Publication not found' };
|
|
@@ -254,6 +286,25 @@ async function removeContact(email) {
|
|
|
254
286
|
return removeSubscriber(email, publicationId);
|
|
255
287
|
}
|
|
256
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Look up a contact in this brand's Beehiiv publication. Resolves the
|
|
291
|
+
* publicationId from config and calls findSubscriber. Mirrors SendGrid's
|
|
292
|
+
* findContact() surface so tests can use the same pattern across both
|
|
293
|
+
* providers.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} email
|
|
296
|
+
* @returns {Promise<object|null>}
|
|
297
|
+
*/
|
|
298
|
+
async function findContact(email) {
|
|
299
|
+
const publicationId = getPublicationId();
|
|
300
|
+
|
|
301
|
+
if (!publicationId) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return findSubscriber(email, publicationId);
|
|
306
|
+
}
|
|
307
|
+
|
|
257
308
|
/**
|
|
258
309
|
* Build Beehiiv custom_fields array from a user doc.
|
|
259
310
|
* Resolves all field values, then maps to display names for Beehiiv.
|
|
@@ -290,7 +341,7 @@ async function resolveSegmentIds() {
|
|
|
290
341
|
return _segmentIdCache;
|
|
291
342
|
}
|
|
292
343
|
|
|
293
|
-
const publicationId =
|
|
344
|
+
const publicationId = getPublicationId();
|
|
294
345
|
|
|
295
346
|
if (!publicationId) {
|
|
296
347
|
return {};
|
|
@@ -300,7 +351,7 @@ async function resolveSegmentIds() {
|
|
|
300
351
|
const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
|
|
301
352
|
response: 'json',
|
|
302
353
|
headers: headers(),
|
|
303
|
-
timeout:
|
|
354
|
+
timeout: 60000,
|
|
304
355
|
});
|
|
305
356
|
|
|
306
357
|
_segmentIdCache = {};
|
|
@@ -337,7 +388,7 @@ async function resolveSegmentIds() {
|
|
|
337
388
|
* @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
|
|
338
389
|
*/
|
|
339
390
|
async function createPost(options) {
|
|
340
|
-
const publicationId = options.publicationId ||
|
|
391
|
+
const publicationId = options.publicationId || getPublicationId();
|
|
341
392
|
|
|
342
393
|
if (!publicationId) {
|
|
343
394
|
return { success: false, error: 'Publication not found' };
|
|
@@ -419,6 +470,8 @@ module.exports = {
|
|
|
419
470
|
|
|
420
471
|
// Contacts
|
|
421
472
|
addContact,
|
|
473
|
+
findContact,
|
|
474
|
+
findSubscriber,
|
|
422
475
|
removeContact,
|
|
423
476
|
buildFields,
|
|
424
477
|
|
|
@@ -9,6 +9,12 @@ const { resolveFieldValues } = require('../constants.js');
|
|
|
9
9
|
|
|
10
10
|
const BASE_URL = 'https://api.sendgrid.com/v3';
|
|
11
11
|
|
|
12
|
+
// SendGrid's API is normally fast (<2s) but spikes past 10s during their
|
|
13
|
+
// hiccups, dropping signups silently. 60s is generous but harmless — the
|
|
14
|
+
// metadata calls (resolveFieldIds, getListId) are cached for the process
|
|
15
|
+
// lifetime so a slow first call costs nothing in steady state.
|
|
16
|
+
const SENDGRID_TIMEOUT_MS = 60000;
|
|
17
|
+
|
|
12
18
|
// --- Internal helpers ---
|
|
13
19
|
|
|
14
20
|
function headers() {
|
|
@@ -35,7 +41,7 @@ async function resolveFieldIds() {
|
|
|
35
41
|
const data = await fetch(`${BASE_URL}/marketing/field_definitions`, {
|
|
36
42
|
response: 'json',
|
|
37
43
|
headers: headers(),
|
|
38
|
-
timeout:
|
|
44
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
_fieldIdCache = {};
|
|
@@ -70,7 +76,7 @@ async function resolveSegmentIds() {
|
|
|
70
76
|
const data = await fetch(`${BASE_URL}/marketing/segments/2.0`, {
|
|
71
77
|
response: 'json',
|
|
72
78
|
headers: headers(),
|
|
73
|
-
timeout:
|
|
79
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
_segmentIdCache = {};
|
|
@@ -125,34 +131,59 @@ async function upsertContacts({ contacts, listIds }) {
|
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
/**
|
|
128
|
-
*
|
|
134
|
+
* Look up a SendGrid contact by email. Returns the contact object (id, email,
|
|
135
|
+
* list_ids, custom_fields, ...) or null if not found.
|
|
136
|
+
*
|
|
137
|
+
* Useful for tests that need to verify whether a contact landed in the list
|
|
138
|
+
* after a marketing sync.
|
|
129
139
|
*
|
|
130
140
|
* @param {string} email
|
|
131
|
-
* @returns {
|
|
141
|
+
* @returns {Promise<object|null>}
|
|
132
142
|
*/
|
|
133
|
-
async function
|
|
143
|
+
async function findContact(email) {
|
|
134
144
|
try {
|
|
135
|
-
// Step 1: Get contact ID by email
|
|
136
145
|
const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
|
|
137
146
|
method: 'post',
|
|
138
147
|
response: 'json',
|
|
139
148
|
headers: headers(),
|
|
140
|
-
timeout:
|
|
149
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
141
150
|
body: { emails: [email] },
|
|
142
151
|
});
|
|
143
152
|
|
|
144
|
-
|
|
153
|
+
return searchData.result?.[email]?.contact || null;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// 404 is the normal "not in contacts" response — return null silently.
|
|
156
|
+
if (e.status === 404) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
console.error('SendGrid findContact error:', e);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Remove a contact from SendGrid by email address.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} email
|
|
168
|
+
* @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
|
|
169
|
+
*/
|
|
170
|
+
async function removeContact(email) {
|
|
171
|
+
try {
|
|
172
|
+
// Step 1: Get contact ID by email
|
|
173
|
+
const contact = await findContact(email);
|
|
174
|
+
|
|
175
|
+
if (!contact?.id) {
|
|
145
176
|
return { success: true, skipped: true, reason: 'Contact not found' };
|
|
146
177
|
}
|
|
147
178
|
|
|
148
|
-
const contactId =
|
|
179
|
+
const contactId = contact.id;
|
|
149
180
|
|
|
150
181
|
// Step 2: Delete contact by ID
|
|
151
182
|
const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
|
|
152
183
|
method: 'delete',
|
|
153
184
|
response: 'json',
|
|
154
185
|
headers: headers(),
|
|
155
|
-
timeout:
|
|
186
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
156
187
|
});
|
|
157
188
|
|
|
158
189
|
if (deleteData.job_id) {
|
|
@@ -167,69 +198,96 @@ async function removeContact(email) {
|
|
|
167
198
|
}
|
|
168
199
|
|
|
169
200
|
/**
|
|
170
|
-
* Get
|
|
201
|
+
* Get this brand's SendGrid Marketing list ID.
|
|
202
|
+
*
|
|
203
|
+
* Reads `Manager.config.marketing.sendgrid.listId` — populated by OMEGA's
|
|
204
|
+
* `sendgrid/ensure/list.js` at brand-onboarding time, same as how Beehiiv's
|
|
205
|
+
* `publicationId` works. No runtime API call, no fuzzy-match fragility.
|
|
206
|
+
*
|
|
207
|
+
* If the brand hasn't been onboarded yet (listId missing/empty), logs a
|
|
208
|
+
* warning and returns null — the marketing sync will still succeed, but the
|
|
209
|
+
* contact lands in SendGrid's global pool instead of the brand's list. Fix:
|
|
210
|
+
* run OMEGA's sendgrid service to populate the config.
|
|
171
211
|
*
|
|
172
|
-
* @
|
|
173
|
-
* @returns {string|null} List ID or null
|
|
212
|
+
* @returns {string|null} List ID or null if not configured
|
|
174
213
|
*/
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const data = await fetch(url, {
|
|
186
|
-
response: 'json',
|
|
187
|
-
headers: headers(),
|
|
188
|
-
timeout: 10000,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (!data.result || data.result.length === 0) {
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const matchedList = data.result.find(list =>
|
|
196
|
-
list.name.toLowerCase() === brandNameLower
|
|
197
|
-
|| list.name.toLowerCase().includes(brandNameLower)
|
|
198
|
-
|| brandNameLower.includes(list.name.toLowerCase())
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
if (matchedList) {
|
|
202
|
-
return matchedList.id;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
allLists.push(...data.result);
|
|
206
|
-
|
|
207
|
-
if (!data._metadata?.next) {
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const nextUrl = new URL(data._metadata.next);
|
|
212
|
-
pageToken = nextUrl.searchParams.get('page_token');
|
|
213
|
-
|
|
214
|
-
if (!pageToken) {
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (allLists.length === 1) {
|
|
220
|
-
return allLists[0].id;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (allLists.length > 0) {
|
|
224
|
-
console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
225
|
-
}
|
|
226
|
-
} catch (e) {
|
|
227
|
-
console.error('SendGrid list lookup error:', e);
|
|
214
|
+
function getListId() {
|
|
215
|
+
const listId = Manager.config.marketing?.sendgrid?.listId;
|
|
216
|
+
|
|
217
|
+
if (!listId) {
|
|
218
|
+
console.warn(
|
|
219
|
+
'SendGrid: marketing.sendgrid.listId is not set in config. '
|
|
220
|
+
+ 'Contact will be added to All Contacts only, not the brand list. '
|
|
221
|
+
+ 'Run OMEGA to populate.',
|
|
222
|
+
);
|
|
223
|
+
return null;
|
|
228
224
|
}
|
|
229
225
|
|
|
230
|
-
return
|
|
226
|
+
return listId;
|
|
231
227
|
}
|
|
232
228
|
|
|
229
|
+
// LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
|
|
230
|
+
// in case the config-based approach has an edge case we haven't seen yet.
|
|
231
|
+
// Delete once we've verified the config-based approach works across all brands.
|
|
232
|
+
//
|
|
233
|
+
// async function getListIdByFuzzyMatch() {
|
|
234
|
+
// const brandName = Manager.config.brand?.name;
|
|
235
|
+
// const brandNameLower = (brandName || '').toLowerCase();
|
|
236
|
+
// const allLists = [];
|
|
237
|
+
// let pageToken = '';
|
|
238
|
+
// const pageSize = 1000;
|
|
239
|
+
//
|
|
240
|
+
// try {
|
|
241
|
+
// while (true) {
|
|
242
|
+
// const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
|
|
243
|
+
// const data = await fetch(url, {
|
|
244
|
+
// response: 'json',
|
|
245
|
+
// headers: headers(),
|
|
246
|
+
// timeout: SENDGRID_TIMEOUT_MS,
|
|
247
|
+
// });
|
|
248
|
+
//
|
|
249
|
+
// if (!data.result || data.result.length === 0) {
|
|
250
|
+
// break;
|
|
251
|
+
// }
|
|
252
|
+
//
|
|
253
|
+
// const matchedList = data.result.find(list =>
|
|
254
|
+
// list.name.toLowerCase() === brandNameLower
|
|
255
|
+
// || list.name.toLowerCase().includes(brandNameLower)
|
|
256
|
+
// || brandNameLower.includes(list.name.toLowerCase())
|
|
257
|
+
// );
|
|
258
|
+
//
|
|
259
|
+
// if (matchedList) {
|
|
260
|
+
// return matchedList.id;
|
|
261
|
+
// }
|
|
262
|
+
//
|
|
263
|
+
// allLists.push(...data.result);
|
|
264
|
+
//
|
|
265
|
+
// if (!data._metadata?.next) {
|
|
266
|
+
// break;
|
|
267
|
+
// }
|
|
268
|
+
//
|
|
269
|
+
// const nextUrl = new URL(data._metadata.next);
|
|
270
|
+
// pageToken = nextUrl.searchParams.get('page_token');
|
|
271
|
+
//
|
|
272
|
+
// if (!pageToken) {
|
|
273
|
+
// break;
|
|
274
|
+
// }
|
|
275
|
+
// }
|
|
276
|
+
//
|
|
277
|
+
// if (allLists.length === 1) {
|
|
278
|
+
// return allLists[0].id;
|
|
279
|
+
// }
|
|
280
|
+
//
|
|
281
|
+
// if (allLists.length > 0) {
|
|
282
|
+
// console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
283
|
+
// }
|
|
284
|
+
// } catch (e) {
|
|
285
|
+
// console.error('SendGrid list lookup error:', e);
|
|
286
|
+
// }
|
|
287
|
+
//
|
|
288
|
+
// return null;
|
|
289
|
+
// }
|
|
290
|
+
|
|
233
291
|
// --- Single Sends (Campaigns) ---
|
|
234
292
|
|
|
235
293
|
/**
|
|
@@ -354,7 +412,7 @@ async function cancelSingleSend(singleSendId) {
|
|
|
354
412
|
method: 'delete',
|
|
355
413
|
response: 'json',
|
|
356
414
|
headers: headers(),
|
|
357
|
-
timeout:
|
|
415
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
358
416
|
});
|
|
359
417
|
|
|
360
418
|
return { success: true };
|
|
@@ -375,7 +433,7 @@ async function getSingleSend(singleSendId) {
|
|
|
375
433
|
const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
|
|
376
434
|
response: 'json',
|
|
377
435
|
headers: headers(),
|
|
378
|
-
timeout:
|
|
436
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
379
437
|
});
|
|
380
438
|
|
|
381
439
|
return data.id ? data : null;
|
|
@@ -400,7 +458,7 @@ async function listSingleSends(options) {
|
|
|
400
458
|
const data = await fetch(url, {
|
|
401
459
|
response: 'json',
|
|
402
460
|
headers: headers(),
|
|
403
|
-
timeout:
|
|
461
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
404
462
|
});
|
|
405
463
|
|
|
406
464
|
return data.result || [];
|
|
@@ -437,7 +495,7 @@ async function addContact({ email, firstName, lastName, company, customFields })
|
|
|
437
495
|
}
|
|
438
496
|
}
|
|
439
497
|
|
|
440
|
-
const listId =
|
|
498
|
+
const listId = getListId();
|
|
441
499
|
const result = await upsertContacts({
|
|
442
500
|
contacts: [contact],
|
|
443
501
|
listIds: listId ? [listId] : [],
|
|
@@ -508,7 +566,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
508
566
|
const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
|
|
509
567
|
response: 'json',
|
|
510
568
|
headers: headers(),
|
|
511
|
-
timeout:
|
|
569
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
512
570
|
});
|
|
513
571
|
|
|
514
572
|
console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
|
|
@@ -602,6 +660,7 @@ module.exports = {
|
|
|
602
660
|
|
|
603
661
|
// Contacts
|
|
604
662
|
addContact,
|
|
663
|
+
findContact,
|
|
605
664
|
removeContact,
|
|
606
665
|
getSegmentContacts,
|
|
607
666
|
bulkDeleteContacts,
|
package/src/test/runner.js
CHANGED
|
@@ -119,25 +119,6 @@ class TestRunner {
|
|
|
119
119
|
await this.runTestsInDir(projectTestsDir, 'project');
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Post-run cleanup: scrub test accounts from third-party marketing providers
|
|
123
|
-
// (SendGrid/Beehiiv) so each test run leaves the contact list in the same
|
|
124
|
-
// state it found it. Pairs with the pre-run cleanup as defense in depth —
|
|
125
|
-
// pre-run handles crashed previous runs, post-run handles the current run.
|
|
126
|
-
// Only fires in extended mode (normal mode never touches real providers).
|
|
127
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
128
|
-
process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
|
|
129
|
-
try {
|
|
130
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
131
|
-
apiUrl: this.options.apiUrl,
|
|
132
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
133
|
-
});
|
|
134
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
135
|
-
} catch (e) {
|
|
136
|
-
// Post-run cleanup is best-effort — failures shouldn't change the test result
|
|
137
|
-
console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
122
|
// Cleanup rules context
|
|
142
123
|
if (this.rulesContext) {
|
|
143
124
|
await this.rulesContext.cleanup();
|
|
@@ -229,18 +210,6 @@ class TestRunner {
|
|
|
229
210
|
const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
|
|
230
211
|
console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
|
|
231
212
|
|
|
232
|
-
// Clean any leftover test accounts from third-party marketing providers
|
|
233
|
-
// (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
|
|
234
|
-
// killed run doesn't leave the contact list polluted.
|
|
235
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
236
|
-
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
237
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
238
|
-
apiUrl: this.options.apiUrl,
|
|
239
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
240
|
-
});
|
|
241
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
213
|
process.stdout.write(chalk.gray(' Creating test accounts... '));
|
|
245
214
|
|
|
246
215
|
// Create fresh test accounts
|