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.
Files changed (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CLAUDE.md +26 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/emulator.js +14 -4
  5. package/src/cli/commands/test.js +4 -10
  6. package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
  7. package/src/manager/cron/frequent/abandoned-carts.js +7 -5
  8. package/src/manager/cron/frequent/email-queue.js +56 -0
  9. package/src/manager/events/auth/before-signin.js +3 -0
  10. package/src/manager/events/auth/on-delete.js +8 -0
  11. package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
  14. package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
  15. package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
  16. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
  17. package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
  18. package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
  21. package/src/manager/functions/core/actions/api/test/health.js +1 -0
  22. package/src/manager/helpers/api-manager.js +2 -2
  23. package/src/manager/helpers/user.js +3 -1
  24. package/src/manager/index.js +15 -10
  25. package/src/manager/libraries/email/constants.js +243 -0
  26. package/src/manager/libraries/email/index.js +145 -0
  27. package/src/manager/libraries/email/marketing/index.js +377 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +258 -0
  29. package/src/manager/libraries/email/providers/sendgrid.js +429 -0
  30. package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
  31. package/src/manager/libraries/email/validation.js +168 -0
  32. package/src/manager/routes/admin/cron/post.js +3 -3
  33. package/src/manager/routes/admin/email/post.js +1 -1
  34. package/src/manager/routes/admin/stats/get.js +2 -2
  35. package/src/manager/routes/{app → brand}/get.js +1 -1
  36. package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
  37. package/src/manager/routes/marketing/contact/delete.js +2 -164
  38. package/src/manager/routes/marketing/contact/post.js +45 -298
  39. package/src/manager/routes/marketing/contact/put.js +39 -0
  40. package/src/manager/routes/payments/cancel/post.js +11 -0
  41. package/src/manager/routes/special/electron-client/post.js +3 -3
  42. package/src/manager/routes/test/health/get.js +1 -0
  43. package/src/manager/routes/user/data-request/delete.js +2 -2
  44. package/src/manager/routes/user/data-request/get.js +2 -2
  45. package/src/manager/routes/user/data-request/post.js +2 -2
  46. package/src/manager/routes/user/delete.js +1 -1
  47. package/src/manager/routes/user/feedback/post.js +12 -8
  48. package/src/manager/routes/user/signup/post.js +48 -37
  49. package/src/manager/schemas/admin/email/post.js +4 -4
  50. package/src/manager/schemas/marketing/contact/delete.js +3 -1
  51. package/src/manager/schemas/marketing/contact/post.js +3 -1
  52. package/src/manager/schemas/marketing/contact/put.js +6 -0
  53. package/src/manager/schemas/special/electron-client/post.js +2 -2
  54. package/src/manager/schemas/user/feedback/post.js +2 -2
  55. package/src/test/run-tests.js +1 -1
  56. package/src/test/runner.js +22 -10
  57. package/src/test/test-accounts.js +9 -0
  58. package/src/test/utils/extended-mode-warning.js +11 -0
  59. package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  60. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  61. package/test/functions/admin/edit-post.js +2 -2
  62. package/test/functions/admin/write-repo-content.js +2 -2
  63. package/test/functions/general/add-marketing-contact.js +21 -23
  64. package/test/helpers/email-validation.js +420 -0
  65. package/test/helpers/email.js +119 -6
  66. package/test/helpers/marketing-lifecycle.js +121 -0
  67. package/test/helpers/user.js +2 -2
  68. package/test/routes/admin/create-post.js +2 -2
  69. package/test/routes/admin/post.js +2 -2
  70. package/test/routes/admin/repo-content.js +2 -2
  71. package/test/routes/marketing/contact.js +21 -24
  72. package/test/routes/payments/cancel.js +18 -0
@@ -2,7 +2,6 @@
2
2
  * DELETE /marketing/contact - Remove marketing contact
3
3
  * Admin-only endpoint to unsubscribe from newsletter
4
4
  */
5
- const fetch = require('wonderful-fetch');
6
5
 
7
6
  module.exports = async ({ assistant, Manager, settings, analytics }) => {
8
7
 
@@ -26,21 +25,9 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
26
25
  return assistant.respond('Email is required', { code: 400 });
27
26
  }
28
27
 
29
- // Get brand name from Manager config
30
- const brandName = Manager.config.brand?.name;
31
-
32
28
  // Remove from providers
33
- const providerResults = {};
34
-
35
- // SendGrid
36
- if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
37
- providerResults.sendgrid = await removeFromSendGrid(email);
38
- }
39
-
40
- // Beehiiv
41
- if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
42
- providerResults.beehiiv = await removeFromBeehiiv(email, brandName);
43
- }
29
+ const mailer = Manager.Email(assistant);
30
+ const providerResults = await mailer.remove(email, { providers });
44
31
 
