@sendinel/mcp-server 1.0.9 → 1.0.11
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 +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +14 -13
package/build/tools/campaigns.js
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
-
import {
|
|
3
|
+
import { NAME_FALLBACKS, renderTemplateString as renderTemplateVars, resolveBrandTokens } from '@sendinel/domain';
|
|
4
|
+
import { adminDb, db } from '../db.js';
|
|
4
5
|
import { getProjectId } from '../project.js';
|
|
5
6
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
6
|
-
import { buildVoicePrompt, buildEmailSystemPrompt, validateTemplate } from '../lib/voice.js';
|
|
7
|
+
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
7
8
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
8
9
|
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
9
10
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
10
|
-
import { getScheduleWindowBounds, groupScheduleByCampaign } from '../lib/schedule.js';
|
|
11
|
+
import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
|
|
11
12
|
import { sendEmailForSite } from '../lib/email-provider.js';
|
|
12
13
|
import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
13
14
|
import { logAudit } from '../audit.js';
|
|
14
15
|
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
15
16
|
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
17
|
+
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
16
18
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
17
19
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
20
|
+
const SENT_EMAIL_STATUSES = ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed'];
|
|
21
|
+
function stripHtml(value) {
|
|
22
|
+
return value
|
|
23
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
24
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
25
|
+
.replace(/<[^>]+>/g, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
18
29
|
function normalizeTriggerValue(value) {
|
|
19
30
|
if (typeof value !== 'string')
|
|
20
31
|
return null;
|
|
@@ -35,6 +46,21 @@ function buildCampaignTriggerConfig(value) {
|
|
|
35
46
|
return null;
|
|
36
47
|
return { events, tags };
|
|
37
48
|
}
|
|
49
|
+
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
50
|
+
if (sendAt === undefined)
|
|
51
|
+
return null;
|
|
52
|
+
if (sendAt === null) {
|
|
53
|
+
updates.send_at = null;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (campaignType !== 'broadcast')
|
|
57
|
+
return 'send_at only applies to broadcast campaigns';
|
|
58
|
+
const parsed = parseFutureDate(sendAt, 'send_at');
|
|
59
|
+
if ('error' in parsed)
|
|
60
|
+
return parsed.error;
|
|
61
|
+
updates.send_at = parsed.scheduledAt;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
38
64
|
async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
39
65
|
if (input.trigger_event_name !== undefined)
|
|
40
66
|
updates.trigger_event_name = input.trigger_event_name?.trim() || null;
|
|
@@ -225,32 +251,14 @@ export function registerCampaignTools(server) {
|
|
|
225
251
|
abandoned_days: z.number().int().min(1).max(365).optional(),
|
|
226
252
|
},
|
|
227
253
|
}, async ({ dry_run = false, dormant_draft_days, abandoned_days }) => {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
return j({ error: 'SENDINEL_API_KEY is required to run review_campaign_portfolio from MCP' });
|
|
231
|
-
const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
232
|
-
const res = await fetch(`${base}/api/v1/campaigns/review`, {
|
|
233
|
-
method: 'POST',
|
|
234
|
-
headers: {
|
|
235
|
-
Authorization: `Bearer ${apiKey}`,
|
|
236
|
-
'Content-Type': 'application/json',
|
|
237
|
-
'X-Sendinel-Project-Id': getProjectId(),
|
|
238
|
-
},
|
|
239
|
-
body: JSON.stringify({
|
|
254
|
+
const result = await callSendinelV1('/api/v1/campaigns/review', {
|
|
255
|
+
body: {
|
|
240
256
|
dry_run,
|
|
241
257
|
...(dormant_draft_days !== undefined ? { dormant_draft_days } : {}),
|
|
242
258
|
...(abandoned_days !== undefined ? { abandoned_days } : {}),
|
|
243
|
-
}
|
|
259
|
+
},
|
|
244
260
|
});
|
|
245
|
-
|
|
246
|
-
if (!res.ok)
|
|
247
|
-
return j({ error: `Campaign portfolio review failed: ${res.status}`, body });
|
|
248
|
-
try {
|
|
249
|
-
return j(JSON.parse(body));
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
return txt(body);
|
|
253
|
-
}
|
|
261
|
+
return result.ok ? j(result.data) : j({ error: result.error, status: result.status });
|
|
254
262
|
});
|
|
255
263
|
server.registerTool('get_campaign_calendar', {
|
|
256
264
|
title: 'Get Campaign Calendar',
|
|
@@ -389,7 +397,7 @@ export function registerCampaignTools(server) {
|
|
|
389
397
|
idempotentHint: false,
|
|
390
398
|
openWorldHint: false,
|
|
391
399
|
},
|
|
392
|
-
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.',
|
|
400
|
+
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. For type=broadcast, send_at schedules the send for a future instant; the campaign must still be activated via update_campaign_status.',
|
|
393
401
|
inputSchema: {
|
|
394
402
|
site_id: z.string().uuid(),
|
|
395
403
|
name: z.string().min(1).max(200),
|
|
@@ -402,8 +410,9 @@ export function registerCampaignTools(server) {
|
|
|
402
410
|
trigger_type: z.enum(['date_field']).optional().describe('Set for elapsed-time triggers keyed on a contact custom_properties date field'),
|
|
403
411
|
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[] }.'),
|
|
404
412
|
segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
|
|
413
|
+
send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. The campaign must still be activated via update_campaign_status.'),
|
|
405
414
|
},
|
|
406
|
-
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }) => {
|
|
415
|
+
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at }) => {
|
|
407
416
|
const limit = await checkPlanLimit('active_campaigns');
|
|
408
417
|
if (!limit.allowed) {
|
|
409
418
|
return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
|
|
@@ -418,6 +427,9 @@ export function registerCampaignTools(server) {
|
|
|
418
427
|
sends_per_hour: sends_per_hour ?? null,
|
|
419
428
|
requires_approval,
|
|
420
429
|
};
|
|
430
|
+
const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
|
|
431
|
+
if (scheduleError)
|
|
432
|
+
return j({ error: scheduleError, code: 'INVALID_FIELD' });
|
|
421
433
|
const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
422
434
|
if (targetingError)
|
|
423
435
|
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
|
@@ -970,6 +982,133 @@ export function registerCampaignTools(server) {
|
|
|
970
982
|
}
|
|
971
983
|
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
|
|
972
984
|
});
|
|
985
|
+
server.registerTool('list_sequence_templates', {
|
|
986
|
+
title: 'List Sequence Templates',
|
|
987
|
+
annotations: {
|
|
988
|
+
readOnlyHint: true,
|
|
989
|
+
destructiveHint: false,
|
|
990
|
+
idempotentHint: true,
|
|
991
|
+
openWorldHint: false,
|
|
992
|
+
},
|
|
993
|
+
description: 'List curated multi-step sequence templates available to clone into a draft campaign. Use clone_sequence_template to clone one.',
|
|
994
|
+
inputSchema: {
|
|
995
|
+
category: z.enum(['welcome', 'reengagement', 'winback', 'onboarding', 'nurture', 'launch', 'event', 'transactional']).optional().describe('Filter by category'),
|
|
996
|
+
},
|
|
997
|
+
}, async ({ category }) => {
|
|
998
|
+
let query = db()
|
|
999
|
+
.from('sequence_templates')
|
|
1000
|
+
.select('id, slug, category, name, description, use_case, estimated_steps, estimated_duration_days')
|
|
1001
|
+
.order('category')
|
|
1002
|
+
.order('name');
|
|
1003
|
+
if (category)
|
|
1004
|
+
query = query.eq('category', category);
|
|
1005
|
+
const { data, error } = await query;
|
|
1006
|
+
if (error)
|
|
1007
|
+
return txt(`Error: ${error.message}`);
|
|
1008
|
+
return j(data ?? []);
|
|
1009
|
+
});
|
|
1010
|
+
server.registerTool('clone_sequence_template', {
|
|
1011
|
+
title: 'Clone Sequence Template',
|
|
1012
|
+
annotations: {
|
|
1013
|
+
readOnlyHint: false,
|
|
1014
|
+
destructiveHint: false,
|
|
1015
|
+
idempotentHint: false,
|
|
1016
|
+
openWorldHint: false,
|
|
1017
|
+
},
|
|
1018
|
+
description: 'Clone a curated sequence template into a new DRAFT campaign with its steps copied in. The result is not activated and no contacts are enrolled.',
|
|
1019
|
+
inputSchema: {
|
|
1020
|
+
template_id: z.string().uuid().describe('Sequence template to clone, from list_sequence_templates'),
|
|
1021
|
+
new_name: z.string().min(1).optional().describe('Defaults to "<template name> (cloned)"'),
|
|
1022
|
+
site_id: z.string().uuid().optional().describe('Defaults to the project\'s oldest active site'),
|
|
1023
|
+
},
|
|
1024
|
+
}, async ({ template_id, new_name, site_id }) => {
|
|
1025
|
+
const projectId = getProjectId();
|
|
1026
|
+
const { data: template, error: templateError } = await db()
|
|
1027
|
+
.from('sequence_templates')
|
|
1028
|
+
.select('*')
|
|
1029
|
+
.eq('id', template_id)
|
|
1030
|
+
.maybeSingle();
|
|
1031
|
+
if (templateError)
|
|
1032
|
+
return txt(`Error: ${templateError.message}`);
|
|
1033
|
+
if (!template)
|
|
1034
|
+
return txt('Error: Template not found');
|
|
1035
|
+
const limit = await checkPlanLimit('active_campaigns');
|
|
1036
|
+
if (!limit.allowed)
|
|
1037
|
+
return txt(`Active campaign limit reached (${limit.current}/${limit.limit}).`);
|
|
1038
|
+
let targetSiteId = site_id ?? null;
|
|
1039
|
+
if (targetSiteId) {
|
|
1040
|
+
const { data: targetSite, error: targetSiteError } = await db()
|
|
1041
|
+
.from('sites')
|
|
1042
|
+
.select('id')
|
|
1043
|
+
.eq('id', targetSiteId)
|
|
1044
|
+
.eq('project_id', projectId)
|
|
1045
|
+
.maybeSingle();
|
|
1046
|
+
if (targetSiteError)
|
|
1047
|
+
return txt(`Error: ${targetSiteError.message}`);
|
|
1048
|
+
if (!targetSite)
|
|
1049
|
+
return txt('Error: Site not found for this project');
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
const { data: site, error: siteError } = await db()
|
|
1053
|
+
.from('sites')
|
|
1054
|
+
.select('id')
|
|
1055
|
+
.eq('project_id', projectId)
|
|
1056
|
+
.eq('active', true)
|
|
1057
|
+
.order('created_at', { ascending: true })
|
|
1058
|
+
.limit(1)
|
|
1059
|
+
.maybeSingle();
|
|
1060
|
+
if (siteError)
|
|
1061
|
+
return txt(`Error: ${siteError.message}`);
|
|
1062
|
+
targetSiteId = site?.id ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
if (!targetSiteId) {
|
|
1065
|
+
return txt('Error: A sending domain is required before cloning a template — create one with create_site first, or pass site_id.');
|
|
1066
|
+
}
|
|
1067
|
+
const cloneName = new_name?.trim() || `${template.name} (cloned)`;
|
|
1068
|
+
const { data: campaign, error: campaignError } = await db()
|
|
1069
|
+
.from('campaigns')
|
|
1070
|
+
.insert({
|
|
1071
|
+
project_id: projectId,
|
|
1072
|
+
site_id: targetSiteId,
|
|
1073
|
+
name: cloneName,
|
|
1074
|
+
type: 'sequence',
|
|
1075
|
+
status: 'draft',
|
|
1076
|
+
})
|
|
1077
|
+
.select()
|
|
1078
|
+
.single();
|
|
1079
|
+
if (campaignError)
|
|
1080
|
+
return txt(`Error creating campaign: ${campaignError.message}`);
|
|
1081
|
+
const steps = Array.isArray(template.template?.steps) ? template.template.steps : [];
|
|
1082
|
+
if (steps.length > 0) {
|
|
1083
|
+
const { error: stepsError } = await db()
|
|
1084
|
+
.from('campaign_steps')
|
|
1085
|
+
.insert(steps.map((step, index) => ({
|
|
1086
|
+
campaign_id: campaign.id,
|
|
1087
|
+
step_order: index,
|
|
1088
|
+
subject: step.subject || `Email ${index + 1}`,
|
|
1089
|
+
body_html: step.body_html || '<p>Edit this email before sending.</p>',
|
|
1090
|
+
body_text: step.body_text ?? null,
|
|
1091
|
+
delay_days: step.delay_days ?? 0,
|
|
1092
|
+
delay_hours: step.delay_hours ?? 0,
|
|
1093
|
+
condition_type: step.condition_type ?? null,
|
|
1094
|
+
condition_lookback_hours: step.condition_lookback_hours ?? null,
|
|
1095
|
+
})));
|
|
1096
|
+
if (stepsError)
|
|
1097
|
+
return txt(`Campaign created but steps failed: ${stepsError.message}`);
|
|
1098
|
+
}
|
|
1099
|
+
await logAudit({
|
|
1100
|
+
action: 'sequence_template.cloned',
|
|
1101
|
+
resourceType: 'campaign',
|
|
1102
|
+
resourceId: campaign.id,
|
|
1103
|
+
details: { template_id, template_slug: template.slug, source: 'mcp' },
|
|
1104
|
+
}).catch(() => { });
|
|
1105
|
+
return j({
|
|
1106
|
+
...campaign,
|
|
1107
|
+
steps_copied: steps.length,
|
|
1108
|
+
cloned_from_template_id: template_id,
|
|
1109
|
+
message: `Created draft campaign "${cloneName}" from template "${template.name}" (${steps.length} step(s) copied). It is a DRAFT — not active, and no contacts are enrolled.`,
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
973
1112
|
server.registerTool('generate_email', {
|
|
974
1113
|
title: 'Generate Email',
|
|
975
1114
|
annotations: {
|
|
@@ -1018,7 +1157,7 @@ export function registerCampaignTools(server) {
|
|
|
1018
1157
|
? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name} <${site.from_email}>`
|
|
1019
1158
|
: 'No brand info available';
|
|
1020
1159
|
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1021
|
-
let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt)}
|
|
1160
|
+
let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt, project?.brand_settings ?? null)}
|
|
1022
1161
|
|
|
1023
1162
|
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1024
1163
|
{
|
|
@@ -1314,15 +1453,22 @@ Adapt your tone accordingly:
|
|
|
1314
1453
|
.single();
|
|
1315
1454
|
if (campErr)
|
|
1316
1455
|
return txt(`Error creating campaign: ${campErr.message}`);
|
|
1317
|
-
const { data: site } = await
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1456
|
+
const [{ data: site }, { data: project }] = await Promise.all([
|
|
1457
|
+
db()
|
|
1458
|
+
.from('sites')
|
|
1459
|
+
.select('name, sending_domain, from_name, from_email, brand_voice')
|
|
1460
|
+
.eq('id', site_id)
|
|
1461
|
+
.eq('project_id', projectId)
|
|
1462
|
+
.maybeSingle(),
|
|
1463
|
+
db()
|
|
1464
|
+
.from('projects')
|
|
1465
|
+
.select('brand_settings')
|
|
1466
|
+
.eq('id', projectId)
|
|
1467
|
+
.maybeSingle(),
|
|
1468
|
+
]);
|
|
1323
1469
|
const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
|
|
1324
|
-
const voicePrompt = buildVoicePrompt(site?.brand_voice);
|
|
1325
|
-
const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt)}
|
|
1470
|
+
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1471
|
+
const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt, project?.brand_settings ?? null)}
|
|
1326
1472
|
|
|
1327
1473
|
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1328
1474
|
{
|
|
@@ -1579,16 +1725,22 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1579
1725
|
inputSchema: {
|
|
1580
1726
|
to: z.string().email().describe('Recipient email address'),
|
|
1581
1727
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
1582
|
-
subject: z.string().describe('Email subject line'),
|
|
1728
|
+
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
1583
1729
|
html: z.string().optional().describe('HTML body'),
|
|
1584
1730
|
text: z.string().optional().describe('Plain text body'),
|
|
1585
1731
|
reply_to: z.string().email().optional().describe('Reply-to address'),
|
|
1586
1732
|
idempotency_key: z.string().optional().describe('Unique key to prevent duplicate sends'),
|
|
1733
|
+
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
1734
|
+
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
1587
1735
|
},
|
|
1588
|
-
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key }) => {
|
|
1736
|
+
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
1589
1737
|
const projectId = getProjectId();
|
|
1590
|
-
if (
|
|
1738
|
+
if (template_id && html)
|
|
1739
|
+
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
1740
|
+
if (!template_id && !html && !text)
|
|
1591
1741
|
return txt('At least one of html or text is required');
|
|
1742
|
+
if (!template_id && !subject?.trim())
|
|
1743
|
+
return txt('Error: subject is required');
|
|
1592
1744
|
// Suppression check
|
|
1593
1745
|
const { data: suppression } = await db()
|
|
1594
1746
|
.from('suppressions')
|
|
@@ -1619,18 +1771,66 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1619
1771
|
// Site verification
|
|
1620
1772
|
const { data: site } = await db()
|
|
1621
1773
|
.from('sites')
|
|
1622
|
-
.select('from_email, from_name')
|
|
1774
|
+
.select('from_email, from_name, name, brand_voice')
|
|
1623
1775
|
.eq('id', site_id)
|
|
1624
1776
|
.eq('project_id', projectId)
|
|
1625
1777
|
.maybeSingle();
|
|
1626
1778
|
if (!site)
|
|
1627
1779
|
return txt('Error: Site not found');
|
|
1780
|
+
let resolvedSubject = subject;
|
|
1781
|
+
let resolvedHtml = html;
|
|
1782
|
+
let resolvedText = text;
|
|
1783
|
+
if (template_id) {
|
|
1784
|
+
const { data: template, error: templateError } = await db()
|
|
1785
|
+
.from('campaign_templates')
|
|
1786
|
+
.select('id, subject_hint, body_html, body_text, format')
|
|
1787
|
+
.eq('id', template_id)
|
|
1788
|
+
.eq('project_id', projectId)
|
|
1789
|
+
.maybeSingle();
|
|
1790
|
+
if (templateError)
|
|
1791
|
+
return txt(`Error: ${templateError.message}`);
|
|
1792
|
+
if (!template)
|
|
1793
|
+
return txt('Error: Template not found');
|
|
1794
|
+
const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
|
|
1795
|
+
const projectBrand = (project?.brand_settings ?? null);
|
|
1796
|
+
const siteBrand = (site.brand_voice ?? null);
|
|
1797
|
+
const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
|
|
1798
|
+
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined);
|
|
1799
|
+
const vars = template_vars ?? {};
|
|
1800
|
+
const renderVars = { ...brandVars, ...vars };
|
|
1801
|
+
const subjectFallingBack = !subject?.trim();
|
|
1802
|
+
const requiredVars = deriveTemplateVariables({
|
|
1803
|
+
body_html: template.format === 'text' ? null : (template.body_html ?? ''),
|
|
1804
|
+
body_text: template.format === 'text' ? (template.body_text ?? '') : null,
|
|
1805
|
+
subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
|
|
1806
|
+
});
|
|
1807
|
+
const satisfied = new Set([
|
|
1808
|
+
...Object.keys(vars),
|
|
1809
|
+
'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
|
|
1810
|
+
...Object.keys(NAME_FALLBACKS),
|
|
1811
|
+
]);
|
|
1812
|
+
const missing = requiredVars.filter((name) => !satisfied.has(name));
|
|
1813
|
+
if (missing.length) {
|
|
1814
|
+
return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (template.format === 'text') {
|
|
1817
|
+
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars);
|
|
1818
|
+
resolvedHtml = undefined;
|
|
1819
|
+
}
|
|
1820
|
+
else {
|
|
1821
|
+
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars);
|
|
1822
|
+
resolvedText = text ?? stripHtml(resolvedHtml);
|
|
1823
|
+
}
|
|
1824
|
+
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars);
|
|
1825
|
+
}
|
|
1826
|
+
if (!resolvedSubject?.trim())
|
|
1827
|
+
return txt('Error: subject is required');
|
|
1628
1828
|
const payload = {
|
|
1629
1829
|
from: `${site.from_name} <${site.from_email}>`,
|
|
1630
1830
|
to,
|
|
1631
|
-
subject,
|
|
1632
|
-
html,
|
|
1633
|
-
text,
|
|
1831
|
+
subject: resolvedSubject,
|
|
1832
|
+
html: resolvedHtml,
|
|
1833
|
+
text: resolvedText,
|
|
1634
1834
|
replyTo: reply_to,
|
|
1635
1835
|
};
|
|
1636
1836
|
let sendResult;
|
|
@@ -1647,13 +1847,13 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1647
1847
|
contact_id: null,
|
|
1648
1848
|
to_email: to,
|
|
1649
1849
|
from_email: site.from_email,
|
|
1650
|
-
subject,
|
|
1850
|
+
subject: resolvedSubject,
|
|
1651
1851
|
status: 'sent',
|
|
1652
1852
|
category: 'transactional',
|
|
1653
1853
|
sent_at: new Date().toISOString(),
|
|
1654
1854
|
provider_message_id: sendResult.messageId ?? null,
|
|
1655
1855
|
idempotency_key: idempotency_key ?? null,
|
|
1656
|
-
body_html:
|
|
1856
|
+
body_html: resolvedHtml ?? null,
|
|
1657
1857
|
}).select('id').single();
|
|
1658
1858
|
return j({
|
|
1659
1859
|
sent: true,
|
|
@@ -1676,8 +1876,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1676
1876
|
inputSchema: {
|
|
1677
1877
|
html: z.string().describe('HTML email content to validate'),
|
|
1678
1878
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
|
1879
|
+
custom_variables: z.array(z.string()).optional().describe('Variable names (without braces) this template legitimately uses beyond the fixed known set.'),
|
|
1679
1880
|
},
|
|
1680
|
-
}, async ({ html, site_id }) => {
|
|
1881
|
+
}, async ({ html, site_id, custom_variables }) => {
|
|
1681
1882
|
let voiceConfig = null;
|
|
1682
1883
|
if (site_id) {
|
|
1683
1884
|
const { data: site } = await db()
|
|
@@ -1688,7 +1889,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1688
1889
|
.maybeSingle();
|
|
1689
1890
|
voiceConfig = site?.brand_voice ?? null;
|
|
1690
1891
|
}
|
|
1691
|
-
const result = validateTemplate(html, voiceConfig);
|
|
1892
|
+
const result = validateTemplate(html, voiceConfig, { customVariables: custom_variables });
|
|
1692
1893
|
return j(result);
|
|
1693
1894
|
});
|
|
1694
1895
|
server.registerTool('check_content_consistency', {
|
|
@@ -2127,7 +2328,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2127
2328
|
idempotentHint: true,
|
|
2128
2329
|
openWorldHint: false,
|
|
2129
2330
|
},
|
|
2130
|
-
description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. Only provided fields are updated.',
|
|
2331
|
+
description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
2131
2332
|
inputSchema: {
|
|
2132
2333
|
campaign_id: z.string().uuid(),
|
|
2133
2334
|
name: z.string().min(1).max(200).optional(),
|
|
@@ -2144,13 +2345,28 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2144
2345
|
},
|
|
2145
2346
|
}, 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 }) => {
|
|
2146
2347
|
const projectId = getProjectId();
|
|
2348
|
+
let campaignType;
|
|
2349
|
+
if (send_at !== undefined && send_at !== null) {
|
|
2350
|
+
const { data: existing, error: fetchError } = await db()
|
|
2351
|
+
.from('campaigns')
|
|
2352
|
+
.select('id, type')
|
|
2353
|
+
.eq('id', campaign_id)
|
|
2354
|
+
.eq('project_id', projectId)
|
|
2355
|
+
.maybeSingle();
|
|
2356
|
+
if (fetchError)
|
|
2357
|
+
return j({ error: fetchError.message });
|
|
2358
|
+
if (!existing)
|
|
2359
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
2360
|
+
campaignType = existing.type;
|
|
2361
|
+
}
|
|
2147
2362
|
const updates = { updated_at: new Date().toISOString() };
|
|
2148
2363
|
if (name !== undefined)
|
|
2149
2364
|
updates.name = name;
|
|
2150
2365
|
if (site_id !== undefined)
|
|
2151
2366
|
updates.site_id = site_id;
|
|
2152
|
-
|
|
2153
|
-
|
|
2367
|
+
const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
|
|
2368
|
+
if (scheduleError)
|
|
2369
|
+
return j({ error: scheduleError, code: 'INVALID_FIELD' });
|
|
2154
2370
|
if (topic !== undefined)
|
|
2155
2371
|
updates.topic = topic;
|
|
2156
2372
|
if (sends_per_hour !== undefined)
|
|
@@ -2191,7 +2407,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2191
2407
|
.maybeSingle();
|
|
2192
2408
|
if (campErr || !campaign)
|
|
2193
2409
|
return j({ error: campErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
2194
|
-
const [{ data: steps }, { count: enrollCount }] = await Promise.all([
|
|
2410
|
+
const [{ data: steps, error: stepsError }, { count: enrollCount, error: enrollError }, { count: sentCount, error: sentError }, { count: queuedEmailCount, error: queuedEmailError }, { count: failedSendCount, error: failedSendError }, { data: queueRows, error: queueError }, { count: dlqUnresolvedCount, error: dlqError },] = await Promise.all([
|
|
2195
2411
|
db()
|
|
2196
2412
|
.from('campaign_steps')
|
|
2197
2413
|
.select('*')
|
|
@@ -2202,8 +2418,66 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2202
2418
|
.select('id', { count: 'exact', head: true })
|
|
2203
2419
|
.eq('campaign_id', campaign_id)
|
|
2204
2420
|
.eq('status', 'active'),
|
|
2421
|
+
db()
|
|
2422
|
+
.from('email_log')
|
|
2423
|
+
.select('id', { count: 'exact', head: true })
|
|
2424
|
+
.eq('project_id', projectId)
|
|
2425
|
+
.eq('campaign_id', campaign_id)
|
|
2426
|
+
.in('status', SENT_EMAIL_STATUSES),
|
|
2427
|
+
db()
|
|
2428
|
+
.from('email_log')
|
|
2429
|
+
.select('id', { count: 'exact', head: true })
|
|
2430
|
+
.eq('project_id', projectId)
|
|
2431
|
+
.eq('campaign_id', campaign_id)
|
|
2432
|
+
.eq('status', 'queued'),
|
|
2433
|
+
db()
|
|
2434
|
+
.from('email_log')
|
|
2435
|
+
.select('id', { count: 'exact', head: true })
|
|
2436
|
+
.eq('project_id', projectId)
|
|
2437
|
+
.eq('campaign_id', campaign_id)
|
|
2438
|
+
.eq('status', 'failed'),
|
|
2439
|
+
db()
|
|
2440
|
+
.from('send_queue')
|
|
2441
|
+
.select('status, next_retry_at')
|
|
2442
|
+
.eq('project_id', projectId)
|
|
2443
|
+
.eq('campaign_id', campaign_id)
|
|
2444
|
+
.in('status', ['pending', 'processing', 'retry']),
|
|
2445
|
+
db()
|
|
2446
|
+
.from('send_dlq')
|
|
2447
|
+
.select('id', { count: 'exact', head: true })
|
|
2448
|
+
.eq('project_id', projectId)
|
|
2449
|
+
.eq('resolved', false)
|
|
2450
|
+
.eq('queue_snapshot->>campaign_id', campaign_id),
|
|
2205
2451
|
]);
|
|
2206
|
-
|
|
2452
|
+
const visibilityError = stepsError ?? enrollError ?? sentError ?? queuedEmailError ?? failedSendError ?? queueError ?? dlqError;
|
|
2453
|
+
if (visibilityError)
|
|
2454
|
+
return j({ error: visibilityError.message, code: 'CAMPAIGN_VISIBILITY_QUERY_FAILED' });
|
|
2455
|
+
const outstandingRows = queueRows ?? [];
|
|
2456
|
+
const pendingOrRetryRows = outstandingRows.filter((row) => row.status !== 'processing');
|
|
2457
|
+
const retryingCount = outstandingRows.filter((row) => row.status === 'retry').length;
|
|
2458
|
+
const processingCount = outstandingRows.filter((row) => row.status === 'processing').length;
|
|
2459
|
+
const nextRetryAt = pendingOrRetryRows
|
|
2460
|
+
.map((row) => row.next_retry_at)
|
|
2461
|
+
.filter((value) => Boolean(value))
|
|
2462
|
+
.sort()[0] ?? null;
|
|
2463
|
+
const queuedCount = queuedEmailCount ?? 0;
|
|
2464
|
+
const failedCount = failedSendCount ?? 0;
|
|
2465
|
+
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
2466
|
+
return j({
|
|
2467
|
+
...campaign,
|
|
2468
|
+
total_sent: sentCount ?? 0,
|
|
2469
|
+
steps: steps ?? [],
|
|
2470
|
+
active_enrollments: enrollCount ?? 0,
|
|
2471
|
+
send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
|
|
2472
|
+
queued_email_count: queuedCount,
|
|
2473
|
+
failed_send_count: failedCount,
|
|
2474
|
+
queue_outstanding_count: outstandingRows.length,
|
|
2475
|
+
queue_pending_count: pendingOrRetryRows.length,
|
|
2476
|
+
queue_retrying_count: retryingCount,
|
|
2477
|
+
queue_processing_count: processingCount,
|
|
2478
|
+
queue_next_retry_at: nextRetryAt,
|
|
2479
|
+
dlq_unresolved_count: unresolvedCount,
|
|
2480
|
+
});
|
|
2207
2481
|
});
|
|
2208
2482
|
server.registerTool('unenroll_contact', {
|
|
2209
2483
|
title: 'Unenroll Contact',
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
3
|
+
import { BRAND_ASSET_KINDS } from '../lib/brand-asset-persist.js';
|
|
4
|
+
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
5
|
+
const kindSchema = z.enum(BRAND_ASSET_KINDS);
|
|
6
|
+
async function callCloudImportApi(path, init) {
|
|
7
|
+
return callSendinelV1(path, init);
|
|
8
|
+
}
|
|
9
|
+
export function registerCloudImportTools(server) {
|
|
10
|
+
server.registerTool('list_staged_imports', {
|
|
11
|
+
title: 'List Staged Cloud Imports',
|
|
12
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
13
|
+
description: 'List cloud-import files staged for this project (fetching, staged, committing, or errored) awaiting an explicit commit_staged_import or discard_staged_import call.',
|
|
14
|
+
inputSchema: {},
|
|
15
|
+
}, async () => {
|
|
16
|
+
const result = await callCloudImportApi('/api/dashboard/cloud-import/staged', { method: 'GET' });
|
|
17
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
18
|
+
});
|
|
19
|
+
server.registerTool('commit_staged_import', {
|
|
20
|
+
title: 'Commit Staged Cloud Import',
|
|
21
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
22
|
+
description: 'Commit a staged cloud-import file into the media library as a brand asset. Only works while status is "staged" or "error"; already-committed imports return skipped_existing instead of erroring.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
staged_import_id: z.string().uuid(),
|
|
25
|
+
filename: z.string().max(255).optional(),
|
|
26
|
+
collection: z.string().max(120).optional(),
|
|
27
|
+
tags: z.array(z.string()).max(50).optional(),
|
|
28
|
+
brand_kit_id: z.string().uuid().optional(),
|
|
29
|
+
folder_id: z.string().uuid().optional(),
|
|
30
|
+
kind: kindSchema.optional(),
|
|
31
|
+
},
|
|
32
|
+
}, async ({ staged_import_id, ...body }) => {
|
|
33
|
+
const result = await callCloudImportApi(`/api/dashboard/cloud-import/${staged_import_id}/commit`, { method: 'POST', body });
|
|
34
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
35
|
+
});
|
|
36
|
+
server.registerTool('discard_staged_import', {
|
|
37
|
+
title: 'Discard Staged Cloud Import',
|
|
38
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
39
|
+
description: 'Discard a staged cloud-import file and remove its temporary storage object. Fails if the import was already committed or discarded.',
|
|
40
|
+
inputSchema: { staged_import_id: z.string().uuid() },
|
|
41
|
+
}, async ({ staged_import_id }) => {
|
|
42
|
+
const result = await callCloudImportApi(`/api/dashboard/cloud-import/${staged_import_id}/discard`, { method: 'POST' });
|
|
43
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
44
|
+
});
|
|
45
|
+
server.registerTool('list_synced_folders', {
|
|
46
|
+
title: 'List Google Drive Synced Folders',
|
|
47
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
48
|
+
description: 'List Google Drive folders registered for persistent Media Library sync, including last sync time and any recent per-file failures.',
|
|
49
|
+
inputSchema: {},
|
|
50
|
+
}, async () => {
|
|
51
|
+
const result = await callCloudImportApi('/api/dashboard/google-drive-synced-folders', { method: 'GET' });
|
|
52
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
53
|
+
});
|
|
54
|
+
server.registerTool('add_synced_folder', {
|
|
55
|
+
title: 'Add Google Drive Synced Folder',
|
|
56
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
57
|
+
description: 'Register a Google Drive folder for persistent Media Library sync. This does not grant access: first share the folder as a Viewer with drive-ingest@sendinel-ai.iam.gserviceaccount.com. This call verifies the service account can see the folder, and only then registers recurring sync.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
folder_id: z.string().min(1).describe('The Google Drive folder ID from the folder URL, not a display name.'),
|
|
60
|
+
folder_name: z.string().max(255).optional(),
|
|
61
|
+
brand_kit_id: z.string().uuid().optional(),
|
|
62
|
+
},
|
|
63
|
+
}, async (args) => {
|
|
64
|
+
const result = await callCloudImportApi('/api/dashboard/google-drive-synced-folders', { method: 'POST', body: args });
|
|
65
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
66
|
+
});
|
|
67
|
+
server.registerTool('remove_synced_folder', {
|
|
68
|
+
title: 'Remove Google Drive Synced Folder',
|
|
69
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
70
|
+
description: 'Stop persistent Media Library sync for a Google Drive folder. Does not revoke the Drive share itself or delete already-imported assets.',
|
|
71
|
+
inputSchema: { synced_folder_id: z.string().uuid() },
|
|
72
|
+
}, async ({ synced_folder_id }) => {
|
|
73
|
+
const result = await callCloudImportApi(`/api/dashboard/google-drive-synced-folders/${synced_folder_id}`, { method: 'DELETE' });
|
|
74
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
type BrandVisualContext = {
|
|
3
|
+
brandColors: string[];
|
|
4
|
+
visualStyle: string;
|
|
5
|
+
logoUrl: string;
|
|
6
|
+
resolvedButtonColor?: string;
|
|
7
|
+
promptBlock: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function resolveBrandVisualContext(site: {
|
|
10
|
+
unsub_brand_color?: string | null;
|
|
11
|
+
} | null | undefined, brandSettings: Record<string, unknown> | null | undefined): BrandVisualContext;
|
|
2
12
|
export declare function registerCompoundTools(server: McpServer): void;
|
|
13
|
+
export {};
|