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
@@ -1997,6 +1997,7 @@
1997
1997
  "gmail2.gq",
1998
1998
  "gmailot.com",
1999
1999
  "gmatch.org",
2000
+ "gmeenramy.com",
2000
2001
  "gmial.com",
2001
2002
  "gmx1mail.top",
2002
2003
  "gmxmail.top",
@@ -4041,6 +4042,7 @@
4041
4042
  "rustydoor.com",
4042
4043
  "rustyload.com",
4043
4044
  "ruu.kr",
4045
+ "ruutukf.com",
4044
4046
  "rvb.ro",
4045
4047
  "rwstatus.com",
4046
4048
  "rygel.infos.st",
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Brand-fit filter for newsletter sources.
3
+ *
4
+ * Given a pool of raw newsletter sources and a brand's `marketing.beehiiv.content`
5
+ * config, asks an AI to score each source for brand fit (0-10), then drops
6
+ * anything below the threshold. This prevents off-topic sources from leaking
7
+ * into the structure generator.
8
+ *
9
+ * Fit scoring is a single AI call (one round-trip for the whole pool), using
10
+ * a small/cheap model by default.
11
+ */
12
+ const DEFAULT_MODELS = {
13
+ openai: 'gpt-5.4-mini',
14
+ anthropic: 'claude-opus',
15
+ 'claude-code': 'claude-opus-4-7',
16
+ };
17
+
18
+ const DEFAULT_THRESHOLD = 6; // 0-10 scale; only sources scoring >= this make it through
19
+
20
+ const FILTER_SCHEMA = {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ required: ['scores'],
24
+ properties: {
25
+ scores: {
26
+ type: 'array',
27
+ items: {
28
+ type: 'object',
29
+ additionalProperties: false,
30
+ required: ['id', 'fit', 'reason'],
31
+ properties: {
32
+ id: { type: 'string' },
33
+ fit: { type: 'integer', minimum: 0, maximum: 10 },
34
+ reason: { type: 'string' },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Score sources for brand fit and return only the ones above threshold.
43
+ *
44
+ * @param {object} args
45
+ * @param {Array<object>} args.sources - raw source records
46
+ * @param {object} args.brand - { name, description }
47
+ * @param {object} args.newsletterConfig
48
+ * @param {object} args.ai
49
+ * @param {object} args.assistant
50
+ * @param {number} [args.threshold] - override fit threshold (default 6)
51
+ * @returns {Promise<{kept: object[], scores: object[]}>}
52
+ */
53
+ async function filterSources({ sources, brand, newsletterConfig, ai, assistant, threshold }) {
54
+ if (!sources?.length) {
55
+ return { kept: [], scores: [] };
56
+ }
57
+
58
+ const t = typeof threshold === 'number' ? threshold : DEFAULT_THRESHOLD;
59
+ const provider = newsletterConfig?.provider?.filter || 'openai';
60
+ const model = newsletterConfig?.model?.filter || DEFAULT_MODELS[provider];
61
+ const startTime = Date.now();
62
+
63
+ assistant.log(`Newsletter filter: scoring ${sources.length} sources (provider=${provider} threshold=${t})`);
64
+
65
+ const systemPrompt = buildSystemPrompt(brand, newsletterConfig);
66
+ const userPrompt = buildUserPrompt(sources);
67
+
68
+ let scores = [];
69
+ let aiResult = null;
70
+
71
+ try {
72
+ aiResult = await ai.request({
73
+ provider,
74
+ model,
75
+ messages: [
76
+ { role: 'system', content: systemPrompt },
77
+ { role: 'user', content: userPrompt },
78
+ ],
79
+ response: 'json',
80
+ schema: FILTER_SCHEMA,
81
+ maxTokens: 8000,
82
+ temperature: 0.2,
83
+ moderate: false,
84
+ });
85
+
86
+ scores = aiResult.content?.scores || [];
87
+
88
+ if (!scores.length) {
89
+ assistant.log(`Newsletter filter: AI returned no scores. Raw content: ${JSON.stringify(aiResult.content)?.slice(0, 500)}`);
90
+ }
91
+ } catch (e) {
92
+ assistant.error(`Filter failed: ${e.message}. Falling back to no filtering.`);
93
+ return {
94
+ kept: sources,
95
+ scores: [],
96
+ meta: { provider, model, durationMs: Date.now() - startTime, error: e.message },
97
+ };
98
+ }
99
+
100
+ // Index scores by source id
101
+ const scoreById = new Map(scores.map((s) => [s.id, s]));
102
+
103
+ // Log all scores up front for visibility (sorted highest fit first)
104
+ const sortedScores = [...scores].sort((a, b) => b.fit - a.fit);
105
+ for (const s of sortedScores) {
106
+ assistant.log(` fit=${s.fit} ${s.id} — ${s.reason}`);
107
+ }
108
+
109
+ // Keep sources scoring >= threshold; preserve original order
110
+ const kept = sources.filter((s) => {
111
+ const score = scoreById.get(s.id);
112
+ return score && score.fit >= t;
113
+ });
114
+
115
+ assistant.log(`Newsletter filter: kept ${kept.length}/${sources.length} sources (threshold=${t})`);
116
+
117
+ return {
118
+ kept,
119
+ scores,
120
+ meta: {
121
+ provider,
122
+ model,
123
+ durationMs: Date.now() - startTime,
124
+ threshold: t,
125
+ sourcesIn: sources.length,
126
+ sourcesKept: kept.length,
127
+ tokens: aiResult?.tokens || null,
128
+ },
129
+ };
130
+ }
131
+
132
+ function buildSystemPrompt(brand, config) {
133
+ const tone = config?.tone || 'professional';
134
+ const instructions = config?.instructions || '';
135
+ const categories = (config?.categories || []).join(', ') || 'general';
136
+
137
+ return [
138
+ `You evaluate which third-party newsletter content is on-brand for ${brand?.name || 'a brand'} to feature in their own newsletter.`,
139
+ brand?.tagline ? `\n${brand.name} tagline: ${brand.tagline}` : '',
140
+ brand?.description ? `\n${brand.name} description: ${brand.description}` : '',
141
+ instructions ? `\nNewsletter focus: ${instructions}` : '',
142
+ `\nContent categories this brand wants: ${categories}`,
143
+ `\nIntended tone: ${tone}`,
144
+ '',
145
+ 'The user will give you a JSON array of source articles (forwarded newsletter excerpts).',
146
+ 'For EACH source, judge whether the topic fits this brand\'s audience and assign a fit score 0–10:',
147
+ ' 10 = directly about this brand\'s domain — their audience definitely cares',
148
+ ' 7 = adjacent/related topic — plausible audience interest',
149
+ ' 4 = tangentially relevant',
150
+ ' 0 = off-topic — wrong audience or wrong domain',
151
+ '',
152
+ 'Be honest and discerning — off-topic content should score low even if interesting.',
153
+ 'Fit is about whether THIS BRAND\'S audience would care, not about the article\'s general quality.',
154
+ '',
155
+ 'Output schema: { "scores": [{ "id": string, "fit": integer, "reason": string }] }',
156
+ 'RULES:',
157
+ ' • Use the EXACT id value from each input source (e.g. "-OsTDh6dAWqUQrcq7B3T"). Do NOT invent or rename ids.',
158
+ ' • Return one entry per input source — same count, same ids.',
159
+ ' • reason: one short sentence (≤20 words).',
160
+ ].filter(Boolean).join('\n');
161
+ }
162
+
163
+ function buildUserPrompt(sources) {
164
+ // Hand the AI a JSON array — eliminates ambiguity about what the id is
165
+ const payload = sources.map((s) => {
166
+ const raw = s.source || {};
167
+ return {
168
+ id: s.id,
169
+ from: raw.from || s.from || '',
170
+ subject: raw.subject || s.subject || '',
171
+ categories: s.categories || (s.category ? [s.category] : []),
172
+ preview: (raw.content || '').slice(0, 400).replace(/\s+/g, ' '),
173
+ };
174
+ });
175
+
176
+ return `Score these ${sources.length} sources for brand fit. Return EXACTLY one score per source, using the "id" field verbatim from the input.\n\nInput sources:\n${JSON.stringify(payload, null, 2)}`;
177
+ }
178
+
179
+ module.exports = { filterSources, FILTER_SCHEMA, DEFAULT_THRESHOLD };
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Asset host — uploads newsletter assets (per-section PNGs + the final
3
+ * rendered `newsletter.html`) to the public `itw-creative-works/newsletter-assets`
4
+ * GitHub repo as a single atomic commit.
5
+ *
6
+ * Returns publicly resolvable `raw.githubusercontent.com` URLs:
7
+ * - Image URLs: embedded in the HTML via <img src=...> so Beehiiv / SendGrid /
8
+ * any inbox can render them
9
+ * - HTML URL: download link for manual paste into Beehiiv (and a stable
10
+ * archive of every issue's final rendered form)
11
+ *
12
+ * Public-safety guarantees baked in:
13
+ * - Only accepts PNG buffers for images — verified by magic-byte check
14
+ * - HTML must be a non-empty string (no buffers, no other types)
15
+ * - Path validated against a strict allowlist regex per file kind
16
+ * - Repo / branch hardcoded (no env override) so a misconfigured caller
17
+ * can't redirect uploads elsewhere
18
+ * - One atomic commit per newsletter (Git Trees API)
19
+ *
20
+ * Pattern lifted from src/manager/routes/admin/post/post.js#commitAll —
21
+ * keep them in sync if the GitHub upload conventions change.
22
+ */
23
+ const { Octokit } = require('@octokit/rest');
24
+
25
+ const REPO_OWNER = 'itw-creative-works';
26
+ const REPO_NAME = 'newsletter-assets';
27
+ const REPO_BRANCH = 'main';
28
+
29
+ const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}`;
30
+
31
+ // `{brandId}/{campaignId}/section-N.png` — both ids are kebab/alphanumeric
32
+ const IMAGE_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
33
+ // `{brandId}/{campaignId}/newsletter.html` — fixed file name, same folder
34
+ const HTML_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.html$/;
35
+
36
+ // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
37
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
38
+
39
+ /**
40
+ * Upload newsletter assets (PNGs + optional HTML) as a single atomic commit.
41
+ *
42
+ * @param {object} args
43
+ * @param {Array<{png: Buffer}>} [args.images] - section images (only `png` is used).
44
+ * Optional — pass an empty array or omit if
45
+ * you only want to upload the HTML (rare).
46
+ * @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
47
+ * `{brandId}/{campaignId}/newsletter.html`.
48
+ * @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
49
+ * @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
50
+ * Folder names use this verbatim — stable forever.
51
+ * @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
52
+ * `git log` reads as a human-browseable history.
53
+ * @param {string} [args.commitMessage] - Full override of the default commit message
54
+ * @param {string} [args.token] - GitHub token (defaults to process.env.GITHUB_TOKEN)
55
+ * @param {object} [args.assistant] - logger
56
+ * @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, folderUrl: string, commitSha: string }>}
57
+ */
58
+ async function uploadAssets({ images, html, brandId, campaignId, subject, commitMessage, token, assistant }) {
59
+ const hasImages = Array.isArray(images) && images.length > 0;
60
+ const hasHtml = typeof html === 'string' && html.length > 0;
61
+
62
+ if (!hasImages && !hasHtml) {
63
+ throw new Error('image-host: at least one of images[] or html must be provided');
64
+ }
65
+
66
+ validateBrandId(brandId);
67
+ validateCampaignId(campaignId);
68
+
69
+ const githubToken = token || process.env.GITHUB_TOKEN;
70
+
71
+ if (!githubToken) {
72
+ throw new Error('image-host: GITHUB_TOKEN env var (or token arg) is required');
73
+ }
74
+
75
+ const log = (msg) => assistant?.log ? assistant.log(`[image-host] ${msg}`) : null;
76
+
77
+ // 1. Build the list of files to commit. Validate each before we touch GitHub.
78
+ const files = [];
79
+
80
+ if (hasImages) {
81
+ for (let i = 0; i < images.length; i++) {
82
+ const img = images[i];
83
+ const path = `${brandId}/${campaignId}/section-${i + 1}.png`;
84
+
85
+ if (!IMAGE_PATH_REGEX.test(path)) {
86
+ throw new Error(`image-host: refusing to upload — invalid image path "${path}"`);
87
+ }
88
+
89
+ if (!Buffer.isBuffer(img.png)) {
90
+ throw new Error(`image-host: section ${i + 1} png is not a Buffer (got ${typeof img.png})`);
91
+ }
92
+
93
+ if (!img.png.slice(0, 8).equals(PNG_MAGIC)) {
94
+ throw new Error(`image-host: section ${i + 1} buffer is not a valid PNG (magic bytes mismatch)`);
95
+ }
96
+
97
+ files.push({
98
+ path,
99
+ contentBase64: img.png.toString('base64'),
100
+ kind: 'image',
101
+ });
102
+ }
103
+ }
104
+
105
+ if (hasHtml) {
106
+ const path = `${brandId}/${campaignId}/newsletter.html`;
107
+
108
+ if (!HTML_PATH_REGEX.test(path)) {
109
+ throw new Error(`image-host: refusing to upload — invalid html path "${path}"`);
110
+ }
111
+
112
+ files.push({
113
+ path,
114
+ contentBase64: Buffer.from(html, 'utf8').toString('base64'),
115
+ kind: 'html',
116
+ });
117
+ }
118
+
119
+ const imageCount = files.filter((f) => f.kind === 'image').length;
120
+ const summary = [
121
+ imageCount ? `${imageCount} PNG${imageCount === 1 ? '' : 's'}` : null,
122
+ hasHtml ? 'newsletter.html' : null,
123
+ ].filter(Boolean).join(' + ');
124
+
125
+ log(`uploading ${summary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
126
+
127
+ const octokit = new Octokit({ auth: githubToken });
128
+
129
+ // 2. Get current main branch ref + tree
130
+ const { data: refData } = await octokit.rest.git.getRef({
131
+ owner: REPO_OWNER,
132
+ repo: REPO_NAME,
133
+ ref: `heads/${REPO_BRANCH}`,
134
+ });
135
+
136
+ const baseCommitSha = refData.object.sha;
137
+
138
+ const { data: baseCommit } = await octokit.rest.git.getCommit({
139
+ owner: REPO_OWNER,
140
+ repo: REPO_NAME,
141
+ commit_sha: baseCommitSha,
142
+ });
143
+
144
+ // 3. Create a blob per file
145
+ const treeItems = [];
146
+
147
+ for (const file of files) {
148
+ const { data: blob } = await octokit.rest.git.createBlob({
149
+ owner: REPO_OWNER,
150
+ repo: REPO_NAME,
151
+ content: file.contentBase64,
152
+ encoding: 'base64',
153
+ });
154
+
155
+ treeItems.push({
156
+ path: file.path,
157
+ mode: '100644',
158
+ type: 'blob',
159
+ sha: blob.sha,
160
+ });
161
+ }
162
+
163
+ // 4. Build new tree on top of base
164
+ const { data: newTree } = await octokit.rest.git.createTree({
165
+ owner: REPO_OWNER,
166
+ repo: REPO_NAME,
167
+ base_tree: baseCommit.tree.sha,
168
+ tree: treeItems,
169
+ });
170
+
171
+ // 5. Commit. Default message format: "[brand] campaignId — Subject" so
172
+ // `git log` doubles as a human-readable index of the (opaque) folder names.
173
+ const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
174
+ const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
175
+
176
+ const { data: newCommit } = await octokit.rest.git.createCommit({
177
+ owner: REPO_OWNER,
178
+ repo: REPO_NAME,
179
+ message,
180
+ tree: newTree.sha,
181
+ parents: [baseCommitSha],
182
+ });
183
+
184
+ // 6. Update branch ref
185
+ await octokit.rest.git.updateRef({
186
+ owner: REPO_OWNER,
187
+ repo: REPO_NAME,
188
+ ref: `heads/${REPO_BRANCH}`,
189
+ sha: newCommit.sha,
190
+ });
191
+
192
+ // 7. Split the URL list by kind so callers can grab images + html independently.
193
+ const imageFiles = files.filter((f) => f.kind === 'image');
194
+ const htmlFile = files.find((f) => f.kind === 'html');
195
+
196
+ const result = {
197
+ urls: imageFiles.map((f) => `${RAW_BASE}/${f.path}`),
198
+ paths: imageFiles.map((f) => f.path),
199
+ folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${REPO_BRANCH}/${brandId}/${campaignId}`,
200
+ commitSha: newCommit.sha,
201
+ };
202
+
203
+ if (htmlFile) {
204
+ result.htmlUrl = `${RAW_BASE}/${htmlFile.path}`;
205
+ result.htmlPath = htmlFile.path;
206
+ }
207
+
208
+ log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
209
+
210
+ return result;
211
+ }
212
+
213
+ function validateBrandId(brandId) {
214
+ if (!brandId || !/^[a-z0-9-]+$/.test(brandId)) {
215
+ throw new Error(`image-host: brandId must be lowercase alphanumeric+hyphens (got "${brandId}")`);
216
+ }
217
+ }
218
+
219
+ function validateCampaignId(campaignId) {
220
+ if (!campaignId || !/^[A-Za-z0-9_-]+$/.test(campaignId)) {
221
+ throw new Error(`image-host: campaignId must be alphanumeric/_/- (got "${campaignId}")`);
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ uploadAssets,
227
+ REPO_OWNER,
228
+ REPO_NAME,
229
+ REPO_BRANCH,
230
+ RAW_BASE,
231
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * MJML newsletter template — turns a structured newsletter into email-safe HTML.
3
+ *
4
+ * Dispatches by `newsletterConfig.template` into one of the layouts in
5
+ * `./templates/` and compiles the resulting MJML to HTML.
6
+ *
7
+ * Templates are pure builders — they take the structured data + theme tokens
8
+ * and return MJML. This file owns the compilation, UTM tagging, and brand
9
+ * resolution that's identical across templates.
10
+ */
11
+ const mjml = require('mjml');
12
+
13
+ const { resolveTemplate } = require('./templates/index.js');
14
+ const { formatAddress } = require('./templates/shared.js');
15
+ const { tagLinks } = require('../../utm');
16
+
17
+ const DEFAULT_THEME = {
18
+ primaryColor: '#5B5BFF',
19
+ secondaryColor: '#1E1E2A',
20
+ accentColor: '#F6F7FB',
21
+ font: 'Inter, system-ui, sans-serif',
22
+ };
23
+
24
+ const DEFAULT_TEMPLATE = 'clean';
25
+
26
+ /**
27
+ * Render the newsletter to email-safe HTML.
28
+ *
29
+ * @param {object} args
30
+ * @param {object} args.brand - { name, id, url }
31
+ * @param {object} args.newsletterConfig - marketing.beehiiv.content (theme, template, ...)
32
+ * @param {object} args.structure - Output from structure.js
33
+ * @param {string[]} args.imagePaths - One entry per section (URL or local path)
34
+ * @param {string} [args.campaign] - Used for UTM utm_campaign
35
+ * @param {string} [args.template] - Template override (otherwise newsletterConfig.template)
36
+ * @param {Array<object>} [args.sponsorships] - Brand-owned sponsorship promos to inject (merged with config)
37
+ * @returns {Promise<{mjml: string, html: string, template: string, errors: object[]}>}
38
+ */
39
+ async function renderNewsletter({ brand, newsletterConfig, structure, imagePaths, campaign, template, sponsorships }) {
40
+ const theme = { ...DEFAULT_THEME, ...(newsletterConfig?.theme || {}) };
41
+ const brandName = brand?.name || 'Newsletter';
42
+ const brandUrl = brand?.url || '#';
43
+ const brandId = brand?.id || '';
44
+ const brandAddress = formatAddress(brand?.address);
45
+
46
+ const templateName = template || newsletterConfig?.template || DEFAULT_TEMPLATE;
47
+ const builder = resolveTemplate(templateName);
48
+
49
+ // Resolve sponsorships: per-call override beats per-campaign beats config defaults
50
+ const resolvedSponsorships = Array.isArray(sponsorships) && sponsorships.length
51
+ ? sponsorships
52
+ : (newsletterConfig?.sponsorships || []);
53
+
54
+ const mjmlString = builder.build({
55
+ structure,
56
+ imagePaths,
57
+ theme,
58
+ brandName,
59
+ brandUrl,
60
+ brandAddress,
61
+ sponsorships: resolvedSponsorships,
62
+ now: new Date(),
63
+ });
64
+
65
+ const compiled = await mjml(mjmlString, { validationLevel: 'soft' });
66
+
67
+ if (compiled.errors?.length) {
68
+ // Soft — log but don't throw. MJML often emits warnings that don't affect output.
69
+ // eslint-disable-next-line no-console
70
+ console.warn('MJML compilation warnings:', compiled.errors.map((e) => e.message));
71
+ }
72
+
73
+ const html = tagLinks(compiled.html, {
74
+ brandUrl,
75
+ brandId,
76
+ campaign: campaign || 'newsletter',
77
+ type: 'marketing',
78
+ });
79
+
80
+ return { mjml: mjmlString, html, template: templateName, errors: compiled.errors || [] };
81
+ }
82
+
83
+ module.exports = { renderNewsletter };