45
32
  // Log result
46
33
  assistant.log('marketing/contact delete result:', {
@@ -56,152 +43,3 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
56
43
  providers: providerResults,
57
44
  });
58
45
  };
59
-
60
- // Helper: Remove contact from SendGrid
61
- async function removeFromSendGrid(email) {
62
- try {
63
- // Step 1: Get contact ID by email
64
- const searchData = await fetch('https://api.sendgrid.com/v3/marketing/contacts/search/emails', {
65
- method: 'post',
66
- response: 'json',
67
- headers: {
68
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
69
- },
70
- timeout: 10000,
71
- body: { emails: [email] },
72
- });
73
-
74
- if (!searchData.result?.[email]?.contact?.id) {
75
- return { success: true, skipped: true, reason: 'Contact not found' };
76
- }
77
-
78
- const contactId = searchData.result[email].contact.id;
79
-
80
- // Step 2: Delete contact by ID
81
- const deleteData = await fetch(`https://api.sendgrid.com/v3/marketing/contacts?ids=${contactId}`, {
82
- method: 'delete',
83
- response: 'json',
84
- headers: {
85
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
86
- },
87
- timeout: 10000,
88
- });
89
-
90
- if (deleteData.job_id) {
91
- return { success: true, jobId: deleteData.job_id };
92
- }
93
-
94
- return { success: false, error: deleteData.errors?.[0]?.message || 'Delete failed' };
95
- } catch (e) {
96
- console.error('SendGrid remove error:', e);
97
- return { success: false, error: e.message };
98
- }
99
- }
100
-
101
- // Helper: Get Beehiiv publication ID by brand name
102
- async function getBeehiivPublicationId(brandName) {
103
- if (!brandName) {
104
- console.error('Beehiiv: Brand name is required to find publication');
105
- return null;
106
- }
107
-
108
- const brandNameLower = brandName.toLowerCase();
109
- const allPublications = [];
110
- let page = 1;
111
- const limit = 100;
112
-
113
- try {
114
- while (true) {
115
- const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
116
- response: 'json',
117
- headers: {
118
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
119
- },
120
- timeout: 10000,
121
- });
122
-
123
- if (!data.data || data.data.length === 0) {
124
- break;
125
- }
126
-
127
- const matchedPub = data.data.find(pub =>
128
- pub.name.toLowerCase() === brandNameLower
129
- || pub.name.toLowerCase().includes(brandNameLower)
130
- || brandNameLower.includes(pub.name.toLowerCase())
131
- );
132
-
133
- if (matchedPub) {
134
- return matchedPub.id;
135
- }
136
-
137
- allPublications.push(...data.data);
138
-
139
- if (data.data.length < limit) {
140
- break;
141
- }
142
-
143
- page++;
144
- }
145
-
146
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
147
- } catch (e) {
148
- console.error('Beehiiv publication lookup error:', e);
149
- }
150
-
151
- return null;
152
- }
153
-
154
- // Helper: Remove contact from Beehiiv
155
- async function removeFromBeehiiv(email, brandName) {
156
- try {
157
- const pubId = await getBeehiivPublicationId(brandName);
158
- if (!pubId) {
159
- return { success: false, error: `Publication not found for brand "${brandName}"` };
160
- }
161
-
162
- // Step 1: Get subscription by email
163
- const encodedEmail = encodeURIComponent(email);
164
-
165
- let searchData;
166
- try {
167
- searchData = await fetch(
168
- `https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/by_email/${encodedEmail}`,
169
- {
170
- response: 'json',
171
- headers: {
172
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
173
- },
174
- timeout: 10000,
175
- }
176
- );
177
- } catch (e) {
178
- if (e.status === 404) {
179
- return { success: true, skipped: true, reason: 'Subscriber not found' };
180
- }
181
- throw e;
182
- }
183
-
184
- if (!searchData.data?.id) {
185
- return { success: true, skipped: true, reason: 'Subscription not found' };
186
- }
187
-
188
- const subscriptionId = searchData.data.id;
189
-
190
- // Step 2: Permanently DELETE the subscription
191
- await fetch(
192
- `https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/${subscriptionId}`,
193
- {
194
- method: 'delete',
195
- headers: {
196
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
197
- },
198
- timeout: 10000,
199
- }
200
- );
201
-
202
- return { success: true, deleted: true, subscriptionId };
203
- } catch (e) {
204
- console.error('Beehiiv remove error:', e);
205
- return { success: false, error: e.message };
206
- }
207
- }
@@ -2,15 +2,10 @@
2
2
  * POST /marketing/contact - Add marketing contact
