@sendinel/mcp-server 1.0.4 → 1.0.5
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/build/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +121 -5
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +9 -3
package/build/tools/campaigns.js
CHANGED
|
@@ -5,13 +5,260 @@ import { getProjectId } from '../project.js';
|
|
|
5
5
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
6
6
|
import { buildVoicePrompt, buildEmailSystemPrompt, validateTemplate } from '../lib/voice.js';
|
|
7
7
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
8
|
-
import {
|
|
8
|
+
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
9
9
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
10
10
|
import { getScheduleWindowBounds, groupScheduleByCampaign } from '../lib/schedule.js';
|
|
11
|
+
import { sendEmailForSite } from '../lib/email-provider.js';
|
|
12
|
+
import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
13
|
+
import { logAudit } from '../audit.js';
|
|
14
|
+
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
11
15
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
12
16
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
17
|
+
function normalizeTriggerValue(value) {
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
return null;
|
|
20
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
21
|
+
return normalized || null;
|
|
22
|
+
}
|
|
23
|
+
function normalizeTriggerList(value) {
|
|
24
|
+
const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
25
|
+
return Array.from(new Set(raw.map(normalizeTriggerValue).filter((item) => Boolean(item))));
|
|
26
|
+
}
|
|
27
|
+
function buildCampaignTriggerConfig(value) {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
29
|
+
return null;
|
|
30
|
+
const config = value;
|
|
31
|
+
const events = normalizeTriggerList(config.events ?? config.event_names ?? config.trigger_event_names);
|
|
32
|
+
const tags = normalizeTriggerList(config.tags ?? config.trigger_tags ?? config.entity_tags ?? config.project_tags);
|
|
33
|
+
if (!events.length && !tags.length)
|
|
34
|
+
return null;
|
|
35
|
+
return { events, tags };
|
|
36
|
+
}
|
|
37
|
+
async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
38
|
+
if (input.trigger_event_name !== undefined)
|
|
39
|
+
updates.trigger_event_name = input.trigger_event_name?.trim() || null;
|
|
40
|
+
if (input.trigger_tag !== undefined)
|
|
41
|
+
updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
|
|
42
|
+
if (input.trigger_type === 'date_field') {
|
|
43
|
+
const config = input.trigger_config ?? {};
|
|
44
|
+
const field = typeof config.field === 'string' ? config.field.trim() : '';
|
|
45
|
+
if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
|
|
46
|
+
return 'date field is required';
|
|
47
|
+
updates.trigger_type = 'date_field';
|
|
48
|
+
updates.trigger_config = {
|
|
49
|
+
field,
|
|
50
|
+
days_before: Math.max(0, Math.min(30, Number(config.days_before ?? 0) || 0)),
|
|
51
|
+
recurring: config.recurring !== false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
else if (input.trigger_config !== undefined) {
|
|
55
|
+
if (input.trigger_config === null) {
|
|
56
|
+
updates.trigger_config = null;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const config = buildCampaignTriggerConfig(input.trigger_config);
|
|
60
|
+
if (!config)
|
|
61
|
+
return 'trigger_config must include events or tags';
|
|
62
|
+
updates.trigger_config = config;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (input.segment_id !== undefined) {
|
|
66
|
+
if (!input.segment_id) {
|
|
67
|
+
updates.segment_id = null;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const { data: segment, error } = await db()
|
|
71
|
+
.from('segments')
|
|
72
|
+
.select('id')
|
|
73
|
+
.eq('id', input.segment_id)
|
|
74
|
+
.eq('project_id', projectId)
|
|
75
|
+
.maybeSingle();
|
|
76
|
+
if (error)
|
|
77
|
+
return error.message;
|
|
78
|
+
if (!segment)
|
|
79
|
+
return 'Segment not found';
|
|
80
|
+
updates.segment_id = input.segment_id;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function applySegmentPaymentRules(query, payment) {
|
|
86
|
+
if (!payment)
|
|
87
|
+
return query;
|
|
88
|
+
let q = query;
|
|
89
|
+
if (payment.plan)
|
|
90
|
+
q = q.contains('custom_properties', { payment: { plan: payment.plan } });
|
|
91
|
+
if (payment.mrr_cents !== undefined)
|
|
92
|
+
q = q.contains('custom_properties', { payment: { mrr_cents: payment.mrr_cents } });
|
|
93
|
+
if (payment.ltv_cents !== undefined)
|
|
94
|
+
q = q.contains('custom_properties', { payment: { ltv_cents: payment.ltv_cents } });
|
|
95
|
+
if (payment.subscription_status)
|
|
96
|
+
q = q.contains('custom_properties', { payment: { subscription_status: payment.subscription_status } });
|
|
97
|
+
q = applySegmentPaymentDateRule(q, 'trial_ends_at', payment.trial_ends_at);
|
|
98
|
+
q = applySegmentPaymentDateRule(q, 'churned_at', payment.churned_at);
|
|
99
|
+
q = applySegmentPaymentDateRule(q, 'paid_at', payment.paid_at);
|
|
100
|
+
return q;
|
|
101
|
+
}
|
|
102
|
+
function applySegmentPaymentDateRule(query, field, rule) {
|
|
103
|
+
if (!rule)
|
|
104
|
+
return query;
|
|
105
|
+
if (typeof rule === 'string') {
|
|
106
|
+
return query.contains('custom_properties', { payment: { [field]: rule } });
|
|
107
|
+
}
|
|
108
|
+
let q = query;
|
|
109
|
+
const column = `custom_properties->payment->>${field}`;
|
|
110
|
+
if (typeof rule.within_days === 'number') {
|
|
111
|
+
const start = new Date();
|
|
112
|
+
const end = new Date(start.getTime() + rule.within_days * 24 * 60 * 60 * 1000);
|
|
113
|
+
q = q.gte(column, start.toISOString()).lte(column, end.toISOString());
|
|
114
|
+
}
|
|
115
|
+
if (rule.after)
|
|
116
|
+
q = q.gte(column, rule.after);
|
|
117
|
+
if (rule.before)
|
|
118
|
+
q = q.lte(column, rule.before);
|
|
119
|
+
return q;
|
|
120
|
+
}
|
|
121
|
+
async function getSegmentContactIds(projectId, rules) {
|
|
122
|
+
let q = db()
|
|
123
|
+
.from('subscribers')
|
|
124
|
+
.select('id, email')
|
|
125
|
+
.eq('project_id', projectId)
|
|
126
|
+
.eq('global_unsubscribed', false)
|
|
127
|
+
.is('cooled_at', null);
|
|
128
|
+
if (rules.lifecycle_stage)
|
|
129
|
+
q = q.eq('lifecycle_stage', rules.lifecycle_stage);
|
|
130
|
+
q = applySegmentPaymentRules(q, rules.payment);
|
|
131
|
+
if (rules.min_score !== undefined)
|
|
132
|
+
q = q.gte('engagement_score', rules.min_score);
|
|
133
|
+
if (rules.max_score !== undefined)
|
|
134
|
+
q = q.lte('engagement_score', rules.max_score);
|
|
135
|
+
if (rules.source)
|
|
136
|
+
q = q.eq('source', rules.source);
|
|
137
|
+
if (rules.subscribed_after)
|
|
138
|
+
q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
|
|
139
|
+
if (rules.subscribed_before)
|
|
140
|
+
q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
|
|
141
|
+
if (rules.has_opened === true)
|
|
142
|
+
q = q.not('last_opened_at', 'is', null);
|
|
143
|
+
if (rules.has_opened === false)
|
|
144
|
+
q = q.is('last_opened_at', null);
|
|
145
|
+
if (rules.has_clicked === true)
|
|
146
|
+
q = q.not('last_clicked_at', 'is', null);
|
|
147
|
+
if (rules.has_clicked === false)
|
|
148
|
+
q = q.is('last_clicked_at', null);
|
|
149
|
+
if (rules.tag)
|
|
150
|
+
q = q.contains('tags', [rules.tag]);
|
|
151
|
+
if (rules.site_id) {
|
|
152
|
+
const { data: subs } = await db()
|
|
153
|
+
.from('site_subscriptions')
|
|
154
|
+
.select('contact_id')
|
|
155
|
+
.eq('site_id', rules.site_id)
|
|
156
|
+
.eq('subscribed', true);
|
|
157
|
+
const siteIds = (subs ?? []).map((s) => s.contact_id);
|
|
158
|
+
if (!siteIds.length)
|
|
159
|
+
return [];
|
|
160
|
+
q = q.in('id', siteIds);
|
|
161
|
+
}
|
|
162
|
+
const { data, error } = await q;
|
|
163
|
+
if (error || !data)
|
|
164
|
+
return [];
|
|
165
|
+
let ids = data.map((c) => c.id);
|
|
166
|
+
const { data: suppressed } = await db()
|
|
167
|
+
.from('suppressions')
|
|
168
|
+
.select('email')
|
|
169
|
+
.eq('project_id', projectId);
|
|
170
|
+
if (suppressed?.length) {
|
|
171
|
+
const suppressedEmails = new Set(suppressed.map((s) => s.email));
|
|
172
|
+
ids = data
|
|
173
|
+
.filter((c) => !suppressedEmails.has(c.email))
|
|
174
|
+
.map((c) => c.id);
|
|
175
|
+
}
|
|
176
|
+
return ids;
|
|
177
|
+
}
|
|
178
|
+
function extractJsonObject(raw) {
|
|
179
|
+
const cleaned = raw.replace(/```(?:json)?/gi, '').replace(/```/g, '').trim();
|
|
180
|
+
const start = cleaned.indexOf('{');
|
|
181
|
+
const end = cleaned.lastIndexOf('}');
|
|
182
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
183
|
+
throw new Error('No JSON object found in consistency response');
|
|
184
|
+
}
|
|
185
|
+
return cleaned.slice(start, end + 1);
|
|
186
|
+
}
|
|
187
|
+
function cleanConsistencyText(value) {
|
|
188
|
+
return typeof value === 'string' ? value.trim().slice(0, 1000) : '';
|
|
189
|
+
}
|
|
190
|
+
function normalizeConsistencyResult(value) {
|
|
191
|
+
const obj = value && typeof value === 'object' ? value : {};
|
|
192
|
+
const rawFlags = Array.isArray(obj.flags) ? obj.flags : [];
|
|
193
|
+
const flags = rawFlags
|
|
194
|
+
.map((flag) => {
|
|
195
|
+
const record = flag && typeof flag === 'object' ? flag : {};
|
|
196
|
+
return {
|
|
197
|
+
claim_a: cleanConsistencyText(record.claim_a),
|
|
198
|
+
location_a: cleanConsistencyText(record.location_a),
|
|
199
|
+
claim_b: cleanConsistencyText(record.claim_b),
|
|
200
|
+
location_b: cleanConsistencyText(record.location_b),
|
|
201
|
+
explanation: cleanConsistencyText(record.explanation),
|
|
202
|
+
};
|
|
203
|
+
})
|
|
204
|
+
.filter((flag) => flag.claim_a && flag.claim_b && flag.explanation)
|
|
205
|
+
.slice(0, 50);
|
|
206
|
+
return {
|
|
207
|
+
consistent: typeof obj.consistent === 'boolean' ? obj.consistent : flags.length === 0,
|
|
208
|
+
flags,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
13
211
|
export function registerCampaignTools(server) {
|
|
212
|
+
server.registerTool('review_campaign_portfolio', {
|
|
213
|
+
title: 'Review Campaign Portfolio',
|
|
214
|
+
annotations: {
|
|
215
|
+
readOnlyHint: true,
|
|
216
|
+
destructiveHint: false,
|
|
217
|
+
idempotentHint: true,
|
|
218
|
+
openWorldHint: false,
|
|
219
|
+
},
|
|
220
|
+
description: 'Run the campaign portfolio review and optionally raise agent proposals for start/end/modify recommendations.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
dry_run: z.boolean().optional().default(false),
|
|
223
|
+
dormant_draft_days: z.number().int().min(1).max(365).optional(),
|
|
224
|
+
abandoned_days: z.number().int().min(1).max(365).optional(),
|
|
225
|
+
},
|
|
226
|
+
}, async ({ dry_run = false, dormant_draft_days, abandoned_days }) => {
|
|
227
|
+
const apiKey = process.env.SENDINEL_API_KEY;
|
|
228
|
+
if (!apiKey)
|
|
229
|
+
return j({ error: 'SENDINEL_API_KEY is required to run review_campaign_portfolio from MCP' });
|
|
230
|
+
const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
231
|
+
const res = await fetch(`${base}/api/v1/campaigns/review`, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
Authorization: `Bearer ${apiKey}`,
|
|
235
|
+
'Content-Type': 'application/json',
|
|
236
|
+
'X-Sendinel-Project-Id': getProjectId(),
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
dry_run,
|
|
240
|
+
...(dormant_draft_days !== undefined ? { dormant_draft_days } : {}),
|
|
241
|
+
...(abandoned_days !== undefined ? { abandoned_days } : {}),
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const body = await res.text();
|
|
245
|
+
if (!res.ok)
|
|
246
|
+
return j({ error: `Campaign portfolio review failed: ${res.status}`, body });
|
|
247
|
+
try {
|
|
248
|
+
return j(JSON.parse(body));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return txt(body);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
14
254
|
server.registerTool('get_campaign_calendar', {
|
|
255
|
+
title: 'Get Campaign Calendar',
|
|
256
|
+
annotations: {
|
|
257
|
+
readOnlyHint: true,
|
|
258
|
+
destructiveHint: false,
|
|
259
|
+
idempotentHint: true,
|
|
260
|
+
openWorldHint: false,
|
|
261
|
+
},
|
|
15
262
|
description: 'Get campaigns grouped by day for a calendar month.',
|
|
16
263
|
inputSchema: { year: z.number().int().min(2000).max(2100), month: z.number().int().min(1).max(12), site_id: z.string().uuid().optional() },
|
|
17
264
|
}, async ({ year, month, site_id }) => {
|
|
@@ -41,6 +288,13 @@ export function registerCampaignTools(server) {
|
|
|
41
288
|
return j({ days });
|
|
42
289
|
});
|
|
43
290
|
server.registerTool('preview_campaign_audience', {
|
|
291
|
+
title: 'Preview Campaign Audience',
|
|
292
|
+
annotations: {
|
|
293
|
+
readOnlyHint: true,
|
|
294
|
+
destructiveHint: false,
|
|
295
|
+
idempotentHint: true,
|
|
296
|
+
openWorldHint: false,
|
|
297
|
+
},
|
|
44
298
|
description: 'Preview active campaign audience and suppression warnings.',
|
|
45
299
|
inputSchema: { campaign_id: z.string().uuid() },
|
|
46
300
|
}, async ({ campaign_id }) => {
|
|
@@ -59,6 +313,13 @@ export function registerCampaignTools(server) {
|
|
|
59
313
|
return j({ total, suppressed_count: suppressedCount, will_receive: Math.max(total - suppressedCount, 0), warnings: suppressedCount ? [`${suppressedCount} contacts are suppressed and will be skipped`] : [], sample: (subscribers ?? []).filter((row) => !suppressed.has(row.email)).slice(0, 20) });
|
|
60
314
|
});
|
|
61
315
|
server.registerTool('list_campaigns', {
|
|
316
|
+
title: 'List Campaigns',
|
|
317
|
+
annotations: {
|
|
318
|
+
readOnlyHint: true,
|
|
319
|
+
destructiveHint: false,
|
|
320
|
+
idempotentHint: true,
|
|
321
|
+
openWorldHint: false,
|
|
322
|
+
},
|
|
62
323
|
description: 'List campaigns, optionally filtered by site and/or status',
|
|
63
324
|
inputSchema: {
|
|
64
325
|
site_id: z.string().uuid().optional(),
|
|
@@ -68,7 +329,7 @@ export function registerCampaignTools(server) {
|
|
|
68
329
|
}, async ({ site_id, status, limit }) => {
|
|
69
330
|
let q = db()
|
|
70
331
|
.from('campaigns')
|
|
71
|
-
.select('id, site_id, name, type, status, total_enrolled, total_sent, total_opened, total_clicked, created_at')
|
|
332
|
+
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at')
|
|
72
333
|
.eq('project_id', getProjectId())
|
|
73
334
|
.order('created_at', { ascending: false })
|
|
74
335
|
.limit(limit ?? 25);
|
|
@@ -82,6 +343,13 @@ export function registerCampaignTools(server) {
|
|
|
82
343
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
83
344
|
});
|
|
84
345
|
server.registerTool('get_schedule', {
|
|
346
|
+
title: 'Get Schedule',
|
|
347
|
+
annotations: {
|
|
348
|
+
readOnlyHint: true,
|
|
349
|
+
destructiveHint: false,
|
|
350
|
+
idempotentHint: true,
|
|
351
|
+
openWorldHint: false,
|
|
352
|
+
},
|
|
85
353
|
description: 'Get scheduled campaign sends for a site within a time window, grouped by campaign.',
|
|
86
354
|
inputSchema: {
|
|
87
355
|
site_id: z.string().uuid(),
|
|
@@ -113,22 +381,48 @@ export function registerCampaignTools(server) {
|
|
|
113
381
|
});
|
|
114
382
|
});
|
|
115
383
|
server.registerTool('create_campaign', {
|
|
116
|
-
|
|
384
|
+
title: 'Create Campaign',
|
|
385
|
+
annotations: {
|
|
386
|
+
readOnlyHint: false,
|
|
387
|
+
destructiveHint: false,
|
|
388
|
+
idempotentHint: false,
|
|
389
|
+
openWorldHint: false,
|
|
390
|
+
},
|
|
391
|
+
description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting.',
|
|
117
392
|
inputSchema: {
|
|
118
393
|
site_id: z.string().uuid(),
|
|
119
394
|
name: z.string().min(1).max(200),
|
|
120
395
|
type: z.enum(['sequence', 'broadcast', 'triggered']),
|
|
121
396
|
description: z.string().optional(),
|
|
397
|
+
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
|
|
122
398
|
requires_approval: z.boolean().optional().default(true),
|
|
399
|
+
trigger_event_name: z.string().optional().describe('For type=triggered: event name that fires this campaign'),
|
|
400
|
+
trigger_tag: z.string().optional().describe('For type=triggered: contact tag that fires this campaign when applied'),
|
|
401
|
+
trigger_type: z.enum(['date_field']).optional().describe('Set for elapsed-time triggers keyed on a contact custom_properties date field'),
|
|
402
|
+
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('For trigger_type=date_field: { field, days_before?, recurring? }. Otherwise: { events?: string[], tags?: string[] }.'),
|
|
403
|
+
segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
|
|
123
404
|
},
|
|
124
|
-
}, async ({ site_id, name, type, description, requires_approval }) => {
|
|
405
|
+
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }) => {
|
|
125
406
|
const limit = await checkPlanLimit('active_campaigns');
|
|
126
407
|
if (!limit.allowed) {
|
|
127
408
|
return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
|
|
128
409
|
}
|
|
410
|
+
const projectId = getProjectId();
|
|
411
|
+
const insertPayload = {
|
|
412
|
+
project_id: projectId,
|
|
413
|
+
site_id,
|
|
414
|
+
name,
|
|
415
|
+
type,
|
|
416
|
+
description,
|
|
417
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
418
|
+
requires_approval,
|
|
419
|
+
};
|
|
420
|
+
const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
421
|
+
if (targetingError)
|
|
422
|
+
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
|
129
423
|
const { data, error } = await db()
|
|
130
424
|
.from('campaigns')
|
|
131
|
-
.insert(
|
|
425
|
+
.insert(insertPayload)
|
|
132
426
|
.select()
|
|
133
427
|
.single();
|
|
134
428
|
if (error)
|
|
@@ -136,6 +430,13 @@ export function registerCampaignTools(server) {
|
|
|
136
430
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
137
431
|
});
|
|
138
432
|
server.registerTool('add_campaign_step', {
|
|
433
|
+
title: 'Add Campaign Step',
|
|
434
|
+
annotations: {
|
|
435
|
+
readOnlyHint: false,
|
|
436
|
+
destructiveHint: false,
|
|
437
|
+
idempotentHint: false,
|
|
438
|
+
openWorldHint: false,
|
|
439
|
+
},
|
|
139
440
|
description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
140
441
|
inputSchema: {
|
|
141
442
|
campaign_id: z.string().uuid(),
|
|
@@ -146,18 +447,47 @@ export function registerCampaignTools(server) {
|
|
|
146
447
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
147
448
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
148
449
|
variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
|
|
450
|
+
survey_id: z.string().uuid().optional(),
|
|
451
|
+
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
|
|
149
452
|
},
|
|
150
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label }) => {
|
|
453
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
|
|
454
|
+
const renderVars = {};
|
|
455
|
+
if (survey_id) {
|
|
456
|
+
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
457
|
+
if (!surveyLink)
|
|
458
|
+
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
459
|
+
renderVars.survey_link = surveyLink;
|
|
460
|
+
}
|
|
461
|
+
const renderedSubject = survey_id ? renderTemplateString(subject, renderVars) : subject;
|
|
462
|
+
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html;
|
|
463
|
+
const renderedText = body_text !== undefined && survey_id ? renderTemplateString(body_text, renderVars) : body_text;
|
|
151
464
|
const { data, error } = await db()
|
|
152
465
|
.from('campaign_steps')
|
|
153
|
-
.insert({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
|
|
466
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
|
|
154
467
|
.select()
|
|
155
468
|
.single();
|
|
156
469
|
if (error)
|
|
157
470
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
471
|
+
await recordBrandAssetUsage({
|
|
472
|
+
client: db(),
|
|
473
|
+
projectId: getProjectId(),
|
|
474
|
+
source: 'campaign_step',
|
|
475
|
+
resourceId: data.id,
|
|
476
|
+
html: renderedHtml,
|
|
477
|
+
assetIds: brand_asset_ids,
|
|
478
|
+
}).catch((err) => {
|
|
479
|
+
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
480
|
+
});
|
|
158
481
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
159
482
|
});
|
|
160
483
|
server.registerTool('update_campaign_status', {
|
|
484
|
+
title: 'Update Campaign Status',
|
|
485
|
+
annotations: {
|
|
486
|
+
readOnlyHint: false,
|
|
487
|
+
destructiveHint: false,
|
|
488
|
+
idempotentHint: true,
|
|
489
|
+
openWorldHint: true,
|
|
490
|
+
},
|
|
161
491
|
description: 'Activate, pause, or archive a campaign. Activating requires human approval via the dashboard — call without approval_id to request one, then call again with the approved token.',
|
|
162
492
|
inputSchema: {
|
|
163
493
|
campaign_id: z.string().uuid(),
|
|
@@ -167,13 +497,22 @@ export function registerCampaignTools(server) {
|
|
|
167
497
|
}, async ({ campaign_id, status, approval_id }) => {
|
|
168
498
|
// Human approval required for activation
|
|
169
499
|
if (status === 'active') {
|
|
500
|
+
const approvalParams = { campaign_id, status };
|
|
170
501
|
if (!approval_id) {
|
|
171
|
-
const
|
|
172
|
-
|
|
502
|
+
const channelContext = currentChannelContext();
|
|
503
|
+
const decision = assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
|
|
504
|
+
if (!decision.allowed)
|
|
505
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
506
|
+
if (decision.tier !== 'autonomous') {
|
|
507
|
+
const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext);
|
|
508
|
+
return j(approval);
|
|
509
|
+
}
|
|
173
510
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
511
|
+
else {
|
|
512
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'update_campaign_status', params: approvalParams });
|
|
513
|
+
if (!check.approved) {
|
|
514
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
515
|
+
}
|
|
177
516
|
}
|
|
178
517
|
}
|
|
179
518
|
const { data, error } = await db()
|
|
@@ -196,6 +535,13 @@ export function registerCampaignTools(server) {
|
|
|
196
535
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
197
536
|
});
|
|
198
537
|
server.registerTool('enroll_contact', {
|
|
538
|
+
title: 'Enroll Contact',
|
|
539
|
+
annotations: {
|
|
540
|
+
readOnlyHint: false,
|
|
541
|
+
destructiveHint: false,
|
|
542
|
+
idempotentHint: false,
|
|
543
|
+
openWorldHint: false,
|
|
544
|
+
},
|
|
199
545
|
description: 'Enroll a single contact in a campaign',
|
|
200
546
|
inputSchema: {
|
|
201
547
|
campaign_id: z.string().uuid(),
|
|
@@ -203,6 +549,20 @@ export function registerCampaignTools(server) {
|
|
|
203
549
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
204
550
|
},
|
|
205
551
|
}, async ({ campaign_id, contact_id, next_send_at }) => {
|
|
552
|
+
const { data: contact, error: contactError } = await db()
|
|
553
|
+
.from('subscribers')
|
|
554
|
+
.select('id, global_unsubscribed, cooled_at')
|
|
555
|
+
.eq('id', contact_id)
|
|
556
|
+
.eq('project_id', getProjectId())
|
|
557
|
+
.maybeSingle();
|
|
558
|
+
if (contactError)
|
|
559
|
+
return txt(`Error: ${contactError.message}`);
|
|
560
|
+
if (!contact)
|
|
561
|
+
return j({ error: 'Contact not found', code: 'NOT_FOUND' });
|
|
562
|
+
if (contact.global_unsubscribed)
|
|
563
|
+
return j({ enrolled: 0, skipped: 'global_unsubscribed' });
|
|
564
|
+
if (contact.cooled_at)
|
|
565
|
+
return j({ enrolled: 0, skipped: 'cooled_contact' });
|
|
206
566
|
const { data, error } = await db()
|
|
207
567
|
.from('campaign_enrollments')
|
|
208
568
|
.insert({ campaign_id, contact_id, next_send_at: next_send_at ?? new Date().toISOString() })
|
|
@@ -218,6 +578,13 @@ export function registerCampaignTools(server) {
|
|
|
218
578
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
219
579
|
});
|
|
220
580
|
server.registerTool('get_enrollment_status', {
|
|
581
|
+
title: 'Get Enrollment Status',
|
|
582
|
+
annotations: {
|
|
583
|
+
readOnlyHint: true,
|
|
584
|
+
destructiveHint: false,
|
|
585
|
+
idempotentHint: true,
|
|
586
|
+
openWorldHint: false,
|
|
587
|
+
},
|
|
221
588
|
description: "Get one contact's enrollment status and sequence position for a campaign",
|
|
222
589
|
inputSchema: {
|
|
223
590
|
campaign_id: z.string().uuid(),
|
|
@@ -275,6 +642,13 @@ export function registerCampaignTools(server) {
|
|
|
275
642
|
});
|
|
276
643
|
});
|
|
277
644
|
server.registerTool('enroll_segment', {
|
|
645
|
+
title: 'Enroll Segment',
|
|
646
|
+
annotations: {
|
|
647
|
+
readOnlyHint: false,
|
|
648
|
+
destructiveHint: false,
|
|
649
|
+
idempotentHint: false,
|
|
650
|
+
openWorldHint: false,
|
|
651
|
+
},
|
|
278
652
|
description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
|
|
279
653
|
inputSchema: {
|
|
280
654
|
campaign_id: z.string().uuid(),
|
|
@@ -297,7 +671,8 @@ export function registerCampaignTools(server) {
|
|
|
297
671
|
.from('subscribers')
|
|
298
672
|
.select('id, email')
|
|
299
673
|
.in('id', ids)
|
|
300
|
-
.eq('global_unsubscribed', false)
|
|
674
|
+
.eq('global_unsubscribed', false)
|
|
675
|
+
.is('cooled_at', null);
|
|
301
676
|
const { data: suppressed } = await db().from('suppressions').select('email').eq('project_id', getProjectId());
|
|
302
677
|
const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
|
|
303
678
|
const eligible = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
|
|
@@ -316,7 +691,228 @@ export function registerCampaignTools(server) {
|
|
|
316
691
|
}
|
|
317
692
|
return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
|
|
318
693
|
});
|
|
694
|
+
server.registerTool('broadcast_to_segment', {
|
|
695
|
+
title: 'Broadcast To Segment',
|
|
696
|
+
annotations: {
|
|
697
|
+
readOnlyHint: false,
|
|
698
|
+
destructiveHint: true,
|
|
699
|
+
idempotentHint: false,
|
|
700
|
+
openWorldHint: true,
|
|
701
|
+
},
|
|
702
|
+
description: 'Create a one-email broadcast campaign for an existing segment, enroll eligible contacts immediately, and activate it. Uses two-call confirmation plus approval before sending to real contacts.',
|
|
703
|
+
inputSchema: {
|
|
704
|
+
site_id: z.string().uuid().describe('Site/sender to send from'),
|
|
705
|
+
segment_id: z.string().uuid().describe('Existing segment to target'),
|
|
706
|
+
subject: z.string().min(1).max(300).describe('Broadcast subject line'),
|
|
707
|
+
body_html: z.string().min(1).describe('Full HTML email body'),
|
|
708
|
+
body_text: z.string().optional().describe('Plain text fallback'),
|
|
709
|
+
name: z.string().min(1).max(200).optional().describe('Optional campaign name. Defaults from subject.'),
|
|
710
|
+
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
|
|
711
|
+
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
712
|
+
approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
|
|
713
|
+
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this broadcast body.'),
|
|
714
|
+
},
|
|
715
|
+
}, async ({ site_id, segment_id, subject, body_html, body_text, name, sends_per_hour, confirmed, approval_id, brand_asset_ids }) => {
|
|
716
|
+
const projectId = getProjectId();
|
|
717
|
+
const campaignName = name ?? `Broadcast - ${subject.slice(0, 120)}`;
|
|
718
|
+
const approvalParams = {
|
|
719
|
+
site_id,
|
|
720
|
+
segment_id,
|
|
721
|
+
subject,
|
|
722
|
+
body_html,
|
|
723
|
+
body_text,
|
|
724
|
+
name: campaignName,
|
|
725
|
+
sends_per_hour,
|
|
726
|
+
};
|
|
727
|
+
const { data: site, error: siteErr } = await db()
|
|
728
|
+
.from('sites')
|
|
729
|
+
.select('id, name, sending_domain, from_email')
|
|
730
|
+
.eq('id', site_id)
|
|
731
|
+
.eq('project_id', projectId)
|
|
732
|
+
.maybeSingle();
|
|
733
|
+
if (siteErr || !site)
|
|
734
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'SITE_NOT_FOUND' });
|
|
735
|
+
const { data: segment, error: segmentErr } = await db()
|
|
736
|
+
.from('segments')
|
|
737
|
+
.select('id, name, rules, site_id')
|
|
738
|
+
.eq('id', segment_id)
|
|
739
|
+
.eq('project_id', projectId)
|
|
740
|
+
.maybeSingle();
|
|
741
|
+
if (segmentErr || !segment)
|
|
742
|
+
return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
743
|
+
const rules = {
|
|
744
|
+
...(segment.rules ?? {}),
|
|
745
|
+
...(segment.site_id ? { site_id: segment.site_id } : {}),
|
|
746
|
+
};
|
|
747
|
+
const contactIds = await getSegmentContactIds(projectId, rules);
|
|
748
|
+
if (!confirmed) {
|
|
749
|
+
return j({
|
|
750
|
+
confirmation_required: true,
|
|
751
|
+
tool: 'broadcast_to_segment',
|
|
752
|
+
campaign: {
|
|
753
|
+
name: campaignName,
|
|
754
|
+
type: 'broadcast',
|
|
755
|
+
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
756
|
+
segment: { id: segment.id, name: segment.name },
|
|
757
|
+
subject,
|
|
758
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
759
|
+
},
|
|
760
|
+
matching_contacts: contactIds.length,
|
|
761
|
+
will_send_at: 'immediately',
|
|
762
|
+
message: `Broadcast "${campaignName}" will enroll ${contactIds.length} eligible contact(s) from segment "${segment.name}" and activate immediately. Call again with confirmed=true to proceed.`,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
if (contactIds.length === 0) {
|
|
766
|
+
return j({
|
|
767
|
+
error: 'No eligible contacts match this segment.',
|
|
768
|
+
code: 'EMPTY_SEGMENT',
|
|
769
|
+
segment_id,
|
|
770
|
+
site_id,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
const channelContext = currentChannelContext();
|
|
774
|
+
const decision = assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
|
|
775
|
+
if (!decision.allowed)
|
|
776
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
777
|
+
if (decision.tier !== 'autonomous') {
|
|
778
|
+
if (!approval_id) {
|
|
779
|
+
const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext);
|
|
780
|
+
return j(approval);
|
|
781
|
+
}
|
|
782
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'broadcast_to_segment', params: approvalParams });
|
|
783
|
+
if (!check.approved) {
|
|
784
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const limit = await checkPlanLimit('active_campaigns');
|
|
788
|
+
if (!limit.allowed) {
|
|
789
|
+
return j({
|
|
790
|
+
error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`,
|
|
791
|
+
code: 'PLAN_LIMIT',
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
const { data: campaign, error: campaignErr } = await db()
|
|
795
|
+
.from('campaigns')
|
|
796
|
+
.insert({
|
|
797
|
+
project_id: projectId,
|
|
798
|
+
site_id,
|
|
799
|
+
segment_id,
|
|
800
|
+
name: campaignName,
|
|
801
|
+
type: 'broadcast',
|
|
802
|
+
status: 'draft',
|
|
803
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
804
|
+
requires_approval: false,
|
|
805
|
+
metadata: { source: 'mcp_broadcast_to_segment' },
|
|
806
|
+
})
|
|
807
|
+
.select('id')
|
|
808
|
+
.single();
|
|
809
|
+
if (campaignErr || !campaign) {
|
|
810
|
+
return j({ error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' });
|
|
811
|
+
}
|
|
812
|
+
const { data: step, error: stepErr } = await db()
|
|
813
|
+
.from('campaign_steps')
|
|
814
|
+
.insert({
|
|
815
|
+
campaign_id: campaign.id,
|
|
816
|
+
step_order: 0,
|
|
817
|
+
subject,
|
|
818
|
+
body_html,
|
|
819
|
+
body_text: body_text ?? null,
|
|
820
|
+
delay_days: 0,
|
|
821
|
+
delay_hours: 0,
|
|
822
|
+
})
|
|
823
|
+
.select('id')
|
|
824
|
+
.single();
|
|
825
|
+
if (stepErr || !step) {
|
|
826
|
+
return j({
|
|
827
|
+
error: stepErr?.message ?? 'Campaign created but failed to add broadcast step',
|
|
828
|
+
code: 'STEP_CREATE_FAILED',
|
|
829
|
+
campaign_id: campaign.id,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
await recordBrandAssetUsage({
|
|
833
|
+
client: db(),
|
|
834
|
+
projectId,
|
|
835
|
+
source: 'campaign_step',
|
|
836
|
+
resourceId: step.id,
|
|
837
|
+
html: body_html,
|
|
838
|
+
assetIds: brand_asset_ids,
|
|
839
|
+
}).catch((err) => {
|
|
840
|
+
console.warn('Failed to record brand asset usage for MCP broadcast:', err);
|
|
841
|
+
});
|
|
842
|
+
const sendAt = new Date().toISOString();
|
|
843
|
+
const batchSize = 500;
|
|
844
|
+
for (let i = 0; i < contactIds.length; i += batchSize) {
|
|
845
|
+
const batch = contactIds.slice(i, i + batchSize).map((contact_id) => ({
|
|
846
|
+
campaign_id: campaign.id,
|
|
847
|
+
contact_id,
|
|
848
|
+
next_send_at: sendAt,
|
|
849
|
+
}));
|
|
850
|
+
const { error: enrollErr } = await db()
|
|
851
|
+
.from('campaign_enrollments')
|
|
852
|
+
.upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
|
|
853
|
+
if (enrollErr) {
|
|
854
|
+
return j({
|
|
855
|
+
error: enrollErr.message,
|
|
856
|
+
code: 'ENROLLMENT_FAILED',
|
|
857
|
+
campaign_id: campaign.id,
|
|
858
|
+
enrolled_before_failure: i,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const { error: activateErr } = await db()
|
|
863
|
+
.from('campaigns')
|
|
864
|
+
.update({
|
|
865
|
+
status: 'active',
|
|
866
|
+
total_enrolled: contactIds.length,
|
|
867
|
+
updated_at: new Date().toISOString(),
|
|
868
|
+
})
|
|
869
|
+
.eq('id', campaign.id)
|
|
870
|
+
.eq('project_id', projectId);
|
|
871
|
+
if (activateErr) {
|
|
872
|
+
return j({
|
|
873
|
+
error: activateErr.message,
|
|
874
|
+
code: 'ACTIVATION_FAILED',
|
|
875
|
+
campaign_id: campaign.id,
|
|
876
|
+
enrolled: contactIds.length,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
await logAudit({
|
|
880
|
+
action: 'campaign.broadcast_to_segment',
|
|
881
|
+
resourceType: 'campaign',
|
|
882
|
+
resourceId: campaign.id,
|
|
883
|
+
details: {
|
|
884
|
+
site_id,
|
|
885
|
+
segment_id,
|
|
886
|
+
contacts_enrolled: contactIds.length,
|
|
887
|
+
source: 'mcp',
|
|
888
|
+
},
|
|
889
|
+
}).catch(() => { });
|
|
890
|
+
fireWebhookEvent('campaign.activated', {
|
|
891
|
+
campaign_id: campaign.id,
|
|
892
|
+
campaign_name: campaignName,
|
|
893
|
+
type: 'broadcast',
|
|
894
|
+
contacts_enrolled: contactIds.length,
|
|
895
|
+
steps: 1,
|
|
896
|
+
}).catch(() => { });
|
|
897
|
+
return j({
|
|
898
|
+
campaign_id: campaign.id,
|
|
899
|
+
step_id: step.id,
|
|
900
|
+
name: campaignName,
|
|
901
|
+
status: 'active',
|
|
902
|
+
segment: { id: segment.id, name: segment.name },
|
|
903
|
+
contacts_enrolled: contactIds.length,
|
|
904
|
+
next_send_at: sendAt,
|
|
905
|
+
message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
|
|
906
|
+
});
|
|
907
|
+
});
|
|
319
908
|
server.registerTool('clone_campaign', {
|
|
909
|
+
title: 'Clone Campaign',
|
|
910
|
+
annotations: {
|
|
911
|
+
readOnlyHint: false,
|
|
912
|
+
destructiveHint: false,
|
|
913
|
+
idempotentHint: false,
|
|
914
|
+
openWorldHint: false,
|
|
915
|
+
},
|
|
320
916
|
description: 'Clone an existing campaign (with all steps) into a new draft campaign',
|
|
321
917
|
inputSchema: {
|
|
322
918
|
campaign_id: z.string().uuid().describe('Source campaign to clone'),
|
|
@@ -344,6 +940,7 @@ export function registerCampaignTools(server) {
|
|
|
344
940
|
name: new_name,
|
|
345
941
|
type: original.type,
|
|
346
942
|
description: original.description,
|
|
943
|
+
sends_per_hour: original.sends_per_hour ?? null,
|
|
347
944
|
requires_approval: original.requires_approval,
|
|
348
945
|
status: 'draft',
|
|
349
946
|
})
|
|
@@ -369,6 +966,13 @@ export function registerCampaignTools(server) {
|
|
|
369
966
|
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
|
|
370
967
|
});
|
|
371
968
|
server.registerTool('generate_email', {
|
|
969
|
+
title: 'Generate Email',
|
|
970
|
+
annotations: {
|
|
971
|
+
readOnlyHint: true,
|
|
972
|
+
destructiveHint: false,
|
|
973
|
+
idempotentHint: true,
|
|
974
|
+
openWorldHint: true,
|
|
975
|
+
},
|
|
372
976
|
description: 'Generate email content (subject, HTML, plain text) from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
|
|
373
977
|
inputSchema: {
|
|
374
978
|
site_id: z.string().uuid(),
|
|
@@ -450,7 +1054,14 @@ Adapt your tone accordingly:
|
|
|
450
1054
|
}
|
|
451
1055
|
});
|
|
452
1056
|
server.registerTool('launch_campaign', {
|
|
453
|
-
|
|
1057
|
+
title: 'Launch Campaign',
|
|
1058
|
+
annotations: {
|
|
1059
|
+
readOnlyHint: false,
|
|
1060
|
+
destructiveHint: true,
|
|
1061
|
+
idempotentHint: false,
|
|
1062
|
+
openWorldHint: true,
|
|
1063
|
+
},
|
|
1064
|
+
description: 'Full campaign lifecycle in one call: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Requires human approval via the dashboard before confirmed execution.',
|
|
454
1065
|
inputSchema: {
|
|
455
1066
|
name: z.string().min(1).describe('Campaign name'),
|
|
456
1067
|
site_id: z.string().uuid(),
|
|
@@ -462,9 +1073,11 @@ Adapt your tone accordingly:
|
|
|
462
1073
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
463
1074
|
delay_hours: z.number().int().min(0).optional().default(0),
|
|
464
1075
|
})).min(1),
|
|
1076
|
+
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
|
|
465
1077
|
confirmed: z.boolean().optional().describe('Set true after previewing to execute'),
|
|
1078
|
+
approval_id: z.string().uuid().optional().describe('Required when executing. Get this by calling with confirmed=true and no approval_id, then have a human approve.'),
|
|
466
1079
|
},
|
|
467
|
-
}, async ({ name, site_id, type, segment_description, segment_id, emails, confirmed }) => {
|
|
1080
|
+
}, async ({ name, site_id, type, segment_description, segment_id, emails, sends_per_hour, confirmed, approval_id }) => {
|
|
468
1081
|
if (!segment_description && !segment_id)
|
|
469
1082
|
return txt('Provide segment_description or segment_id.');
|
|
470
1083
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
@@ -472,6 +1085,31 @@ Adapt your tone accordingly:
|
|
|
472
1085
|
return txt('Error: ANTHROPIC_API_KEY not configured');
|
|
473
1086
|
const anthropic = new Anthropic({ apiKey });
|
|
474
1087
|
const projectId = getProjectId();
|
|
1088
|
+
const approvalParams = {
|
|
1089
|
+
name,
|
|
1090
|
+
site_id,
|
|
1091
|
+
type: type ?? 'sequence',
|
|
1092
|
+
segment_description,
|
|
1093
|
+
segment_id,
|
|
1094
|
+
emails,
|
|
1095
|
+
sends_per_hour,
|
|
1096
|
+
};
|
|
1097
|
+
if (confirmed) {
|
|
1098
|
+
const channelContext = currentChannelContext();
|
|
1099
|
+
const decision = assertChannelCanExecute('launch_campaign', approvalParams, channelContext);
|
|
1100
|
+
if (!decision.allowed)
|
|
1101
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1102
|
+
if (decision.tier !== 'autonomous') {
|
|
1103
|
+
if (!approval_id) {
|
|
1104
|
+
const approval = await requestApproval('launch_campaign', approvalParams, undefined, channelContext);
|
|
1105
|
+
return j(approval);
|
|
1106
|
+
}
|
|
1107
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'launch_campaign', params: approvalParams });
|
|
1108
|
+
if (!check.approved) {
|
|
1109
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
475
1113
|
function applyPaymentRules(query, payment) {
|
|
476
1114
|
if (!payment)
|
|
477
1115
|
return query;
|
|
@@ -510,7 +1148,7 @@ Adapt your tone accordingly:
|
|
|
510
1148
|
}
|
|
511
1149
|
// Helper to get segment contact IDs (simplified inline)
|
|
512
1150
|
const getContactIds = async (rules) => {
|
|
513
|
-
let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false);
|
|
1151
|
+
let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
|
|
514
1152
|
if (rules.lifecycle_stage)
|
|
515
1153
|
q = q.eq('lifecycle_stage', rules.lifecycle_stage);
|
|
516
1154
|
q = applyPaymentRules(q, rules.payment);
|
|
@@ -610,6 +1248,7 @@ Adapt your tone accordingly:
|
|
|
610
1248
|
emails: emails.map((e, i) => ({
|
|
611
1249
|
step: i, brief: e.brief, subject_preview: subjectPreviews[i], delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0,
|
|
612
1250
|
})),
|
|
1251
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
613
1252
|
message: `Campaign "${name}" will target ${contactCount} contacts with ${emails.length} email(s). Call again with confirmed: true to create everything.`,
|
|
614
1253
|
});
|
|
615
1254
|
}
|
|
@@ -631,7 +1270,7 @@ Adapt your tone accordingly:
|
|
|
631
1270
|
return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
|
|
632
1271
|
const { data: campaign, error: campErr } = await db()
|
|
633
1272
|
.from('campaigns')
|
|
634
|
-
.insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, requires_approval: false })
|
|
1273
|
+
.insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
|
|
635
1274
|
.select('id')
|
|
636
1275
|
.single();
|
|
637
1276
|
if (campErr)
|
|
@@ -693,6 +1332,13 @@ Adapt your tone accordingly:
|
|
|
693
1332
|
});
|
|
694
1333
|
});
|
|
695
1334
|
server.registerTool('preview_email', {
|
|
1335
|
+
title: 'Preview Email',
|
|
1336
|
+
annotations: {
|
|
1337
|
+
readOnlyHint: true,
|
|
1338
|
+
destructiveHint: false,
|
|
1339
|
+
idempotentHint: true,
|
|
1340
|
+
openWorldHint: false,
|
|
1341
|
+
},
|
|
696
1342
|
description: 'Preview email content with merge variables resolved using sample contact data. Provide content via step_id, draft_id, or raw body_html/subject.',
|
|
697
1343
|
inputSchema: {
|
|
698
1344
|
step_id: z.string().uuid().optional().describe('Campaign step UUID'),
|
|
@@ -751,7 +1397,14 @@ Adapt your tone accordingly:
|
|
|
751
1397
|
return j({ subject: resolve(subj), body_html: resolve(html), body_text: resolve(text), sample_contact: contact });
|
|
752
1398
|
});
|
|
753
1399
|
server.registerTool('send_test_email', {
|
|
754
|
-
|
|
1400
|
+
title: 'Send Test Email',
|
|
1401
|
+
annotations: {
|
|
1402
|
+
readOnlyHint: false,
|
|
1403
|
+
destructiveHint: true,
|
|
1404
|
+
idempotentHint: false,
|
|
1405
|
+
openWorldHint: true,
|
|
1406
|
+
},
|
|
1407
|
+
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
|
|
755
1408
|
inputSchema: {
|
|
756
1409
|
to_email: z.string().email().describe('Email address to send the test to'),
|
|
757
1410
|
site_id: z.string().uuid().describe('Site UUID (determines from address)'),
|
|
@@ -808,26 +1461,22 @@ Adapt your tone accordingly:
|
|
|
808
1461
|
.maybeSingle();
|
|
809
1462
|
if (!site)
|
|
810
1463
|
return txt('Error: Site not found');
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
const resendKey = process.env.RESEND_API_KEY;
|
|
814
|
-
if (!resendKey)
|
|
815
|
-
return txt('Error: RESEND_API_KEY not configured');
|
|
816
|
-
const resend = new Resend(resendKey);
|
|
1464
|
+
const resolvedHtml = resolve(html);
|
|
1465
|
+
const resolvedText = resolve(text);
|
|
817
1466
|
const payload = {
|
|
818
1467
|
from: `${site.from_name} <${site.from_email}>`,
|
|
819
1468
|
to: to_email,
|
|
820
1469
|
subject: `[TEST] ${resolve(subj)}`,
|
|
1470
|
+
html: resolvedHtml || undefined,
|
|
1471
|
+
text: resolvedText || undefined,
|
|
821
1472
|
};
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (sendError)
|
|
830
|
-
return txt(`Error: Send failed — ${sendError.message}`);
|
|
1473
|
+
let sendResult;
|
|
1474
|
+
try {
|
|
1475
|
+
sendResult = await sendEmailForSite(projectId, site_id, payload);
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
|
|
1479
|
+
}
|
|
831
1480
|
await db().from('email_log').insert({
|
|
832
1481
|
project_id: projectId,
|
|
833
1482
|
site_id,
|
|
@@ -838,13 +1487,26 @@ Adapt your tone accordingly:
|
|
|
838
1487
|
status: 'sent',
|
|
839
1488
|
category: 'test',
|
|
840
1489
|
sent_at: new Date().toISOString(),
|
|
841
|
-
provider_message_id:
|
|
1490
|
+
provider_message_id: sendResult.messageId ?? null,
|
|
842
1491
|
body_html: resolvedHtml ?? null,
|
|
843
1492
|
});
|
|
844
|
-
return j({
|
|
1493
|
+
return j({
|
|
1494
|
+
sent: true,
|
|
1495
|
+
to: to_email,
|
|
1496
|
+
provider: sendResult.provider,
|
|
1497
|
+
provider_message_id: sendResult.messageId,
|
|
1498
|
+
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
1499
|
+
});
|
|
845
1500
|
});
|
|
846
1501
|
server.registerTool('send_transactional', {
|
|
847
|
-
|
|
1502
|
+
title: 'Send Transactional',
|
|
1503
|
+
annotations: {
|
|
1504
|
+
readOnlyHint: false,
|
|
1505
|
+
destructiveHint: true,
|
|
1506
|
+
idempotentHint: false,
|
|
1507
|
+
openWorldHint: true,
|
|
1508
|
+
},
|
|
1509
|
+
description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
|
|
848
1510
|
inputSchema: {
|
|
849
1511
|
to: z.string().email().describe('Recipient email address'),
|
|
850
1512
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
@@ -876,8 +1538,14 @@ Adapt your tone accordingly:
|
|
|
876
1538
|
.eq('project_id', projectId)
|
|
877
1539
|
.eq('idempotency_key', idempotency_key)
|
|
878
1540
|
.maybeSingle();
|
|
879
|
-
if (existing)
|
|
880
|
-
return j({
|
|
1541
|
+
if (existing) {
|
|
1542
|
+
return j({
|
|
1543
|
+
error: 'Duplicate send',
|
|
1544
|
+
id: existing.id,
|
|
1545
|
+
provider_message_id: existing.provider_message_id,
|
|
1546
|
+
resend_id: existing.provider_message_id,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
881
1549
|
}
|
|
882
1550
|
// Site verification
|
|
883
1551
|
const { data: site } = await db()
|
|
@@ -888,26 +1556,21 @@ Adapt your tone accordingly:
|
|
|
888
1556
|
.maybeSingle();
|
|
889
1557
|
if (!site)
|
|
890
1558
|
return txt('Error: Site not found');
|
|
891
|
-
// Send via Resend
|
|
892
|
-
const { Resend } = await import('resend');
|
|
893
|
-
const resendKey = process.env.RESEND_API_KEY;
|
|
894
|
-
if (!resendKey)
|
|
895
|
-
return txt('Error: RESEND_API_KEY not configured');
|
|
896
|
-
const resend = new Resend(resendKey);
|
|
897
1559
|
const payload = {
|
|
898
1560
|
from: `${site.from_name} <${site.from_email}>`,
|
|
899
1561
|
to,
|
|
900
1562
|
subject,
|
|
1563
|
+
html,
|
|
1564
|
+
text,
|
|
1565
|
+
replyTo: reply_to,
|
|
901
1566
|
};
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
if (sendError)
|
|
910
|
-
return txt(`Error: Send failed — ${sendError.message}`);
|
|
1567
|
+
let sendResult;
|
|
1568
|
+
try {
|
|
1569
|
+
sendResult = await sendEmailForSite(projectId, site_id, payload);
|
|
1570
|
+
}
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
|
|
1573
|
+
}
|
|
911
1574
|
// Log to email_log
|
|
912
1575
|
const { data: logRow } = await db().from('email_log').insert({
|
|
913
1576
|
project_id: projectId,
|
|
@@ -919,13 +1582,27 @@ Adapt your tone accordingly:
|
|
|
919
1582
|
status: 'sent',
|
|
920
1583
|
category: 'transactional',
|
|
921
1584
|
sent_at: new Date().toISOString(),
|
|
922
|
-
provider_message_id:
|
|
1585
|
+
provider_message_id: sendResult.messageId ?? null,
|
|
923
1586
|
idempotency_key: idempotency_key ?? null,
|
|
924
1587
|
body_html: html ?? null,
|
|
925
1588
|
}).select('id').single();
|
|
926
|
-
return j({
|
|
1589
|
+
return j({
|
|
1590
|
+
sent: true,
|
|
1591
|
+
id: logRow?.id,
|
|
1592
|
+
to,
|
|
1593
|
+
provider: sendResult.provider,
|
|
1594
|
+
provider_message_id: sendResult.messageId,
|
|
1595
|
+
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
1596
|
+
});
|
|
927
1597
|
});
|
|
928
1598
|
server.registerTool('validate_template', {
|
|
1599
|
+
title: 'Validate Template',
|
|
1600
|
+
annotations: {
|
|
1601
|
+
readOnlyHint: true,
|
|
1602
|
+
destructiveHint: false,
|
|
1603
|
+
idempotentHint: true,
|
|
1604
|
+
openWorldHint: false,
|
|
1605
|
+
},
|
|
929
1606
|
description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, stock phrases, excessive CTAs, missing sign-off, and more.',
|
|
930
1607
|
inputSchema: {
|
|
931
1608
|
html: z.string().describe('HTML email content to validate'),
|
|
@@ -945,11 +1622,91 @@ Adapt your tone accordingly:
|
|
|
945
1622
|
const result = validateTemplate(html, voiceConfig);
|
|
946
1623
|
return j(result);
|
|
947
1624
|
});
|
|
1625
|
+
server.registerTool('check_content_consistency', {
|
|
1626
|
+
title: 'Check Content Consistency',
|
|
1627
|
+
annotations: {
|
|
1628
|
+
readOnlyHint: true,
|
|
1629
|
+
destructiveHint: false,
|
|
1630
|
+
idempotentHint: true,
|
|
1631
|
+
openWorldHint: true,
|
|
1632
|
+
},
|
|
1633
|
+
description: 'Analyze long-form campaign or nurture content for cross-document factual and numeric inconsistencies. Returns contradictions between entities, dates, dollar amounts, percentages, and other concrete claims without rewriting the content.',
|
|
1634
|
+
inputSchema: {
|
|
1635
|
+
content: z.string().min(1).max(120000).describe('Full drafted content to scan, including section labels or multiple documents when available'),
|
|
1636
|
+
site_id: z.string().uuid().optional().describe('Optional site UUID for audit context only'),
|
|
1637
|
+
},
|
|
1638
|
+
}, async ({ content, site_id }) => {
|
|
1639
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1640
|
+
if (!apiKey)
|
|
1641
|
+
return txt('Error: ANTHROPIC_API_KEY not configured');
|
|
1642
|
+
const anthropic = new Anthropic({ apiKey });
|
|
1643
|
+
const system = `You are Sendinel's factual consistency auditor for outbound marketing drafts.
|
|
1644
|
+
|
|
1645
|
+
PREMISES
|
|
1646
|
+
- Analyze only the supplied content. Do not use outside knowledge.
|
|
1647
|
+
- Find contradictions across sections, variants, or documents.
|
|
1648
|
+
- Prioritize concrete factual and numeric claims: names, companies, places, dates, times, dollars, percentages, counts, product facts, eligibility rules, links, and deadlines.
|
|
1649
|
+
- Similar wording is not a contradiction. Only flag claims that cannot both be true or are likely to confuse a recipient.
|
|
1650
|
+
- Do not rewrite the content.
|
|
1651
|
+
|
|
1652
|
+
EXECUTION TRACE
|
|
1653
|
+
1. Extract concrete claims with their section/location clues.
|
|
1654
|
+
2. Compare claims across the full input.
|
|
1655
|
+
3. Keep only material inconsistencies.
|
|
1656
|
+
|
|
1657
|
+
CONCLUSION
|
|
1658
|
+
Return ONLY valid JSON with this exact shape:
|
|
1659
|
+
{
|
|
1660
|
+
"consistent": boolean,
|
|
1661
|
+
"flags": [
|
|
1662
|
+
{
|
|
1663
|
+
"claim_a": "first conflicting claim",
|
|
1664
|
+
"location_a": "section, paragraph, or document label for claim_a",
|
|
1665
|
+
"claim_b": "second conflicting claim",
|
|
1666
|
+
"location_b": "section, paragraph, or document label for claim_b",
|
|
1667
|
+
"explanation": "why the claims conflict"
|
|
1668
|
+
}
|
|
1669
|
+
]
|
|
1670
|
+
}`;
|
|
1671
|
+
let result;
|
|
1672
|
+
try {
|
|
1673
|
+
const response = await anthropic.messages.create({
|
|
1674
|
+
model: getAnthropicModelId(),
|
|
1675
|
+
max_tokens: 1500,
|
|
1676
|
+
system,
|
|
1677
|
+
messages: [{ role: 'user', content }],
|
|
1678
|
+
});
|
|
1679
|
+
const block = response.content.find((item) => item.type === 'text');
|
|
1680
|
+
const raw = block && 'text' in block ? block.text : '';
|
|
1681
|
+
result = normalizeConsistencyResult(JSON.parse(extractJsonObject(raw)));
|
|
1682
|
+
}
|
|
1683
|
+
catch (error) {
|
|
1684
|
+
return txt(`Error: Consistency check failed — ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
1685
|
+
}
|
|
1686
|
+
await logAudit({
|
|
1687
|
+
action: 'check_content_consistency',
|
|
1688
|
+
resourceType: 'content_consistency_check',
|
|
1689
|
+
details: {
|
|
1690
|
+
site_id: site_id ?? null,
|
|
1691
|
+
content_length: content.length,
|
|
1692
|
+
flag_count: result.flags.length,
|
|
1693
|
+
flags: result.flags,
|
|
1694
|
+
},
|
|
1695
|
+
});
|
|
1696
|
+
return j(result);
|
|
1697
|
+
});
|
|
948
1698
|
// ── Orchestrator fast path ──────────────────────────────────────────────
|
|
949
1699
|
// Creates a campaign with pre-built content in a single call.
|
|
950
1700
|
// Designed for external orchestrators (any MCP client, HTTP API, or automation)
|
|
951
1701
|
// that draft their own content and just need Sendinel to deliver it.
|
|
952
1702
|
server.registerTool('create_campaign_with_content', {
|
|
1703
|
+
title: 'Create Campaign With Content',
|
|
1704
|
+
annotations: {
|
|
1705
|
+
readOnlyHint: false,
|
|
1706
|
+
destructiveHint: false,
|
|
1707
|
+
idempotentHint: false,
|
|
1708
|
+
openWorldHint: true,
|
|
1709
|
+
},
|
|
953
1710
|
description: 'Create a campaign with pre-built email content in one call. Creates the campaign, adds email step(s), optionally enrolls a segment, and activates. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
|
|
954
1711
|
inputSchema: {
|
|
955
1712
|
site_id: z.string().uuid().describe('Site to send from'),
|
|
@@ -963,22 +1720,27 @@ Adapt your tone accordingly:
|
|
|
963
1720
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
964
1721
|
})).min(1).describe('Email steps with pre-built content'),
|
|
965
1722
|
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. If omitted, campaign is created in draft without enrollments.'),
|
|
1723
|
+
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
|
|
966
1724
|
activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id.'),
|
|
967
1725
|
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
968
1726
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
|
|
969
1727
|
},
|
|
970
|
-
}, async ({ site_id, name, type, steps, segment_id, activate, confirmed, approval_id }) => {
|
|
1728
|
+
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id }) => {
|
|
971
1729
|
// Human-in-the-loop gate for activation
|
|
972
1730
|
if (activate && confirmed) {
|
|
973
|
-
const
|
|
974
|
-
|
|
1731
|
+
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate };
|
|
1732
|
+
const channelContext = currentChannelContext();
|
|
1733
|
+
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
1734
|
+
if (!decision.allowed)
|
|
1735
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1736
|
+
if (decision.tier !== 'autonomous') {
|
|
975
1737
|
if (!approval_id) {
|
|
976
1738
|
// No approval token — request one
|
|
977
|
-
const approval = await requestApproval('create_campaign_with_content',
|
|
1739
|
+
const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext);
|
|
978
1740
|
return j(approval);
|
|
979
1741
|
}
|
|
980
1742
|
// Have a token — verify it's approved
|
|
981
|
-
const status = await checkApproval(approval_id, { consume: true });
|
|
1743
|
+
const status = await checkApproval(approval_id, { consume: true, action: 'create_campaign_with_content', params: approvalParams });
|
|
982
1744
|
if (!status.approved) {
|
|
983
1745
|
return txt(`Approval ${approval_id} is ${status.status}. ${status.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
984
1746
|
}
|
|
@@ -1021,6 +1783,7 @@ Adapt your tone accordingly:
|
|
|
1021
1783
|
has_text: !!s.body_text,
|
|
1022
1784
|
})),
|
|
1023
1785
|
segment_id: segment_id ?? null,
|
|
1786
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
1024
1787
|
estimated_contacts: contactCount,
|
|
1025
1788
|
will_activate: activate && !!segment_id,
|
|
1026
1789
|
message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : 'No segment — created as draft.'} ${activate && segment_id ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
|
|
@@ -1037,6 +1800,7 @@ Adapt your tone accordingly:
|
|
|
1037
1800
|
type,
|
|
1038
1801
|
status: 'draft',
|
|
1039
1802
|
segment_id: segment_id ?? null,
|
|
1803
|
+
sends_per_hour: sends_per_hour ?? null,
|
|
1040
1804
|
})
|
|
1041
1805
|
.select()
|
|
1042
1806
|
.single();
|
|
@@ -1071,7 +1835,8 @@ Adapt your tone accordingly:
|
|
|
1071
1835
|
.from('subscribers')
|
|
1072
1836
|
.select('id, email')
|
|
1073
1837
|
.in('id', ids)
|
|
1074
|
-
.eq('global_unsubscribed', false)
|
|
1838
|
+
.eq('global_unsubscribed', false)
|
|
1839
|
+
.is('cooled_at', null);
|
|
1075
1840
|
const { data: suppressed } = await db()
|
|
1076
1841
|
.from('suppressions')
|
|
1077
1842
|
.select('email')
|
|
@@ -1120,6 +1885,13 @@ Adapt your tone accordingly:
|
|
|
1120
1885
|
});
|
|
1121
1886
|
});
|
|
1122
1887
|
server.registerTool('list_campaign_steps', {
|
|
1888
|
+
title: 'List Campaign Steps',
|
|
1889
|
+
annotations: {
|
|
1890
|
+
readOnlyHint: true,
|
|
1891
|
+
destructiveHint: false,
|
|
1892
|
+
idempotentHint: true,
|
|
1893
|
+
openWorldHint: false,
|
|
1894
|
+
},
|
|
1123
1895
|
description: 'List all steps for a campaign in send order.',
|
|
1124
1896
|
inputSchema: { campaign_id: z.string().uuid() },
|
|
1125
1897
|
}, async ({ campaign_id }) => {
|
|
@@ -1142,6 +1914,13 @@ Adapt your tone accordingly:
|
|
|
1142
1914
|
return j({ steps: data ?? [], count: data?.length ?? 0 });
|
|
1143
1915
|
});
|
|
1144
1916
|
server.registerTool('update_campaign_step', {
|
|
1917
|
+
title: 'Update Campaign Step',
|
|
1918
|
+
annotations: {
|
|
1919
|
+
readOnlyHint: false,
|
|
1920
|
+
destructiveHint: false,
|
|
1921
|
+
idempotentHint: true,
|
|
1922
|
+
openWorldHint: false,
|
|
1923
|
+
},
|
|
1145
1924
|
description: 'Update a campaign step — change subject, body, delay, or step_order.',
|
|
1146
1925
|
inputSchema: {
|
|
1147
1926
|
step_id: z.string().uuid(),
|
|
@@ -1151,9 +1930,11 @@ Adapt your tone accordingly:
|
|
|
1151
1930
|
delay_days: z.number().int().min(0).optional(),
|
|
1152
1931
|
delay_hours: z.number().int().min(0).optional(),
|
|
1153
1932
|
step_order: z.number().int().min(0).optional(),
|
|
1933
|
+
survey_id: z.string().uuid().optional(),
|
|
1154
1934
|
},
|
|
1155
1935
|
}, async ({ step_id, ...updates }) => {
|
|
1156
1936
|
const projectId = getProjectId();
|
|
1937
|
+
const { survey_id, ...stepUpdates } = updates;
|
|
1157
1938
|
const { data: step, error: stepErr } = await db()
|
|
1158
1939
|
.from('campaign_steps')
|
|
1159
1940
|
.select('id, campaign_id')
|
|
@@ -1169,9 +1950,22 @@ Adapt your tone accordingly:
|
|
|
1169
1950
|
.maybeSingle();
|
|
1170
1951
|
if (!campaign)
|
|
1171
1952
|
return j({ error: 'Step not found', code: 'NOT_FOUND' });
|
|
1953
|
+
const renderVars = {};
|
|
1954
|
+
if (survey_id) {
|
|
1955
|
+
const surveyLink = await resolveSurveyShortLink(db(), projectId, survey_id);
|
|
1956
|
+
if (!surveyLink)
|
|
1957
|
+
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
1958
|
+
renderVars.survey_link = surveyLink;
|
|
1959
|
+
}
|
|
1960
|
+
const renderedUpdates = {
|
|
1961
|
+
...stepUpdates,
|
|
1962
|
+
...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateString(stepUpdates.subject, renderVars) } : {}),
|
|
1963
|
+
...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateString(stepUpdates.body_html, renderVars) } : {}),
|
|
1964
|
+
...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateString(stepUpdates.body_text, renderVars) } : {}),
|
|
1965
|
+
};
|
|
1172
1966
|
const { data, error } = await db()
|
|
1173
1967
|
.from('campaign_steps')
|
|
1174
|
-
.update({ ...
|
|
1968
|
+
.update({ ...renderedUpdates, updated_at: new Date().toISOString() })
|
|
1175
1969
|
.eq('id', step_id)
|
|
1176
1970
|
.select('id, step_order, subject, delay_days, delay_hours, updated_at')
|
|
1177
1971
|
.single();
|
|
@@ -1180,6 +1974,13 @@ Adapt your tone accordingly:
|
|
|
1180
1974
|
return j({ step: data, ok: true });
|
|
1181
1975
|
});
|
|
1182
1976
|
server.registerTool('delete_campaign_step', {
|
|
1977
|
+
title: 'Delete Campaign Step',
|
|
1978
|
+
annotations: {
|
|
1979
|
+
readOnlyHint: false,
|
|
1980
|
+
destructiveHint: true,
|
|
1981
|
+
idempotentHint: false,
|
|
1982
|
+
openWorldHint: false,
|
|
1983
|
+
},
|
|
1183
1984
|
description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
|
|
1184
1985
|
inputSchema: { step_id: z.string().uuid() },
|
|
1185
1986
|
}, async ({ step_id }) => {
|
|
@@ -1211,6 +2012,13 @@ Adapt your tone accordingly:
|
|
|
1211
2012
|
return j({ ok: true, deleted: step_id });
|
|
1212
2013
|
});
|
|
1213
2014
|
server.registerTool('delete_campaign', {
|
|
2015
|
+
title: 'Delete Campaign',
|
|
2016
|
+
annotations: {
|
|
2017
|
+
readOnlyHint: false,
|
|
2018
|
+
destructiveHint: true,
|
|
2019
|
+
idempotentHint: false,
|
|
2020
|
+
openWorldHint: false,
|
|
2021
|
+
},
|
|
1214
2022
|
description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
|
|
1215
2023
|
inputSchema: { campaign_id: z.string().uuid() },
|
|
1216
2024
|
}, async ({ campaign_id }) => {
|
|
@@ -1236,6 +2044,13 @@ Adapt your tone accordingly:
|
|
|
1236
2044
|
return j({ success: true, deleted: campaign.name });
|
|
1237
2045
|
});
|
|
1238
2046
|
server.registerTool('update_campaign', {
|
|
2047
|
+
title: 'Update Campaign',
|
|
2048
|
+
annotations: {
|
|
2049
|
+
readOnlyHint: false,
|
|
2050
|
+
destructiveHint: false,
|
|
2051
|
+
idempotentHint: true,
|
|
2052
|
+
openWorldHint: false,
|
|
2053
|
+
},
|
|
1239
2054
|
description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. Only provided fields are updated.',
|
|
1240
2055
|
inputSchema: {
|
|
1241
2056
|
campaign_id: z.string().uuid(),
|
|
@@ -1246,27 +2061,29 @@ Adapt your tone accordingly:
|
|
|
1246
2061
|
topic: z.string().nullable().optional(),
|
|
1247
2062
|
trigger_event_name: z.string().nullable().optional(),
|
|
1248
2063
|
trigger_tag: z.string().nullable().optional(),
|
|
2064
|
+
trigger_type: z.enum(['date_field']).nullable().optional(),
|
|
2065
|
+
trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
2066
|
+
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
|
|
1249
2067
|
send_in_contact_timezone: z.boolean().optional(),
|
|
1250
2068
|
},
|
|
1251
|
-
}, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, send_in_contact_timezone }) => {
|
|
2069
|
+
}, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone }) => {
|
|
1252
2070
|
const projectId = getProjectId();
|
|
1253
2071
|
const updates = { updated_at: new Date().toISOString() };
|
|
1254
2072
|
if (name !== undefined)
|
|
1255
2073
|
updates.name = name;
|
|
1256
2074
|
if (site_id !== undefined)
|
|
1257
2075
|
updates.site_id = site_id;
|
|
1258
|
-
if (segment_id !== undefined)
|
|
1259
|
-
updates.segment_id = segment_id;
|
|
1260
2076
|
if (send_at !== undefined)
|
|
1261
2077
|
updates.send_at = send_at;
|
|
1262
2078
|
if (topic !== undefined)
|
|
1263
2079
|
updates.topic = topic;
|
|
1264
|
-
if (
|
|
1265
|
-
updates.
|
|
1266
|
-
if (trigger_tag !== undefined)
|
|
1267
|
-
updates.trigger_tag = trigger_tag;
|
|
2080
|
+
if (sends_per_hour !== undefined)
|
|
2081
|
+
updates.sends_per_hour = sends_per_hour;
|
|
1268
2082
|
if (send_in_contact_timezone !== undefined)
|
|
1269
2083
|
updates.send_in_contact_timezone = send_in_contact_timezone;
|
|
2084
|
+
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
2085
|
+
if (targetingError)
|
|
2086
|
+
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
|
1270
2087
|
const { data, error } = await db()
|
|
1271
2088
|
.from('campaigns')
|
|
1272
2089
|
.update(updates)
|
|
@@ -1279,6 +2096,13 @@ Adapt your tone accordingly:
|
|
|
1279
2096
|
return j(data);
|
|
1280
2097
|
});
|
|
1281
2098
|
server.registerTool('get_campaign', {
|
|
2099
|
+
title: 'Get Campaign',
|
|
2100
|
+
annotations: {
|
|
2101
|
+
readOnlyHint: true,
|
|
2102
|
+
destructiveHint: false,
|
|
2103
|
+
idempotentHint: true,
|
|
2104
|
+
openWorldHint: false,
|
|
2105
|
+
},
|
|
1282
2106
|
description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
|
|
1283
2107
|
inputSchema: { campaign_id: z.string().uuid() },
|
|
1284
2108
|
}, async ({ campaign_id }) => {
|
|
@@ -1306,6 +2130,13 @@ Adapt your tone accordingly:
|
|
|
1306
2130
|
return j({ ...campaign, steps: steps ?? [], active_enrollments: enrollCount ?? 0 });
|
|
1307
2131
|
});
|
|
1308
2132
|
server.registerTool('unenroll_contact', {
|
|
2133
|
+
title: 'Unenroll Contact',
|
|
2134
|
+
annotations: {
|
|
2135
|
+
readOnlyHint: false,
|
|
2136
|
+
destructiveHint: false,
|
|
2137
|
+
idempotentHint: true,
|
|
2138
|
+
openWorldHint: false,
|
|
2139
|
+
},
|
|
1309
2140
|
description: 'Remove a contact from an active campaign enrollment. Use when a contact should no longer receive campaign emails.',
|
|
1310
2141
|
inputSchema: {
|
|
1311
2142
|
campaign_id: z.string().uuid(),
|