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 +28 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +32 -38
- package/src/manager/cron/daily/marketing-newsletter-generate.js +118 -0
- package/src/manager/cron/daily/marketing-prune.js +1 -1
- package/src/manager/cron/frequent/marketing-campaigns.js +7 -1
- package/src/manager/libraries/email/generators/newsletter.js +184 -0
- package/src/manager/libraries/email/marketing/index.js +89 -7
- package/src/manager/libraries/email/providers/sendgrid.js +13 -2
- package/src/manager/routes/admin/cron/post.js +1 -1
- package/templates/backend-manager-config.json +4 -0
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
|
@@ -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,
|
|
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
|
|
17
|
-
*
|
|
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
|
|
20
|
-
const
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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-
|
|
55
|
+
id: '_recurring-sale',
|
|
60
56
|
doc: {
|
|
61
57
|
settings: {
|
|
62
|
-
name: '
|
|
63
|
-
subject: '
|
|
64
|
-
preheader: '
|
|
58
|
+
name: '{holiday.name} Sale',
|
|
59
|
+
subject: '{holiday.name} Sale — Upgrade & Save!',
|
|
60
|
+
preheader: 'Limited time offer from {brand.name}',
|
|
65
61
|
content: [
|
|
66
|
-
'#
|
|
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:
|
|
74
|
+
sendAt: nextMonthDay(15, 14),
|
|
79
75
|
status: 'pending',
|
|
80
76
|
type: 'email',
|
|
81
77
|
recurrence: {
|
|
82
|
-
pattern: '
|
|
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-
|
|
96
|
+
id: '_recurring-newsletter',
|
|
102
97
|
doc: {
|
|
103
98
|
settings: {
|
|
104
|
-
name: '
|
|
105
|
-
subject: '
|
|
106
|
-
preheader: '
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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:
|
|
328
|
-
subject:
|
|
329
|
-
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 });
|