backend-manager 5.1.1 → 5.1.4

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 (36) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CLAUDE.md +0 -2
  3. package/README.md +15 -0
  4. package/docs/marketing-campaigns.md +41 -4
  5. package/docs/testing.md +45 -0
  6. package/package.json +1 -1
  7. package/src/cli/commands/emulator.js +18 -1
  8. package/src/cli/commands/test.js +18 -0
  9. package/src/defaults/CLAUDE.md +7 -5
  10. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  11. package/src/manager/index.js +82 -5
  12. package/src/manager/libraries/ai/index.js +21 -0
  13. package/src/manager/libraries/ai/providers/openai.js +75 -0
  14. package/src/manager/libraries/email/data/blocked-local-patterns.js +2 -2
  15. package/src/manager/libraries/email/data/disposable-domains.json +12 -0
  16. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  17. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  18. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  19. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  20. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  21. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  22. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  23. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  24. package/src/manager/libraries/email/generators/newsletter.js +152 -5
  25. package/src/manager/libraries/email/providers/beehiiv.js +7 -1
  26. package/src/manager/routes/admin/post/post.js +3 -3
  27. package/src/manager/routes/test/health/get.js +17 -0
  28. package/src/test/run-tests.js +30 -0
  29. package/src/test/runner.js +11 -8
  30. package/src/test/utils/test-mode-file.js +192 -0
  31. package/test/marketing/fixtures/clean.json +2 -3
  32. package/test/marketing/fixtures/editorial.json +2 -3
  33. package/test/marketing/fixtures/field-report.json +3 -4
  34. package/test/marketing/newsletter-generate.js +62 -48
  35. package/test/marketing/newsletter-templates.js +12 -33
  36. package/test/routes/admin/create-post.js +2 -2
@@ -2135,6 +2135,7 @@
2135
2135
  "hazelnuts4u.com",
2136
2136
  "hazmatshipping.org",
2137
2137
  "hccmail.win",
2138
+ "hdiscord.xyz",
2138
2139
  "headstrong.de",
2139
2140
  "healthforwomen.info",
2140
2141
  "healxo.org",
@@ -2299,6 +2300,7 @@
2299
2300
  "imails.info",
2300
2301
  "imailt.com",
2301
2302
  "imap.fr.nf",
2303
+ "imashr.com",
2302
2304
  "imfaya.com",
2303
2305
  "img-free.com",
2304
2306
  "imgof.com",
@@ -2870,6 +2872,7 @@
2870
2872
  "mail7.io",
2871
2873
  "mail707.com",
2872
2874
  "mail72.com",
2875
+ "mailaddress.de",
2873
2876
  "mailadresi.tk",
2874
2877
  "mailapp.top",
2875
2878
  "mailapril.org",
@@ -2899,6 +2902,7 @@
2899
2902
  "mailbucket.org",
2900
2903
  "mailcat.biz",
2901
2904
  "mailcatch.com",
2905
+ "mailchannels.de",
2902
2906
  "mailchop.com",
2903
2907
  "mailcker.com",
2904
2908
  "maildax.me",
@@ -3412,6 +3416,7 @@
3412
3416
  "nezzart.com",
3413
3417
  "nfast.net",
3414
3418
  "nghienplus.io.vn",
3419
+ "nghienplus.store",
3415
3420
  "nguyenusedcars.com",
3416
3421
  "nh3.ro",
3417
3422
  "nhmi1.com",
@@ -3559,6 +3564,7 @@
3559
3564
  "oloh.ru",
3560
3565
  "oloh.store",
3561
3566
  "olypmall.ru",
3567
+ "omail.de",
3562
3568
  "omail.pro",
3563
3569
  "omarnasrrr.com",
3564
3570
  "omfg.run",
@@ -4546,12 +4552,14 @@
4546
4552
  "tempemail.biz",
4547
4553
  "tempemail.co.za",
4548
4554
  "tempemail.com",
