@sendinel/mcp-server 1.0.134 → 1.0.135
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/lib/action-approvals.js +6 -0
- package/build/lib/approval-snapshot.d.ts +16 -0
- package/build/lib/approval-snapshot.js +32 -4
- package/build/lib/campaign-engagement.d.ts +4 -4
- package/build/tools/campaign-sets.d.ts +9 -0
- package/build/tools/campaign-sets.js +34 -7
- package/package.json +1 -1
|
@@ -192,6 +192,12 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
192
192
|
const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
|
|
193
193
|
const names = display.campaign_names ? `: ${display.campaign_names}` : '';
|
|
194
194
|
const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
|
|
195
|
+
const broadcasts = (p.__approval_snapshot?.campaigns ?? [])
|
|
196
|
+
.filter(c => c.type === 'broadcast');
|
|
197
|
+
if (broadcasts.length) {
|
|
198
|
+
const audiences = broadcasts.map(c => `${c.name ?? c.id}: ${typeof c.recipient_count === 'number' ? c.recipient_count : 'unknown'} eligible recipients${c.audience ? ` (matched ${c.audience.matched}, not signed up for sender ${c.audience.not_signed_up_for_sender}, held back by consent ${JSON.stringify(c.audience.held_back_by_consent)})` : ''}`).join('; ');
|
|
199
|
+
return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — broadcasts enroll nobody by design; total_enrolled stays unchanged (0 until enrollment). Use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending. Triggered campaigns send when their triggers fire. ${audiences}${warnings}`;
|
|
200
|
+
}
|
|
195
201
|
return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
|
|
196
202
|
},
|
|
197
203
|
set_campaign_trigger: (p) => `Change what starts campaign \"${display.campaign_name ?? p.campaign_id}\" from ${display.from_trigger ?? 'its current trigger'} to ${display.to_trigger ?? 'the requested trigger'}; ${typeof display.recipient_count === 'number' ? `up to ${display.recipient_count.toLocaleString('en-US')} people` : 'an unknown number of people'} may start receiving it`,
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
type Params = Record<string, any>;
|
|
2
|
+
/** The same segment scope, sender subscriptions and consent gate used by the send tools. */
|
|
3
|
+
export declare function captureBroadcastAudience(projectId: string, campaign: Params): Promise<{
|
|
4
|
+
recipient_count: null;
|
|
5
|
+
audience: null;
|
|
6
|
+
segment?: undefined;
|
|
7
|
+
contact_ids?: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
segment: any;
|
|
10
|
+
contact_ids: string[];
|
|
11
|
+
recipient_count: number;
|
|
12
|
+
audience: {
|
|
13
|
+
matched: number;
|
|
14
|
+
not_signed_up_for_sender: number;
|
|
15
|
+
held_back_by_consent: Record<string, number>;
|
|
16
|
+
};
|
|
17
|
+
}>;
|
|
2
18
|
/** Optional display facts only. Failures must never prevent requesting or using an approval. */
|
|
3
19
|
export declare function captureApprovalDisplay(action: string, params: Params, projectId: string): Promise<{
|
|
4
20
|
target_name: string;
|
|
@@ -4,6 +4,28 @@ import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, pa
|
|
|
4
4
|
import { callSendinelV1 } from './v1-client.js';
|
|
5
5
|
import { adminDb, db } from '../db.js';
|
|
6
6
|
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
7
|
+
/** The same segment scope, sender subscriptions and consent gate used by the send tools. */
|
|
8
|
+
export async function captureBroadcastAudience(projectId, campaign) {
|
|
9
|
+
if (!campaign.segment_id || !campaign.site_id)
|
|
10
|
+
return { recipient_count: null, audience: null };
|
|
11
|
+
const source = db();
|
|
12
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
13
|
+
const { data: segment, error } = await client.from('segments').select('id, name, rules, site_id')
|
|
14
|
+
.eq('id', campaign.segment_id).eq('project_id', projectId).maybeSingle();
|
|
15
|
+
if (error || !segment)
|
|
16
|
+
throw new Error('Approval segment unavailable');
|
|
17
|
+
const resolved = await resolveSendAudience(projectId, campaign.site_id, scopeSegmentRulesToSite(segment.rules, segment.site_id), campaign.send_class === 'product' ? 'product' : 'broadcast');
|
|
18
|
+
return {
|
|
19
|
+
segment,
|
|
20
|
+
contact_ids: [...resolved.contactIds].sort(),
|
|
21
|
+
recipient_count: resolved.contactIds.length,
|
|
22
|
+
audience: {
|
|
23
|
+
matched: resolved.matched,
|
|
24
|
+
not_signed_up_for_sender: resolved.notOnSender,
|
|
25
|
+
held_back_by_consent: resolved.heldBack,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
7
29
|
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
8
30
|
'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
|
|
9
31
|
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
@@ -159,7 +181,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
159
181
|
typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
|
|
160
182
|
if (ids.length) {
|
|
161
183
|
snapshot.campaigns = await readAllRows(client.from('campaigns')
|
|
162
|
-
.select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
|
|
184
|
+
.select('id, name, site_id, type, segment_id, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
|
|
163
185
|
.eq('project_id', projectId).in('id', ids));
|
|
164
186
|
if (snapshot.campaigns.length !== ids.length)
|
|
165
187
|
throw new Error('Approval target not found');
|
|
@@ -167,11 +189,17 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
167
189
|
.select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
|
|
168
190
|
.in('campaign_id', ids));
|
|
169
191
|
let count = 0;
|
|
170
|
-
for (const
|
|
171
|
-
|
|
192
|
+
for (const campaign of snapshot.campaigns) {
|
|
193
|
+
if (campaign.type === 'broadcast') {
|
|
194
|
+
Object.assign(campaign, await captureBroadcastAudience(projectId, campaign));
|
|
195
|
+
count = count == null || campaign.recipient_count == null ? null : count + campaign.recipient_count;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', campaign.id);
|
|
172
199
|
if (result.error || result.count == null)
|
|
173
200
|
throw new Error('Approval audience count unavailable');
|
|
174
|
-
count
|
|
201
|
+
if (count != null)
|
|
202
|
+
count += result.count;
|
|
175
203
|
}
|
|
176
204
|
snapshot.recipient_count = count;
|
|
177
205
|
}
|
|
@@ -10,12 +10,12 @@ export declare function countUniqueEngagementByCampaign(sb: {
|
|
|
10
10
|
delivered: Map<string, number>;
|
|
11
11
|
error?: undefined;
|
|
12
12
|
} | {
|
|
13
|
-
ok: false;
|
|
14
|
-
error: {
|
|
15
|
-
message: string;
|
|
16
|
-
};
|
|
17
13
|
opened?: undefined;
|
|
18
14
|
clicked?: undefined;
|
|
19
15
|
sent?: undefined;
|
|
20
16
|
delivered?: undefined;
|
|
17
|
+
ok: false;
|
|
18
|
+
error: {
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
21
|
}>;
|
|
@@ -8,6 +8,11 @@ type CampaignSetRow = {
|
|
|
8
8
|
trigger_config?: unknown;
|
|
9
9
|
send_class: string | null;
|
|
10
10
|
site_id: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
segment_id?: string | null;
|
|
13
|
+
recipient_count?: number | null;
|
|
14
|
+
audience?: Record<string, unknown> | null;
|
|
15
|
+
audience_error?: string;
|
|
11
16
|
};
|
|
12
17
|
type StepRow = {
|
|
13
18
|
campaign_id: string;
|
|
@@ -33,6 +38,10 @@ export declare function previewOf(campaigns: CampaignSetRow[], steps: StepRow[])
|
|
|
33
38
|
tag: string;
|
|
34
39
|
} | null;
|
|
35
40
|
send_class: string | null;
|
|
41
|
+
recipient_count?: number | null | undefined;
|
|
42
|
+
audience?: Record<string, unknown> | null | undefined;
|
|
43
|
+
audience_error?: string | undefined;
|
|
44
|
+
activation?: string | undefined;
|
|
36
45
|
emails: {
|
|
37
46
|
step_order: number;
|
|
38
47
|
wait: string;
|
|
@@ -11,6 +11,7 @@ import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
|
11
11
|
import { logAudit } from '../audit.js';
|
|
12
12
|
import { applyCampaignTargetingFields } from './campaigns.js';
|
|
13
13
|
import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
14
|
+
import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
|
|
14
15
|
/**
|
|
15
16
|
* Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
|
|
16
17
|
* activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
|
|
@@ -38,6 +39,7 @@ const CampaignInput = z.object({
|
|
|
38
39
|
description: z.string().max(1000).optional(),
|
|
39
40
|
emails: z.array(EmailInput).min(1).max(10),
|
|
40
41
|
});
|
|
42
|
+
const BROADCAST_ACTIVATION = 'Activating a broadcast enrolls nobody by design; total_enrolled stays unchanged (0 until enrollment). Use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending.';
|
|
41
43
|
/** The event-data filter a campaign starts on, if any (#3689). */
|
|
42
44
|
function triggerWhere(config) {
|
|
43
45
|
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
@@ -71,6 +73,12 @@ export function previewOf(campaigns, steps) {
|
|
|
71
73
|
? { event: c.trigger_event_name, ...(triggerWhere(c.trigger_config) ? { only_when: triggerWhere(c.trigger_config) } : {}) }
|
|
72
74
|
: c.trigger_tag ? { tag: c.trigger_tag } : null,
|
|
73
75
|
send_class: c.send_class,
|
|
76
|
+
...(c.type === 'broadcast' ? {
|
|
77
|
+
recipient_count: c.recipient_count ?? null,
|
|
78
|
+
audience: c.audience ?? null,
|
|
79
|
+
...(c.audience_error ? { audience_error: c.audience_error } : {}),
|
|
80
|
+
activation: BROADCAST_ACTIVATION,
|
|
81
|
+
} : {}),
|
|
74
82
|
emails: steps
|
|
75
83
|
.filter((s) => s.campaign_id === c.id)
|
|
76
84
|
.sort((a, b) => a.step_order - b.step_order)
|
|
@@ -85,7 +93,7 @@ export function previewOf(campaigns, steps) {
|
|
|
85
93
|
export async function loadSet(projectId, campaignIds) {
|
|
86
94
|
const { data: campaigns, error } = await db()
|
|
87
95
|
.from('campaigns')
|
|
88
|
-
.select('id, name, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
|
|
96
|
+
.select('id, name, type, segment_id, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
|
|
89
97
|
.eq('project_id', projectId)
|
|
90
98
|
.in('id', campaignIds);
|
|
91
99
|
if (error)
|
|
@@ -96,7 +104,20 @@ export async function loadSet(projectId, campaignIds) {
|
|
|
96
104
|
.in('campaign_id', campaignIds);
|
|
97
105
|
if (stepsError)
|
|
98
106
|
throw stepsError;
|
|
99
|
-
|
|
107
|
+
const loaded = (campaigns ?? []).map((campaign) => ({ ...campaign }));
|
|
108
|
+
for (const campaign of loaded) {
|
|
109
|
+
if (campaign.type !== 'broadcast')
|
|
110
|
+
continue;
|
|
111
|
+
try {
|
|
112
|
+
const { recipient_count, audience } = await captureBroadcastAudience(projectId, campaign);
|
|
113
|
+
Object.assign(campaign, { recipient_count, audience });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
Object.assign(campaign, { recipient_count: null, audience: null,
|
|
117
|
+
audience_error: error instanceof Error ? error.message : String(error) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { campaigns: loaded, steps: (steps ?? []) };
|
|
100
121
|
}
|
|
101
122
|
/**
|
|
102
123
|
* Writes checked drafts as one campaign set. Shared by create_campaign_set and
|
|
@@ -256,7 +277,7 @@ export function registerCampaignSetTools(server) {
|
|
|
256
277
|
server.registerTool('activate_campaigns', {
|
|
257
278
|
title: 'Activate Campaigns',
|
|
258
279
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
259
|
-
description: 'Turn on a set of draft or paused campaigns behind one human approval. Call 1: give campaign_set_id or campaign_ids; it returns one approval_id with a summary of every campaign, and activates nothing. After a person approves (approve_url works on a phone), call again with only approval_id. It refuses if any campaign or email changed after approval.',
|
|
280
|
+
description: 'Turn on a set of draft or paused campaigns behind one human approval. Call 1: give campaign_set_id or campaign_ids; it returns one approval_id with a summary of every campaign, and activates nothing. After a person approves (approve_url works on a phone), call again with only approval_id. It refuses if any campaign or email changed after approval. Activating a broadcast enrolls nobody; use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending.',
|
|
260
281
|
inputSchema: {
|
|
261
282
|
campaign_set_id: z.string().uuid().optional(),
|
|
262
283
|
campaign_ids: z.array(z.string().uuid()).min(1).max(50).optional(),
|
|
@@ -273,7 +294,7 @@ export function registerCampaignSetTools(server) {
|
|
|
273
294
|
if (rowErr)
|
|
274
295
|
return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
|
|
275
296
|
if (!row)
|
|
276
|
-
return
|
|
297
|
+
return j({ error: `Approval ${approval_id} is not_found. Request a new approval.`, code: 'not_found' });
|
|
277
298
|
const stored = (row.params ?? {});
|
|
278
299
|
const ids = Array.isArray(stored.campaign_ids) ? stored.campaign_ids : [];
|
|
279
300
|
// Check before consuming: what is live now must be exactly what was approved.
|
|
@@ -293,7 +314,7 @@ export function registerCampaignSetTools(server) {
|
|
|
293
314
|
return j(contentNotReadyResponse(readiness.blocking));
|
|
294
315
|
const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
|
|
295
316
|
if (!check.approved)
|
|
296
|
-
return
|
|
317
|
+
return j({ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`, code: 'validation_failed', approval_status: check.status });
|
|
297
318
|
const activated = [];
|
|
298
319
|
const failed = [];
|
|
299
320
|
for (const c of current.campaigns) {
|
|
@@ -314,9 +335,15 @@ export function registerCampaignSetTools(server) {
|
|
|
314
335
|
return j({
|
|
315
336
|
activated,
|
|
316
337
|
failed,
|
|
338
|
+
...(current.campaigns.some(c => c.type === 'broadcast') ? {
|
|
339
|
+
campaigns: previewOf(current.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), current.steps),
|
|
340
|
+
broadcast_activation: BROADCAST_ACTIVATION,
|
|
341
|
+
} : {}),
|
|
317
342
|
message: failed.length
|
|
318
343
|
? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
|
|
319
|
-
:
|
|
344
|
+
: current.campaigns.some(c => c.type === 'broadcast')
|
|
345
|
+
? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
|
|
346
|
+
: `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
|
|
320
347
|
});
|
|
321
348
|
}
|
|
322
349
|
// Call 1: build one approval for the exact set.
|
|
@@ -363,7 +390,7 @@ export function registerCampaignSetTools(server) {
|
|
|
363
390
|
const channelContext = currentChannelContext();
|
|
364
391
|
const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
|
|
365
392
|
if (!decision.allowed)
|
|
366
|
-
return
|
|
393
|
+
return j({ error: decision.reason ?? 'This channel cannot request this action.', code: 'forbidden' });
|
|
367
394
|
const names = set.campaigns.map((c) => c.name);
|
|
368
395
|
const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
|
|
369
396
|
campaign_count: set.campaigns.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.135",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|