backend-manager 5.0.202 → 5.1.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 (68) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CLAUDE.md +43 -1501
  3. package/docs/admin-post-route.md +24 -0
  4. package/docs/ai-library.md +23 -0
  5. package/docs/architecture.md +31 -0
  6. package/docs/auth-hooks.md +74 -0
  7. package/docs/cli-firestore-auth.md +59 -0
  8. package/docs/cli-logs.md +67 -0
  9. package/docs/code-patterns.md +67 -0
  10. package/docs/common-operations.md +64 -0
  11. package/docs/directory-structure.md +119 -0
  12. package/docs/environment-detection.md +7 -0
  13. package/docs/file-naming.md +11 -0
  14. package/docs/marketing-campaigns.md +244 -0
  15. package/docs/marketing-fields.md +25 -0
  16. package/docs/mcp.md +95 -0
  17. package/docs/payment-system.md +325 -0
  18. package/docs/response-headers.md +7 -0
  19. package/docs/routes.md +126 -0
  20. package/docs/sanitization.md +61 -0
  21. package/docs/schemas.md +39 -0
  22. package/docs/stripe-webhook-forwarding.md +18 -0
  23. package/docs/testing.md +129 -0
  24. package/docs/usage-rate-limiting.md +67 -0
  25. package/package.json +8 -4
  26. package/src/defaults/CHANGELOG.md +15 -0
  27. package/src/defaults/CLAUDE.md +8 -4
  28. package/src/defaults/docs/README.md +17 -0
  29. package/src/defaults/test/README.md +33 -0
  30. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  31. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  32. package/src/manager/helpers/settings.js +26 -7
  33. package/src/manager/helpers/utilities.js +21 -0
  34. package/src/manager/index.js +1 -1
  35. package/src/manager/libraries/ai/index.js +162 -0
  36. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  37. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  38. package/src/manager/libraries/ai/providers/openai.js +934 -0
  39. package/src/manager/libraries/disposable-domains.json +2 -0
  40. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  41. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  42. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  43. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  44. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  45. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  46. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  47. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  48. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  49. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  50. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  51. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  52. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  53. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  54. package/src/manager/libraries/email/marketing/index.js +5 -2
  55. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  56. package/src/manager/libraries/openai.js +13 -932
  57. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  58. package/src/manager/routes/admin/post/post.js +10 -17
  59. package/templates/_.env +4 -0
  60. package/templates/_.gitignore +1 -0
  61. package/templates/backend-manager-config.json +48 -4
  62. package/test/helpers/slugify.js +394 -0
  63. package/test/marketing/fixtures/clean.json +31 -0
  64. package/test/marketing/fixtures/editorial.json +31 -0
  65. package/test/marketing/fixtures/field-report.json +54 -0
  66. package/test/marketing/newsletter-generate.js +731 -0
  67. package/test/marketing/newsletter-templates.js +512 -0
  68. package/test/routes/admin/deduplicate-image-alts.js +190 -0
@@ -1,79 +1,361 @@
1
1
  /**
2
2
  * Newsletter generator — pulls content from parent server and assembles a branded newsletter.
3
3
  *
4
- * Called by the marketing-campaigns cron when a campaign has `generator: 'newsletter'`.
5
- * Instead of sending the campaign directly, this generates the content first,
6
- * then returns the assembled settings for the cron to send.
4
+ * Called by the daily pre-generation cron (and the iteration test) when a campaign has
5
+ * `generator: 'newsletter'`. Produces a fully rendered email-safe HTML newsletter ready
6
+ * to ship to Beehiiv / SendGrid.
7
7
  *
8
- * Flow:
9
- * 1. Read newsletter categories from Manager.config.marketing.newsletter.categories
10
- * 2. Fetch ready sources from parent server (GET /newsletter/sources)
11
- * 3. AI assembles sources into branded markdown newsletter
12
- * 4. Mark sources as used on parent server (PUT /newsletter/sources)
13
- * 5. Return assembled settings with content filled in
8
+ * Pipeline:
9
+ * 1. Read newsletter categories from Manager.config.marketing.beehiiv.content.categories
10
+ * 2. Fetch ready sources from parent server (atomic claim via claimFor=brandId)
11
+ * 3. structure.js → AI authors subject, preheader, intro, sections, signoff
12
+ * 4. svg-illustrator.js AI authors one SVG per section, rasterize to PNG
13
+ * 5. mjml-template.js compile MJML email-safe HTML
14
+ * 6. Persist PNGs (caller-provided via opts.persistImage or default no-op)
15
+ * 7. Mark sources as used on parent server
16
+ *
17
+ * Returns { subject, preheader, content, contentHtml, structure, images } so the
18
+ * marketing library can either send the HTML directly (contentHtml) or fall back to
19
+ * the markdown pipeline (content) if needed.
14
20
  */