4555
+ "tempemail.de",
4549
4556
  "tempemail.net",
4550
4557
  "tempemailgen.com",
4551
4558
  "tempemaill.com",
4552
4559
  "tempemailo.org",
4553
4560
  "tempinbox.co.uk",
4554
4561
  "tempinbox.com",
4562
+ "tempmail.at",
4555
4563
  "tempmail.best",
4556
4564
  "tempmail.cc",
4557
4565
  "tempmail.cn",
@@ -4638,6 +4646,7 @@
4638
4646
  "thejoker5.com",
4639
4647
  "thelightningmail.net",
4640
4648
  "thelimestones.com",
4649
+ "themailer.de",
4641
4650
  "thembones.com.au",
4642
4651
  "themegreview.com",
4643
4652
  "themostemail.com",
@@ -4895,6 +4904,7 @@
4895
4904
  "uiu.us",
4896
4905
  "ujijima1129.gq",
4897
4906
  "uk.to",
4907
+ "uki.io.vn",
4898
4908
  "ukm.ovh",
4899
4909
  "ultra.fyi",
4900
4910
  "ultrada.ru",
@@ -5181,6 +5191,7 @@
5181
5191
  "whatpaas.com",
5182
5192
  "whatsaas.com",
5183
5193
  "whiffles.org",
5194
+ "whispermail.org",
5184
5195
  "whitehousecalculator.com",
5185
5196
  "whopy.com",
5186
5197
  "whyspam.me",
@@ -5226,6 +5237,7 @@
5226
5237
  "writeme.us",
5227
5238
  "wronghead.com",
5228
5239
  "ws.gy",
5240
+ "wshu.net",
5229
5241
  "wsym.de",
5230
5242
  "wsypc.com",
5231
5243
  "wudet.men",
@@ -32,6 +32,10 @@ const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/$
32
32
  const IMAGE_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
33
33
  // `{brandId}/{campaignId}/newsletter.html` — fixed file name, same folder
34
34
  const HTML_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.html$/;
35
+ // `{brandId}/{campaignId}/newsletter.md` — markdown view, same folder
36
+ const MARKDOWN_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.md$/;
37
+ // `{brandId}/{campaignId}/summary.md` — short editorial recap, same folder
38
+ const SUMMARY_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/summary\.md$/;
35
39
 
36
40
  // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
37
41
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -45,6 +49,10 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
45
49
  * you only want to upload the HTML (rare).
46
50
  * @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
47
51
  * `{brandId}/{campaignId}/newsletter.html`.
52
+ * @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
53
+ * Uploaded as `{brandId}/{campaignId}/newsletter.md`.
54
+ * @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
55
+ * as `{brandId}/{campaignId}/summary.md`.
48
56
  * @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
49
57
  * @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
50
58
  * Folder names use this verbatim — stable forever.
@@ -55,12 +63,14 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
55
63
  * @param {object} [args.assistant] - logger
56
64
  * @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, folderUrl: string, commitSha: string }>}
57
65
  */
58
- async function uploadAssets({ images, html, brandId, campaignId, subject, commitMessage, token, assistant }) {
66
+ async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, token, assistant }) {
59
67
  const hasImages = Array.isArray(images) && images.length > 0;
60
68
  const hasHtml = typeof html === 'string' && html.length > 0;
69
+ const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
70
+ const hasSummary = typeof summary === 'string' && summary.length > 0;
61
71
 
62
- if (!hasImages && !hasHtml) {
63
- throw new Error('image-host: at least one of images[] or html must be provided');
72
+ if (!hasImages && !hasHtml && !hasMarkdown && !hasSummary) {
73
+ throw new Error('image-host: at least one of images[] / html / markdown / summary must be provided');
64
74
  }
65
75
 
66
76
  validateBrandId(brandId);
@@ -116,13 +126,43 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
116
126
  });
117
127
  }
118
128
 
