backend-manager 5.0.148 → 5.0.149
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 +50 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
const fetch = require('wonderful-fetch');
|
|
2
1
|
const path = require('path');
|
|
3
|
-
const dns = require('dns').promises;
|
|
4
2
|
const recaptcha = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'recaptcha.js'));
|
|
5
|
-
|
|
6
|
-
// Load disposable domains list
|
|
7
|
-
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'disposable-domains.json'));
|
|
8
|
-
const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
|
|
3
|
+
const { validate: validateEmail } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'email', 'validation.js'));
|
|
9
4
|
const { inferContact } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'infer-contact.js'));
|
|
10
5
|
|
|
11
6
|
function Module() {}
|
|
@@ -73,38 +68,29 @@ Module.prototype.main = function () {
|
|
|
73
68
|
}
|
|
74
69
|
}
|
|
75
70
|
|
|
76
|
-
// Email validation
|
|
77
|
-
const validation = { valid: true, checks: {} };
|
|
78
|
-
|
|
79
71
|
// Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
|
|
80
72
|
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
81
73
|
|
|
74
|
+
// Email validation
|
|
75
|
+
let validation = { valid: true, checks: {} };
|
|
76
|
+
|
|
82
77
|
if (!skipValidation) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
validation.valid = false;
|
|
87
|
-
validation.checks.disposable = { blocked: true, domain };
|
|
78
|
+
validation = await validateEmail(email, {
|
|
79
|
+
zerobounce: isAdmin && shouldCallExternalAPIs,
|
|
80
|
+
});
|
|
88
81
|
|
|
82
|
+
if (!validation.valid) {
|
|
89
83
|
// For public requests, return generic success to prevent enumeration
|
|
90
84
|
if (!isAdmin) {
|
|
91
85
|
return resolve({ data: { success: true } });
|
|
92
86
|
}
|
|
93
|
-
return reject(assistant.errorify(`Disposable email domain not allowed: ${domain}`, { code: 400 }));
|
|
94
|
-
}
|
|
95
|
-
validation.checks.disposable = { blocked: false };
|
|
96
|
-
|
|
97
|
-
// MX record check (optional, skip for speed in most cases)
|
|
98
|
-
// const hasMx = await checkMxRecord(domain);
|
|
99
|
-
// validation.checks.mx = { valid: hasMx };
|
|
100
87
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
validation.checks.zerobounce = zbResult;
|
|
105
|
-
if (!zbResult.valid) {
|
|
106
|
-
validation.valid = false;
|
|
88
|
+
const disposable = validation.checks.disposable;
|
|
89
|
+
if (disposable && !disposable.valid) {
|
|
90
|
+
return reject(assistant.errorify(`Disposable email domain not allowed: ${disposable.domain}`, { code: 400 }));
|
|
107
91
|
}
|
|
92
|
+
|
|
93
|
+
return reject(assistant.errorify('Email validation failed', { code: 400 }));
|
|
108
94
|
}
|
|
109
95
|
}
|
|
110
96
|
|
|
@@ -117,35 +103,19 @@ Module.prototype.main = function () {
|
|
|
117
103
|
}
|
|
118
104
|
|
|
119
105
|
// Add to providers
|
|
120
|
-
|
|
106
|
+
let providerResults = {};
|
|
121
107
|
|
|
122
108
|
if (!shouldCallExternalAPIs) {
|
|
123
109
|
assistant.log('add-marketing-contact: Skipping providers (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
|
|
124
110
|
} else {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
source,
|
|
134
|
-
appId: Manager.config.app.id,
|
|
135
|
-
brandName: Manager.config.brand?.name,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Beehiiv
|
|
140
|
-
if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
141
|
-
providerResults.beehiiv = await addToBeehiiv({
|
|
142
|
-
email,
|
|
143
|
-
firstName,
|
|
144
|
-
lastName,
|
|
145
|
-
source,
|
|
146
|
-
brandName: Manager.config.brand?.name,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
111
|
+
const mailer = Manager.Email(assistant);
|
|
112
|
+
providerResults = await mailer.add({
|
|
113
|
+
email,
|
|
114
|
+
firstName,
|
|
115
|
+
lastName,
|
|
116
|
+
source,
|
|
117
|
+
providers,
|
|
118
|
+
});
|
|
149
119
|
}
|
|
150
120
|
|
|
151
121
|
// Log result
|
|
@@ -177,270 +147,4 @@ Module.prototype.main = function () {
|
|
|
177
147
|
});
|
|
178
148
|
};
|
|
179
149
|
|
|
180
|
-
/**
|
|
181
|
-
* Validate email with ZeroBounce API
|
|
182
|
-
*/
|
|
183
|
-
async function validateWithZeroBounce(email) {
|
|
184
|
-
try {
|
|
185
|
-
const data = await fetch(
|
|
186
|
-
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
|
|
187
|
-
{
|
|
188
|
-
response: 'json',
|
|
189
|
-
timeout: 10000,
|
|
190
|
-
}
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
// ZeroBounce returns error in response body (e.g., invalid API key, out of credits)
|
|
194
|
-
if (data.error) {
|
|
195
|
-
console.error('ZeroBounce API error:', data.error);
|
|
196
|
-
return { valid: true, error: data.error }; // Fail open
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Ensure status exists (defensive check)
|
|
200
|
-
if (!data.status) {
|
|
201
|
-
console.error('ZeroBounce unexpected response:', data);
|
|
202
|
-
return { valid: true, error: 'Unexpected response format' };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
valid: data.status === 'valid',
|
|
207
|
-
status: data.status,
|
|
208
|
-
subStatus: data.sub_status || null,
|
|
209
|
-
};
|
|
210
|
-
} catch (e) {
|
|
211
|
-
console.error('ZeroBounce validation error:', e);
|
|
212
|
-
return { valid: true, error: e.message }; // Fail open
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Check if domain has valid MX records
|
|
218
|
-
*/
|
|
219
|
-
async function checkMxRecord(domain) {
|
|
220
|
-
try {
|
|
221
|
-
const records = await dns.resolveMx(domain);
|
|
222
|
-
return records && records.length > 0;
|
|
223
|
-
} catch (e) {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Add contact to SendGrid Marketing Contacts
|
|
230
|
-
*/
|
|
231
|
-
async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
|
|
232
|
-
try {
|
|
233
|
-
// Get list ID matched by brand name
|
|
234
|
-
const listId = await getSendGridListId(brandName);
|
|
235
|
-
|
|
236
|
-
const requestBody = {
|
|
237
|
-
contacts: [
|
|
238
|
-
{
|
|
239
|
-
email,
|
|
240
|
-
first_name: firstName || undefined,
|
|
241
|
-
last_name: lastName || undefined,
|
|
242
|
-
custom_fields: {
|
|
243
|
-
e1_T: source, // Assumes custom field exists for source
|
|
244
|
-
e2_T: appId, // Assumes custom field exists for app_id
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
],
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
// Add to specific list if matched
|
|
251
|
-
if (listId) {
|
|
252
|
-
requestBody.list_ids = [listId];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const data = await fetch('https://api.sendgrid.com/v3/marketing/contacts', {
|
|
256
|
-
method: 'put',
|
|
257
|
-
response: 'json',
|
|
258
|
-
headers: {
|
|
259
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
260
|
-
},
|
|
261
|
-
timeout: 15000,
|
|
262
|
-
body: requestBody,
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
if (data.job_id) {
|
|
266
|
-
return { success: true, jobId: data.job_id, listId };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
|
|
270
|
-
} catch (e) {
|
|
271
|
-
console.error('SendGrid error:', e);
|
|
272
|
-
return { success: false, error: e.message };
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Get SendGrid list ID by matching brand name (with pagination)
|
|
278
|
-
*/
|
|
279
|
-
async function getSendGridListId(brandName) {
|
|
280
|
-
const brandNameLower = (brandName || '').toLowerCase();
|
|
281
|
-
const allLists = [];
|
|
282
|
-
let pageToken = '';
|
|
283
|
-
const pageSize = 1000;
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
// Paginate through all lists
|
|
287
|
-
while (true) {
|
|
288
|
-
const url = `https://api.sendgrid.com/v3/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
|
|
289
|
-
const data = await fetch(url, {
|
|
290
|
-
response: 'json',
|
|
291
|
-
headers: {
|
|
292
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
293
|
-
},
|
|
294
|
-
timeout: 10000,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
if (!data.result || data.result.length === 0) {
|
|
298
|
-
break;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Check for match in this page
|
|
302
|
-
const matchedList = data.result.find(list =>
|
|
303
|
-
list.name.toLowerCase() === brandNameLower
|
|
304
|
-
|| list.name.toLowerCase().includes(brandNameLower)
|
|
305
|
-
|| brandNameLower.includes(list.name.toLowerCase())
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
if (matchedList) {
|
|
309
|
-
return matchedList.id;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
allLists.push(...data.result);
|
|
313
|
-
|
|
314
|
-
// Check for next page token in metadata
|
|
315
|
-
if (!data._metadata?.next) {
|
|
316
|
-
break;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Extract page_token from next URL
|
|
320
|
-
const nextUrl = new URL(data._metadata.next);
|
|
321
|
-
pageToken = nextUrl.searchParams.get('page_token');
|
|
322
|
-
|
|
323
|
-
if (!pageToken) {
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Fallback to first list if only one exists total
|
|
329
|
-
if (allLists.length === 1) {
|
|
330
|
-
return allLists[0].id;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (allLists.length > 0) {
|
|
334
|
-
console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
335
|
-
}
|
|
336
|
-
} catch (e) {
|
|
337
|
-
console.error('SendGrid list lookup error:', e);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Add contact to Beehiiv newsletter
|
|
345
|
-
*/
|
|
346
|
-
async function addToBeehiiv({ email, firstName, lastName, source, brandName }) {
|
|
347
|
-
try {
|
|
348
|
-
// Get publication ID (cached, matched by brand name)
|
|
349
|
-
const pubId = await getBeehiivPublicationId(brandName);
|
|
350
|
-
if (!pubId) {
|
|
351
|
-
return { success: false, error: 'Could not find matching publication' };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`, {
|
|
355
|
-
method: 'post',
|
|
356
|
-
response: 'json',
|
|
357
|
-
headers: {
|
|
358
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
359
|
-
},
|
|
360
|
-
timeout: 15000,
|
|
361
|
-
body: {
|
|
362
|
-
email,
|
|
363
|
-
reactivate_existing: true,
|
|
364
|
-
send_welcome_email: true,
|
|
365
|
-
utm_source: source,
|
|
366
|
-
custom_fields: [
|
|
367
|
-
firstName ? { name: 'first_name', value: firstName } : null,
|
|
368
|
-
lastName ? { name: 'last_name', value: lastName } : null,
|
|
369
|
-
].filter(Boolean),
|
|
370
|
-
},
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
if (data.data?.id) {
|
|
374
|
-
return { success: true, id: data.data.id };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return { success: false, error: data.message || 'Unknown error' };
|
|
378
|
-
} catch (e) {
|
|
379
|
-
console.error('Beehiiv error:', e);
|
|
380
|
-
return { success: false, error: e.message };
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Get Beehiiv publication ID by matching brand name (with pagination)
|
|
386
|
-
* Brand name is REQUIRED - no fallback to random publications
|
|
387
|
-
* @param {string} brandName - Brand name to match (required)
|
|
388
|
-
*/
|
|
389
|
-
async function getBeehiivPublicationId(brandName) {
|
|
390
|
-
// Brand name is required
|
|
391
|
-
if (!brandName) {
|
|
392
|
-
console.error('Beehiiv: Brand name is required to find publication');
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const brandNameLower = brandName.toLowerCase();
|
|
397
|
-
const allPublications = [];
|
|
398
|
-
let page = 1;
|
|
399
|
-
const limit = 100;
|
|
400
|
-
|
|
401
|
-
try {
|
|
402
|
-
// Paginate through all publications
|
|
403
|
-
while (true) {
|
|
404
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
|
|
405
|
-
response: 'json',
|
|
406
|
-
headers: {
|
|
407
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
408
|
-
},
|
|
409
|
-
timeout: 10000,
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
if (!data.data || data.data.length === 0) {
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Check for match in this page
|
|
417
|
-
const matchedPub = data.data.find(pub =>
|
|
418
|
-
pub.name.toLowerCase() === brandNameLower
|
|
419
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
420
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
421
|
-
);
|
|
422
|
-
|
|
423
|
-
if (matchedPub) {
|
|
424
|
-
return matchedPub.id;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
allPublications.push(...data.data);
|
|
428
|
-
|
|
429
|
-
// If we got fewer than limit, we've reached the end
|
|
430
|
-
if (data.data.length < limit) {
|
|
431
|
-
break;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
page++;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// No fallback - brand must match
|
|
438
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
439
|
-
} catch (e) {
|
|
440
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return null;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
150
|
module.exports = Module;
|
|
@@ -10,10 +10,10 @@ module.exports = function (payload, config) {
|
|
|
10
10
|
email: payload.email,
|
|
11
11
|
name: payload.name,
|
|
12
12
|
},
|
|
13
|
+
sender: 'marketing',
|
|
13
14
|
categories: ['download'],
|
|
14
15
|
subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
|
|
15
16
|
template: 'main/misc/app-download-link',
|
|
16
|
-
group: 'marketing',
|
|
17
17
|
copy: false,
|
|
18
18
|
data: {},
|
|
19
19
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const fetch = require('wonderful-fetch');
|
|
2
|
-
|
|
3
1
|
function Module() {}
|
|
4
2
|
|
|
5
3
|
Module.prototype.main = function () {
|
|
@@ -31,21 +29,9 @@ Module.prototype.main = function () {
|
|
|
31
29
|
return reject(assistant.errorify('Email is required', { code: 400 }));
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
// Get brand name from Manager config
|
|
35
|
-
const brandName = Manager.config.brand?.name;
|
|
36
|
-
|
|
37
32
|
// Remove from providers
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
// SendGrid
|
|
41
|
-
if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
|
|
42
|
-
providerResults.sendgrid = await removeFromSendGrid(email);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Beehiiv
|
|
46
|
-
if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
47
|
-
providerResults.beehiiv = await removeFromBeehiiv(email, brandName);
|
|
48
|
-
}
|
|
33
|
+
const mailer = Manager.Email(assistant);
|
|
34
|
+
const providerResults = await mailer.remove(email, { providers });
|
|
49
35
|
|
|
50
36
|
// Log result
|
|
51
37
|
assistant.log('remove-marketing-contact result:', {
|
|
@@ -62,173 +48,4 @@ Module.prototype.main = function () {
|
|
|
62
48
|
});
|
|
63
49
|
};
|
|
64
50
|
|
|
65
|
-
/**
|
|
66
|
-
* Remove contact from SendGrid by email
|
|
67
|
-
* Requires looking up contact ID first, then deleting
|
|
68
|
-
*/
|
|
69
|
-
async function removeFromSendGrid(email) {
|
|
70
|
-
try {
|
|
71
|
-
// Step 1: Get contact ID by email
|
|
72
|
-
const searchData = await fetch('https://api.sendgrid.com/v3/marketing/contacts/search/emails', {
|
|
73
|
-
method: 'post',
|
|
74
|
-
response: 'json',
|
|
75
|
-
headers: {
|
|
76
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
77
|
-
},
|
|
78
|
-
timeout: 10000,
|
|
79
|
-
body: { emails: [email] },
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (!searchData.result?.[email]?.contact?.id) {
|
|
83
|
-
return { success: true, skipped: true, reason: 'Contact not found' };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const contactId = searchData.result[email].contact.id;
|
|
87
|
-
|
|
88
|
-
// Step 2: Delete contact by ID
|
|
89
|
-
const deleteData = await fetch(`https://api.sendgrid.com/v3/marketing/contacts?ids=${contactId}`, {
|
|
90
|
-
method: 'delete',
|
|
91
|
-
response: 'json',
|
|
92
|
-
headers: {
|
|
93
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
94
|
-
},
|
|
95
|
-
timeout: 10000,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
if (deleteData.job_id) {
|
|
99
|
-
return { success: true, jobId: deleteData.job_id };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { success: false, error: deleteData.errors?.[0]?.message || 'Delete failed' };
|
|
103
|
-
} catch (e) {
|
|
104
|
-
console.error('SendGrid remove error:', e);
|
|
105
|
-
return { success: false, error: e.message };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Get Beehiiv publication ID by matching brand name (with pagination)
|
|
111
|
-
* Brand name is REQUIRED - no fallback to random publications
|
|
112
|
-
* @param {string} brandName - Brand name to match (required)
|
|
113
|
-
*/
|
|
114
|
-
async function getBeehiivPublicationId(brandName) {
|
|
115
|
-
// Brand name is required
|
|
116
|
-
if (!brandName) {
|
|
117
|
-
console.error('Beehiiv: Brand name is required to find publication');
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const brandNameLower = brandName.toLowerCase();
|
|
122
|
-
const allPublications = [];
|
|
123
|
-
let page = 1;
|
|
124
|
-
const limit = 100;
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
// Paginate through all publications
|
|
128
|
-
while (true) {
|
|
129
|
-
const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
|
|
130
|
-
response: 'json',
|
|
131
|
-
headers: {
|
|
132
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
133
|
-
},
|
|
134
|
-
timeout: 10000,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
if (!data.data || data.data.length === 0) {
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Check for match in this page
|
|
142
|
-
const matchedPub = data.data.find(pub =>
|
|
143
|
-
pub.name.toLowerCase() === brandNameLower
|
|
144
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
145
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
if (matchedPub) {
|
|
149
|
-
return matchedPub.id;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
allPublications.push(...data.data);
|
|
153
|
-
|
|
154
|
-
// If we got fewer than limit, we've reached the end
|
|
155
|
-
if (data.data.length < limit) {
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
page++;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// No fallback - brand must match
|
|
163
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
164
|
-
} catch (e) {
|
|
165
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Permanently delete subscriber from Beehiiv by email
|
|
173
|
-
* Requires looking up subscription ID first, then calling DELETE endpoint
|
|
174
|
-
* @param {string} email - Email to delete
|
|
175
|
-
* @param {string} brandName - Brand name to match publication (required)
|
|
176
|
-
*/
|
|
177
|
-
async function removeFromBeehiiv(email, brandName) {
|
|
178
|
-
try {
|
|
179
|
-
// Get publication ID by brand name (required)
|
|
180
|
-
const pubId = await getBeehiivPublicationId(brandName);
|
|
181
|
-
if (!pubId) {
|
|
182
|
-
return { success: false, error: `Publication not found for brand "${brandName}"` };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Step 1: Get subscription by email
|
|
186
|
-
const encodedEmail = encodeURIComponent(email);
|
|
187
|
-
|
|
188
|
-
let searchData;
|
|
189
|
-
try {
|
|
190
|
-
searchData = await fetch(
|
|
191
|
-
`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/by_email/${encodedEmail}`,
|
|
192
|
-
{
|
|
193
|
-
response: 'json',
|
|
194
|
-
headers: {
|
|
195
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
196
|
-
},
|
|
197
|
-
timeout: 10000,
|
|
198
|
-
}
|
|
199
|
-
);
|
|
200
|
-
} catch (e) {
|
|
201
|
-
// 404 means subscriber doesn't exist - that's fine for cleanup
|
|
202
|
-
if (e.status === 404) {
|
|
203
|
-
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
204
|
-
}
|
|
205
|
-
throw e;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (!searchData.data?.id) {
|
|
209
|
-
return { success: true, skipped: true, reason: 'Subscription not found' };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const subscriptionId = searchData.data.id;
|
|
213
|
-
|
|
214
|
-
// Step 2: Permanently DELETE the subscription
|
|
215
|
-
await fetch(
|
|
216
|
-
`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/${subscriptionId}`,
|
|
217
|
-
{
|
|
218
|
-
method: 'delete',
|
|
219
|
-
headers: {
|
|
220
|
-
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
221
|
-
},
|
|
222
|
-
timeout: 10000,
|
|
223
|
-
}
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
// DELETE returns 204 No Content on success
|
|
227
|
-
return { success: true, deleted: true, subscriptionId };
|
|
228
|
-
} catch (e) {
|
|
229
|
-
console.error('Beehiiv remove error:', e);
|
|
230
|
-
return { success: false, error: e.message };
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
51
|
module.exports = Module;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
-
const { buildPublicConfig } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'routes', '
|
|
2
|
+
const { buildPublicConfig } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'routes', 'brand', 'get.js'));
|
|
3
3
|
|
|
4
4
|
function Module() {
|
|
5
5
|
|
|
@@ -75,7 +75,7 @@ Module.prototype.main = function () {
|
|
|
75
75
|
timestamp: new Date().toISOString(),
|
|
76
76
|
ip: assistant.request.geolocation.ip,
|
|
77
77
|
country: assistant.request.geolocation.country,
|
|
78
|
-
|
|
78
|
+
brand: buildPublicConfig(Manager.config),
|
|
79
79
|
config: config,
|
|
80
80
|
}
|
|
81
81
|
});
|
|
@@ -14,6 +14,7 @@ Module.prototype.main = function () {
|
|
|
14
14
|
environment: assistant.meta?.environment || 'unknown',
|
|
15
15
|
version: Manager.package?.version || 'unknown',
|
|
16
16
|
bemVersion: Manager.version || 'unknown',
|
|
17
|
+
testExtendedMode: !!process.env.TEST_EXTENDED_MODE,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
assistant.log('Health check', response);
|
|
@@ -26,7 +26,7 @@ function ApiManager(m) {
|
|
|
26
26
|
const self = this;
|
|
27
27
|
self.Manager = m;
|
|
28
28
|
self.options = {
|
|
29
|
-
|
|
29
|
+
brandId: '',
|
|
30
30
|
plans: {},
|
|
31
31
|
maxUsersStored: 10000,
|
|
32
32
|
refetchInterval: 60,
|
|
@@ -41,7 +41,7 @@ ApiManager.prototype.init = function (options) {
|
|
|
41
41
|
const self = this;
|
|
42
42
|
return new Promise(async function(resolve, reject) {
|
|
43
43
|
options = options || {};
|
|
44
|
-
options.
|
|
44
|
+
options.brand = options.brand || '';
|
|
45
45
|
options.plans = options.plans || {};
|
|
46
46
|
|
|
47
47
|
// await self.Manager.libraries.admin.firestore
|
|
@@ -347,11 +347,12 @@ function User(Manager, settings) {
|
|
|
347
347
|
|
|
348
348
|
// Resolves calculated subscription fields that require derivation logic
|
|
349
349
|
// Raw data (product.id, status, trial, cancellation) is on the user object directly
|
|
350
|
-
// Returns: { plan, active, trialing, cancelling }
|
|
350
|
+
// Returns: { plan, active, trialing, cancelling, everPaid }
|
|
351
351
|
// - plan: the plan ID the user effectively has access to RIGHT NOW ('basic' if cancelled/suspended)
|
|
352
352
|
// - active: user has active access (active, trialing, or cancelling)
|
|
353
353
|
// - trialing: user is in an active trial (status is 'active' but trial hasn't expired)
|
|
354
354
|
// - cancelling: cancellation is pending (status is 'active' but cancellation.pending is true)
|
|
355
|
+
// - everPaid: user has had a paid subscription at some point (payment.startDate exists)
|
|
355
356
|
User.resolveSubscription = function resolveSubscription(account) {
|
|
356
357
|
const subscription = (account?.subscription || account?.properties?.subscription) || {};
|
|
357
358
|
const productId = subscription.product?.id || 'basic';
|
|
@@ -372,6 +373,7 @@ User.resolveSubscription = function resolveSubscription(account) {
|
|
|
372
373
|
active,
|
|
373
374
|
trialing,
|
|
374
375
|
cancelling,
|
|
376
|
+
everPaid: (subscription.payment?.startDate?.timestampUNIX || 0) > 0,
|
|
375
377
|
};
|
|
376
378
|
};
|
|
377
379
|
|