backend-manager 5.2.2 → 5.2.3

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.3] - 2026-05-22
18
+
19
+ ### Added
20
+
21
+ - **`marketing.sendgrid.listId` in `templates/backend-manager-config.json`.** Empty-string placeholder for OMEGA's `sendgrid/ensure/list.js` to populate at brand-onboarding time. Mirrors the existing `marketing.beehiiv.publicationId` convention.
22
+ - **`_test.*` local-part block** in `src/manager/libraries/email/data/blocked-local-patterns.js`. Test-suite accounts (`_test.<scenario>@somiibo.com`) are now blocked from reaching SendGrid + Beehiiv. The carved-out exception is `_test.allow_*` — used for live-provider integration tests that intentionally need to round-trip a real contact.
23
+
24
+ ### Changed
25
+
26
+ - **`Marketing.add()` and `Marketing.sync()` now use the full `validate()` pipeline** instead of just `isCorporate()`. Single SSOT for "is this a valid marketing email" — runs format → disposable → corporate → localPart in one call. Stricter behavior: disposable-domain emails (mailinator etc.) and junk local-parts (`noreply`, `test*`, `_test.*`) are now blocked from marketing lists. They were previously waved through because the gate only checked corporate domains.
27
+ - **SendGrid list-ID lookup is now config-only.** `src/manager/libraries/email/providers/sendgrid.js#getListId()` reads `Manager.config.marketing.sendgrid.listId` and returns null if missing — no more runtime API call, no more fuzzy-match-by-brand-name. Old fuzzy logic kept commented out as `getListIdByFuzzyMatch()` backstop. **Brands must run OMEGA's sendgrid service to populate `listId` before this version sees their list assignments work** (without it, contacts land in SendGrid's global "All Contacts" pool, not the brand list).
28
+ - **Beehiiv publication-ID lookup is now config-only.** `src/manager/libraries/email/providers/beehiiv.js#getPublicationId()` reads `Manager.config.marketing.beehiiv.publicationId` and returns null if missing — same shape as SendGrid above. Old fuzzy logic kept commented out as backstop. Beehiiv side already preferred config when set, but now there's no API fallback.
29
+ - **`marketing.beehiiv.publicationId` is now an always-present empty string in the config template** (was a commented-out hint). Matches the SendGrid `listId` shape and means `Manager.config.marketing.beehiiv.publicationId` always returns `""` (never `undefined`) for legacy brands.
30
+ - **SendGrid API timeouts bumped from 10s → 60s** via a new top-level `SENDGRID_TIMEOUT_MS` constant in `sendgrid.js`. All 9 fetch sites updated. Catches the intermittent SendGrid backend hiccups that were dropping signups silently with "Request timed out".
31
+
17
32
  # [5.2.2] - 2026-05-21
18
33
 
19
34
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.2",
3
+ "version": "5.2.3",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -3,9 +3,14 @@
3
3
  * Kept as JS (not JSON) so patterns stay as native RegExp literals.
4
4
  */
5
5
  module.exports = [
6
- /^\d+$/, // All numeric: 123456
7
- /^(.)\1{2,}$/, // Repeating single char: aaaa, xxxx
8
- /^[a-z]{1,2}\d+$/, // Single letter + numbers: a123, x999
9
- /^test/, // Starts with test: test, testuser, test123, test.user
10
- /^example/, // Starts with example: example, exampleuser, example.user
6
+ /^\d+$/, // All numeric: 123456
7
+ /^(.)\1{2,}$/, // Repeating single char: aaaa, xxxx
8
+ /^[a-z]{1,2}\d+$/, // Single letter + numbers: a123, x999
9
+ /^test/, // Starts with test: test, testuser, test123, test.user
10
+ /^example/, // Starts with example: example, exampleuser, example.user
11
+ // Test-suite accounts use `_test.<scenario>@...` so they don't collide with
12
+ // real signups. Block them from reaching SendGrid/Beehiiv so live lists stay
13
+ // clean. `_test.allow_*` is the carved-out exception for live-provider
14
+ // integration tests that intentionally need to round-trip a real contact.
15
+ /^_test\.(?!allow_)/,
11
16
  ];
@@ -29,7 +29,7 @@ const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
29
29
 
30
30
  const { TEMPLATES, GROUPS, SENDERS } = require('../constants.js');
31
31
  const { tagLinks } = require('../utm.js');
32
- const { isCorporate } = require('../validation.js');
32
+ const { validate } = require('../validation.js');
33
33
  const sendgridProvider = require('../providers/sendgrid.js');
34
34
  const beehiivProvider = require('../providers/beehiiv.js');
35
35
 