15
21
  const fetch = require('wonderful-fetch');
16
22
 
23
+ const { filterSources } = require('./lib/filter.js');
24
+ const { generateStructure } = require('./lib/structure.js');
25
+ const { generateSectionImage } = require('./lib/svg-illustrator.js');
26
+ const { renderNewsletter } = require('./lib/mjml-template.js');
27
+ const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
28
+
17
29
  /**
18
30
  * Generate newsletter content from parent server sources.
19
31
  *
20
32
  * @param {object} Manager - BEM Manager instance
21
33
  * @param {object} assistant - BEM assistant instance
22
34
  * @param {object} settings - Campaign settings from the recurring template
23
- * @returns {object} Updated settings with content filled in, or null if no content available
35
+ * @param {object} [opts] - Optional overrides used by the iteration test
36
+ * @param {function} [opts.persistImage] - async (image, idx) => imagePath (URL or relative path).
37
+ * When provided, takes precedence over imageHost — used by the
38
+ * iteration test to write PNGs locally for preview.
39
+ * @param {'github'|'local'} [opts.imageHost] - Where to host images for the final HTML.
40
+ * Defaults to 'github' (uploads to itw-creative-works/newsletter-assets).
41
+ * Local skips upload — only useful if persistImage is also set.
42
+ * @param {string} [opts.campaignId] - Stable ID used as the folder name in newsletter-assets.
43
+ * Defaults to the Firestore campaign doc ID if available.
44
+ * @param {object[]} [opts.sources] - Pre-fetched sources (bypasses parent server claim)
45
+ * @param {boolean} [opts.skipClaim] - Don't call PUT to mark sources as used
46
+ * @param {boolean} [opts.skipImages] - Skip SVG/PNG generation (use placeholders)
47
+ * @returns {object|null} Updated settings with content filled in, or null if unavailable
24
48
  */
