@sendinel/mcp-server 1.0.34 → 1.0.36
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/README.md +2 -2
- package/build/lib/action-approvals.js +7 -4
- package/build/server.js +3 -2
- package/build/tools/automations.js +3 -34
- package/build/tools/campaign-sets.d.ts +54 -0
- package/build/tools/campaign-sets.js +74 -41
- package/build/tools/campaigns.js +4 -7
- package/build/tools/convert-imported.d.ts +2 -0
- package/build/tools/convert-imported.js +259 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
|
|
|
12
12
|
|
|
13
13
|
## Tool groups
|
|
14
14
|
|
|
15
|
-
Sendinel currently exposes 34 tool groups and
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 319 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
16
16
|
|
|
17
17
|
| Group | Count | Purpose |
|
|
18
18
|
|-------|-------|---------|
|
|
@@ -23,7 +23,7 @@ Sendinel currently exposes 34 tool groups and 318 tools (gateway mode adds 3 met
|
|
|
23
23
|
| `approvals` | 2 | Poll and list risk-tiered action approvals |
|
|
24
24
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
25
25
|
| `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
|
|
26
|
-
| `campaigns` |
|
|
26
|
+
| `campaigns` | 43 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
|
|
27
27
|
| `cloud-import` | 6 | Cloud storage import |
|
|
28
28
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
29
29
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
1
2
|
import { db, requestContext } from '../db.js';
|
|
2
3
|
import { getProjectId } from '../project.js';
|
|
3
4
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
@@ -131,12 +132,14 @@ export function currentChannelContext() {
|
|
|
131
132
|
}
|
|
132
133
|
/** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
|
|
133
134
|
export function approvalExcerpt(html, max = 200) {
|
|
134
|
-
|
|
135
|
+
// Outlook-only comment content ("96") is dropped and character codes decoded, so an approver
|
|
136
|
+
// reads the email the way a recipient does.
|
|
137
|
+
const text = decodeHtmlEntities(stripHtmlComments(String(html ?? ''))
|
|
135
138
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
136
139
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
137
|
-
.replace(/<[
|
|
138
|
-
.replace(
|
|
139
|
-
.replace(
|
|
140
|
+
.replace(/<title[\s\S]*?<\/title>/gi, ' ')
|
|
141
|
+
.replace(/<[^>]+>/g, ' '))
|
|
142
|
+
.replace(/[\s\u00A0\u2002\u2003\u2009\u200A]+/g, ' ')
|
|
140
143
|
.trim();
|
|
141
144
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
142
145
|
}
|
package/build/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { runWithToolContext } from './db.js';
|
|
|
2
2
|
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
3
3
|
import { registerCampaignTools } from './tools/campaigns.js';
|
|
4
4
|
import { registerCampaignSetTools } from './tools/campaign-sets.js';
|
|
5
|
+
import { registerConvertImportedTools } from './tools/convert-imported.js';
|
|
5
6
|
import { registerContactTools } from './tools/contacts.js';
|
|
6
7
|
import { registerDraftTools } from './tools/drafts.js';
|
|
7
8
|
import { registerGdprTools } from './tools/gdpr.js';
|
|
@@ -38,7 +39,7 @@ import { registerCloudImportTools } from './tools/cloud-import.js';
|
|
|
38
39
|
const GROUP_REGISTRY = {
|
|
39
40
|
analytics: registerAnalyticsTools,
|
|
40
41
|
// Campaign sets (#3669) live in their own module but belong to the campaigns group.
|
|
41
|
-
campaigns: (server) => { registerCampaignTools(server); registerCampaignSetTools(server); },
|
|
42
|
+
campaigns: (server) => { registerCampaignTools(server); registerCampaignSetTools(server); registerConvertImportedTools(server); },
|
|
42
43
|
contacts: registerContactTools,
|
|
43
44
|
segments: registerSegmentTools,
|
|
44
45
|
lists: registerListTools,
|
|
@@ -85,7 +86,7 @@ const WRITE_TOOLS = new Set([
|
|
|
85
86
|
'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
|
|
86
87
|
'review_campaign_portfolio',
|
|
87
88
|
'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
|
|
88
|
-
'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'create_campaign_set', 'activate_campaigns', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
|
|
89
|
+
'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'create_campaign_set', 'activate_campaigns', 'convert_imported_campaigns', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
|
|
89
90
|
'create_social_campaign', 'schedule_social_post',
|
|
90
91
|
'create_social_post', 'update_social_post', 'delete_social_post',
|
|
91
92
|
'create_draft', 'approve_draft', 'reject_draft',
|
|
@@ -5,41 +5,10 @@ import { getProjectId } from '../project.js';
|
|
|
5
5
|
import { decrypt } from '../crypto.js';
|
|
6
6
|
import { buildVoicePrompt, buildEmailSystemPrompt } from '../lib/voice.js';
|
|
7
7
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
8
|
+
// Shared with the dashboard, including trigger_config.where event-data filters (#3689).
|
|
9
|
+
import { campaignMatchesTrigger, normalizeTriggerList as normalizeList } from '@sendinel/domain';
|
|
8
10
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
11
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
10
|
-
function normalizeValue(value) {
|
|
11
|
-
if (typeof value !== 'string')
|
|
12
|
-
return null;
|
|
13
|
-
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
14
|
-
return normalized || null;
|
|
15
|
-
}
|
|
16
|
-
function normalizeList(value) {
|
|
17
|
-
const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
18
|
-
return Array.from(new Set(raw.map(normalizeValue).filter((item) => Boolean(item))));
|
|
19
|
-
}
|
|
20
|
-
function triggerConfig(value) {
|
|
21
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
22
|
-
return { events: [], tags: [] };
|
|
23
|
-
const config = value;
|
|
24
|
-
return {
|
|
25
|
-
events: normalizeList(config.events ?? config.event_names ?? config.trigger_event_names),
|
|
26
|
-
tags: normalizeList(config.tags ?? config.trigger_tags ?? config.entity_tags ?? config.project_tags),
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
function campaignMatchesTrigger(campaign, input) {
|
|
30
|
-
const config = triggerConfig(campaign.trigger_config);
|
|
31
|
-
const legacyEvent = normalizeValue(campaign.trigger_event_name);
|
|
32
|
-
const legacyTag = normalizeValue(campaign.trigger_tag);
|
|
33
|
-
const campaignEvents = Array.from(new Set([...(legacyEvent ? [legacyEvent] : []), ...config.events]));
|
|
34
|
-
const campaignTags = Array.from(new Set([...(legacyTag ? [legacyTag] : []), ...config.tags]));
|
|
35
|
-
const event = normalizeValue(input.event);
|
|
36
|
-
const tags = new Set((input.tags ?? []).map(normalizeValue).filter((tag) => Boolean(tag)));
|
|
37
|
-
if (campaignEvents.length > 0 && (!event || !campaignEvents.includes(event)))
|
|
38
|
-
return false;
|
|
39
|
-
if (campaignTags.length > 0 && !campaignTags.some((tag) => tags.has(tag)))
|
|
40
|
-
return false;
|
|
41
|
-
return campaignEvents.length > 0 || campaignTags.length > 0;
|
|
42
|
-
}
|
|
43
12
|
async function enrollSubscriberIntoTriggeredCampaigns(input) {
|
|
44
13
|
const { subscriber_id, campaign_id, site_id, trigger_event_name, trigger_event_names, trigger_tag, trigger_tags, next_send_at, context } = input;
|
|
45
14
|
const triggerEvents = Array.from(new Set([...normalizeList(trigger_event_names), ...(trigger_event_name ? [trigger_event_name] : [])]));
|
|
@@ -85,7 +54,7 @@ async function enrollSubscriberIntoTriggeredCampaigns(input) {
|
|
|
85
54
|
return txt(`Error: ${campaignError.message}`);
|
|
86
55
|
const campaigns = campaign_id
|
|
87
56
|
? (candidateCampaigns ?? [])
|
|
88
|
-
: (candidateCampaigns ?? []).filter((campaign) => triggerEvents.some(event => campaignMatchesTrigger(campaign, { event, tags: triggerTags }))
|
|
57
|
+
: (candidateCampaigns ?? []).filter((campaign) => triggerEvents.some(event => campaignMatchesTrigger(campaign, { event, tags: triggerTags, data: context ?? null }))
|
|
89
58
|
|| (!triggerEvents.length && campaignMatchesTrigger(campaign, { tags: triggerTags })));
|
|
90
59
|
if (!campaigns?.length)
|
|
91
60
|
return j({ enrolled: 0, campaigns: [], skipped: 'no_matching_active_automation' });
|
|
@@ -5,6 +5,7 @@ type CampaignSetRow = {
|
|
|
5
5
|
status: string;
|
|
6
6
|
trigger_event_name: string | null;
|
|
7
7
|
trigger_tag: string | null;
|
|
8
|
+
trigger_config?: unknown;
|
|
8
9
|
send_class: string | null;
|
|
9
10
|
site_id: string;
|
|
10
11
|
};
|
|
@@ -20,5 +21,58 @@ type StepRow = {
|
|
|
20
21
|
};
|
|
21
22
|
/** Fingerprint of everything an approver saw: triggers and every email's exact content. */
|
|
22
23
|
export declare function campaignSetContentHash(campaigns: CampaignSetRow[], steps: StepRow[]): string;
|
|
24
|
+
export declare function previewOf(campaigns: CampaignSetRow[], steps: StepRow[]): {
|
|
25
|
+
campaign_id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
status: string;
|
|
28
|
+
trigger: {
|
|
29
|
+
event: string;
|
|
30
|
+
only_when?: Record<string, string> | null | undefined;
|
|
31
|
+
tag?: undefined;
|
|
32
|
+
} | {
|
|
33
|
+
tag: string;
|
|
34
|
+
} | null;
|
|
35
|
+
send_class: string | null;
|
|
36
|
+
emails: {
|
|
37
|
+
step_order: number;
|
|
38
|
+
wait: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
body_text: string;
|
|
41
|
+
}[];
|
|
42
|
+
}[];
|
|
43
|
+
export declare function loadSet(projectId: string, campaignIds: string[]): Promise<{
|
|
44
|
+
campaigns: CampaignSetRow[];
|
|
45
|
+
steps: StepRow[];
|
|
46
|
+
}>;
|
|
47
|
+
export type DraftEmail = {
|
|
48
|
+
subject: string;
|
|
49
|
+
body_html: string;
|
|
50
|
+
body_text: string | null;
|
|
51
|
+
blocks: unknown[] | null;
|
|
52
|
+
delay_days: number | null;
|
|
53
|
+
delay_hours: number | null;
|
|
54
|
+
};
|
|
55
|
+
export type DraftCampaign = {
|
|
56
|
+
name: string;
|
|
57
|
+
trigger_event_name?: string | null;
|
|
58
|
+
trigger_tag?: string | null;
|
|
59
|
+
/** Event-data filter: the campaign starts only when the event's data has these values. */
|
|
60
|
+
where?: Record<string, string> | null;
|
|
61
|
+
send_class?: string | null;
|
|
62
|
+
description?: string | null;
|
|
63
|
+
metadata?: Record<string, unknown>;
|
|
64
|
+
emails: DraftEmail[];
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Writes checked drafts as one campaign set. Shared by create_campaign_set and
|
|
68
|
+
* convert_imported_campaigns (#3689), so both produce the same rows and activate_campaigns
|
|
69
|
+
* treats them the same way. Returns the new set, or an error result to hand back as is.
|
|
70
|
+
*/
|
|
71
|
+
export declare function writeCampaignDrafts(projectId: string, siteId: string, drafts: DraftCampaign[], source: string): Promise<{
|
|
72
|
+
setId: string;
|
|
73
|
+
createdIds: string[];
|
|
74
|
+
} | {
|
|
75
|
+
error: Record<string, unknown>;
|
|
76
|
+
}>;
|
|
23
77
|
export declare function registerCampaignSetTools(server: McpServer): void;
|
|
24
78
|
export {};
|
|
@@ -37,12 +37,21 @@ const CampaignInput = z.object({
|
|
|
37
37
|
description: z.string().max(1000).optional(),
|
|
38
38
|
emails: z.array(EmailInput).min(1).max(10),
|
|
39
39
|
});
|
|
40
|
+
/** The event-data filter a campaign starts on, if any (#3689). */
|
|
41
|
+
function triggerWhere(config) {
|
|
42
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
43
|
+
return null;
|
|
44
|
+
const where = config.where;
|
|
45
|
+
return where && typeof where === 'object' && !Array.isArray(where) && Object.keys(where).length ? where : null;
|
|
46
|
+
}
|
|
40
47
|
/** Fingerprint of everything an approver saw: triggers and every email's exact content. */
|
|
41
48
|
export function campaignSetContentHash(campaigns, steps) {
|
|
42
49
|
const ordered = [...campaigns].sort((a, b) => a.id.localeCompare(b.id)).map((c) => ({
|
|
43
50
|
id: c.id,
|
|
44
51
|
trigger_event_name: c.trigger_event_name,
|
|
45
52
|
trigger_tag: c.trigger_tag,
|
|
53
|
+
// Only present when set, so fingerprints of campaigns without a filter are unchanged.
|
|
54
|
+
...(triggerWhere(c.trigger_config) ? { trigger_where: triggerWhere(c.trigger_config) } : {}),
|
|
46
55
|
send_class: c.send_class,
|
|
47
56
|
site_id: c.site_id,
|
|
48
57
|
steps: steps
|
|
@@ -52,12 +61,14 @@ export function campaignSetContentHash(campaigns, steps) {
|
|
|
52
61
|
}));
|
|
53
62
|
return createHash('sha256').update(JSON.stringify(ordered)).digest('hex');
|
|
54
63
|
}
|
|
55
|
-
function previewOf(campaigns, steps) {
|
|
64
|
+
export function previewOf(campaigns, steps) {
|
|
56
65
|
return campaigns.map((c) => ({
|
|
57
66
|
campaign_id: c.id,
|
|
58
67
|
name: c.name,
|
|
59
68
|
status: c.status,
|
|
60
|
-
trigger: c.trigger_event_name
|
|
69
|
+
trigger: c.trigger_event_name
|
|
70
|
+
? { event: c.trigger_event_name, ...(triggerWhere(c.trigger_config) ? { only_when: triggerWhere(c.trigger_config) } : {}) }
|
|
71
|
+
: c.trigger_tag ? { tag: c.trigger_tag } : null,
|
|
61
72
|
send_class: c.send_class,
|
|
62
73
|
emails: steps
|
|
63
74
|
.filter((s) => s.campaign_id === c.id)
|
|
@@ -70,10 +81,10 @@ function previewOf(campaigns, steps) {
|
|
|
70
81
|
})),
|
|
71
82
|
}));
|
|
72
83
|
}
|
|
73
|
-
async function loadSet(projectId, campaignIds) {
|
|
84
|
+
export async function loadSet(projectId, campaignIds) {
|
|
74
85
|
const { data: campaigns, error } = await db()
|
|
75
86
|
.from('campaigns')
|
|
76
|
-
.select('id, name, status, trigger_event_name, trigger_tag, send_class, site_id')
|
|
87
|
+
.select('id, name, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
|
|
77
88
|
.eq('project_id', projectId)
|
|
78
89
|
.in('id', campaignIds);
|
|
79
90
|
if (error)
|
|
@@ -86,6 +97,54 @@ async function loadSet(projectId, campaignIds) {
|
|
|
86
97
|
throw stepsError;
|
|
87
98
|
return { campaigns: (campaigns ?? []), steps: (steps ?? []) };
|
|
88
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Writes checked drafts as one campaign set. Shared by create_campaign_set and
|
|
102
|
+
* convert_imported_campaigns (#3689), so both produce the same rows and activate_campaigns
|
|
103
|
+
* treats them the same way. Returns the new set, or an error result to hand back as is.
|
|
104
|
+
*/
|
|
105
|
+
export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
|
|
106
|
+
const setId = randomUUID();
|
|
107
|
+
const createdIds = [];
|
|
108
|
+
for (const c of drafts) {
|
|
109
|
+
const insertPayload = {
|
|
110
|
+
project_id: projectId,
|
|
111
|
+
site_id: siteId,
|
|
112
|
+
name: c.name,
|
|
113
|
+
type: 'triggered',
|
|
114
|
+
status: 'draft',
|
|
115
|
+
description: c.description ?? null,
|
|
116
|
+
requires_approval: true,
|
|
117
|
+
...(c.send_class ? { send_class: c.send_class } : {}),
|
|
118
|
+
metadata: { ...(c.metadata ?? {}), source, campaign_set_id: setId },
|
|
119
|
+
};
|
|
120
|
+
const targetingError = await applyCampaignTargetingFields(insertPayload, {
|
|
121
|
+
trigger_event_name: c.trigger_event_name ?? null,
|
|
122
|
+
trigger_tag: c.trigger_tag ?? null,
|
|
123
|
+
...(c.where && Object.keys(c.where).length && c.trigger_event_name ? { trigger_config: { events: [c.trigger_event_name], where: c.where } } : {}),
|
|
124
|
+
}, projectId);
|
|
125
|
+
if (targetingError)
|
|
126
|
+
return { error: { error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds } };
|
|
127
|
+
const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
|
|
128
|
+
if (error || !campaign)
|
|
129
|
+
return { error: { error: error?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
|
|
130
|
+
createdIds.push(campaign.id);
|
|
131
|
+
const rows = c.emails.map((e, k) => ({
|
|
132
|
+
campaign_id: campaign.id,
|
|
133
|
+
step_order: k,
|
|
134
|
+
subject: e.subject,
|
|
135
|
+
body_html: e.body_html,
|
|
136
|
+
body_text: e.body_text,
|
|
137
|
+
blocks: e.blocks,
|
|
138
|
+
delay_days: e.delay_days,
|
|
139
|
+
delay_hours: e.delay_hours,
|
|
140
|
+
variant_label: 'A',
|
|
141
|
+
}));
|
|
142
|
+
const { error: stepError } = await db().from('campaign_steps').insert(rows);
|
|
143
|
+
if (stepError)
|
|
144
|
+
return { error: { error: stepError.message, code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
|
|
145
|
+
}
|
|
146
|
+
return { setId, createdIds };
|
|
147
|
+
}
|
|
89
148
|
export function registerCampaignSetTools(server) {
|
|
90
149
|
server.registerTool('create_campaign_set', {
|
|
91
150
|
title: 'Create Campaign Set',
|
|
@@ -164,43 +223,17 @@ export function registerCampaignSetTools(server) {
|
|
|
164
223
|
if (problems.length)
|
|
165
224
|
return j({ error: 'Nothing was created. Fix these first.', code: 'INVALID_SET', problems });
|
|
166
225
|
// Write the drafts.
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
requires_approval: true,
|
|
179
|
-
...(c.send_class ? { send_class: c.send_class } : {}),
|
|
180
|
-
metadata: { source: 'mcp_create_campaign_set', campaign_set_id: setId },
|
|
181
|
-
};
|
|
182
|
-
const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name: c.trigger_event_name ?? null, trigger_tag: c.trigger_tag ?? null }, projectId);
|
|
183
|
-
if (targetingError)
|
|
184
|
-
return j({ error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds });
|
|
185
|
-
const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
|
|
186
|
-
if (error || !campaign)
|
|
187
|
-
return j({ error: error?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds });
|
|
188
|
-
createdIds.push(campaign.id);
|
|
189
|
-
const rows = drafted[i].map((e, k) => ({
|
|
190
|
-
campaign_id: campaign.id,
|
|
191
|
-
step_order: k,
|
|
192
|
-
subject: e.subject,
|
|
193
|
-
body_html: e.body_html,
|
|
194
|
-
body_text: e.body_text,
|
|
195
|
-
blocks: e.blocks,
|
|
196
|
-
delay_days: e.delay_days,
|
|
197
|
-
delay_hours: e.delay_hours,
|
|
198
|
-
variant_label: 'A',
|
|
199
|
-
}));
|
|
200
|
-
const { error: stepError } = await db().from('campaign_steps').insert(rows);
|
|
201
|
-
if (stepError)
|
|
202
|
-
return j({ error: stepError.message, code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds });
|
|
203
|
-
}
|
|
226
|
+
const written = await writeCampaignDrafts(projectId, site.id, campaigns.map((c, i) => ({
|
|
227
|
+
name: c.name,
|
|
228
|
+
trigger_event_name: c.trigger_event_name ?? null,
|
|
229
|
+
trigger_tag: c.trigger_tag ?? null,
|
|
230
|
+
send_class: c.send_class ?? null,
|
|
231
|
+
description: c.description ?? null,
|
|
232
|
+
emails: drafted[i],
|
|
233
|
+
})), 'mcp_create_campaign_set');
|
|
234
|
+
if ('error' in written)
|
|
235
|
+
return j(written.error);
|
|
236
|
+
const { setId, createdIds } = written;
|
|
204
237
|
await logAudit({ action: 'campaign.set_created', resourceType: 'campaign_set', resourceId: setId, details: { campaign_ids: createdIds, site_id: site.id, source: 'mcp' } }).catch(() => { });
|
|
205
238
|
let preview;
|
|
206
239
|
try {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
-
import { NAME_FALLBACKS, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
|
|
3
|
+
import { NAME_FALLBACKS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
|
|
4
4
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
5
5
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
6
6
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -45,14 +45,11 @@ function normalizeTriggerList(value) {
|
|
|
45
45
|
return Array.from(new Set(raw.map(normalizeTriggerValue).filter((item) => Boolean(item))));
|
|
46
46
|
}
|
|
47
47
|
function buildCampaignTriggerConfig(value) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const config = value;
|
|
51
|
-
const events = normalizeTriggerList(config.events ?? config.event_names ?? config.trigger_event_names);
|
|
52
|
-
const tags = normalizeTriggerList(config.tags ?? config.trigger_tags ?? config.entity_tags ?? config.project_tags);
|
|
48
|
+
// Keeps an event-data filter (where), so editing a campaign never widens who it starts for (#3689).
|
|
49
|
+
const { events, tags, where } = normalizeTriggerConfig(value);
|
|
53
50
|
if (!events.length && !tags.length)
|
|
54
51
|
return null;
|
|
55
|
-
return { events, tags };
|
|
52
|
+
return { events, tags, ...(Object.keys(where).length ? { where } : {}) };
|
|
56
53
|
}
|
|
57
54
|
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
58
55
|
if (sendAt === undefined)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { planCampaignConversion } from '@sendinel/domain';
|
|
3
|
+
import { db } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
import { checkPlanLimit } from '../plan-limits.js';
|
|
6
|
+
import { validateTemplate } from '../lib/voice.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
8
|
+
import { loadSet, previewOf, writeCampaignDrafts } from './campaign-sets.js';
|
|
9
|
+
/**
|
|
10
|
+
* convert_imported_campaigns (#3689): turns campaigns imported from another platform (Customer.io
|
|
11
|
+
* first) into new Sendinel drafts that can send. Call it without create to preview: converted
|
|
12
|
+
* subject and text per email, the wait before each, the trigger, and every flag (prices, codes,
|
|
13
|
+
* billing claims, testimonials, blank placeholders). Call it again with create: true to write
|
|
14
|
+
* the drafts as one campaign set. The imported campaigns are never changed. Drafts send nothing;
|
|
15
|
+
* activate_campaigns turns them on behind one approval.
|
|
16
|
+
*/
|
|
17
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
18
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
19
|
+
const Conversion = z.object({
|
|
20
|
+
campaign_id: z.string().uuid().describe('An imported campaign (from list_campaigns; imported ones have imported_from set).'),
|
|
21
|
+
name: z.string().min(1).max(200).optional().describe('Name for the new draft. Defaults to the imported name.'),
|
|
22
|
+
trigger_event_name: z.string().min(1).max(120).optional().describe('Event (POST /v1/events) that starts the new campaign.'),
|
|
23
|
+
where: z.record(z.string(), z.string()).optional().describe('Start only when the event data has these values, e.g. {"variant":"free_low"}. Also picks that version of the copy. Use it to make one campaign per version.'),
|
|
24
|
+
choose: z.record(z.string(), z.string()).optional().describe('Which version to keep where the copy changes with a value, e.g. {"variant":"paid_engaged"}.'),
|
|
25
|
+
emails: z.array(z.number().int().min(1)).optional().describe('Emails to keep, numbered from 1. Defaults to all.'),
|
|
26
|
+
waits: z.array(z.object({ email: z.number().int().min(1), days: z.number().int().min(0).max(365).optional(), hours: z.number().int().min(0).max(23).optional() })).optional()
|
|
27
|
+
.describe('Wait before an email (numbered as in the import). Overrides the import.'),
|
|
28
|
+
edits: z.array(z.object({ email: z.number().int().min(1).optional(), find: z.string().min(1), replace: z.string() })).optional()
|
|
29
|
+
.describe('Exact text replacements on the converted copy, e.g. to remove an offer. Without email they apply to every email.'),
|
|
30
|
+
event_keys: z.array(z.string()).optional().describe('Data keys the trigger event sends. Used to catch placeholders that would come out blank.'),
|
|
31
|
+
send_class: z.enum(['broadcast', 'product']).optional().describe('product for account, trial and onboarding email; broadcast for marketing.'),
|
|
32
|
+
});
|
|
33
|
+
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
34
|
+
async function eventKeysFromHistory(projectId, eventName) {
|
|
35
|
+
const { data, error } = await db()
|
|
36
|
+
.from('contact_events')
|
|
37
|
+
.select('metadata')
|
|
38
|
+
.eq('project_id', projectId)
|
|
39
|
+
.eq('title', eventName)
|
|
40
|
+
.order('created_at', { ascending: false })
|
|
41
|
+
.limit(200);
|
|
42
|
+
if (error)
|
|
43
|
+
return null;
|
|
44
|
+
const keys = new Set();
|
|
45
|
+
for (const row of (data ?? [])) {
|
|
46
|
+
if (row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata))
|
|
47
|
+
for (const key of Object.keys(row.metadata))
|
|
48
|
+
keys.add(key);
|
|
49
|
+
}
|
|
50
|
+
return Array.from(keys);
|
|
51
|
+
}
|
|
52
|
+
const CONTACT_SAMPLE = 1000;
|
|
53
|
+
/**
|
|
54
|
+
* Contact property keys the project's contacts actually have, from the most recent contacts.
|
|
55
|
+
* complete is true only when every contact was read. Null when the lookup failed.
|
|
56
|
+
*/
|
|
57
|
+
async function contactPropertyKeys(projectId) {
|
|
58
|
+
const { data, count, error } = await db()
|
|
59
|
+
.from('subscribers')
|
|
60
|
+
.select('custom_properties', { count: 'exact' })
|
|
61
|
+
.eq('project_id', projectId)
|
|
62
|
+
.order('created_at', { ascending: false })
|
|
63
|
+
.limit(CONTACT_SAMPLE);
|
|
64
|
+
if (error)
|
|
65
|
+
return null;
|
|
66
|
+
const keys = new Set();
|
|
67
|
+
for (const row of (data ?? [])) {
|
|
68
|
+
const props = row.custom_properties;
|
|
69
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
70
|
+
for (const [key, value] of Object.entries(props))
|
|
71
|
+
if (value !== null && value !== undefined && value !== '')
|
|
72
|
+
keys.add(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const checked = (data ?? []).length;
|
|
76
|
+
return { keys: Array.from(keys), complete: typeof count === 'number' && count <= checked, checked };
|
|
77
|
+
}
|
|
78
|
+
function reviewView(plan) {
|
|
79
|
+
return {
|
|
80
|
+
source: { campaign_id: plan.source_campaign_id, name: plan.source_name },
|
|
81
|
+
name: plan.name,
|
|
82
|
+
trigger: plan.trigger_event_name ? { event: plan.trigger_event_name, ...(Object.keys(plan.where).length ? { only_when: plan.where } : {}) } : null,
|
|
83
|
+
version_chosen: Object.keys(plan.choose).length ? plan.choose : undefined,
|
|
84
|
+
emails: plan.emails.map((email) => ({
|
|
85
|
+
email: email.email,
|
|
86
|
+
from_import_email: email.source_step,
|
|
87
|
+
wait: email.delay_days === null && email.delay_hours === null ? 'not set' : `${email.delay_days ?? 0}d ${email.delay_hours ?? 0}h`,
|
|
88
|
+
original_subject: email.original_subject,
|
|
89
|
+
subject: email.subject,
|
|
90
|
+
text: email.preview_text,
|
|
91
|
+
check_these: email.flags.map((flag) => ({ kind: flag.kind, message: flag.message, ...(flag.excerpt ? { where: flag.excerpt } : {}) })),
|
|
92
|
+
})),
|
|
93
|
+
...(plan.blocking.length ? { must_fix: plan.blocking } : {}),
|
|
94
|
+
...(plan.notes.length ? { notes: plan.notes } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function registerConvertImportedTools(server) {
|
|
98
|
+
server.registerTool('convert_imported_campaigns', {
|
|
99
|
+
title: 'Convert Imported Campaigns',
|
|
100
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
101
|
+
description: 'Turn campaigns imported from another email platform (Customer.io, Mailchimp, …) into new Sendinel drafts that can send. Without create it only previews: each email\'s converted subject and text, its wait, the trigger, and plain-words flags for anything to check (prices, discount codes, billing claims, testimonials, numbers, placeholders the event doesn\'t send). Fix what it lists under must_fix with trigger_event_name, choose/where, emails, waits and edits, then call again with create: true to write the drafts as one set. The imported campaigns are not changed. Drafts send nothing; turn them on with activate_campaigns.',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
conversions: z.array(Conversion).min(1).max(20).describe('One entry per new campaign. The same imported campaign can appear twice with different where values to make one campaign per version.'),
|
|
104
|
+
site_id: z.string().uuid().optional().describe('Sender. Optional when the project has exactly one sender.'),
|
|
105
|
+
known_codes: z.array(z.string()).optional().describe('Discount codes that really exist in your payment platform. Other codes in the copy are then flagged as not found.'),
|
|
106
|
+
create: z.boolean().optional().describe('false (default): preview only. true: write the drafts.'),
|
|
107
|
+
},
|
|
108
|
+
}, async ({ conversions, site_id, known_codes, create }) => {
|
|
109
|
+
const projectId = getProjectId();
|
|
110
|
+
const ids = [...new Set(conversions.map((c) => c.campaign_id))];
|
|
111
|
+
const { data: sources, error: sourceErr } = await db()
|
|
112
|
+
.from('campaigns')
|
|
113
|
+
.select('id, name, status, site_id, imported_from, trigger_event_name, metadata')
|
|
114
|
+
.eq('project_id', projectId)
|
|
115
|
+
.in('id', ids);
|
|
116
|
+
if (sourceErr)
|
|
117
|
+
return j({ error: sourceErr.message, code: 'CAMPAIGN_LOOKUP_FAILED' });
|
|
118
|
+
const byId = new Map((sources ?? []).map((c) => [c.id, c]));
|
|
119
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
120
|
+
if (missing.length)
|
|
121
|
+
return j({ error: 'Some campaigns were not found in this project.', code: 'NOT_FOUND', missing });
|
|
122
|
+
const notImported = ids.filter((id) => !byId.get(id).imported_from);
|
|
123
|
+
if (notImported.length)
|
|
124
|
+
return j({ error: 'Only imported campaigns can be converted.', code: 'NOT_IMPORTED', campaigns: notImported.map((id) => ({ id, name: byId.get(id).name })) });
|
|
125
|
+
const { data: stepRows, error: stepErr } = await db()
|
|
126
|
+
.from('campaign_steps')
|
|
127
|
+
.select('campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours')
|
|
128
|
+
.in('campaign_id', ids);
|
|
129
|
+
if (stepErr)
|
|
130
|
+
return j({ error: stepErr.message, code: 'STEP_LOOKUP_FAILED' });
|
|
131
|
+
const steps = (stepRows ?? []);
|
|
132
|
+
// Event data seen in history, per trigger. A failed lookup is reported, never treated as "none".
|
|
133
|
+
const historyNotes = [];
|
|
134
|
+
const historyKeys = new Map();
|
|
135
|
+
for (const eventName of new Set(conversions.map((c) => c.trigger_event_name ?? byId.get(c.campaign_id).trigger_event_name).filter((e) => Boolean(e)))) {
|
|
136
|
+
const keys = await eventKeysFromHistory(projectId, eventName);
|
|
137
|
+
if (keys === null)
|
|
138
|
+
historyNotes.push(`Couldn't read the event history for "${eventName}"; only event_keys were used to check placeholders.`);
|
|
139
|
+
else
|
|
140
|
+
historyKeys.set(eventName, keys);
|
|
141
|
+
}
|
|
142
|
+
const contactProperties = await contactPropertyKeys(projectId);
|
|
143
|
+
if (contactProperties === null)
|
|
144
|
+
historyNotes.push("Couldn't read your contacts, so contact properties in the copy weren't checked.");
|
|
145
|
+
const plans = conversions.map((c) => {
|
|
146
|
+
const source = byId.get(c.campaign_id);
|
|
147
|
+
const eventName = c.trigger_event_name ?? source.trigger_event_name ?? '';
|
|
148
|
+
return planCampaignConversion({ id: source.id, name: source.name, trigger_event_name: source.trigger_event_name }, steps.filter((s) => s.campaign_id === source.id), c, { knownEventKeys: historyKeys.get(eventName) ?? [], knownCodes: known_codes, ...(contactProperties ? { contactProperties } : {}) });
|
|
149
|
+
});
|
|
150
|
+
// Converting the same import into the same name twice is almost always a mistake.
|
|
151
|
+
const { data: existing, error: existingErr } = await db()
|
|
152
|
+
.from('campaigns')
|
|
153
|
+
.select('id, name, status, metadata')
|
|
154
|
+
.eq('project_id', projectId)
|
|
155
|
+
.in('metadata->>converted_from', ids);
|
|
156
|
+
if (existingErr)
|
|
157
|
+
return j({ error: existingErr.message, code: 'CAMPAIGN_LOOKUP_FAILED' });
|
|
158
|
+
const earlier = (existing ?? []);
|
|
159
|
+
plans.forEach((plan) => {
|
|
160
|
+
const twin = earlier.find((e) => e.metadata?.converted_from === plan.source_campaign_id && e.name === plan.name);
|
|
161
|
+
if (twin)
|
|
162
|
+
plan.blocking.push(`"${plan.name}" was already made from this import (campaign ${twin.id}, ${twin.status}). Use a different name or work on that one.`);
|
|
163
|
+
});
|
|
164
|
+
const names = plans.map((p) => p.name);
|
|
165
|
+
names.forEach((name, i) => { if (names.indexOf(name) !== i)
|
|
166
|
+
plans[i].blocking.push(`Two new campaigns would both be called "${name}". Give each a different name.`); });
|
|
167
|
+
const review = plans.map(reviewView);
|
|
168
|
+
const mustFix = plans.flatMap((p) => p.blocking.map((b) => `${p.name}: ${b}`));
|
|
169
|
+
if (!create) {
|
|
170
|
+
return j({
|
|
171
|
+
mode: 'preview',
|
|
172
|
+
nothing_written: true,
|
|
173
|
+
campaigns: review,
|
|
174
|
+
...(historyNotes.length ? { notes: historyNotes } : {}),
|
|
175
|
+
next: mustFix.length
|
|
176
|
+
? 'Fix everything under must_fix, then preview again. Show the person every subject, text and check_these item before creating.'
|
|
177
|
+
: 'Show the person every subject, text and check_these item. When they are happy, call again with create: true.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (mustFix.length)
|
|
181
|
+
return j({ error: 'Nothing was created. Fix these first.', code: 'CONVERSION_NOT_READY', must_fix: mustFix, campaigns: review });
|
|
182
|
+
// Sender: the given one, the imports' own, or the only one. Never guessed between several.
|
|
183
|
+
const { data: sites, error: sitesErr } = await db().from('sites').select('id, name, from_email, unsub_link_enabled').eq('project_id', projectId);
|
|
184
|
+
if (sitesErr)
|
|
185
|
+
return j({ error: sitesErr.message, code: 'SITE_LOOKUP_FAILED' });
|
|
186
|
+
const importSites = [...new Set(ids.map((id) => byId.get(id).site_id).filter(Boolean))];
|
|
187
|
+
const pick = site_id ?? (importSites.length === 1 ? importSites[0] : sites?.length === 1 ? sites[0].id : null);
|
|
188
|
+
const site = (sites ?? []).find((s) => s.id === pick);
|
|
189
|
+
if (!site) {
|
|
190
|
+
return j({
|
|
191
|
+
error: site_id ? 'Site not found in this project.' : 'Pick the sender for the new campaigns. Pass site_id.',
|
|
192
|
+
code: site_id ? 'SITE_NOT_FOUND' : 'SITE_REQUIRED',
|
|
193
|
+
sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const limit = await checkPlanLimit('active_campaigns');
|
|
197
|
+
if (!limit.allowed)
|
|
198
|
+
return j({ error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' });
|
|
199
|
+
const footerAdded = site.unsub_link_enabled !== false;
|
|
200
|
+
const problems = [];
|
|
201
|
+
const drafts = plans.map((plan, i) => {
|
|
202
|
+
const customVariables = plan.contact_properties.map((key) => `custom.${key}`);
|
|
203
|
+
for (const email of plan.emails) {
|
|
204
|
+
const check = validateTemplate(email.body_html, null, { subject: email.subject, bodyText: email.body_text ?? undefined, customVariables });
|
|
205
|
+
const blocking = check.errors.filter((msg) => !(footerAdded && msg.startsWith('Missing unsubscribe link')));
|
|
206
|
+
problems.push(...blocking.map((msg) => `${plan.name}, email ${email.email}: ${msg}`));
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
name: plan.name,
|
|
210
|
+
trigger_event_name: plan.trigger_event_name,
|
|
211
|
+
where: Object.keys(plan.where).length ? plan.where : null,
|
|
212
|
+
send_class: conversions[i].send_class ?? null,
|
|
213
|
+
description: `Converted from the imported campaign "${plan.source_name}".`,
|
|
214
|
+
metadata: {
|
|
215
|
+
converted_from: plan.source_campaign_id,
|
|
216
|
+
converted_from_name: plan.source_name,
|
|
217
|
+
review_flags: plan.emails.flatMap((email) => email.flags.map((flag) => ({ email: email.email, kind: flag.kind, message: flag.message, ...(flag.excerpt ? { excerpt: flag.excerpt } : {}) }))),
|
|
218
|
+
},
|
|
219
|
+
emails: plan.emails.map((email) => ({
|
|
220
|
+
subject: email.subject,
|
|
221
|
+
body_html: email.body_html,
|
|
222
|
+
body_text: email.body_text,
|
|
223
|
+
blocks: null,
|
|
224
|
+
delay_days: email.delay_days,
|
|
225
|
+
delay_hours: email.delay_hours,
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
if (problems.length)
|
|
230
|
+
return j({ error: 'Nothing was created. Fix these first.', code: 'INVALID_SET', problems });
|
|
231
|
+
const written = await writeCampaignDrafts(projectId, site.id, drafts, 'mcp_convert_imported_campaigns');
|
|
232
|
+
if ('error' in written)
|
|
233
|
+
return j(written.error);
|
|
234
|
+
const { setId, createdIds } = written;
|
|
235
|
+
await logAudit({
|
|
236
|
+
action: 'campaign.converted_from_import',
|
|
237
|
+
resourceType: 'campaign_set',
|
|
238
|
+
resourceId: setId,
|
|
239
|
+
details: { campaign_ids: createdIds, converted_from: plans.map((p) => p.source_campaign_id), site_id: site.id, source: 'mcp' },
|
|
240
|
+
}).catch(() => { });
|
|
241
|
+
let preview;
|
|
242
|
+
try {
|
|
243
|
+
const loaded = await loadSet(projectId, createdIds);
|
|
244
|
+
preview = previewOf(loaded.campaigns, loaded.steps);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return j({ campaign_set_id: setId, campaign_ids: createdIds, status: 'draft', warning: `Created, but the preview could not be read back: ${error instanceof Error ? error.message : String(error)}` });
|
|
248
|
+
}
|
|
249
|
+
return j({
|
|
250
|
+
campaign_set_id: setId,
|
|
251
|
+
campaign_ids: createdIds,
|
|
252
|
+
status: 'draft',
|
|
253
|
+
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
254
|
+
campaigns: preview,
|
|
255
|
+
check_these: review.map((r) => ({ name: r.name, emails: r.emails.map((e) => ({ email: e.email, check_these: e.check_these })) })),
|
|
256
|
+
next: 'These are drafts and send nothing. Show the person every subject, text and check_these item. To turn them on, call activate_campaigns with campaign_set_id; it asks for one approval for the whole set.',
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
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",
|