backend-manager 5.1.2 → 5.2.0

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 (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. package/test/routes/user/signup.js +114 -0
@@ -24,6 +24,7 @@ const { filterSources } = require('./lib/filter.js');
24
24
  const { generateStructure } = require('./lib/structure.js');
25
25
  const { generateSectionImage } = require('./lib/svg-illustrator.js');
26
26
  const { renderNewsletter } = require('./lib/mjml-template.js');
27
+ const { renderMarkdown } = require('./lib/markdown-renderer.js');
27
28
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
28
29
 
29
30
  /**
@@ -76,7 +77,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
76
77
  return null;
77
78
  }
78
79
 
79
- const parentUrl = Manager.config?.parent;
80
+ const parentUrl = Manager.getParentApiUrl();
80
81
 
81
82
  if (!parentUrl) {
82
83
  assistant.log('Newsletter generator: no parent URL configured');
@@ -208,16 +209,34 @@ async function generate(Manager, assistant, settings, opts = {}) {
208
209
  sponsorships,
209
210
  });
210
211
 
211
- // 3b. Upload the rendered HTML to GitHub alongside the images. Lives in the
212
- // same {brandId}/{campaignId}/ folder as newsletter.html. The folder URL
213
- // becomes the canonical archive of the issue, browseable + downloadable.
212
+ // 3a. Programmatic markdown view same `structure`, walked by code (no AI).
213
+ // The markdown is split into ## blocks per section so it can be pasted
214
+ // into Beehiiv's block editor with ad blocks inserted between dispatches.
215
+ // `summary` is a separate short editorial recap, written by the AI
216
+ // during the structure step.
217
+ const markdown = renderMarkdown({
218
+ structure,
219
+ brand,
220
+ imagePaths,
221
+ sponsorships,
222
+ });
223
+
224
+ const summaryText = (structure.summary || '').trim();
225
+
226
+ // 3b. Upload the rendered HTML + markdown + summary to GitHub alongside the
227
+ // images. All four kinds live in the same {brandId}/{campaignId}/ folder.
228
+ // The folder URL becomes the canonical archive of the issue.
214
229
  let assetsFolderUrl = null;
215
230
  let htmlUrl = null;
231
+ let markdownUrl = null;
232
+ let summaryUrl = null;
216
233
 
217
234
  if (host === 'github') {
218
235
  try {
219
236
  const upload = await uploadAssets({
220
237
  html,
238
+ markdown,
239
+ summary: summaryText || undefined,
221
240
  brandId: brand?.id,
222
241
  campaignId,
223
242
  subject: structure.subject,
@@ -225,6 +244,8 @@ async function generate(Manager, assistant, settings, opts = {}) {
225
244
  });
226
245
  assetsFolderUrl = upload.folderUrl;
227
246
  htmlUrl = upload.htmlUrl;
247
+ markdownUrl = upload.markdownUrl || null;
248
+ summaryUrl = upload.summaryUrl || null;
228
249
  } catch (e) {
229
250
  assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
230
251
  }
@@ -238,6 +259,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
238
259
  // succeeds regardless. beehiivConfig was already resolved at the top
239
260
  // of the function for the initial enabled-check.
240
261
  let beehiivPostId = null;
262
+ let beehiivFailureReason = null;
241
263
 
242
264
  if (host === 'github' && beehiivConfig?.enabled) {
243
265
  try {
@@ -248,6 +270,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
248
270
  subject: structure.subject,
249
271
  preheader: structure.preheader,
250
272
  content: html,
273
+ contentTags: Array.isArray(structure.tags) ? structure.tags : [],
251
274
  status: 'draft',
252
275
  });
253
276
 
@@ -256,16 +279,39 @@ async function generate(Manager, assistant, settings, opts = {}) {
256
279
  assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
257
280
  } else {
258
281
  // Expected today until Enterprise plan — log, do not throw.
259
- assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${result?.error || 'unknown'}`);
282
+ beehiivFailureReason = result?.error || 'unknown error';
283
+ assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${beehiivFailureReason}`);
260
284
  }
261
285
  } catch (e) {
286
+ beehiivFailureReason = e.message;
262
287
  assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
263
288
  }
264
289
  }
265
290
 
291
+ // 3d. Fallback alert email — sent to the brand's internal alerts inbox when
292
+ // Beehiiv draft creation fails. The newsletter is fully generated and
293
+ // archived to GitHub at this point, so the email contains everything
294
+ // needed for a human to manually upload to Beehiiv: HTML URL (one-shot
295
+ // paste), markdown URL (per-section blocks for ad insertion), summary
296
+ // URL, and the tags to set. Failure of THIS email is logged but never
297
+ // blocks the cron — the campaign doc is still written either way.
298
+ if (beehiivFailureReason && htmlUrl) {
299
+ await sendBeehiivFallbackEmail(Manager, assistant, {
300
+ brand,
301
+ subject: structure.subject,
302
+ preheader: structure.preheader,
303
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
304
+ htmlUrl,
305
+ markdownUrl,
306
+ summaryUrl,
307
+ folderUrl: assetsFolderUrl,
308
+ reason: beehiivFailureReason,
309
+ });
310
+ }
311
+
266
312
  // 4. Mark sources as used on parent server (unless caller opted out)
267
313
  if (!opts.skipClaim) {
268
- const parentUrl = Manager.config?.parent;
314
+ const parentUrl = Manager.getParentApiUrl();
269
315
 
270
316
  if (parentUrl) {
271
317
  await claimSources(parentUrl, sources, brand?.id, assistant);
@@ -308,8 +354,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
308
354
  campaignId,
309
355
  folderUrl: assetsFolderUrl,
310
356
  htmlUrl,
357
+ markdownUrl,
358
+ summaryUrl,
311
359
  imageUrls: imagePaths,
312
360
  beehiivPostId,
361
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
313
362
  } : null;
314
363
 
315
364
  return {
@@ -318,14 +367,112 @@ async function generate(Manager, assistant, settings, opts = {}) {
318
367
  preheader: structure.preheader,
319
368
  content: '', // legacy markdown field, unused when contentHtml is set
320
369
  contentHtml: html, // pre-rendered email-safe HTML
370
+ contentMarkdown: markdown, // programmatic markdown view (per-section blocks for Beehiiv paste)
371
+ summary: summaryText, // editorial recap (separate from preheader)
372
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
321
373
  structure, // structured copy for debugging / migration
322
374
  mjml, // raw MJML for debugging
323
375
  images: opts._lastImages || [], // image buffers for the iteration test to persist locally
324
- assets, // GitHub asset URLs (folder, html, images) — null in local mode
376
+ assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
325
377
  meta, // per-step provider/model/cost/timing telemetry
326
378
  };
327
379
  }
328
380
 
381
+ /**
382
+ * Send an internal alert email when Beehiiv draft creation fails so the
383
+ * brand team knows there's a ready newsletter waiting for manual upload.
384
+ *
385
+ * Uses `sender: 'internal'` which auto-resolves to `alerts@{brandDomain}` via
386
+ * the SENDERS table in email/constants.js. Recipient is the same alerts@
387
+ * address — a self-addressed operational alert, no human inbox involved.
388
+ *
389
+ * Errors here are logged but never thrown — the alert is best-effort. If
390
+ * brand.url is unset, the email is skipped entirely.
391
+ *
392
+ * @param {object} Manager
393
+ * @param {object} assistant
394
+ * @param {object} args
395
+ * @param {object} args.brand
396
+ * @param {string} args.subject - The newsletter's subject (used in the alert subject)
397
+ * @param {string} args.preheader
398
+ * @param {string[]} args.tags
399
+ * @param {string} args.htmlUrl - GitHub raw URL to the fully-rendered HTML
400
+ * @param {string} [args.markdownUrl] - GitHub raw URL to the per-section markdown
401
+ * @param {string} [args.summaryUrl] - GitHub raw URL to the 2-3 sentence summary
402
+ * @param {string} [args.folderUrl] - GitHub folder URL (browseable archive)
403
+ * @param {string} args.reason - Why Beehiiv upload failed (API error message)
404
+ */
405
+ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
406
+ // Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
407
+ // The `sender: 'internal'` SENDERS entry already resolves the FROM address
408
+ // to this; we mirror the same domain for the TO so it's a self-addressed
409
+ // operational alert (no human inbox involved).
410
+ const brandDomain = (Manager.config?.brand?.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
411
+
412
+ if (!brandDomain) {
413
+ assistant.log('Newsletter generator: Beehiiv fallback email skipped — no brand.url');
414
+ return;
415
+ }
416
+
417
+ const alertsEmail = `alerts@${brandDomain}`;
418
+
419
+ try {
420
+ const email = Manager.Email(assistant);
421
+ const messageLines = [];
422
+
423
+ messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
424
+ messageLines.push('');
425
+ messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
426
+ messageLines.push('');
427
+ messageLines.push('<strong>Newsletter details:</strong>');
428
+ messageLines.push('<ul>');
429
+ messageLines.push(`<li><strong>Subject:</strong> ${args.subject}</li>`);
430
+ messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</li>`);
431
+ if (args.tags?.length) {
432
+ messageLines.push(`<li><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
433
+ }
434
+ messageLines.push('</ul>');
435
+ messageLines.push('');
436
+ messageLines.push('<strong>Assets (manual upload links):</strong>');
437
+ messageLines.push('<ul>');
438
+ messageLines.push(`<li><strong>Full HTML</strong> (one-shot paste): <a href="${args.htmlUrl}">${args.htmlUrl}</a></li>`);
439
+ if (args.markdownUrl) {
440
+ messageLines.push(`<li><strong>Per-section markdown</strong> (paste as separate blocks, ads between): <a href="${args.markdownUrl}">${args.markdownUrl}</a></li>`);
441
+ }
442
+ if (args.summaryUrl) {
443
+ messageLines.push(`<li><strong>Summary</strong> (2-3 sentence recap): <a href="${args.summaryUrl}">${args.summaryUrl}</a></li>`);
444
+ }
445
+ if (args.folderUrl) {
446
+ messageLines.push(`<li><strong>All assets</strong>: <a href="${args.folderUrl}">${args.folderUrl}</a></li>`);
447
+ }
448
+ messageLines.push('</ul>');
449
+
450
+ await email.send({
451
+ sender: 'internal', // resolves to alerts@{brandDomain}
452
+ to: alertsEmail,
453
+ copy: false, // self-addressed operational alert — no CC/BCC clutter
454
+ subject: `Newsletter ready for manual Beehiiv upload: "${args.subject}"`,
455
+ template: 'core/card',
456
+ categories: ['marketing/newsletter-manual-upload'],
457
+ data: {
458
+ email: {
459
+ preview: `Beehiiv upload failed — newsletter awaiting manual upload from ${args.folderUrl || 'GitHub archive'}`,
460
+ },
461
+ body: {
462
+ title: 'Newsletter Ready for Manual Upload',
463
+ message: messageLines.join('\n'),
464
+ },
465
+ },
466
+ });
467
+
468
+ assistant.log(`Newsletter generator: Beehiiv fallback alert sent to ${alertsEmail}`);
469
+ } catch (e) {
470
+ // Best-effort — log and move on. We don't want a misconfigured email
471
+ // setup to break the cron's Firestore write.
472
+ assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
473
+ }
474
+ }
475
+
329
476
  /**
330
477
  * Sum tokens + cost across all steps (filter, structure, all SVGs).
331
478
  */