3
3
  * Public endpoint to subscribe to newsletter, with admin options
4
4
  */
5
- const fetch = require('wonderful-fetch');
6
- const path = require('path');
7
- const dns = require('dns').promises;
8
5
  const recaptcha = require('../../../libraries/recaptcha.js');
9
-
10
- // Load disposable domains list
11
- const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', 'libraries', 'disposable-domains.json'));
12
- const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
13
- const { inferContact } = require(path.join(__dirname, '..', '..', '..', 'libraries', 'infer-contact.js'));
6
+ const { validate: validateEmail, ALL_CHECKS } = require('../../../libraries/email/validation.js');
7
+ const { inferContact } = require('../../../libraries/infer-contact.js');
8
+ const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
14
9
 
15
10
  module.exports = async ({ assistant, Manager, settings, analytics }) => {
16
11
 
@@ -28,21 +23,44 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
28
23
 
29
24
  // Admin-only options
30
25
  const tags = isAdmin ? settings.tags : [];
31
- const providers = isAdmin ? settings.providers : ['sendgrid', 'beehiiv'];
26
+ const providers = isAdmin ? settings.providers : DEFAULT_PROVIDERS;
32
27
  const skipValidation = isAdmin ? settings.skipValidation : false;
33
28
 
34
- // Validate email is provided
35
- if (!email) {
36
- return assistant.respond('Email is required', { code: 400 });
37
- }
29
+ // Email validation run free checks before reCAPTCHA/rate limit
30
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
31
+
32
+ // skipValidation (admin-only) reduces to just format + disposable
33
+ // Admin gets full checks including mailbox verification when external APIs are enabled
34
+ const checks = skipValidation
35
+ ? ['format']
36
+ : (isAdmin && shouldCallExternalAPIs ? ALL_CHECKS : undefined);
37
+
38
+ const validation = await validateEmail(email, { checks });
39
+
40
+ if (!validation.valid) {
41
+ // For public requests, return generic success to prevent email enumeration
42
+ if (!isAdmin) {
43
+ return assistant.respond({ success: true });
44
+ }
45
+
46
+ const { format, localPart, disposable } = validation.checks;
47
+
48
+ if (format && !format.valid) {
49
+ return assistant.respond('Invalid email format', { code: 400 });
50
+ }
51
+
52
+ if (localPart && !localPart.valid) {
53
+ return assistant.respond(`Blocked email local part: ${localPart.localPart}`, { code: 400 });
54
+ }
38
55
 
39
- // Basic email format validation
40
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
41
- if (!emailRegex.test(email)) {
42
- return assistant.respond('Invalid email format', { code: 400 });
56
+ if (disposable && !disposable.valid) {
57
+ return assistant.respond(`Disposable email domain not allowed: ${disposable.domain}`, { code: 400 });
58
+ }
59
+
60
+ return assistant.respond('Email validation failed', { code: 400 });
43
61
  }
44
62
 
45
- // Public access protection
63
+ // Public access protection (after validation so we don't waste reCAPTCHA on garbage)
46
64
  if (!isAdmin) {
47
65
  // Verify reCAPTCHA (skip during automated tests)
48
66
  if (!assistant.isTesting()) {
@@ -67,37 +85,6 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
67
85
  }
68
86
  }
69
87
 
70
- // Email validation
71
- const validation = { valid: true, checks: {} };
72
-
73
- // Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
74
- const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
75
-
76
- if (!skipValidation) {
77
- // Check disposable domain
78
- const domain = email.split('@')[1];
79
- if (DISPOSABLE_SET.has(domain.toLowerCase())) {
80
- validation.valid = false;
81
- validation.checks.disposable = { blocked: true, domain };
82
-
83
- // For public requests, return generic success to prevent enumeration
84
- if (!isAdmin) {
85
- return assistant.respond({ success: true });
86
- }
87
- return assistant.respond(`Disposable email domain not allowed: ${domain}`, { code: 400 });
88
- }
89
- validation.checks.disposable = { blocked: false };
90
-
91
- // ZeroBounce validation (admin only, if key exists)
92
- if (isAdmin && process.env.ZEROBOUNCE_API_KEY && shouldCallExternalAPIs) {
93
- const zbResult = await validateWithZeroBounce(email);
94
- validation.checks.zerobounce = zbResult;
95
- if (!zbResult.valid) {
96
- validation.valid = false;
97
- }
98
- }
99
- }
100
-
101
88
  // Infer name if not provided
102
89
  let nameInferred = null;
103
90
  if (!firstName && !lastName) {
@@ -107,35 +94,19 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
107
94
  }
108
95
 
109
96
  // Add to providers
110
- const providerResults = {};
97
+ let providerResults = {};
111
98
 
112
99
  if (!shouldCallExternalAPIs) {
113
100
  assistant.log('marketing/contact: Skipping providers (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
114
101
  } else {
115
- assistant.log('marketing/contact: Adding contact to providers:', { providers });
116
-
117
- // SendGrid Marketing Contacts
118
- if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
119
- providerResults.sendgrid = await addToSendGrid({
120
- email,
121
- firstName,
122
- lastName,
123
- source,
124
- appId: Manager.config.app.id,
125
- brandName: Manager.config.brand?.name,
126
- });
127
- }
128
-
129
- // Beehiiv
130
- if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
131
- providerResults.beehiiv = await addToBeehiiv({
132
- email,
133
- firstName,
134
- lastName,
135
- source,
136
- brandName: Manager.config.brand?.name,
137
- });
138
- }
102
+ const mailer = Manager.Email(assistant);
103
+ providerResults = await mailer.add({
104
+ email,
105
+ firstName,
106
+ lastName,
107
+ source,
108
+ providers,
109
+ });
139
110
  }
140
111
 
141
112
  // Log result
@@ -162,227 +133,3 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
162
133
  // Public: generic response
163
134
  return assistant.respond({ success: true });
164
135
  };