@@ -73,9 +73,13 @@ Marketing.prototype.add = async function (options) {
73
73
  return {};
74
74
  }
75
75
 
76
- if (isCorporate(email)) {
77
- assistant.warn(`Marketing.add(): Blocked corporate/social-media domain, skipping: ${email}`);
78
- return { blocked: 'corporate', email };
76
+ // SSOT for what "valid marketing email" means — format, disposable, corporate,
77
+ // localPart junk (incl. _test.* test-suite accounts). Single validate() call
78
+ // catches all of these before we hit SendGrid/Beehiiv.
79
+ const validation = await validate(email);
80
+ if (!validation.valid) {
81
+ assistant.warn(`Marketing.add(): Validation failed, skipping: ${email}`, validation.checks);
82
+ return { blocked: 'validation', email, checks: validation.checks };
79
83
  }
80
84
 
81
85
  if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
@@ -157,9 +161,13 @@ Marketing.prototype.sync = async function (userDocOrUid) {
157
161
  return {};
158
162
  }
159
163
 
160
- if (isCorporate(email)) {
161
- assistant.warn(`Marketing.sync(): Blocked corporate/social-media domain, skipping: ${email}`);
162
- return { blocked: 'corporate', email };
164
+ // SSOT for what "valid marketing email" means — format, disposable, corporate,
165
+ // localPart junk (incl. _test.* test-suite accounts). Single validate() call
166
+ // catches all of these before we hit SendGrid/Beehiiv.
167
+ const validation = await validate(email);
168
+ if (!validation.valid) {
169
+ assistant.warn(`Marketing.sync(): Validation failed, skipping: ${email}`, validation.checks);
170
+ return { blocked: 'validation', email, checks: validation.checks };
163
171
  }
164
172
 
165
173
  if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
@@ -131,80 +131,96 @@ async function removeSubscriber(email, publicationId) {
131
131
  }
132
132
 
133
133
  /**
134
- * Get a Beehiiv publication ID by brand name (fuzzy match).
134
+ * Get this brand's Beehiiv publication ID.
135
135
  *
136
- * @param {string} brandName
137
- * @returns {string|null} Publication ID or null
136
+ * Reads `Manager.config.marketing.beehiiv.publicationId` — populated by
137
+ * OMEGA's `beehiiv/ensure/publication.js` at brand-onboarding time. No
138
+ * runtime API call, no fuzzy-match fragility.
139
+ *
140
+ * If the brand hasn't been onboarded yet (publicationId missing/empty), logs
141
+ * a warning and returns null — the marketing sync will skip Beehiiv for this
142
+ * brand. Fix: run OMEGA's beehiiv service to populate.
143
+ *
144
+ * @returns {string|null} Publication ID or null if not configured
138
145
  */
139
- let _publicationIdCache = null;
140
-
141
- async function getPublicationId() {
142
- if (_publicationIdCache) {
143
- return _publicationIdCache;
144
- }
145
-
146
- // Use publicationId from config if set (skips API call)
147
- const configPubId = Manager.config?.marketing?.beehiiv?.publicationId;
148
-
149
- if (configPubId) {
150
- _publicationIdCache = configPubId;
151
- return configPubId;
152
- }
153
-
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;
146
+ function getPublicationId() {
147
+ const publicationId = Manager.config?.marketing?.beehiiv?.publicationId;
157
148
 
158
- if (!brandName) {
159
- console.error('Beehiiv: Brand name is required to find publication');
149
+ if (!publicationId) {
150
+ console.warn(
151
+ 'Beehiiv: marketing.beehiiv.publicationId is not set in config. '
152
+ + 'Subscriber will NOT be added to a publication. '
153
+ + 'Run OMEGA to populate.',
154
+ );
160
155
  return null;
161
156
  }
162
157
 
163
- const brandNameLower = brandName.toLowerCase();
164
- const allPublications = [];
165
- let page = 1;
166
- const limit = 100;
167
-
168
- try {
169
- while (true) {
170
- const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
171
- response: 'json',
172
- headers: headers(),
173
- timeout: 10000,
174
- });
175
-
176
- if (!data.data || data.data.length === 0) {
177
- break;
178
- }
179
-
180
- const matchedPub = data.data.find(pub =>
181
- pub.name.toLowerCase() === brandNameLower
182
- || pub.name.toLowerCase().includes(brandNameLower)
183
- || brandNameLower.includes(pub.name.toLowerCase())
184
- );
185
-
186
- if (matchedPub) {
187
- _publicationIdCache = matchedPub.id;
188
- return matchedPub.id;
189
- }
190
-
191
- allPublications.push(...data.data);
192
-
193
- if (data.data.length < limit) {
194
- break;
195
- }
196
-
197
- page++;
198
- }
199
-
200
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
201
- } catch (e) {
202
- console.error('Beehiiv publication lookup error:', e);
203
- }
204
-
205
- return null;
158
+ return publicationId;
206
159
  }
207
160
 
161
+ // LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
162
+ // in case the config-based approach has an edge case we haven't seen yet.
163
+ // Delete once we've verified the config-based approach works across all brands.
164
+ //
165
+ // let _publicationIdCache = null;
166
+ //
167
+ // async function getPublicationIdByFuzzyMatch() {
168
+ // if (_publicationIdCache) {
169
+ // return _publicationIdCache;
170
+ // }
171
+ //
172
+ // const brandName = Manager.config?.brand?.name;
173
+ //
174
+ // if (!brandName) {
175
+ // console.error('Beehiiv: Brand name is required to find publication');
176
+ // return null;
177
+ // }
178
+ //
179
+ // const brandNameLower = brandName.toLowerCase();
180
+ // const allPublications = [];
181
+ // let page = 1;
182
+ // const limit = 100;
183
+ //
184
+ // try {
185
+ // while (true) {
186
+ // const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
187
+ // response: 'json',
188
+ // headers: headers(),
189
+ // timeout: 10000,
190
+ // });
191
+ //
192
+ // if (!data.data || data.data.length === 0) {
193
+ // break;
194
+ // }
195
+ //
196
+ // const matchedPub = data.data.find(pub =>
197
+ // pub.name.toLowerCase() === brandNameLower
198
+ // || pub.name.toLowerCase().includes(brandNameLower)
199
+ // || brandNameLower.includes(pub.name.toLowerCase())
200
+ // );
201
+ //
202
+ // if (matchedPub) {
203
+ // _publicationIdCache = matchedPub.id;
204
+ // return matchedPub.id;
205
+ // }
206
+ //
207
+ // allPublications.push(...data.data);
208
+ //
209
+ // if (data.data.length < limit) {
210
+ // break;
211
+ // }
212
+ //
213
+ // page++;
214
+ // }
215
+ //
216
+ // console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
217
+ // } catch (e) {
218
+ // console.error('Beehiiv publication lookup error:', e);
219
+ // }
220
+ //
221
+ // return null;
222
+ // }
223
+
208
224
  /**
209
225
  * Add a contact to Beehiiv — resolves publication, adds subscriber with optional custom fields.
210
226
  *
@@ -217,7 +233,7 @@ async function getPublicationId() {
217
233
  * @returns {{ success: boolean, id?: string, error?: string }}
218
234
  */
219
235
  async function addContact({ email, firstName, lastName, company, source, customFields }) {
220
- const publicationId = await getPublicationId();
236
+ const publicationId = getPublicationId();
221
237
 
222
238
  if (!publicationId) {
223
239
  return { success: false, error: 'Publication not found' };
@@ -245,7 +261,7 @@ async function addContact({ email, firstName, lastName, company, source, customF
245
261
  * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
246
262
  */
247
263
  async function removeContact(email) {
248
- const publicationId = await getPublicationId();
264
+ const publicationId = getPublicationId();
249
265
 
250
266
  if (!publicationId) {
251
267
  return { success: false, error: 'Publication not found' };
@@ -290,7 +306,7 @@ async function resolveSegmentIds() {
290
306
  return _segmentIdCache;
291
307
  }
292
308
 
293
- const publicationId = await getPublicationId();
309
+ const publicationId = getPublicationId();
294
310
 
295
311
  if (!publicationId) {
296
312
  return {};
@@ -337,7 +353,7 @@ async function resolveSegmentIds() {
337
353
  * @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
338
354
  */
339
355
  async function createPost(options) {
340
- const publicationId = options.publicationId || await getPublicationId();
356
+ const publicationId = options.publicationId || getPublicationId();
341
357
 
342
358
  if (!publicationId) {
343
359
  return { success: false, error: 'Publication not found' };
@@ -9,6 +9,12 @@ const { resolveFieldValues } = require('../constants.js');
9
9
 
10
10
  const BASE_URL = 'https://api.sendgrid.com/v3';
11
11
 
12
+ // SendGrid's API is normally fast (<2s) but spikes past 10s during their
13
+ // hiccups, dropping signups silently. 60s is generous but harmless — the
14
+ // metadata calls (resolveFieldIds, getListId) are cached for the process
15
+ // lifetime so a slow first call costs nothing in steady state.
16
+ const SENDGRID_TIMEOUT_MS = 60000;
17
+
12
18
  // --- Internal helpers ---
13
19
 
14
20
  function headers() {
@@ -35,7 +41,7 @@ async function resolveFieldIds() {
35
41
  const data = await fetch(`${BASE_URL}/marketing/field_definitions`, {
36
42
  response: 'json',
37
43
  headers: headers(),
38
- timeout: 10000,
44
+ timeout: SENDGRID_TIMEOUT_MS,
39
45
  });
40
46
 
41
47
  _fieldIdCache = {};
@@ -70,7 +76,7 @@ async function resolveSegmentIds() {
70
76
  const data = await fetch(`${BASE_URL}/marketing/segments/2.0`, {
71
77
  response: 'json',
72
78
  headers: headers(),
73
- timeout: 10000,
79
+ timeout: SENDGRID_TIMEOUT_MS,
74
80
  });
75
81
 
76
82
  _segmentIdCache = {};
@@ -137,7 +143,7 @@ async function removeContact(email) {
137
143
  method: 'post',
138
144
  response: 'json',
139
145
  headers: headers(),
140
- timeout: 10000,
146
+ timeout: SENDGRID_TIMEOUT_MS,
141
147
  body: { emails: [email] },
142
148
  });
143
149
 
@@ -152,7 +158,7 @@ async function removeContact(email) {
152
158
  method: 'delete',
153
159
  response: 'json',
154
160
  headers: headers(),
155
- timeout: 10000,
161
+ timeout: SENDGRID_TIMEOUT_MS,
156
162
  });
157
163
 
158
164
  if (deleteData.job_id) {
@@ -167,69 +173,96 @@ async function removeContact(email) {
167
173
  }
168
174
 
169
175
  /**
170
- * Get a SendGrid list ID by brand name (fuzzy match).
176
+ * Get this brand's SendGrid Marketing list ID.
177
+ *
178
+ * Reads `Manager.config.marketing.sendgrid.listId` — populated by OMEGA's
179
+ * `sendgrid/ensure/list.js` at brand-onboarding time, same as how Beehiiv's
180
+ * `publicationId` works. No runtime API call, no fuzzy-match fragility.
171
181
  *
172
- * @param {string} brandName
173
- * @returns {string|null} List ID or null
182
+ * If the brand hasn't been onboarded yet (listId missing/empty), logs a
183
+ * warning and returns null the marketing sync will still succeed, but the
184
+ * contact lands in SendGrid's global pool instead of the brand's list. Fix:
185
+ * run OMEGA's sendgrid service to populate the config.
186
+ *
187
+ * @returns {string|null} List ID or null if not configured
174
188
  */
175
- async function getListId() {
176
- const brandName = Manager.config.brand?.name;
177
- const brandNameLower = (brandName || '').toLowerCase();
178
- const allLists = [];
179
- let pageToken = '';
180
- const pageSize = 1000;
181
-
182
- try {
183
- while (true) {
184
- const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
185
- const data = await fetch(url, {
186
- response: 'json',
187
- headers: headers(),
188
- timeout: 10000,
189
- });
190
-
191
- if (!data.result || data.result.length === 0) {
192
- break;
193
- }
194
-
195
- const matchedList = data.result.find(list =>
196
- list.name.toLowerCase() === brandNameLower
197
- || list.name.toLowerCase().includes(brandNameLower)
198
- || brandNameLower.includes(list.name.toLowerCase())
199
- );
200
-
201
- if (matchedList) {
202
- return matchedList.id;
203
- }
204
-
205
- allLists.push(...data.result);
206
-
207
- if (!data._metadata?.next) {
208
- break;
209
- }
210
-
211
- const nextUrl = new URL(data._metadata.next);
212
- pageToken = nextUrl.searchParams.get('page_token');
213
-
214
- if (!pageToken) {
215
- break;
216
- }
217
- }
218
-
219
- if (allLists.length === 1) {
220
- return allLists[0].id;
221
- }
222
-
223
- if (allLists.length > 0) {
224
- console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
225
- }
226
- } catch (e) {
227
- console.error('SendGrid list lookup error:', e);
189
+ function getListId() {
190
+ const listId = Manager.config.marketing?.sendgrid?.listId;
191
+
192
+ if (!listId) {
193
+ console.warn(
194
+ 'SendGrid: marketing.sendgrid.listId is not set in config. '
195
+ + 'Contact will be added to All Contacts only, not the brand list. '
196
+ + 'Run OMEGA to populate.',
197
+ );
198
+ return null;
228
199
  }
229
200
 
230
- return null;
201
+ return listId;
231
202
  }
232
203
 
204
+ // LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
205
+ // in case the config-based approach has an edge case we haven't seen yet.
206
+ // Delete once we've verified the config-based approach works across all brands.
207
+ //
208
+ // async function getListIdByFuzzyMatch() {
209
+ // const brandName = Manager.config.brand?.name;
210
+ // const brandNameLower = (brandName || '').toLowerCase();
211
+ // const allLists = [];
212
+ // let pageToken = '';
213
+ // const pageSize = 1000;
214
+ //
215
+ // try {
216
+ // while (true) {
217
+ // const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
218
+ // const data = await fetch(url, {
219
+ // response: 'json',
220
+ // headers: headers(),
221
+ // timeout: SENDGRID_TIMEOUT_MS,
222
+ // });
223
+ //
224
+ // if (!data.result || data.result.length === 0) {
225
+ // break;
226
+ // }
227
+ //
228
+ // const matchedList = data.result.find(list =>
229
+ // list.name.toLowerCase() === brandNameLower
230
+ // || list.name.toLowerCase().includes(brandNameLower)
231
+ // || brandNameLower.includes(list.name.toLowerCase())
232
+ // );
233
+ //
234
+ // if (matchedList) {
235
+ // return matchedList.id;
236
+ // }
237
+ //
238
+ // allLists.push(...data.result);
239
+ //
240
+ // if (!data._metadata?.next) {
241
+ // break;
242
+ // }
243
+ //
244
+ // const nextUrl = new URL(data._metadata.next);
245
+ // pageToken = nextUrl.searchParams.get('page_token');
246
+ //
247
+ // if (!pageToken) {
248
+ // break;
249
+ // }
250
+ // }
251
+ //
252
+ // if (allLists.length === 1) {
253
+ // return allLists[0].id;
254
+ // }
255
+ //
256
+ // if (allLists.length > 0) {
257
+ // console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
258
+ // }
259
+ // } catch (e) {
260
+ // console.error('SendGrid list lookup error:', e);
261
+ // }
262
+ //
263
+ // return null;
264
+ // }
265
+
233
266
  // --- Single Sends (Campaigns) ---
234
267
 
235
268
  /**
@@ -354,7 +387,7 @@ async function cancelSingleSend(singleSendId) {
354
387
  method: 'delete',
355
388
  response: 'json',
356
389
  headers: headers(),
357
- timeout: 10000,
390
+ timeout: SENDGRID_TIMEOUT_MS,
358
391
  });
359
392
 
360
393
  return { success: true };
@@ -375,7 +408,7 @@ async function getSingleSend(singleSendId) {
375
408
  const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
376
409
  response: 'json',
377
410
  headers: headers(),
378
- timeout: 10000,
411
+ timeout: SENDGRID_TIMEOUT_MS,
379
412
  });
380
413
 
381
414
  return data.id ? data : null;
@@ -400,7 +433,7 @@ async function listSingleSends(options) {
400
433
  const data = await fetch(url, {
401
434
  response: 'json',
402
435
  headers: headers(),
403
- timeout: 10000,
436
+ timeout: SENDGRID_TIMEOUT_MS,
404
437
  });
405
438
 
406
439
  return data.result || [];
@@ -437,7 +470,7 @@ async function addContact({ email, firstName, lastName, company, customFields })
437
470
  }
438
471
  }
439
472
 
440
- const listId = await getListId();
473
+ const listId = getListId();
441
474
  const result = await upsertContacts({
442
475
  contacts: [contact],
443
476
  listIds: listId ? [listId] : [],
@@ -508,7 +541,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
508
541
  const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
509
542
  response: 'json',
510
543
  headers: headers(),
511
- timeout: 10000,
544
+ timeout: SENDGRID_TIMEOUT_MS,
512
545
  });
513
546
 
514
547
  console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
@@ -138,10 +138,11 @@
138
138
  marketing: {
139
139
  sendgrid: {
140
140
  enabled: true,
141
+ listId: '', // SendGrid Marketing list UUID. Populated by OMEGA's sendgrid/ensure/list.js. Skips runtime list-discovery API call.
141
142
  },
142
143
  beehiiv: {
143
144
  enabled: false,
144
- // publicationId: 'pub_xxxxx', // Set to skip fuzzy-match API call
145
+ publicationId: '', // Beehiiv publication ID (e.g., 'pub_xxxxx'). Populated by OMEGA's beehiiv/ensure/publication.js. Required for marketing sync to Beehiiv.
145
146
  // Content pipeline. Lives under the provider that publishes the result —
146
147
  // Beehiiv for newsletters, eventually SendGrid for promo blasts. The
147
148
  // shape is the same regardless of provider: sources, tone, template,