25
- async function generate(Manager, assistant, settings) {
26
- const config = Manager.config?.marketing?.newsletter;
49
+ async function generate(Manager, assistant, settings, opts = {}) {
50
+ // Content pipeline config lives under the provider that publishes the result.
51
+ // For newsletters, that's beehiiv (`marketing.beehiiv.content`). The whole
52
+ // pipeline is gated by beehiiv.enabled — disabling beehiiv disables newsletter
53
+ // generation as a side effect (correct, since there's nowhere for the
54
+ // generated content to land).
55
+ const beehiivConfig = Manager.config?.marketing?.beehiiv;
56
+ const config = beehiivConfig?.content;
27
57
 
28
- if (!config?.enabled) {
29
- assistant.log('Newsletter generator: disabled in config');
58
+ if (!beehiivConfig?.enabled) {
59
+ assistant.log('Newsletter generator: beehiiv disabled in config');
30
60
  return null;
31
61
  }
32
62
 
33
- const categories = config.categories || [];
34
-
35
- if (!categories.length) {
36
- assistant.log('Newsletter generator: no categories configured');
63
+ if (!config) {
64
+ assistant.log('Newsletter generator: no marketing.beehiiv.content config block');
37
65
  return null;
38
66
  }
39
67
 
40
- const parentUrl = Manager.config?.parent?.apiUrl;
68
+ // Either use pre-fetched sources (iteration test) or fetch from parent
69
+ let sources = opts.sources;
41
70
 
42
- if (!parentUrl) {
43
- assistant.log('Newsletter generator: no parent API URL configured');
44
- return null;
45
- }
71
+ if (!sources) {
72
+ const categories = config.categories || [];
73
+
74
+ if (!categories.length) {
75
+ assistant.log('Newsletter generator: no categories configured');
76
+ return null;
77
+ }
78
+
79
+ const parentUrl = Manager.config?.parent;
80
+
81
+ if (!parentUrl) {
82
+ assistant.log('Newsletter generator: no parent URL configured');
83
+ return null;
84
+ }
46
85
 
47
- // Fetch and atomically claim sources from parent server
48
- const brandId = Manager.config?.brand?.id;
49
- const sources = await fetchSources(parentUrl, categories, brandId, assistant);
86
+ const brandId = Manager.config?.brand?.id;
87
+ sources = await fetchSources(parentUrl, categories, brandId, assistant);
88
+ }
50
89
 
51
- if (!sources.length) {
90
+ if (!sources?.length) {
52
91
  assistant.log('Newsletter generator: no sources available');
53
92
  return null;
54
93
  }
55
94
 
56
- assistant.log(`Newsletter generator: ${sources.length} sources found, assembling...`);
57
-
58
95
  const brand = Manager.config?.brand;
96
+ const ai = Manager.AI(assistant);
97
+ const pipelineStart = Date.now();
59
98
 
60
- // AI assembles sources into newsletter with subject + preheader + content
61
- const assembled = await assembleNewsletter(Manager, assistant, sources, brand);
99
+ // 1. Filter drop sources that don't fit the brand
100
+ const { kept: filteredSources, scores, meta: filterMeta } = await filterSources({
101
+ sources,
102
+ brand,
103
+ newsletterConfig: config,
104
+ ai,
105
+ assistant,
106
+ threshold: opts.fitThreshold,
107
+ });
62
108
 
63
- if (!assembled) {
64
- assistant.log('Newsletter generator: AI assembly failed');
109
+ if (!filteredSources.length) {
110
+ assistant.log('Newsletter generator: no sources passed brand-fit filter, skipping');
65
111
  return null;
66
112
  }
67
113
 
68
- // Mark sources as used on parent server
69
- await claimSources(parentUrl, sources, brand?.id, assistant);
114
+ assistant.log(`Newsletter generator: assembling from ${filteredSources.length} brand-fit sources (out of ${sources.length})`);
115
+
116
+ // 2. Structure
117
+ const structure = await generateStructure({
118
+ sources: filteredSources,
119
+ brand,
120
+ newsletterConfig: config,
121
+ ai,
122
+ assistant,
123
+ });
124
+
125
+ assistant.log(`Newsletter generator: structure ready (${structure.sections.length} sections)`);
126
+
127
+ // Asset hosting target — controls what URLs end up in <img src=...> AND
128
+ // whether the rendered HTML gets uploaded too. Two values:
129
+ // - 'github': upload PNGs + newsletter.html to itw-creative-works/newsletter-assets,
130
+ // embed raw.githubusercontent.com URLs. The production cron path.
131
+ // - 'local': use whatever persistImage returns (iteration test writes to disk).
132
+ // Defaults to 'github' so production cron path "just works" without flag fiddling.
133
+ const host = opts.imageHost || 'github';
134
+
135
+ // campaignId — used as the GitHub folder name (and as the doc ID in production).
136
+ // Resolution priority (most-specific first):
137
+ // 1. opts.campaignId — explicit override (test runs, cron paths)
138
+ // 2. settings.id — marketing-campaigns/{id} doc ID in production
139
+ // 3. sources[0].id — when exactly one source is being processed
140
+ // (iteration test pinning to a single source —
141
+ // means re-running against the same source
142
+ // overwrites the same folder, no churn)
143
+ // 4. generatePushId() — final fallback for ad-hoc runs with no anchor
144
+ const campaignId = opts.campaignId
145
+ || settings?.id
146
+ || (sources.length === 1 ? sources[0].id : null)
147
+ || generatePushId();
148
+
149
+ // 2. SVG illustrations (parallel) + upload PNGs first so we have URLs
150
+ // available to embed in the HTML render below.
151
+ let imagePaths = [];
152
+
153
+ if (!opts.skipImages) {
154
+ const images = await Promise.all(
155
+ structure.sections.map((s) => generateSectionImage({
156
+ imagePrompt: s.image_prompt,
157
+ brand,
158
+ newsletterConfig: config,
159
+ ai,
160
+ assistant,
161
+ }))
162
+ );
163
+
164
+ // Run persistImage side-effects first (writes to disk, etc.). The local
165
+ // paths it returns are used as a fallback if no host produces URLs.
166
+ const persistedPaths = typeof opts.persistImage === 'function'
167
+ ? await Promise.all(images.map((img, i) => opts.persistImage(img, i)))
168
+ : null;
169
+
170
+ if (host === 'github') {
171
+ try {
172
+ const { urls } = await uploadAssets({
173
+ images,
174
+ brandId: brand?.id,
175
+ campaignId,
176
+ subject: structure.subject,
177
+ assistant,
178
+ });
179
+ imagePaths = urls;
180
+ } catch (e) {
181
+ assistant.error(`Newsletter generator: image upload failed — ${e.message}`);
182
+ imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
183
+ }
184
+ } else {
185
+ // 'local' — use persisted paths if available, else placeholder
186
+ imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
187
+ }
188
+
189
+ assistant.log(`Newsletter generator: ${images.length} images rendered`);
190
+
191
+ // Stash images on the return for callers that want to access raw buffers
192
+ opts._lastImages = images;
193
+ }
194
+
195
+ // 3. MJML → HTML
196
+ // Sponsorships precedence: opts.sponsorships > campaign settings.sponsorships > config.sponsorships
197
+ const sponsorships = opts.sponsorships
198
+ || settings?.sponsorships
199
+ || config?.sponsorships
200
+ || [];
201
+
202
+ const { html, mjml } = await renderNewsletter({
203
+ brand,
204
+ newsletterConfig: config,
205
+ structure,
206
+ imagePaths,
207
+ campaign: settings?.name || 'newsletter',
208
+ sponsorships,
209
+ });
210
+
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.
214
+ let assetsFolderUrl = null;
215
+ let htmlUrl = null;
216
+
217
+ if (host === 'github') {
218
+ try {
219
+ const upload = await uploadAssets({
220
+ html,
221
+ brandId: brand?.id,
222
+ campaignId,
223
+ subject: structure.subject,
224
+ assistant,
225
+ });
226
+ assetsFolderUrl = upload.folderUrl;
227
+ htmlUrl = upload.htmlUrl;
228
+ } catch (e) {
229
+ assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
230
+ }
231
+ }
232
+
233
+ // 3c. Upload to Beehiiv as a draft. Uses the same complete HTML with
234
+ // CDN image URLs already embedded. Today this will fail (Beehiiv's
235
+ // post-creation API requires Enterprise plan), but we ship it anyway
236
+ // so the day we upgrade to Enterprise it Just Works. Failure is logged,
237
+ // never thrown — the rest of the pipeline (GH archive, Firestore doc)
238
+ // succeeds regardless. beehiivConfig was already resolved at the top
239
+ // of the function for the initial enabled-check.
240
+ let beehiivPostId = null;
241
+
242
+ if (host === 'github' && beehiivConfig?.enabled) {
243
+ try {
244
+ const beehiivProvider = require('../providers/beehiiv.js');
245
+ const result = await beehiivProvider.createPost({
246
+ publicationId: beehiivConfig.publicationId, // explicit — avoids singleton-Manager dependency
247
+ title: structure.subject,
248
+ subject: structure.subject,
249
+ preheader: structure.preheader,
250
+ content: html,
251
+ status: 'draft',
252
+ });
253
+
254
+ if (result?.success && result.id) {
255
+ beehiivPostId = result.id;
256
+ assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
257
+ } else {
258
+ // Expected today until Enterprise plan — log, do not throw.
259
+ assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${result?.error || 'unknown'}`);
260
+ }
261
+ } catch (e) {
262
+ assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
263
+ }
264
+ }
265
+
266
+ // 4. Mark sources as used on parent server (unless caller opted out)
267
+ if (!opts.skipClaim) {
268
+ const parentUrl = Manager.config?.parent;
269
+
270
+ if (parentUrl) {
271
+ await claimSources(parentUrl, sources, brand?.id, assistant);
272
+ }
273
+ }
274
+
275
+ // Aggregate per-step metadata for telemetry / cost tracking
276
+ const meta = {
277
+ timestamp: new Date().toISOString(),
278
+ totalDurationMs: Date.now() - pipelineStart,
279
+ brand: { id: brand?.id, name: brand?.name },
280
+ config: {
281
+ categories: config.categories,
282
+ tone: config.tone,
283
+ template: config.template,
284
+ },
285
+ sources: {
286
+ total: sources.length,
287
+ filtered: filteredSources.length,
288
+ scores,
289
+ },
290
+ steps: {
291
+ filter: filterMeta || null,
292
+ structure: structure._meta || null,
293
+ images: (opts._lastImages || []).map((img, i) => ({
294
+ section: i + 1,
295
+ ...(img.meta || {}),
296
+ })),
297
+ },
298
+ totals: aggregateTotals(filterMeta, structure._meta, opts._lastImages),
299
+ };
300
+
301
+ // Public asset URLs — stamped onto the generated campaign doc by the cron
302
+ // so you can find the GitHub folder + downloadable HTML + image URLs without
303
+ // re-deriving them. Null entries mean GitHub upload was skipped (local mode)
304
+ // or failed (errors already logged above). beehiivPostId is the ID of the
305
+ // draft post created on Beehiiv (or null if creation failed — expected until
306
+ // we move off the free Beehiiv plan).
307
+ const assets = host === 'github' ? {
308
+ campaignId,
309
+ folderUrl: assetsFolderUrl,
310
+ htmlUrl,
311
+ imageUrls: imagePaths,
312
+ beehiivPostId,
313
+ } : null;
70
314
 
71
- // Return updated settings — AI-generated fields override template placeholders
72
315
  return {
73
316
  ...settings,
74
- subject: assembled.subject,
75
- preheader: assembled.preheader,
76
- content: assembled.content,
317
+ subject: structure.subject,
318
+ preheader: structure.preheader,
319
+ content: '', // legacy markdown field, unused when contentHtml is set
320
+ contentHtml: html, // pre-rendered email-safe HTML
321
+ structure, // structured copy for debugging / migration
322
+ mjml, // raw MJML for debugging
323
+ images: opts._lastImages || [], // image buffers for the iteration test to persist locally
324
+ assets, // GitHub asset URLs (folder, html, images) — null in local mode
325
+ meta, // per-step provider/model/cost/timing telemetry
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Sum tokens + cost across all steps (filter, structure, all SVGs).
331
+ */
332
+ function aggregateTotals(filterMeta, structureMeta, images) {
333
+ let inputTokens = 0;
334
+ let outputTokens = 0;
335
+ let totalCost = 0;
336
+ let aiCalls = 0;
337
+
338
+ const collect = (m) => {
339
+ if (!m?.tokens) return;
340
+ inputTokens += m.tokens.input?.count || 0;
341
+ outputTokens += m.tokens.output?.count || 0;
342
+ totalCost += m.tokens.total?.price || 0;
343
+ aiCalls++;
344
+ };
345
+
346
+ collect(filterMeta);
347
+ collect(structureMeta);
348
+
349
+ for (const img of images || []) {
350
+ collect(img.meta);
351
+ }
352
+
353
+ return {
354
+ aiCalls,
355
+ inputTokens,
356
+ outputTokens,
357
+ totalTokens: inputTokens + outputTokens,
358
+ totalCostUSD: Number(totalCost.toFixed(4)),
77
359
  };
78
360
  }
79
361
 
@@ -85,7 +367,7 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
85
367
 
86
368
  for (const category of categories) {
87
369
  try {
88
- const data = await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
370
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
89
371
  method: 'get',
90
372
  response: 'json',
91
373
  timeout: 15000,
@@ -101,71 +383,20 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
101
383
  allSources.push(...data.sources);
102
384
  }
103
385
  } catch (e) {
104
- assistant.error(`Newsletter generator: Failed to fetch ${category} sources:`, e.message);
386
+ assistant.error(`Newsletter generator: Failed to fetch ${category} sources: ${e.message}`);
105
387
  }
106
388
  }
107
389
 
108
390
  return allSources;
109
391
  }
110
392
 
111
- /**
112
- * Assemble newsletter sources into a branded newsletter via AI.
113
- * Returns { subject, preheader, content } or null on failure.
114
- */
115
- async function assembleNewsletter(Manager, assistant, sources, brand) {
116
- const ai = require('../../openai.js');
117
-
118
- const sourceSummaries = sources.map((s, i) =>
119
- `[${i + 1}] ${s.ai?.headline || s.subject}\n${s.ai?.summary || ''}\nTakeaways: ${(s.ai?.takeaways || []).join('; ')}`
120
- ).join('\n\n');
121
-
122
- try {
123
- const result = await ai.request({
124
- model: 'gpt-4o-mini',
125
- messages: [
126
- {
127
- role: 'system',
128
- content: `You are a newsletter writer for ${brand?.name || 'a tech company'}. ${brand?.description || ''}
129
-
130
- Given source articles, write a branded newsletter in markdown. Be concise, engaging, and professional.
131
-
132
- Respond in JSON:
133
- {
134
- "subject": "Catchy email subject line (max 60 chars, no emojis)",
135
- "preheader": "Preview text that complements the subject (max 100 chars)",
136
- "content": "Full newsletter body in markdown with ## section headers"
137
- }
138
-
139
- Guidelines:
140
- - Start with a brief intro (1-2 sentences)
141
- - Each source becomes a section with ## header
142
- - Rewrite in your own voice — don't copy verbatim
143
- - End with a short sign-off
144
- - Keep it scannable — use bold, bullets, short paragraphs`,
145
- },
146
- {
147
- role: 'user',
148
- content: `Write a newsletter from these ${sources.length} sources:\n\n${sourceSummaries}`,
149
- },
150
- ],
151
- response_format: { type: 'json_object' },
152
- apiKey: process.env.BACKEND_MANAGER_OPENAI_API_KEY,
153
- });
154
-
155
- return result.content;
156
- } catch (e) {
157
- assistant.error('Newsletter AI assembly failed:', e.message);
158
- return null;
159
- }
160
- }
161
-
162
393
  /**
163
394
  * Mark sources as used on the parent server.
164
395
  */
165
396
  async function claimSources(parentUrl, sources, brandId, assistant) {
166
397
  for (const source of sources) {
167
398
  try {
168
- await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
399
+ await fetch(`${parentUrl}/newsletter-sources`, {
169
400
  method: 'put',
170
401
  response: 'json',
171
402
  timeout: 10000,
@@ -176,9 +407,60 @@ async function claimSources(parentUrl, sources, brandId, assistant) {
176
407
  },
177
408
  });
178
409
  } catch (e) {
179
- assistant.error(`Newsletter generator: Failed to claim source ${source.id}:`, e.message);
410
+ assistant.error(`Newsletter generator: Failed to claim source ${source.id}: ${e.message}`);
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Generate a 20-character Firebase push ID (RTDB-style).
417
+ *
418
+ * Format: 20 chars, starts with a timestamp-encoded prefix, lexicographically
419
+ * sortable by creation time. Matches the ID scheme used by Firebase Realtime
420
+ * Database `.push()` and by ITW's `newsletter-sources/{id}`.
421
+ *
422
+ * Used when no real `marketing-campaigns/{id}` doc exists yet — typically only
423
+ * the iteration test. Production cron passes the actual Firestore ID.
424
+ *
425
+ * Algorithm reference: https://gist.github.com/mikelehen/3596a30bd69384624c11
426
+ */
427
+ let _lastPushTime = 0;
428
+ const _lastRandChars = [];
429
+ const PUSH_CHARS = '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
430
+
431
+ function generatePushId() {
432
+ let now = Date.now();
433
+ const duplicateTime = (now === _lastPushTime);
434
+ _lastPushTime = now;
435
+
436
+ const timeStampChars = new Array(8);
437
+ for (let i = 7; i >= 0; i--) {
438
+ timeStampChars[i] = PUSH_CHARS.charAt(now % 64);
439
+ now = Math.floor(now / 64);
440
+ }
441
+
442
+ let id = timeStampChars.join('');
443
+
444
+ if (!duplicateTime) {
445
+ for (let i = 0; i < 12; i++) {
446
+ _lastRandChars[i] = Math.floor(Math.random() * 64);
447
+ }
448
+ } else {
449
+ // Increment to ensure monotonicity within the same millisecond
450
+ let i = 11;
451
+ for (; i >= 0 && _lastRandChars[i] === 63; i--) {
452
+ _lastRandChars[i] = 0;
453
+ }
454
+ if (i >= 0) {
455
+ _lastRandChars[i]++;
180
456
  }
181
457
  }
458
+
459
+ for (let i = 0; i < 12; i++) {
460
+ id += PUSH_CHARS.charAt(_lastRandChars[i]);
461
+ }
462
+
463
+ return id;
182
464
  }
183
465
 
184
- module.exports = { generate };
466
+ module.exports = { generate, fetchSources, claimSources, generatePushId };
@@ -292,8 +292,11 @@ Marketing.prototype.sendCampaign = async function (settings) {
292
292
  };
293
293
  }
294
294
 
295
- // Convert markdown content to HTML, then tag links with UTM params
296
- let contentHtml = resolvedSettings.content ? md.render(resolvedSettings.content) : '';
295
+ // Use pre-rendered HTML (from a generator) if present, otherwise render markdown.
296
+ // Generators like newsletter produce email-safe HTML via MJML — skip the markdown
297
+ // pipeline entirely. tagLinks() is still applied so UTM params get injected.
298
+ let contentHtml = resolvedSettings.contentHtml
299
+ || (resolvedSettings.content ? md.render(resolvedSettings.content) : '');
297
300
 
298
301
  if (contentHtml) {
299
302
  contentHtml = tagLinks(contentHtml, {
@@ -151,8 +151,9 @@ async function getPublicationId() {
151
151
  return configPubId;
152
152
  }
153
153
 
154
- // Fuzzy-match by brand name
155
- const brandName = Manager.config.brand?.name;
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;
156
157
 
157
158
  if (!brandName) {
158
159
  console.error('Beehiiv: Brand name is required to find publication');
@@ -322,6 +323,9 @@ async function resolveSegmentIds() {
322
323
  *
323
324
  * @param {object} options
324
325
  * @param {string} options.title - Post title (required)
326
+ * @param {string} [options.publicationId] - Explicit publication ID (bypasses getPublicationId lookup).
327
+ * Preferred when the caller already knows it (e.g. newsletter.js
328
+ * reads it from marketing.beehiiv.publicationId).
325
329
  * @param {string} [options.subject] - Email subject line (defaults to title)
326
330
  * @param {string} [options.preheader] - Email preview text
327
331
  * @param {string} [options.content] - HTML content body
@@ -332,7 +336,7 @@ async function resolveSegmentIds() {
332
336
  * @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
333
337
  */
334
338
  async function createPost(options) {
335
- const publicationId = await getPublicationId();
339
+ const publicationId = options.publicationId || await getPublicationId();
336
340
 
337
341
  if (!publicationId) {
338
342
  return { success: false, error: 'Publication not found' };