165
-
166
- // Helper: Validate email with ZeroBounce API
167
- async function validateWithZeroBounce(email) {
168
- try {
169
- const data = await fetch(
170
- `https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
171
- { response: 'json', timeout: 10000 }
172
- );
173
-
174
- if (data.error) {
175
- console.error('ZeroBounce API error:', data.error);
176
- return { valid: true, error: data.error };
177
- }
178
-
179
- if (!data.status) {
180
- console.error('ZeroBounce unexpected response:', data);
181
- return { valid: true, error: 'Unexpected response format' };
182
- }
183
-
184
- return {
185
- valid: data.status === 'valid',
186
- status: data.status,
187
- subStatus: data.sub_status || null,
188
- };
189
- } catch (e) {
190
- console.error('ZeroBounce validation error:', e);
191
- return { valid: true, error: e.message };
192
- }
193
- }
194
-
195
- // Helper: Add contact to SendGrid
196
- async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
197
- try {
198
- const listId = await getSendGridListId(brandName);
199
-
200
- const requestBody = {
201
- contacts: [
202
- {
203
- email,
204
- first_name: firstName || undefined,
205
- last_name: lastName || undefined,
206
- custom_fields: {
207
- e1_T: source,
208
- e2_T: appId,
209
- },
210
- },
211
- ],
212
- };
213
-
214
- if (listId) {
215
- requestBody.list_ids = [listId];
216
- }
217
-
218
- const data = await fetch('https://api.sendgrid.com/v3/marketing/contacts', {
219
- method: 'put',
220
- response: 'json',
221
- headers: {
222
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
223
- },
224
- timeout: 15000,
225
- body: requestBody,
226
- });
227
-
228
- if (data.job_id) {
229
- return { success: true, jobId: data.job_id, listId };
230
- }
231
-
232
- return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
233
- } catch (e) {
234
- console.error('SendGrid error:', e);
235
- return { success: false, error: e.message };
236
- }
237
- }
238
-
239
- // Helper: Get SendGrid list ID by brand name
240
- async function getSendGridListId(brandName) {
241
- const brandNameLower = (brandName || '').toLowerCase();
242
- const allLists = [];
243
- let pageToken = '';
244
- const pageSize = 1000;
245
-
246
- try {
247
- while (true) {
248
- const url = `https://api.sendgrid.com/v3/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
249
- const data = await fetch(url, {
250
- response: 'json',
251
- headers: {
252
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
253
- },
254
- timeout: 10000,
255
- });
256
-
257
- if (!data.result || data.result.length === 0) {
258
- break;
259
- }
260
-
261
- const matchedList = data.result.find(list =>
262
- list.name.toLowerCase() === brandNameLower
263
- || list.name.toLowerCase().includes(brandNameLower)
264
- || brandNameLower.includes(list.name.toLowerCase())
265
- );
266
-
267
- if (matchedList) {
268
- return matchedList.id;
269
- }
270
-
271
- allLists.push(...data.result);
272
-
273
- if (!data._metadata?.next) {
274
- break;
275
- }
276
-
277
- const nextUrl = new URL(data._metadata.next);
278
- pageToken = nextUrl.searchParams.get('page_token');
279
-
280
- if (!pageToken) {
281
- break;
282
- }
283
- }
284
-
285
- if (allLists.length === 1) {
286
- return allLists[0].id;
287
- }
288
-
289
- if (allLists.length > 0) {
290
- console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
291
- }
292
- } catch (e) {
293
- console.error('SendGrid list lookup error:', e);
294
- }
295
-
296
- return null;
297
- }
298
-
299
- // Helper: Add contact to Beehiiv
300
- async function addToBeehiiv({ email, firstName, lastName, source, brandName }) {
301
- try {
302
- const pubId = await getBeehiivPublicationId(brandName);
303
- if (!pubId) {
304
- return { success: false, error: 'Could not find matching publication' };
305
- }
306
-
307
- const data = await fetch(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`, {
308
- method: 'post',
309
- response: 'json',
310
- headers: {
311
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
312
- },
313
- timeout: 15000,
314
- body: {
315
- email,
316
- reactivate_existing: true,
317
- send_welcome_email: true,
318
- utm_source: source,
319
- custom_fields: [
320
- firstName ? { name: 'first_name', value: firstName } : null,
321
- lastName ? { name: 'last_name', value: lastName } : null,
322
- ].filter(Boolean),
323
- },
324
- });
325
-
326
- if (data.data?.id) {
327
- return { success: true, id: data.data.id };
328
- }
329
-
330
- return { success: false, error: data.message || 'Unknown error' };
331
- } catch (e) {
332
- console.error('Beehiiv error:', e);
333
- return { success: false, error: e.message };
334
- }
335
- }
336
-
337
- // Helper: Get Beehiiv publication ID by brand name
338
- async function getBeehiivPublicationId(brandName) {
339
- if (!brandName) {
340
- console.error('Beehiiv: Brand name is required to find publication');
341
- return null;
342
- }
343
-
344
- const brandNameLower = brandName.toLowerCase();
345
- const allPublications = [];
346
- let page = 1;
347
- const limit = 100;
348
-
349
- try {
350
- while (true) {
351
- const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
352
- response: 'json',
353
- headers: {
354
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
355
- },
356
- timeout: 10000,
357
- });
358
-
359
- if (!data.data || data.data.length === 0) {
360
- break;
361
- }
362
-
363
- const matchedPub = data.data.find(pub =>
364
- pub.name.toLowerCase() === brandNameLower
365
- || pub.name.toLowerCase().includes(brandNameLower)
366
- || brandNameLower.includes(pub.name.toLowerCase())
367
- );
368
-
369
- if (matchedPub) {
370
- return matchedPub.id;
371
- }
372
-
373
- allPublications.push(...data.data);
374
-
375
- if (data.data.length < limit) {
376
- break;
377
- }
378
-
379
- page++;
380
- }
381
-
382
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
383
- } catch (e) {
384
- console.error('Beehiiv publication lookup error:', e);
385
- }
386
-
387
- return null;
388
- }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * PUT /marketing/contact - Sync marketing contact by UID
3
+ * Admin-only endpoint to re-sync a user's data to marketing providers
4
+ */
5
+
6
+ module.exports = async ({ assistant, Manager, settings, analytics }) => {
7
+
8
+ // Initialize Usage to check auth level
9
+ const usage = await Manager.Usage().init(assistant, {
10
+ unauthenticatedMode: 'firestore',
11
+ });
12
+ const isAdmin = usage.user.roles?.admin;
13
+
14
+ // Admin only endpoint
15
+ if (!isAdmin) {
16
+ return assistant.respond('Admin access required', { code: 403 });
17
+ }
18
+
19
+ const uid = (settings.uid || '').trim();
20
+
21
+ if (!uid) {
22
+ return assistant.respond('UID is required', { code: 400 });
23
+ }
24
+
25
+ // Sync via email library (accepts UID string, resolves user doc internally)
26
+ const mailer = Manager.Email(assistant);
27
+ const result = await mailer.sync(uid);
28
+
29
+ // Log result
30
+ assistant.log('marketing/contact sync result:', { uid, providers: result });
31
+
32
+ // Track analytics
33
+ analytics.event('marketing/contact', { action: 'sync' });
34
+
35
+ return assistant.respond({
36
+ success: true,
37
+ providers: result,
38
+ });
39
+ };
@@ -32,6 +32,17 @@ module.exports = async ({ assistant, user, settings }) => {
32
32
  return assistant.respond('No active paid subscription found', { code: 400 });
33
33
  }
34
34
 
35
+ // Guard: subscription younger than 24 hours
36
+ const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
37
+ if (startDateUNIX) {
38
+ const ageMs = Date.now() - startDateUNIX;
39
+ const twentyFourHoursMs = 24 * 60 * 60 * 1000;
40
+ if (ageMs < twentyFourHoursMs) {
41
+ assistant.log(`Cancel rejected: uid=${uid}, subscription is only ${Math.round(ageMs / 1000 / 60)} minutes old`);
42
+ return assistant.respond('Your subscription is still being set up. Please try again after 24-48 hours.', { code: 400 });
43
+ }
44
+ }
45
+
35
46
  // Guard: already pending cancellation
36
47
  if (subscription.cancellation?.pending === true) {
37
48
  assistant.log(`Cancel rejected: uid=${uid}, cancellation already pending`);
@@ -3,12 +3,12 @@
3
3
  * Returns client configuration with optional auth
4
4
  */
5
5
  const path = require('path');
6
- const { buildPublicConfig } = require(path.join(__dirname, '..', '..', 'app', 'get.js'));
6
+ const { buildPublicConfig } = require(path.join(__dirname, '..', '..', 'brand', 'get.js'));
7
7
 
8
8
  module.exports = async ({ assistant, Manager, settings, analytics, libraries }) => {
9
9
  const { admin } = libraries;
10
10
 
11
- // appId/app fallback to Manager.config
11
+ // brandId/brand fallback to Manager.config
12
12
  let uid = settings.uid;
13
13
  let config = settings.config;
14
14
 
@@ -52,7 +52,7 @@ module.exports = async ({ assistant, Manager, settings, analytics, libraries })
52
52
  timestamp: new Date().toISOString(),
53
53
  ip: assistant.request.geolocation.ip,
54
54
  country: assistant.request.geolocation.country,
55
- app: buildPublicConfig(Manager.config),
55
+ brand: buildPublicConfig(Manager.config),
56
56
  config: config,
57
57
  });
58
58
  };
@@ -6,6 +6,7 @@ module.exports = async ({ assistant, Manager }) => {
6
6
  environment: assistant.meta?.environment || 'unknown',
7
7
  version: Manager.package?.version || 'unknown',
8
8
  bemVersion: Manager.version || 'unknown',
9
+ testExtendedMode: !!process.env.TEST_EXTENDED_MODE,
9
10
  };
10
11
 
11
12
  assistant.log('Health check', response);
@@ -50,11 +50,11 @@ function sendCancellationEmail(assistant, user, requestId) {
50
50
  const uid = user.auth.uid;
51
51
 
52
52
  mailer.send({
53
- to: user.auth.email,
53
+ to: user,
54
+ sender: 'account',
54
55
  categories: ['account/data-request-cancelled'],
55
56
  subject: `Your data request has been cancelled #${requestId}`,
56
57
  template: 'default',
57
- group: 'account',
58
58
  copy: true,
59
59
  data: {
60
60
  email: {
@@ -193,11 +193,11 @@ function sendDownloadEmail(assistant, user, requestId, downloads) {
193
193
  const downloadDate = assistant.meta.startTime.timestamp;
194
194
 
195
195
  mailer.send({
196
- to: user.auth.email,
196
+ to: user,
197
+ sender: 'account',
197
198
  categories: ['account/data-request-download'],
198
199
  subject: `Your data has been downloaded #${requestId}`,
199
200
  template: 'default',
200
- group: 'account',
201
201
  copy: true,
202
202
  data: {
203
203
  email: {