129
+ if (hasMarkdown) {
130
+ const path = `${brandId}/${campaignId}/newsletter.md`;
131
+
132
+ if (!MARKDOWN_PATH_REGEX.test(path)) {
133
+ throw new Error(`image-host: refusing to upload — invalid markdown path "${path}"`);
134
+ }
135
+
136
+ files.push({
137
+ path,
138
+ contentBase64: Buffer.from(markdown, 'utf8').toString('base64'),
139
+ kind: 'markdown',
140
+ });
141
+ }
142
+
143
+ if (hasSummary) {
144
+ const path = `${brandId}/${campaignId}/summary.md`;
145
+
146
+ if (!SUMMARY_PATH_REGEX.test(path)) {
147
+ throw new Error(`image-host: refusing to upload — invalid summary path "${path}"`);
148
+ }
149
+
150
+ files.push({
151
+ path,
152
+ contentBase64: Buffer.from(summary, 'utf8').toString('base64'),
153
+ kind: 'summary',
154
+ });
155
+ }
156
+
119
157
  const imageCount = files.filter((f) => f.kind === 'image').length;
120
- const summary = [
158
+ const fileSummary = [
121
159
  imageCount ? `${imageCount} PNG${imageCount === 1 ? '' : 's'}` : null,
122
160
  hasHtml ? 'newsletter.html' : null,
161
+ hasMarkdown ? 'newsletter.md' : null,
162
+ hasSummary ? 'summary.md' : null,
123
163
  ].filter(Boolean).join(' + ');
124
164
 
125
- log(`uploading ${summary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
165
+ log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
126
166
 
127
167
  const octokit = new Octokit({ auth: githubToken });
128
168
 
@@ -189,9 +229,11 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
189
229
  sha: newCommit.sha,
190
230
  });
191
231
 
192
- // 7. Split the URL list by kind so callers can grab images + html independently.
232
+ // 7. Split the URL list by kind so callers can grab each independently.
193
233
  const imageFiles = files.filter((f) => f.kind === 'image');
194
234
  const htmlFile = files.find((f) => f.kind === 'html');
235
+ const markdownFile = files.find((f) => f.kind === 'markdown');
236
+ const summaryFile = files.find((f) => f.kind === 'summary');
195
237
 
196
238
  const result = {
197
239
  urls: imageFiles.map((f) => `${RAW_BASE}/${f.path}`),
@@ -205,6 +247,16 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
205
247
  result.htmlPath = htmlFile.path;
206
248
  }
207
249
 
250
+ if (markdownFile) {
251
+ result.markdownUrl = `${RAW_BASE}/${markdownFile.path}`;
252
+ result.markdownPath = markdownFile.path;
253
+ }
254
+
255
+ if (summaryFile) {
256
+ result.summaryUrl = `${RAW_BASE}/${summaryFile.path}`;
257
+ result.summaryPath = summaryFile.path;
258
+ }
259
+
208
260
  log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
209
261
 
210
262
  return result;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Markdown renderer — deterministic, no AI cost. Walks the `structure` object
3
+ * produced by structure.js and emits a markdown document suitable for pasting
4
+ * into Beehiiv's block editor (one section per `## heading` block, so you can
5
+ * drop ad blocks between dispatches).
6
+ *
7
+ * Why this exists:
8
+ * - The MJML-rendered HTML is one giant styled block. Pasting it into Beehiiv
9
+ * defeats the block editor — ads can't be inserted between sections.
10
+ * - Markdown gives Beehiiv (and any future provider) a clean per-section
11
+ * structure that maps to native blocks.
12
+ *
13
+ * Why programmatic, not AI:
14
+ * - Cost: zero. Layout never changes between issues for a given template.
15
+ * - Determinism: same `structure` → same markdown. Easy to test.
16
+ * - SSOT: the AI-authored `structure` is the source of truth; markdown and
17
+ * HTML are two views of the same data.
18
+ *
19
+ * Template-awareness:
20
+ * - `clean`/`editorial` use the classic shape (intro + sections[]).
21
+ * - `field-report` uses dispatches[] with kickers, bylines, dataPoints.
22
+ * - The renderer reads `structure._meta.template` (set by structure.js) to
23
+ * pick the body strategy; falls back to "classic sections" if absent.
24
+ *
25
+ * Sections render as standalone blocks so each can be pasted independently:
26
+ * - heading (## ...)
27
+ * - image (markdown ![alt](url) — only if image URL is present)
28
+ * - body
29
+ * - CTA as a markdown link on its own line
30
+ * - horizontal rule between sections (---)
31
+ */
32
+
33
+ /**
34
+ * Render a newsletter structure as markdown.
35
+ *
36
+ * @param {object} args
37
+ * @param {object} args.structure - The output of generateStructure()
38
+ * @param {object} [args.brand] - { name, url, id }
39
+ * @param {string[]} [args.imagePaths] - Per-section image URLs (same order as sections/dispatches)
40
+ * @param {Array<{position, html, image_url, link_url}>} [args.sponsorships] - Optional sponsorships
41
+ * @returns {string} Markdown document
42
+ */
43
+ function renderMarkdown({ structure, brand, imagePaths, sponsorships }) {
44
+ if (!structure) {
45
+ throw new Error('markdown-renderer: structure is required');
46
+ }
47
+
48
+ const template = structure._meta?.template || 'clean';
49
+ const parts = [];
50
+
51
+ // ----- Header -----
52
+ if (structure.subject) {
53
+ parts.push(`# ${structure.subject}`);
54
+ }
55
+
56
+ if (structure.preheader) {
57
+ parts.push(`_${structure.preheader}_`);
58
+ }
59
+
60
+ // Field-report-only opener: TLDR strip + dateline
61
+ if (template === 'field-report') {
62
+ if (structure.dateline) {
63
+ parts.push(`**${structure.dateline.toUpperCase()} —** _Filed today_`);
64
+ }
65
+
66
+ if (structure.tldr) {
67
+ parts.push(`> **TL;DR** — ${structure.tldr}`);
68
+ }
69
+ }
70
+
71
+ // Classic intro
72
+ if (structure.intro) {
73
+ parts.push(structure.intro);
74
+ }
75
+
76
+ // Top sponsorship (above sections)
77
+ const topSponsorship = pickSponsorship(sponsorships, 'top');
78
+ if (topSponsorship) {
79
+ parts.push('---');
80
+ parts.push(renderSponsorshipMarkdown(topSponsorship));
81
+ }
82
+
83
+ parts.push('---');
84
+
85
+ // ----- Body -----
86
+ const sections = getBodySections(structure, template);
87
+
88
+ for (let i = 0; i < sections.length; i++) {
89
+ const block = renderSection(sections[i], i, imagePaths, template);
90
+
91
+ if (!block) {
92
+ continue;
93
+ }
94
+
95
+ parts.push(block);
96
+ parts.push('---');
97
+
98
+ // Mid-section sponsorship (after the middle dispatch)
99
+ const middleIdx = Math.floor(sections.length / 2);
100
+
101
+ if (i === middleIdx - 1) {
102
+ const midSponsorship = pickSponsorship(sponsorships, 'middle');
103
+
104
+ if (midSponsorship) {
105
+ parts.push(renderSponsorshipMarkdown(midSponsorship));
106
+ parts.push('---');
107
+ }
108
+ }
109
+ }
110
+
111
+ // End sponsorship (above signoff)
112
+ const endSponsorship = pickSponsorship(sponsorships, 'end');
113
+ if (endSponsorship) {
114
+ parts.push(renderSponsorshipMarkdown(endSponsorship));
115
+ parts.push('---');
116
+ }
117
+
118
+ // ----- Footer -----
119
+ if (structure.signoff) {
120
+ // Signoffs are stored with literal "\n" — convert to a markdown line break.
121
+ parts.push(structure.signoff.replace(/\n/g, ' \n'));
122
+ }
123
+
124
+ if (Array.isArray(structure.citations) && structure.citations.length) {
125
+ parts.push('---');
126
+ parts.push('## Notes');
127
+
128
+ for (let i = 0; i < structure.citations.length; i++) {
129
+ const c = structure.citations[i];
130
+
131
+ if (!c?.note) {
132
+ continue;
133
+ }
134
+
135
+ const src = c.source ? ` — _${c.source}_` : '';
136
+ parts.push(`${i + 1}. ${c.note}${src}`);
137
+ }
138
+ }
139
+
140
+ if (Array.isArray(structure.tags) && structure.tags.length) {
141
+ parts.push(`_Tags: ${structure.tags.map((t) => `#${t}`).join(' ')}_`);
142
+ }
143
+
144
+ if (brand?.name && brand?.url) {
145
+ parts.push(`---\n_You're receiving this because you subscribed to [${brand.name}](${brand.url})._`);
146
+ }
147
+
148
+ // Join with blank-line separators; collapse any accidental triple-newlines.
149
+ return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
150
+ }
151
+
152
+ /**
153
+ * Extract the "body sections" — what we'll loop over to render as ## headings.
154
+ * The shape depends on the template.
155
+ */
156
+ function getBodySections(structure, template) {
157
+ if (template === 'field-report' && Array.isArray(structure.dispatches)) {
158
+ return structure.dispatches.map((d) => ({
159
+ kind: 'dispatch',
160
+ title: d.headline,
161
+ kicker: d.kicker,
162
+ byline: d.byline,
163
+ location: d.location,
164
+ lede: d.lede,
165
+ body: d.dispatch,
166
+ dataPoints: d.dataPoints,
167
+ image_prompt: d.image_prompt,
168
+ }));
169
+ }
170
+
171
+ // Classic shape (clean, editorial)
172
+ if (Array.isArray(structure.sections)) {
173
+ return structure.sections.map((s) => ({
174
+ kind: 'section',
175
+ title: s.title,
176
+ body: s.body,
177
+ image_prompt: s.image_prompt,
178
+ }));
179
+ }
180
+
181
+ return [];
182
+ }
183
+
184
+ /**
185
+ * Render a single section/dispatch as a self-contained markdown block.
186
+ * Empty sections (no title and no body) return null so the caller can skip
187
+ * them entirely — avoids "## undefined" stubs and dangling `---` dividers.
188
+ */
189
+ function renderSection(section, idx, imagePaths, template) {
190
+ if (!section?.title && !section?.body) {
191
+ return null;
192
+ }
193
+
194
+ const lines = [];
195
+
196
+ // Kicker prefix (field-report only)
197
+ if (section.kicker) {
198
+ lines.push(`**${section.kicker.toUpperCase()}**`);
199
+ }
200
+
201
+ if (section.title) {
202
+ lines.push(`## ${section.title}`);
203
+ }
204
+
205
+ // Byline / location bar (field-report only)
206
+ if (section.byline || section.location) {
207
+ const parts = [];
208
+ if (section.location) parts.push(`**${section.location.toUpperCase()}**`);
209
+ if (section.byline) parts.push(`_${section.byline}_`);
210
+ lines.push(parts.join(' — '));
211
+ }
212
+
213
+ // Image (if hosted)
214
+ const imageUrl = imagePaths && imagePaths[idx];
215
+
216
+ if (imageUrl && !imageUrl.startsWith('about:')) {
217
+ const alt = section.image_prompt || section.title || `Section ${idx + 1}`;
218
+ lines.push(`![${alt.replace(/[\[\]]/g, '')}](${imageUrl})`);
219
+ }
220
+
221
+ // Lede (field-report)
222
+ if (section.lede) {
223
+ lines.push(`_${section.lede}_`);
224
+ }
225
+
226
+ // Data points (field-report)
227
+ if (Array.isArray(section.dataPoints) && section.dataPoints.length) {
228
+ const rows = section.dataPoints
229
+ .map((dp) => `| **${dp.label || ''}** | ${dp.value || ''} |`)
230
+ .join('\n');
231
+ lines.push(`| Metric | Value |\n|---|---|\n${rows}`);
232
+ }
233
+
234
+ // Body
235
+ if (section.body) {
236
+ lines.push(section.body);
237
+ }
238
+
239
+ return lines.join('\n\n');
240
+ }
241
+
242
+ /**
243
+ * Pick the first sponsorship matching the requested position. Position is
244
+ * one of 'top' | 'middle' | 'end'. Returns null if no match.
245
+ */
246
+ function pickSponsorship(sponsorships, position) {
247
+ if (!Array.isArray(sponsorships) || !sponsorships.length) {
248
+ return null;
249
+ }
250
+
251
+ return sponsorships.find((s) => (s?.position || 'top') === position) || null;
252
+ }
253
+
254
+ /**
255
+ * Render a sponsorship as markdown. Sponsorships in the HTML template are
256
+ * styled blocks; in markdown they're a simple "Sponsored by" callout with
257
+ * optional image and link.
258
+ */
259
+ function renderSponsorshipMarkdown(sp) {
260
+ const lines = ['**Sponsored**'];
261
+
262
+ if (sp.image_url && sp.link_url) {
263
+ lines.push(`[![Sponsor](${sp.image_url})](${sp.link_url})`);
264
+ } else if (sp.image_url) {
265
+ lines.push(`![Sponsor](${sp.image_url})`);
266
+ }
267
+
268
+ if (sp.html) {
269
+ // Strip HTML tags for the markdown view; keep the text.
270
+ const text = String(sp.html).replace(/<[^>]+>/g, '').trim();
271
+ if (text) {
272
+ lines.push(text);
273
+ }
274
+ }
275
+
276
+ if (sp.link_url) {
277
+ lines.push(`**[Learn more →](${sp.link_url})**`);
278
+ }
279
+
280
+ return lines.join('\n\n');
281
+ }
282
+
283
+ module.exports = {
284
+ renderMarkdown,
285
+ };
@@ -44,11 +44,23 @@ const DEFAULT_MODELS = {
44
44
  const BASE_SCHEMA = {
45
45
  type: 'object',
46
46
  additionalProperties: false,
47
- required: ['subject', 'preheader', 'signoff', 'citations'],
47
+ required: ['subject', 'preheader', 'signoff', 'citations', 'tags', 'summary'],
48
48
  properties: {
49
49
  subject: { type: 'string', maxLength: 80 },
50
50
  preheader: { type: 'string', maxLength: 120 },
51
51
  signoff: { type: 'string' },
52
+ // Two-to-three-sentence editorial summary of the issue. Used as the body of
53
+ // `summary.md` alongside the newsletter, and as a preview snippet when the
54
+ // issue is shared. Distinct from preheader (which is an inbox-preview hook).
55
+ summary: { type: 'string', maxLength: 600 },
56
+ // Topical tags for the issue. Flow into Beehiiv's `content_tags` field on
57
+ // the post draft (array of strings, lowercase, kebab-case preferred).
58
+ // Empty array is valid.
59
+ tags: {
60
+ type: 'array',
61
+ maxItems: 5,
62
+ items: { type: 'string', maxLength: 40 },
63
+ },
52
64
  // Citations for hard data (statistics, numbers, direct quotes) pulled from sources.
53
65
  // Rendered as a small footnote section at the bottom of the newsletter — never inline.
54
66
  // Empty array is valid (most newsletters won't need citations).
@@ -169,6 +181,8 @@ async function generateStructure({ sources, brand, newsletterConfig, ai, assista
169
181
  structure.preheader = structure.preheader || '';
170
182
  structure.signoff = structure.signoff || `Best,\nThe ${brand?.name || 'Team'} Team`;
171
183
  structure.citations = Array.isArray(structure.citations) ? structure.citations : [];
184
+ structure.tags = Array.isArray(structure.tags) ? structure.tags : [];
185
+ structure.summary = typeof structure.summary === 'string' ? structure.summary : '';
172
186
 
173
187
  // Let the template normalize its own fields (e.g. sections defaults).
174
188
  // Falls back to a sane default if the template doesn't ship one.
@@ -226,10 +240,13 @@ function buildClassicSystemPrompt(brand, config) {
226
240
  'CONTENT REQUIREMENTS:',
227
241
  '- Subject (≤60 chars, no emojis, attention-grabbing but not clickbait)',
228
242
  '- Preheader (≤100 chars, complements the subject)',
243
+ '- Summary (2-3 sentences, plain text, no markdown) — an editorial recap of the issue, written like a TL;DR. Distinct from preheader (which is an inbox hook). This is what someone reads if they only have 10 seconds.',
244
+ '- Tags (3-5 short topical tags, lowercase, kebab-case, no spaces) — e.g. "linkedin", "creator-economy", "platform-policy". Empty array is fine if nothing fits.',
229
245
  '- Intro (1-2 sentences, markdown allowed) — frame the issue as if you are setting up your own reporting',
230
246
  '- 3-5 sections — each is ONE topic, rewritten in your voice as original content',
231
- '- Each section: title (compelling, scannable), body (80-150 words, markdown OK), optional cta { label, url }',
247
+ '- Each section: title (compelling, scannable), body (80-150 words, markdown OK)',
232
248
  '- Each section: image_prompt — one-sentence visual description for an illustrator. Be specific about subject/style.',
249
+ '- Do NOT include CTAs, "read more" links, or any URLs in section bodies. The newsletter is a self-contained read — never invent links or send readers off-property.',
233
250
  `- Signoff: a SHORT human sign-off, formatted as two lines with \\n between them. First line is a closing phrase like "Best,", "Cheers,", "Until next week,", or "Stay sharp,". Second line is the team name like "The ${brand?.name || 'Team'} Team". Example: "Best,\\nThe ${brand?.name || 'Team'} Team". Do NOT write a summary, tagline, motto, or thematic conclusion sentence — this is the literal way you sign off the email, like the end of a letter.`,
234
251
  '- citations: array of { note, source } for any hard data referenced. Empty array if none.',
235
252
  '',
@@ -2,16 +2,25 @@
2
2
  * SVG illustrator — AI authors per-section SVG illustrations, rasterized to PNG.
3
3
  *
4
4
  * Each section in a newsletter gets one illustration. Default provider is
5
- * Anthropic (Claude generates cleaner geometric SVG than GPT in practice).
5
+ * OpenAI Codex (gpt-5.3-codex) markup/code-specialized GPT-5 variant tuned
6
+ * for structured output. SVG is just structured markup, so Codex is the right
7
+ * fit. Anthropic is supported as a fallback provider.
6
8
  *
7
9
  * Output is both the raw SVG string (for debugging) and a rasterized PNG buffer
8
10
  * (for embedding). Local file persistence is the caller's responsibility — this
9
11
  * module returns buffers only.
12
+ *
13
+ * Provider-specific default models:
14
+ * openai → gpt-5.3-codex (Codex family is markup/code-specialized; SVG is
15
+ * structured markup. ~$0.005-0.015/image.)
16
+ * anthropic → claude-opus (Claude is good at artistic SVG.)
10
17
  */
11
18
  const { Resvg } = require('@resvg/resvg-js');
12
19
 
20
+ const DEFAULT_PROVIDER = 'openai';
21
+
13
22
  const DEFAULT_MODELS = {
14
- openai: 'gpt-5.4-mini',
23
+ openai: 'gpt-5.3-codex',
15
24
  anthropic: 'claude-opus',
16
25
  'claude-code': 'claude-opus-4-7',
17
26
  };
@@ -30,7 +39,7 @@ const PNG_WIDTH = 800; // 2x display width of 400px container
30
39
  * @returns {Promise<{svg: string, png: Buffer, fallback: boolean}>}
31
40
  */
32
41
  async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
33
- const provider = newsletterConfig?.provider?.svg || 'anthropic';
42
+ const provider = newsletterConfig?.provider?.svg || DEFAULT_PROVIDER;
34
43
  const model = newsletterConfig?.model?.svg || DEFAULT_MODELS[provider];
35
44
  const startTime = Date.now();
36
45
 
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Both templates consume the same shape:
5
5
  * - intro: 1-2 sentence preamble
6
- * - sections: list of {title, body, cta?, image_prompt}
6
+ * - sections: list of {title, body, image_prompt}
7
7
  *
8
8
  * Defined here so adding a new field (e.g. an eyebrow per section) updates
9
9
  * every classic-style template at once. Templates with fundamentally
10
10
  * different content shapes (Field Report, Postcard, Almanac) declare their
11
11
  * own schema instead.
12
+ *
13
+ * NOTE: CTAs / outbound links are intentionally NOT part of the schema. The
14
+ * AI cannot reliably author URLs without inventing them (it can't browse the
15
+ * brand's site and has no real source URLs), so we forbid the field entirely.
16
+ * Newsletters are self-contained — link out from the rendered HTML manually
17
+ * (sponsorship blocks, footer) rather than from generated section bodies.
12
18
  */
13
19
  const CLASSIC_SCHEMA = {
14
20
  required: ['intro', 'sections'],
@@ -21,19 +27,10 @@ const CLASSIC_SCHEMA = {
21
27
  items: {
22
28
  type: 'object',
23
29
  additionalProperties: false,
24
- required: ['title', 'body', 'image_prompt', 'cta'],
30
+ required: ['title', 'body', 'image_prompt'],
25
31
  properties: {
26
32
  title: { type: 'string' },
27
33
  body: { type: 'string' },
28
- cta: {
29
- type: ['object', 'null'],
30
- additionalProperties: false,
31
- required: ['label', 'url'],
32
- properties: {
33
- label: { type: 'string' },
34
- url: { type: 'string' },
35
- },
36
- },
37
34
  image_prompt: { type: 'string' },
38
35
  },
39
36
  },
@@ -43,7 +40,7 @@ const CLASSIC_SCHEMA = {
43
40
 
44
41
  /**
45
42
  * Normalize a classic structure post-AI-call. Ensures every section has the
46
- * fields the templates expect, even when the AI omits an optional like cta.
43
+ * fields the templates expect, even when the AI omits an optional.
47
44
  */
48
45
  function normalizeClassic(structure) {
49
46
  if (!Array.isArray(structure.sections)) {
@@ -53,7 +50,6 @@ function normalizeClassic(structure) {
53
50
  structure.sections = structure.sections.map((s, i) => ({
54
51
  title: s.title || `Section ${i + 1}`,
55
52
  body: s.body || '',
56
- cta: s.cta || null,
57
53
  image_prompt: s.image_prompt || '',
58
54
  }));
59
55
 
@@ -74,7 +74,7 @@ module.exports = {
74
74
  name: 'clean',
75
75
  description: 'Stripe / Linear marketing aesthetic. Safe, conservative, works everywhere.',
76
76
  requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
77
- optional: ['citations', 'image_prompt', 'cta'],
77
+ optional: ['citations', 'image_prompt'],
78
78
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
79
79
  },
80
80
  schema: CLASSIC_SCHEMA,
@@ -309,7 +309,7 @@ module.exports = {
309
309
  name: 'editorial',
310
310
  description: 'Magazine-style: masthead, drop-cap intro, numbered sections, pull-quotes, italic signoff.',
311
311
  requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
312
- optional: ['citations', 'image_prompt', 'cta'],
312
+ optional: ['citations', 'image_prompt'],
313
313
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
314
314
  },
315
315
  schema: CLASSIC_SCHEMA,