backend-manager 5.0.156 → 5.0.158

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,34 @@ 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.0.158] - 2026-03-17
18
+ ### Added
19
+ - Newsletter generator system (`libraries/email/generators/newsletter.js`) — fetches sources from parent server, AI assembles branded content with subject/preheader
20
+ - Daily pre-generation cron (`cron/daily/marketing-newsletter-generate.js`) — generates newsletter content 24 hours before sendAt for calendar review
21
+ - `marketing.newsletter.enabled` and `marketing.newsletter.categories` config options
22
+ - `generator` field on campaign docs — tells cron to run content generation instead of sending directly
23
+
24
+ ### Changed
25
+ - Seed campaign IDs are now timing-agnostic: `_recurring-sale`, `_recurring-newsletter`
26
+ - Recurrence timing removed from enforced fields — consuming projects can freely change schedule
27
+ - Newsletter subject/preheader are now AI-generated (empty in seed template)
28
+ - Frequent cron skips generator campaigns (handled by daily pre-generation cron)
29
+ - Admin cron route now passes `libraries` to cron handlers
30
+
31
+ # [5.0.157] - 2026-03-17
32
+ ### Added
33
+ - Campaign template variables via `powertools.template()` — `{brand.name}`, `{season.name}`, `{holiday.name}`, `{date.month}`, `{date.year}`, `{date.full}`
34
+ - Separate SEASONS (Winter/Spring/Summer/Fall) and HOLIDAYS (New Year, Valentine's Day, Black Friday, Christmas, etc.) maps
35
+ - Audit logging in `getSegmentContacts()` — logs export start, poll status, download count, timeout
36
+
37
+ ### Changed
38
+ - Seed sale campaign: quarterly → monthly on 15th, uses `{holiday.name}` template vars, targets free + cancelled + churned users, excludes paid
39
+ - Prune cron calls segment export with 3-minute timeout for large segments
40
+
41
+ ### Fixed
42
+ - S3 presigned URL download broken by wonderful-fetch cache buster — set `cacheBreaker: false`
43
+ - CSV header parsing: normalize to lowercase for case-insensitive column matching
44
+
17
45
  # [5.0.156] - 2026-03-17
18
46
  ### Added
19
47
  - Marketing campaign system with full CRUD routes (`POST/GET/PUT/DELETE /marketing/campaign`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.156",
3
+ "version": "5.0.158",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -9,29 +9,25 @@ const moment = require('moment');
9
9
  * - enforced: Fields that MUST match on every setup run (overwritten if changed)
10
10
  *
11
11
  * Fields NOT in `enforced` are only set on creation and never touched again,
12
- * allowing runtime changes (sendAt advances, status changes, content edits).
12
+ * allowing runtime changes (sendAt advances, recurrence timing, content edits).
13
+ *
14
+ * IDs and names are timing-agnostic so consuming projects can change
15
+ * the recurrence pattern without breaking the ID.
13
16
  */
14
17
 
15
18
  /**
16
- * Get the next occurrence of a quarterly date from now.
17
- * Quarters: Jan 1, Apr 1, Jul 1, Oct 1.
19
+ * Get the next occurrence of a specific day of month.
20
+ * @param {number} dayOfMonth - Day (1-31)
21
+ * @param {number} hour - Hour (UTC)
18
22
  */
19
- function nextQuarter(hour) {
20
- const now = moment.utc();
21
- const quarters = [
22
- moment.utc({ month: 0, day: 1, hour }),
23
- moment.utc({ month: 3, day: 1, hour }),
24
- moment.utc({ month: 6, day: 1, hour }),
25
- moment.utc({ month: 9, day: 1, hour }),
26
- ];
23
+ function nextMonthDay(dayOfMonth, hour) {
24
+ const next = moment.utc().startOf('month').date(dayOfMonth).hour(hour);
27
25
 
28
- for (const q of quarters) {
29
- if (q.year(now.year()).isAfter(now)) {
30
- return q.year(now.year()).unix();
31
- }
26
+ if (next.isBefore(moment.utc())) {
27
+ next.add(1, 'month');
32
28
  }
33
29
 
34
- return quarters[0].year(now.year() + 1).unix();
30
+ return next.unix();
35
31
  }
36
32
 
37
33
  /**
@@ -56,30 +52,31 @@ function buildSeedCampaigns() {
56
52
 
57
53
  return [
58
54
  {
59
- id: '_recurring-quarterly-sale',
55
+ id: '_recurring-sale',
60
56
  doc: {
61
57
  settings: {
62
- name: 'Quarterly Sale',
63
- subject: 'Limited Time — Upgrade & Save!',
64
- preheader: 'Our biggest discount this quarter',
58
+ name: '{holiday.name} Sale',
59
+ subject: '{holiday.name} Sale — Upgrade & Save!',
60
+ preheader: 'Limited time offer from {brand.name}',
65
61
  content: [
66
- '# Quarterly Sale',
62
+ '# {holiday.name} Sale',
67
63
  '',
68
- 'For a limited time, upgrade your plan and save big.',
64
+ 'For a limited time, upgrade your **{brand.name}** plan and save big.',
69
65
  '',
70
66
  'Don\'t miss out — this offer ends soon!',
71
67
  ].join('\n'),
72
68
  template: 'default',
73
69
  sender: 'marketing',
74
70
  providers: ['sendgrid'],
75
- segments: ['subscription_free'],
76
- excludeSegments: [],
71
+ segments: ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
72
+ excludeSegments: ['subscription_paid'],
77
73
  },
78
- sendAt: nextQuarter(14),
74
+ sendAt: nextMonthDay(15, 14),
79
75
  status: 'pending',
80
76
  type: 'email',
81
77
  recurrence: {
82
- pattern: 'quarterly',
78
+ pattern: 'monthly',
79
+ day: 15,
83
80
  hour: 14,
84
81
  },
85
82
  metadata: {
@@ -87,30 +84,29 @@ function buildSeedCampaigns() {
87
84
  updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
88
85
  },
89
86
  },
90
- // Fields enforced on every setup run (deep path → value)
91
87
  enforced: {
92
88
  'type': 'email',
93
- 'recurrence.pattern': 'quarterly',
94
- 'recurrence.hour': 14,
95
89
  'settings.providers': ['sendgrid'],
96
90
  'settings.sender': 'marketing',
97
- 'settings.segments': ['subscription_free'],
91
+ 'settings.segments': ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
92
+ 'settings.excludeSegments': ['subscription_paid'],
98
93
  },
99
94
  },
100
95
  {
101
- id: '_recurring-weekly-newsletter',
96
+ id: '_recurring-newsletter',
102
97
  doc: {
103
98
  settings: {
104
- name: 'Weekly Newsletter',
105
- subject: 'This Week\'s Update',
106
- preheader: 'News, tips, and updates',
107
- content: '',
99
+ name: '{brand.name} Newsletter — {date.month} {date.year}',
100
+ subject: '', // Generated by AI
101
+ preheader: '', // Generated by AI
102
+ content: '', // Generated at send time by newsletter generator
108
103
  sender: 'newsletter',
109
104
  providers: ['beehiiv'],
110
105
  },
111
106
  sendAt: nextWeekday(1, 10),
112
107
  status: 'pending',
113
108
  type: 'email',
109
+ generator: 'newsletter',
114
110
  recurrence: {
115
111
  pattern: 'weekly',
116
112
  hour: 10,
@@ -123,9 +119,7 @@ function buildSeedCampaigns() {
123
119
  },
124
120
  enforced: {
125
121
  'type': 'email',
126
- 'recurrence.pattern': 'weekly',
127
- 'recurrence.hour': 10,
128
- 'recurrence.day': 1,
122
+ 'generator': 'newsletter',
129
123
  'settings.providers': ['beehiiv'],
130
124
  'settings.sender': 'newsletter',
131
125
  },
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Newsletter pre-generation cron job
3
+ *
4
+ * Runs daily. Looks for generator campaigns (e.g., _recurring-newsletter)
5
+ * with sendAt within the next 24 hours. Generates content via AI and creates
6
+ * a NEW standalone pending campaign with the real content.
7
+ *
8
+ * The generated campaign appears on the calendar for review.
9
+ * The frequent cron picks it up and sends it when sendAt is due.
10
+ *
11
+ * After generating, advances the recurring doc's sendAt to the next occurrence.
12
+ *
13
+ * Runs on bm_cronDaily.
14
+ */
15
+ const moment = require('moment');
16
+ const pushid = require('pushid');
17
+
18
+ // Generator modules — keyed by generator field value
19
+ const generators = {
20
+ newsletter: require('../../libraries/email/generators/newsletter.js'),
21
+ };
22
+
23
+ module.exports = async ({ Manager, assistant, libraries }) => {
24
+ const { admin } = libraries;
25
+
26
+ const now = Math.round(Date.now() / 1000);
27
+ const oneDayFromNow = now + (24 * 60 * 60);
28
+
29
+ // Find generator campaigns with sendAt within the next 24 hours
30
+ const snapshot = await admin.firestore()
31
+ .collection('marketing-campaigns')
32
+ .where('status', '==', 'pending')
33
+ .where('sendAt', '<=', oneDayFromNow)
34
+ .get();
35
+
36
+ // Filter to only generator campaigns (can't query on field existence in Firestore)
37
+ const generatorDocs = snapshot.docs.filter(doc => doc.data().generator);
38
+
39
+ if (!generatorDocs.length) {
40
+ assistant.log('No generator campaigns due within 24 hours');
41
+ return;
42
+ }
43
+
44
+ assistant.log(`Pre-generating ${generatorDocs.length} campaign(s)...`);
45
+
46
+ for (const doc of generatorDocs) {
47
+ const data = doc.data();
48
+ const { settings, type, generator, recurrence } = data;
49
+ const campaignId = doc.id;
50
+
51
+ if (!generators[generator]) {
52
+ assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
53
+ continue;
54
+ }
55
+
56
+ assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
57
+
58
+ // Run the generator
59
+ const generated = await generators[generator].generate(Manager, assistant, settings);
60
+
61
+ if (!generated) {
62
+ assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
63
+ continue;
64
+ }
65
+
66
+ // Create a new standalone campaign with the generated content
67
+ const newId = pushid();
68
+ const nowISO = new Date().toISOString();
69
+ const nowUNIX = Math.round(Date.now() / 1000);
70
+
71
+ await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
72
+ settings: generated,
73
+ type,
74
+ sendAt: data.sendAt,
75
+ status: 'pending',
76
+ generatedFrom: campaignId,
77
+ metadata: {
78
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
79
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
80
+ },
81
+ });
82
+
83
+ assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
84
+
85
+ // Advance the recurring doc's sendAt to the next occurrence
86
+ if (recurrence) {
87
+ const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
88
+
89
+ await doc.ref.set({
90
+ sendAt: nextSendAt,
91
+ metadata: {
92
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
93
+ },
94
+ }, { merge: true });
95
+
96
+ assistant.log(`Advanced ${campaignId} sendAt to ${moment.unix(nextSendAt).toISOString()}`);
97
+ }
98
+ }
99
+
100
+ assistant.log('Pre-generation complete');
101
+ };
102
+
103
+ /**
104
+ * Calculate the next occurrence unix timestamp.
105
+ */
106
+ function getNextOccurrence(currentSendAt, recurrence) {
107
+ const current = moment.unix(currentSendAt);
108
+ const { pattern } = recurrence;
109
+
110
+ switch (pattern) {
111
+ case 'daily': return current.add(1, 'day').unix();
112
+ case 'weekly': return current.add(1, 'week').unix();
113
+ case 'monthly': return current.add(1, 'month').unix();
114
+ case 'quarterly': return current.add(3, 'months').unix();
115
+ case 'yearly': return current.add(1, 'year').unix();
116
+ default: return current.add(1, 'month').unix();
117
+ }
118
+ }
@@ -95,7 +95,7 @@ async function stagePrune(Manager, assistant) {
95
95
  return;
96
96
  }
97
97
 
98
- const exportResult = await sendgridProvider.getSegmentContacts(pruneSegmentId);
98
+ const exportResult = await sendgridProvider.getSegmentContacts(pruneSegmentId, 180000);
99
99
 
100
100
  if (!exportResult.success) {
101
101
  assistant.error('Marketing prune: Failed to export segment:', exportResult.error);
@@ -40,11 +40,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
40
40
 
41
41
  const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
42
42
  const data = doc.data();
43
- const { settings, type, recurrence } = data;
43
+ let { settings, type, recurrence, generator } = data;
44
44
  const campaignId = doc.id;
45
45
 
46
46
  assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
47
47
 
48
+ // --- Generator campaigns are handled by the daily pre-generation cron, not here ---
49
+ if (generator) {
50
+ assistant.log(`Skipping generator campaign ${campaignId} — handled by daily pre-generation cron`);
51
+ return;
52
+ }
53
+
48
54
  // --- Dispatch by type ---
49
55
  let campaignResults;
50
56
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Newsletter generator — pulls content from parent server and assembles a branded newsletter.
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.
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
14
+ */
15
+ const fetch = require('wonderful-fetch');
16
+
17
+ /**
18
+ * Generate newsletter content from parent server sources.
19
+ *
20
+ * @param {object} Manager - BEM Manager instance
21
+ * @param {object} assistant - BEM assistant instance
22
+ * @param {object} settings - Campaign settings from the recurring template
23
+ * @returns {object} Updated settings with content filled in, or null if no content available
24
+ */
25
+ async function generate(Manager, assistant, settings) {
26
+ const config = Manager.config?.marketing?.newsletter;
27
+
28
+ if (!config?.enabled) {
29
+ assistant.log('Newsletter generator: disabled in config');
30
+ return null;
31
+ }
32
+
33
+ const categories = config.categories || [];
34
+
35
+ if (!categories.length) {
36
+ assistant.log('Newsletter generator: no categories configured');
37
+ return null;
38
+ }
39
+
40
+ const parentUrl = Manager.config?.parent?.apiUrl;
41
+
42
+ if (!parentUrl) {
43
+ assistant.log('Newsletter generator: no parent API URL configured');
44
+ return null;
45
+ }
46
+
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);
50
+
51
+ if (!sources.length) {
52
+ assistant.log('Newsletter generator: no sources available');
53
+ return null;
54
+ }
55
+
56
+ assistant.log(`Newsletter generator: ${sources.length} sources found, assembling...`);
57
+
58
+ const brand = Manager.config?.brand;
59
+
60
+ // AI assembles sources into newsletter with subject + preheader + content
61
+ const assembled = await assembleNewsletter(Manager, assistant, sources, brand);
62
+
63
+ if (!assembled) {
64
+ assistant.log('Newsletter generator: AI assembly failed');
65
+ return null;
66
+ }
67
+
68
+ // Mark sources as used on parent server
69
+ await claimSources(parentUrl, sources, brand?.id, assistant);
70
+
71
+ // Return updated settings — AI-generated fields override template placeholders
72
+ return {
73
+ ...settings,
74
+ subject: assembled.subject,
75
+ preheader: assembled.preheader,
76
+ content: assembled.content,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Fetch ready newsletter sources from the parent server.
82
+ */
83
+ async function fetchSources(parentUrl, categories, brandId, assistant) {
84
+ const allSources = [];
85
+
86
+ for (const category of categories) {
87
+ try {
88
+ const data = await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
89
+ method: 'get',
90
+ response: 'json',
91
+ timeout: 15000,
92
+ query: {
93
+ category,
94
+ limit: 3,
95
+ claimFor: brandId,
96
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
97
+ },
98
+ });
99
+
100
+ if (data.sources?.length) {
101
+ allSources.push(...data.sources);
102
+ }
103
+ } catch (e) {
104
+ assistant.error(`Newsletter generator: Failed to fetch ${category} sources:`, e.message);
105
+ }
106
+ }
107
+
108
+ return allSources;
109
+ }
110
+
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
+ /**
163
+ * Mark sources as used on the parent server.
164
+ */
165
+ async function claimSources(parentUrl, sources, brandId, assistant) {
166
+ for (const source of sources) {
167
+ try {
168
+ await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
169
+ method: 'put',
170
+ response: 'json',
171
+ timeout: 10000,
172
+ body: {
173
+ id: source.id,
174
+ usedBy: brandId || 'unknown',
175
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
176
+ },
177
+ });
178
+ } catch (e) {
179
+ assistant.error(`Newsletter generator: Failed to claim source ${source.id}:`, e.message);
180
+ }
181
+ }
182
+ }
183
+
184
+ module.exports = { generate };
@@ -271,9 +271,22 @@ Marketing.prototype.sendCampaign = async function (settings) {
271
271
  const results = {};
272
272
  const promises = [];
273
273
 
274
- // Convert markdown content to HTML, then tag links with UTM params
274
+ // Resolve campaign-level variables: {brand.name}, {season}, {year}, etc.
275
+ // Uses single braces via powertools.template() — distinct from {{template}} vars handled by SendGrid
275
276
  const brand = Manager.config?.brand;
276
- let contentHtml = settings.content ? md.render(settings.content) : '';
277
+ const templateContext = buildTemplateContext(brand);
278
+ const template = require('node-powertools').template;
279
+
280
+ const resolvedSettings = {
281
+ ...settings,
282
+ name: template(settings.name || '', templateContext),
283
+ subject: template(settings.subject || '', templateContext),
284
+ preheader: template(settings.preheader || '', templateContext),
285
+ content: template(settings.content || '', templateContext),
286
+ };
287
+
288
+ // Convert markdown content to HTML, then tag links with UTM params
289
+ let contentHtml = resolvedSettings.content ? md.render(resolvedSettings.content) : '';
277
290
 
278
291
  if (contentHtml) {
279
292
  contentHtml = tagLinks(contentHtml, {
@@ -300,7 +313,7 @@ Marketing.prototype.sendCampaign = async function (settings) {
300
313
  // Beehiiv: segment resolution will go here when Beehiiv segments are supported
301
314
 
302
315
  assistant.log('Marketing.sendCampaign():', {
303
- name: settings.name,
316
+ name: resolvedSettings.name,
304
317
  providers: useProviders,
305
318
  sendAt: settings.sendAt || 'draft',
306
319
  });
@@ -308,7 +321,7 @@ Marketing.prototype.sendCampaign = async function (settings) {
308
321
  // --- SendGrid ---
309
322
  if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
310
323
  const sgSettings = {
311
- ...settings,
324
+ ...resolvedSettings,
312
325
  segments: resolvedSegments.sendgrid?.segments || [],
313
326
  excludeSegments: resolvedSegments.sendgrid?.excludeSegments || [],
314
327
  };
@@ -324,9 +337,9 @@ Marketing.prototype.sendCampaign = async function (settings) {
324
337
  if (useProviders.includes('beehiiv') && self.providers.beehiiv) {
325
338
  promises.push(
326
339
  beehiivProvider.createPost({
327
- title: settings.name,
328
- subject: settings.subject,
329
- preheader: settings.preheader,
340
+ title: resolvedSettings.name,
341
+ subject: resolvedSettings.subject,
342
+ preheader: resolvedSettings.preheader,
330
343
  content: contentHtml,
331
344
  sendAt: settings.sendAt,
332
345
  segments: settings.segments,
@@ -459,4 +472,73 @@ Marketing.prototype.listCampaigns = async function (options) {
459
472
  return sendgridProvider.listSingleSends(options);
460
473
  };
461
474
 
475
+ // --- Campaign variable resolution ---
476
+
477
+ const SEASONS = {
478
+ 0: 'Winter', // Jan
479
+ 1: 'Winter', // Feb
480
+ 2: 'Spring', // Mar
481
+ 3: 'Spring', // Apr
482
+ 4: 'Spring', // May
483
+ 5: 'Summer', // Jun
484
+ 6: 'Summer', // Jul
485
+ 7: 'Summer', // Aug
486
+ 8: 'Fall', // Sep
487
+ 9: 'Fall', // Oct
488
+ 10: 'Fall', // Nov
489
+ 11: 'Winter', // Dec
490
+ };
491
+
492
+ const HOLIDAYS = {
493
+ 0: 'New Year', // Jan
494
+ 1: 'Valentine\'s Day', // Feb
495
+ 2: 'Spring', // Mar
496
+ 3: 'Spring', // Apr
497
+ 4: 'Memorial Day', // May
498
+ 5: 'Summer', // Jun
499
+ 6: 'Independence Day', // Jul
500
+ 7: 'Back to School', // Aug
501
+ 8: 'Labor Day', // Sep
502
+ 9: 'Halloween', // Oct
503
+ 10: 'Black Friday', // Nov
504
+ 11: 'Christmas', // Dec
505
+ };
506
+
507
+ const MONTH_NAMES = [
508
+ 'January', 'February', 'March', 'April', 'May', 'June',
509
+ 'July', 'August', 'September', 'October', 'November', 'December',
510
+ ];
511
+
512
+ /**
513
+ * Build template context for campaign variable resolution.
514
+ * Used with powertools.template() — supports nested paths like {brand.name}.
515
+ *
516
+ * Available variables:
517
+ * {brand.name}, {brand.id}, {brand.url}
518
+ * {season.name} — Winter, Spring, Summer, Fall
519
+ * {holiday.name} — New Year, Valentine's Day, Black Friday, Christmas, etc.
520
+ * {date.month} — January, February, etc.
521
+ * {date.year} — 2026
522
+ * {date.full} — March 17, 2026
523
+ */
524
+ function buildTemplateContext(brand) {
525
+ const now = new Date();
526
+ const month = now.getMonth();
527
+
528
+ return {
529
+ brand: brand || {},
530
+ season: {
531
+ name: SEASONS[month],
532
+ },
533
+ holiday: {
534
+ name: HOLIDAYS[month],
535
+ },
536
+ date: {
537
+ month: MONTH_NAMES[month],
538
+ year: String(now.getFullYear()),
539
+ full: now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
540
+ },
541
+ };
542
+ }
543
+
462
544
  module.exports = Marketing;
@@ -495,11 +495,15 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
495
495
  return { success: false, error: 'Failed to start export' };
496
496
  }
497
497
 
498
+ console.log(`SendGrid getSegmentContacts: Export started (job: ${exportData.id})`);
499
+
498
500
  // Poll for completion
499
501
  const startTime = Date.now();
502
+ let pollCount = 0;
500
503
 
501
504
  while (Date.now() - startTime < maxWaitMs) {
502
505
  await new Promise(r => setTimeout(r, 3000));
506
+ pollCount++;
503
507
 
504
508
  const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
505
509
  response: 'json',
@@ -507,11 +511,14 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
507
511
  timeout: 10000,
508
512
  });
509
513
 
514
+ console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
515
+
510
516
  if (statusData.status === 'ready' && statusData.urls?.length) {
511
- // Download CSV
517
+ // Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
512
518
  const csvText = await fetch(statusData.urls[0], {
513
519
  response: 'text',
514
520
  timeout: 30000,
521
+ cacheBreaker: false,
515
522
  });
516
523
 
517
524
  // Parse CSV — first line is headers, find email and id columns
@@ -521,7 +528,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
521
528
  return { success: true, contacts: [] };
522
529
  }
523
530
 
524
- const headerCols = lines[0].split(',');
531
+ const headerCols = lines[0].split(',').map(h => h.replace(/"/g, '').trim().toLowerCase());
525
532
  const emailIdx = headerCols.indexOf('email');
526
533
  const idIdx = headerCols.indexOf('contact_id');
527
534
 
@@ -537,14 +544,18 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
537
544
  }
538
545
  }
539
546
 
547
+ console.log(`SendGrid getSegmentContacts: Downloaded ${contacts.length} contacts (${Date.now() - startTime}ms)`);
548
+
540
549
  return { success: true, contacts };
541
550
  }
542
551
 
543
552
  if (statusData.status === 'failure') {
553
+ console.error('SendGrid getSegmentContacts: Export failed');
544
554
  return { success: false, error: 'Export failed' };
545
555
  }
546
556
  }
547
557
 
558
+ console.error(`SendGrid getSegmentContacts: Timed out after ${maxWaitMs}ms (${pollCount} polls)`);
548
559
  return { success: false, error: 'Export timed out' };
549
560
  } catch (e) {
550
561
  console.error('SendGrid getSegmentContacts error:', e);
@@ -24,7 +24,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
24
24
  // Run the cron job
25
25
  const cronPath = require('path').resolve(__dirname, `../../../cron/${settings.id}.js`);
26
26
  const cronHandler = require(cronPath);
27
- const result = await cronHandler({ Manager, assistant, context: {} }).catch(e => e);
27
+ const result = await cronHandler({ Manager, assistant, context: {}, libraries: Manager.libraries }).catch(e => e);
28
28
 
29
29
  if (result instanceof Error) {
30
30
  return assistant.respond(result.message, { code: 500 });
@@ -133,6 +133,10 @@
133
133
  prune: {
134
134
  enabled: true,
135
135
  },
136
+ newsletter: {
137
+ enabled: false,
138
+ categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
139
+ },
136
140
  },
137
141
  firebaseConfig: {
138
142
  apiKey: '123-456',