@@ -329,6 +329,7 @@ async function resolveSegmentIds() {
329
329
  * @param {string} [options.subject] - Email subject line (defaults to title)
330
330
  * @param {string} [options.preheader] - Email preview text
331
331
  * @param {string} [options.content] - HTML content body
332
+ * @param {Array<string>} [options.contentTags] - Topical tags attached to the post (Beehiiv `content_tags`)
332
333
  * @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
333
334
  * @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
334
335
  * @param {Array<string>} [options.segments] - Segment IDs to include
@@ -342,7 +343,7 @@ async function createPost(options) {
342
343
  return { success: false, error: 'Publication not found' };
343
344
  }
344
345
 
345
- const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
346
+ const { title, subject, preheader, content, contentTags, status, sendAt, segments, excludeSegments } = options;
346
347
 
347
348
  try {
348
349
  const body = {
@@ -355,6 +356,11 @@ async function createPost(options) {
355
356
  body.body_content = content;
356
357
  }
357
358
 
359
+ // Tags (Beehiiv field is `content_tags`, array of strings)
360
+ if (Array.isArray(contentTags) && contentTags.length) {
361
+ body.content_tags = contentTags;
362
+ }
363
+
358
364
  // Scheduling
359
365
  if (sendAt && sendAt !== 'now') {
360
366
  body.scheduled_at = new Date(sendAt).toISOString();
@@ -409,6 +415,7 @@ async function createPost(options) {
409
415
  module.exports = {
410
416
  // Resolution
411
417
  resolveSegmentIds,
418
+ getPublicationId,
412
419
 
413
420
  // Contacts
414
421
  addContact,
@@ -452,6 +452,18 @@ function resolveProduct(raw, config) {
452
452
  return { id: 'basic', name: 'Basic' };
453
453
  }
454
454
 
455
+ // Test-mode sentinel: the test processor synthesizes "_test_<id>" when no real
456
+ // Stripe product is configured. Map it back to the matching BEM product so the
457
+ // pipeline can be exercised end-to-end without real Stripe credentials.
458
+ if (typeof stripeProductId === 'string' && stripeProductId.startsWith('_test_')) {
459
+ const bemId = stripeProductId.slice('_test_'.length);
460
+ const product = config.payment.products.find((p) => p.id === bemId);
461
+ if (product) {
462
+ return { id: product.id, name: product.name || product.id };
463
+ }
464
+ return { id: 'basic', name: 'Basic' };
465
+ }
466
+
455
467
  for (const product of config.payment.products) {
456
468
  // Match current product ID
457
469
  if (product.stripe?.productId === stripeProductId) {
@@ -174,5 +174,12 @@ function resolveStripeProductId(productId, config) {
174
174
 
175
175
  const product = config.payment.products.find(p => p.id === productId);
176
176
 
177
- return product?.stripe?.productId || null;
177
+ if (!product) {
178
+ return null;
179
+ }
180
+
181
+ // Real Stripe product ID if configured, otherwise the "_test_<id>" sentinel that the
182
+ // Stripe resolver recognizes and maps back to the BEM product. Lets reconstruction
183
+ // work in brands without real Stripe (Somiibo uses PayPal, Chargebee, etc.).
184
+ return product.stripe?.productId || `_test_${product.id}`;
178
185
  }
@@ -16,8 +16,9 @@ module.exports = async ({ assistant, user, settings }) => {
16
16
  return assistant.respond('Admin required.', { code: 403 });
17
17
  }
18
18
 
19
- // Accept single email or array of emails
20
- const emails = Array.isArray(settings.emails)
19
+ // Accept single email or array of emails. Schema defaults `emails` to [], so check length:
20
+ // an empty default should NOT shadow a provided single `email`.
21
+ const emails = Array.isArray(settings.emails) && settings.emails.length > 0
21
22
  ? settings.emails
22
23
  : [settings.email];
23
24
 
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
82
82
  settings.layout = settings.layout;
83
83
  settings.date = moment(settings.date || now).subtract(1, 'days').format('YYYY-MM-DD');
84
84
  settings.id = settings.id || Math.round(new Date(now).getTime() / 1000);
85
- settings.path = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
85
+ settings.directory = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
86
86
  settings.githubUser = settings.githubUser || bemRepo.user;
87
87
  settings.githubRepo = settings.githubRepo || bemRepo.name;
88
88
 
@@ -108,7 +108,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
108
108
  );
109
109
 
110
110
  // Build post file entry
111
- const postFilename = `${settings.path}/${settings.date}-${settings.url}.md`;
111
+ settings.path = `${settings.directory}/${settings.date}-${settings.url}.md`;
112
112
  const allFiles = [
113
113
  ...imageFiles.map(img => ({
114
114
  path: img.githubPath,
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
116
116
  encoding: 'base64',
117
117
  })),
118
118
  {
119
- path: postFilename,
119
+ path: settings.path,
120
120
  content: Buffer.from(formattedContent).toString('base64'),
121
121
  encoding: 'base64',
122
122
  },
@@ -1,24 +1,118 @@
1
1
  /**
2
- * POST /marketing/email-preferences - Update email preferences
3
- * Public endpoint — no authentication required
2
+ * POST /marketing/email-preferences - Update marketing email consent
4
3
  *
5
- * Supports two actions:
6
- * - "unsubscribe": Adds email to SendGrid ASM suppression group
7
- * - "resubscribe": Removes email from SendGrid ASM suppression group
4
+ * Two supported modes:
5
+ *
6
+ * 1) Authenticated (account-page toggle):
7
+ * - User must be logged in.
8
+ * - settings.action = 'opt-in' | 'opt-out'.
9
+ * - Writes canonical consent.marketing to user doc.
10
+ * - Hits BOTH SendGrid + Beehiiv via the email library (one source of truth).
11
+ *
12
+ * 2) Anonymous (HMAC unsubscribe link from email footer):
13
+ * - No login. settings.email + settings.asmId + settings.sig + settings.action.
14
+ * - HMAC validates the link was generated by us.
15
+ * - Hits SendGrid ASM directly (legacy behavior — preserves existing one-click unsub).
16
+ * - Also writes canonical consent.marketing to user doc when email maps to a user.
8
17
  */
9
18
  const fetch = require('wonderful-fetch');
10
19
  const crypto = require('crypto');
11
20
 
12
21
  const RATE_LIMIT = 5;
13
22
 
14
- module.exports = async ({ assistant, Manager, settings, analytics }) => {
23
+ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
24
+
25
+ // --- AUTHENTICATED MODE ---
26
+ if (user.authenticated) {
27
+ return handleAuthenticated({ assistant, Manager, user, settings, analytics });
28
+ }
29
+
30
+ // --- ANONYMOUS HMAC MODE ---
31
+ return handleAnonymous({ assistant, Manager, settings, analytics });
32
+ };
33
+
34
+ /**
35
+ * Authenticated user toggling marketing on/off from the account page.
36
+ */
37
+ async function handleAuthenticated({ assistant, Manager, user, settings, analytics }) {
38
+ const { admin } = Manager.libraries;
39
+ const action = settings.action;
40
+
41
+ if (action !== 'subscribe' && action !== 'unsubscribe') {
42
+ return assistant.respond('Invalid action — must be "subscribe" or "unsubscribe"', { code: 400 });
43
+ }
44
+
45
+ // Rate-limit per-user (defense against accidental toggling spam)
46
+ const usage = await Manager.Usage().init(assistant);
47
+ const currentUsage = usage.getUsage('email-preferences');
48
+ if (currentUsage >= RATE_LIMIT) {
49
+ return assistant.respond('Rate limit exceeded', { code: 429 });
50
+ }
51
+ usage.increment('email-preferences');
52
+ await usage.update();
53
+
54
+ const uid = user.auth.uid;
55
+ const email = user.auth.email;
56
+ const ip = assistant.request.geolocation?.ip || null;
57
+ const timestamp = assistant.meta.startTime.timestamp;
58
+ const timestampUNIX = assistant.meta.startTime.timestampUNIX;
59
+
60
+ // Build the consent.marketing mutation
61
+ const marketingPatch = action === 'subscribe'
62
+ ? {
63
+ status: 'granted',
64
+ grantedAt: { timestamp, timestampUNIX, source: 'account', ip, text: null },
65
+ // Leave revokedAt untouched — informational record of most recent revoke
66
+ }
67
+ : {
68
+ status: 'revoked',
69
+ revokedAt: { timestamp, timestampUNIX, source: 'account', ip, text: null },
70
+ // Leave grantedAt untouched — informational record of original grant
71
+ };
72
+
73
+ await admin.firestore().doc(`users/${uid}`).set({
74
+ consent: { marketing: marketingPatch },
75
+ metadata: Manager.Metadata().set({ tag: 'marketing/email-preferences' }),
76
+ }, { merge: true });
77
+
78
+ assistant.log(`email-preferences (auth): ${uid} → ${action}`);
79
+
80
+ // Skip provider calls in test mode unless extended mode is on
81
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
82
+
83
+ if (shouldCallExternalAPIs) {
84
+ const mailer = Manager.Email(assistant);
85
+
86
+ try {
87
+ if (action === 'unsubscribe') {
88
+ await mailer.remove(email);
89
+ } else {
90
+ await mailer.sync(uid);
91
+ }
92
+ } catch (e) {
93
+ assistant.error(`email-preferences (auth) provider sync failed:`, e);
94
+ // Doc is already updated — provider sync is best-effort. Don't fail the request.
95
+ }
96
+ } else {
97
+ assistant.log('email-preferences (auth): Skipping provider calls (BEM_TESTING=true)');
98
+ }
99
+
100
+ analytics.event('marketing/email-preferences', { action, mode: 'authenticated' });
101
+
102
+ return assistant.respond({ success: true, action });
103
+ }
104
+
105
+ /**
106
+ * Anonymous HMAC unsubscribe link (preserves existing email-footer one-click flow).
107
+ */
108
+ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
109
+ const { admin } = Manager.libraries;
15
110
 
16
- // Extract parameters
17
111
  const email = (settings.email || '').trim().toLowerCase();
18
112
  const asmId = parseInt(settings.asmId, 10);
19
113
  const action = settings.action || 'unsubscribe';
20
114
 
21
- // Validate email
115
+ // Validate inputs
22
116
  if (!email) {
23
117
  return assistant.respond('Email is required', { code: 400 });
24
118
  }
@@ -28,29 +122,25 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
28
122
  return assistant.respond('Invalid email format', { code: 400 });
29
123
  }
30
124
 
31
- // Validate ASM group ID
32
125
  if (!asmId || isNaN(asmId)) {
33
126
  return assistant.respond('ASM group ID is required', { code: 400 });
34
127
  }
35
128
 
36
- // Validate action
37
- if (action !== 'unsubscribe' && action !== 'resubscribe') {
129
+ if (action !== 'subscribe' && action !== 'unsubscribe') {
38
130
  return assistant.respond('Invalid action', { code: 400 });
39
131
  }
40
132
 
41
- // Verify HMAC signature (proves the link was generated by our server)
133
+ // HMAC validation (proves we generated this link)
42
134
  const expectedSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email).digest('hex');
43
135
  if (settings.sig !== expectedSig) {
44
136
  return assistant.respond('Invalid signature', { code: 403 });
45
137
  }
46
138
 
47
- // Initialize Usage for rate limiting (key: IP forces unauthenticated storage always)
139
+ // IP rate limiting (anonymous flow unauthenticated firestore storage)
48
140
  const usage = await Manager.Usage().init(assistant, {
49
141
  unauthenticatedMode: 'firestore',
50
142
  key: assistant.request.geolocation.ip,
51
143
  });
52
-
53
- // Rate limiting (manual check since email-preferences isn't in product limits)
54
144
  const currentUsage = usage.getUsage('email-preferences');
55
145
  if (currentUsage >= RATE_LIMIT) {
56
146
  return assistant.respond('Rate limit exceeded', { code: 429 });
@@ -58,37 +148,34 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
58
148
  usage.increment('email-preferences');
59
149
  await usage.update();
60
150
 
61
- // Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
151
+ // Mirror to the user doc if this email maps to a user (best-effort, silent on miss)
152
+ await mirrorAnonymousToUserDoc({ assistant, Manager, email, action });
153
+
154
+ // Call SendGrid ASM (legacy behavior)
62
155
  const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
63
156
 
64
157
  if (!shouldCallExternalAPIs) {
65
- assistant.log('marketing/email-preferences: Skipping SendGrid (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
158
+ assistant.log('email-preferences (anon): Skipping SendGrid (BEM_TESTING=true)');
66
159
  return assistant.respond({ success: true });
67
160
  }
68
161
 
69
- // Call SendGrid ASM API
70
162
  try {
71
163
  if (action === 'unsubscribe') {
72
- // Add email to suppression group
164
+ // POST returns JSON ({recipient_emails: [...]}) on success
73
165
  await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions`, {
74
166
  method: 'POST',
75
167
  response: 'json',
76
- headers: {
77
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
78
- },
168
+ headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
79
169
  timeout: 10000,
80
- body: {
81
- recipient_emails: [email],
82
- },
170
+ body: { recipient_emails: [email] },
83
171
  });
84
172
  } else {
85
- // Remove email from suppression group
173
+ // DELETE returns 204 with empty body — must NOT request response: 'json'
174
+ // or wonderful-fetch throws SyntaxError on the empty body.
86
175
  await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions/${encodeURIComponent(email)}`, {
87
176
  method: 'DELETE',
88
- response: 'json',
89
- headers: {
90
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
91
- },
177
+ response: 'text',
178
+ headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
92
179
  timeout: 10000,
93
180
  });
94
181
  }
@@ -97,12 +184,53 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
97
184
  return assistant.respond('Failed to process your request', { code: 500 });
98
185
  }
99
186
 
100
- // Log result
101
- assistant.log('marketing/email-preferences result:', { email, asmId, action });
102
-
103
- // Track analytics
104
- analytics.event('marketing/email-preferences', { action });
187
+ assistant.log('email-preferences (anon) result:', { email, asmId, action });
188
+ analytics.event('marketing/email-preferences', { action, mode: 'anonymous' });
105
189
 
106
- // Generic success (no sensitive info leakage)
107
190
  return assistant.respond({ success: true });
108
- };
191
+ }
192
+
193
+ /**
194
+ * Anonymous HMAC unsub also writes to the user doc (if found) so consent stays in sync.
195
+ * Silent if the email doesn't map to a user. Source is recorded as 'sendgrid' since the
196
+ * HMAC link only fires from SendGrid email footers.
197
+ */
198
+ async function mirrorAnonymousToUserDoc({ assistant, Manager, email, action }) {
199
+ const { admin } = Manager.libraries;
200
+
201
+ const snapshot = await admin.firestore().collection('users')
202
+ .where('auth.email', '==', email)
203
+ .limit(1)
204
+ .get()
205
+ .catch((e) => {
206
+ assistant.error('email-preferences (anon): Failed to look up user by email:', e);
207
+ return null;
208
+ });
209
+
210
+ if (!snapshot || snapshot.empty) {
211
+ return; // Silent — email may not map to a current user
212
+ }
213
+
214
+ const userDoc = snapshot.docs[0];
215
+ const uid = userDoc.id;
216
+ const timestamp = assistant.meta.startTime.timestamp;
217
+ const timestampUNIX = assistant.meta.startTime.timestampUNIX;
218
+
219
+ const marketingPatch = action === 'unsubscribe'
220
+ ? {
221
+ status: 'revoked',
222
+ revokedAt: { timestamp, timestampUNIX, source: 'sendgrid', ip: null, text: null },
223
+ }
224
+ : {
225
+ status: 'granted',
226
+ grantedAt: { timestamp, timestampUNIX, source: 'sendgrid', ip: null, text: null },
227
+ };
228
+
229
+ await admin.firestore().doc(`users/${uid}`).set({
230
+ consent: { marketing: marketingPatch },
231
+ metadata: Manager.Metadata().set({ tag: 'marketing/email-preferences' }),
232
+ }, { merge: true })
233
+ .catch((e) => {
234
+ assistant.error(`email-preferences (anon): Failed to mirror to user doc ${uid}:`, e);
235
+ });
236
+ }