@sendinel/mcp-server 1.0.10 → 1.0.12
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/advisor.js +3 -3
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +105 -8
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +349 -60
- 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-proposals.js +1 -1
- package/build/tools/data.js +448 -4
- package/build/tools/delivery-ops.js +525 -7
- 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 +565 -35
- 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 +13 -12
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' });
|
|
@@ -618,7 +630,7 @@ export function registerCampaignTools(server) {
|
|
|
618
630
|
return j({ error: 'Campaign or contact not found', code: 'NOT_FOUND' });
|
|
619
631
|
const { data: enrollment, error } = await db()
|
|
620
632
|
.from('campaign_enrollments')
|
|
621
|
-
.select('id, status, current_step,
|
|
633
|
+
.select('id, status, current_step, enrolled_at, completed_at, next_send_at, exit_reason:exited_reason')
|
|
622
634
|
.eq('campaign_id', campaign_id)
|
|
623
635
|
.eq('contact_id', contact_id)
|
|
624
636
|
.maybeSingle();
|
|
@@ -627,19 +639,34 @@ export function registerCampaignTools(server) {
|
|
|
627
639
|
if (!enrollment) {
|
|
628
640
|
return j({ enrolled: false, enrollment: null, campaign: campaignRes.data, contact: contactRes.data });
|
|
629
641
|
}
|
|
630
|
-
const { count: stepsSent } = await
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
642
|
+
const [{ count: stepsSent, error: stepsSentError }, currentStepResult] = await Promise.all([
|
|
643
|
+
db()
|
|
644
|
+
.from('email_log')
|
|
645
|
+
.select('id', { count: 'exact', head: true })
|
|
646
|
+
.eq('project_id', projectId)
|
|
647
|
+
.eq('campaign_id', campaign_id)
|
|
648
|
+
.eq('contact_id', contact_id)
|
|
649
|
+
.not('status', 'eq', 'failed'),
|
|
650
|
+
enrollment.current_step == null
|
|
651
|
+
? Promise.resolve({ data: null, error: null })
|
|
652
|
+
: db()
|
|
653
|
+
.from('campaign_steps')
|
|
654
|
+
.select('id')
|
|
655
|
+
.eq('campaign_id', campaign_id)
|
|
656
|
+
.eq('step_order', enrollment.current_step)
|
|
657
|
+
.maybeSingle(),
|
|
658
|
+
]);
|
|
659
|
+
if (stepsSentError)
|
|
660
|
+
return txt(`Error: ${stepsSentError.message}`);
|
|
661
|
+
if (currentStepResult.error)
|
|
662
|
+
return txt(`Error: ${currentStepResult.error.message}`);
|
|
637
663
|
return j({
|
|
638
664
|
enrolled: true,
|
|
639
665
|
campaign: campaignRes.data,
|
|
640
666
|
contact: contactRes.data,
|
|
641
667
|
enrollment: {
|
|
642
668
|
...enrollment,
|
|
669
|
+
current_step_id: currentStepResult.data?.id ?? null,
|
|
643
670
|
current_step_order: enrollment.current_step ?? 0,
|
|
644
671
|
steps_sent: stepsSent ?? 0,
|
|
645
672
|
},
|
|
@@ -970,6 +997,133 @@ export function registerCampaignTools(server) {
|
|
|
970
997
|
}
|
|
971
998
|
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
|
|
972
999
|
});
|
|
1000
|
+
server.registerTool('list_sequence_templates', {
|
|
1001
|
+
title: 'List Sequence Templates',
|
|
1002
|
+
annotations: {
|
|
1003
|
+
readOnlyHint: true,
|
|
1004
|
+
destructiveHint: false,
|
|
1005
|
+
idempotentHint: true,
|
|
1006
|
+
openWorldHint: false,
|
|
1007
|
+
},
|
|
1008
|
+
description: 'List curated multi-step sequence templates available to clone into a draft campaign. Use clone_sequence_template to clone one.',
|
|
1009
|
+
inputSchema: {
|
|
1010
|
+
category: z.enum(['welcome', 'reengagement', 'winback', 'onboarding', 'nurture', 'launch', 'event', 'transactional']).optional().describe('Filter by category'),
|
|
1011
|
+
},
|
|
1012
|
+
}, async ({ category }) => {
|
|
1013
|
+
let query = db()
|
|
1014
|
+
.from('sequence_templates')
|
|
1015
|
+
.select('id, slug, category, name, description, use_case, estimated_steps, estimated_duration_days')
|
|
1016
|
+
.order('category')
|
|
1017
|
+
.order('name');
|
|
1018
|
+
if (category)
|
|
1019
|
+
query = query.eq('category', category);
|
|
1020
|
+
const { data, error } = await query;
|
|
1021
|
+
if (error)
|
|
1022
|
+
return txt(`Error: ${error.message}`);
|
|
1023
|
+
return j(data ?? []);
|
|
1024
|
+
});
|
|
1025
|
+
server.registerTool('clone_sequence_template', {
|
|
1026
|
+
title: 'Clone Sequence Template',
|
|
1027
|
+
annotations: {
|
|
1028
|
+
readOnlyHint: false,
|
|
1029
|
+
destructiveHint: false,
|
|
1030
|
+
idempotentHint: false,
|
|
1031
|
+
openWorldHint: false,
|
|
1032
|
+
},
|
|
1033
|
+
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.',
|
|
1034
|
+
inputSchema: {
|
|
1035
|
+
template_id: z.string().uuid().describe('Sequence template to clone, from list_sequence_templates'),
|
|
1036
|
+
new_name: z.string().min(1).optional().describe('Defaults to "<template name> (cloned)"'),
|
|
1037
|
+
site_id: z.string().uuid().optional().describe('Defaults to the project\'s oldest active site'),
|
|
1038
|
+
},
|
|
1039
|
+
}, async ({ template_id, new_name, site_id }) => {
|
|
1040
|
+
const projectId = getProjectId();
|
|
1041
|
+
const { data: template, error: templateError } = await db()
|
|
1042
|
+
.from('sequence_templates')
|
|
1043
|
+
.select('*')
|
|
1044
|
+
.eq('id', template_id)
|
|
1045
|
+
.maybeSingle();
|
|
1046
|
+
if (templateError)
|
|
1047
|
+
return txt(`Error: ${templateError.message}`);
|
|
1048
|
+
if (!template)
|
|
1049
|
+
return txt('Error: Template not found');
|
|
1050
|
+
const limit = await checkPlanLimit('active_campaigns');
|
|
1051
|
+
if (!limit.allowed)
|
|
1052
|
+
return txt(`Active campaign limit reached (${limit.current}/${limit.limit}).`);
|
|
1053
|
+
let targetSiteId = site_id ?? null;
|
|
1054
|
+
if (targetSiteId) {
|
|
1055
|
+
const { data: targetSite, error: targetSiteError } = await db()
|
|
1056
|
+
.from('sites')
|
|
1057
|
+
.select('id')
|
|
1058
|
+
.eq('id', targetSiteId)
|
|
1059
|
+
.eq('project_id', projectId)
|
|
1060
|
+
.maybeSingle();
|
|
1061
|
+
if (targetSiteError)
|
|
1062
|
+
return txt(`Error: ${targetSiteError.message}`);
|
|
1063
|
+
if (!targetSite)
|
|
1064
|
+
return txt('Error: Site not found for this project');
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
const { data: site, error: siteError } = await db()
|
|
1068
|
+
.from('sites')
|
|
1069
|
+
.select('id')
|
|
1070
|
+
.eq('project_id', projectId)
|
|
1071
|
+
.eq('active', true)
|
|
1072
|
+
.order('created_at', { ascending: true })
|
|
1073
|
+
.limit(1)
|
|
1074
|
+
.maybeSingle();
|
|
1075
|
+
if (siteError)
|
|
1076
|
+
return txt(`Error: ${siteError.message}`);
|
|
1077
|
+
targetSiteId = site?.id ?? null;
|
|
1078
|
+
}
|
|
1079
|
+
if (!targetSiteId) {
|
|
1080
|
+
return txt('Error: A sending domain is required before cloning a template — create one with create_site first, or pass site_id.');
|
|
1081
|
+
}
|
|
1082
|
+
const cloneName = new_name?.trim() || `${template.name} (cloned)`;
|
|
1083
|
+
const { data: campaign, error: campaignError } = await db()
|
|
1084
|
+
.from('campaigns')
|
|
1085
|
+
.insert({
|
|
1086
|
+
project_id: projectId,
|
|
1087
|
+
site_id: targetSiteId,
|
|
1088
|
+
name: cloneName,
|
|
1089
|
+
type: 'sequence',
|
|
1090
|
+
status: 'draft',
|
|
1091
|
+
})
|
|
1092
|
+
.select()
|
|
1093
|
+
.single();
|
|
1094
|
+
if (campaignError)
|
|
1095
|
+
return txt(`Error creating campaign: ${campaignError.message}`);
|
|
1096
|
+
const steps = Array.isArray(template.template?.steps) ? template.template.steps : [];
|
|
1097
|
+
if (steps.length > 0) {
|
|
1098
|
+
const { error: stepsError } = await db()
|
|
1099
|
+
.from('campaign_steps')
|
|
1100
|
+
.insert(steps.map((step, index) => ({
|
|
1101
|
+
campaign_id: campaign.id,
|
|
1102
|
+
step_order: index,
|
|
1103
|
+
subject: step.subject || `Email ${index + 1}`,
|
|
1104
|
+
body_html: step.body_html || '<p>Edit this email before sending.</p>',
|
|
1105
|
+
body_text: step.body_text ?? null,
|
|
1106
|
+
delay_days: step.delay_days ?? 0,
|
|
1107
|
+
delay_hours: step.delay_hours ?? 0,
|
|
1108
|
+
condition_type: step.condition_type ?? null,
|
|
1109
|
+
condition_lookback_hours: step.condition_lookback_hours ?? null,
|
|
1110
|
+
})));
|
|
1111
|
+
if (stepsError)
|
|
1112
|
+
return txt(`Campaign created but steps failed: ${stepsError.message}`);
|
|
1113
|
+
}
|
|
1114
|
+
await logAudit({
|
|
1115
|
+
action: 'sequence_template.cloned',
|
|
1116
|
+
resourceType: 'campaign',
|
|
1117
|
+
resourceId: campaign.id,
|
|
1118
|
+
details: { template_id, template_slug: template.slug, source: 'mcp' },
|
|
1119
|
+
}).catch(() => { });
|
|
1120
|
+
return j({
|
|
1121
|
+
...campaign,
|
|
1122
|
+
steps_copied: steps.length,
|
|
1123
|
+
cloned_from_template_id: template_id,
|
|
1124
|
+
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.`,
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
973
1127
|
server.registerTool('generate_email', {
|
|
974
1128
|
title: 'Generate Email',
|
|
975
1129
|
annotations: {
|
|
@@ -1018,7 +1172,7 @@ export function registerCampaignTools(server) {
|
|
|
1018
1172
|
? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name} <${site.from_email}>`
|
|
1019
1173
|
: 'No brand info available';
|
|
1020
1174
|
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1021
|
-
let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt)}
|
|
1175
|
+
let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt, project?.brand_settings ?? null)}
|
|
1022
1176
|
|
|
1023
1177
|
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1024
1178
|
{
|
|
@@ -1314,15 +1468,22 @@ Adapt your tone accordingly:
|
|
|
1314
1468
|
.single();
|
|
1315
1469
|
if (campErr)
|
|
1316
1470
|
return txt(`Error creating campaign: ${campErr.message}`);
|
|
1317
|
-
const { data: site } = await
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1471
|
+
const [{ data: site }, { data: project }] = await Promise.all([
|
|
1472
|
+
db()
|
|
1473
|
+
.from('sites')
|
|
1474
|
+
.select('name, sending_domain, from_name, from_email, brand_voice')
|
|
1475
|
+
.eq('id', site_id)
|
|
1476
|
+
.eq('project_id', projectId)
|
|
1477
|
+
.maybeSingle(),
|
|
1478
|
+
db()
|
|
1479
|
+
.from('projects')
|
|
1480
|
+
.select('brand_settings')
|
|
1481
|
+
.eq('id', projectId)
|
|
1482
|
+
.maybeSingle(),
|
|
1483
|
+
]);
|
|
1323
1484
|
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)}
|
|
1485
|
+
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1486
|
+
const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt, project?.brand_settings ?? null)}
|
|
1326
1487
|
|
|
1327
1488
|
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1328
1489
|
{
|
|
@@ -1579,16 +1740,22 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1579
1740
|
inputSchema: {
|
|
1580
1741
|
to: z.string().email().describe('Recipient email address'),
|
|
1581
1742
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
1582
|
-
subject: z.string().describe('Email subject line'),
|
|
1743
|
+
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
1583
1744
|
html: z.string().optional().describe('HTML body'),
|
|
1584
1745
|
text: z.string().optional().describe('Plain text body'),
|
|
1585
1746
|
reply_to: z.string().email().optional().describe('Reply-to address'),
|
|
1586
1747
|
idempotency_key: z.string().optional().describe('Unique key to prevent duplicate sends'),
|
|
1748
|
+
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
1749
|
+
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
1587
1750
|
},
|
|
1588
|
-
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key }) => {
|
|
1751
|
+
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
1589
1752
|
const projectId = getProjectId();
|
|
1590
|
-
if (
|
|
1753
|
+
if (template_id && html)
|
|
1754
|
+
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
1755
|
+
if (!template_id && !html && !text)
|
|
1591
1756
|
return txt('At least one of html or text is required');
|
|
1757
|
+
if (!template_id && !subject?.trim())
|
|
1758
|
+
return txt('Error: subject is required');
|
|
1592
1759
|
// Suppression check
|
|
1593
1760
|
const { data: suppression } = await db()
|
|
1594
1761
|
.from('suppressions')
|
|
@@ -1619,18 +1786,66 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1619
1786
|
// Site verification
|
|
1620
1787
|
const { data: site } = await db()
|
|
1621
1788
|
.from('sites')
|
|
1622
|
-
.select('from_email, from_name')
|
|
1789
|
+
.select('from_email, from_name, name, brand_voice')
|
|
1623
1790
|
.eq('id', site_id)
|
|
1624
1791
|
.eq('project_id', projectId)
|
|
1625
1792
|
.maybeSingle();
|
|
1626
1793
|
if (!site)
|
|
1627
1794
|
return txt('Error: Site not found');
|
|
1795
|
+
let resolvedSubject = subject;
|
|
1796
|
+
let resolvedHtml = html;
|
|
1797
|
+
let resolvedText = text;
|
|
1798
|
+
if (template_id) {
|
|
1799
|
+
const { data: template, error: templateError } = await db()
|
|
1800
|
+
.from('campaign_templates')
|
|
1801
|
+
.select('id, subject_hint, body_html, body_text, format')
|
|
1802
|
+
.eq('id', template_id)
|
|
1803
|
+
.eq('project_id', projectId)
|
|
1804
|
+
.maybeSingle();
|
|
1805
|
+
if (templateError)
|
|
1806
|
+
return txt(`Error: ${templateError.message}`);
|
|
1807
|
+
if (!template)
|
|
1808
|
+
return txt('Error: Template not found');
|
|
1809
|
+
const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
|
|
1810
|
+
const projectBrand = (project?.brand_settings ?? null);
|
|
1811
|
+
const siteBrand = (site.brand_voice ?? null);
|
|
1812
|
+
const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
|
|
1813
|
+
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined);
|
|
1814
|
+
const vars = template_vars ?? {};
|
|
1815
|
+
const renderVars = { ...brandVars, ...vars };
|
|
1816
|
+
const subjectFallingBack = !subject?.trim();
|
|
1817
|
+
const requiredVars = deriveTemplateVariables({
|
|
1818
|
+
body_html: template.format === 'text' ? null : (template.body_html ?? ''),
|
|
1819
|
+
body_text: template.format === 'text' ? (template.body_text ?? '') : null,
|
|
1820
|
+
subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
|
|
1821
|
+
});
|
|
1822
|
+
const satisfied = new Set([
|
|
1823
|
+
...Object.keys(vars),
|
|
1824
|
+
'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
|
|
1825
|
+
...Object.keys(NAME_FALLBACKS),
|
|
1826
|
+
]);
|
|
1827
|
+
const missing = requiredVars.filter((name) => !satisfied.has(name));
|
|
1828
|
+
if (missing.length) {
|
|
1829
|
+
return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
|
|
1830
|
+
}
|
|
1831
|
+
if (template.format === 'text') {
|
|
1832
|
+
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars);
|
|
1833
|
+
resolvedHtml = undefined;
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars);
|
|
1837
|
+
resolvedText = text ?? stripHtml(resolvedHtml);
|
|
1838
|
+
}
|
|
1839
|
+
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars);
|
|
1840
|
+
}
|
|
1841
|
+
if (!resolvedSubject?.trim())
|
|
1842
|
+
return txt('Error: subject is required');
|
|
1628
1843
|
const payload = {
|
|
1629
1844
|
from: `${site.from_name} <${site.from_email}>`,
|
|
1630
1845
|
to,
|
|
1631
|
-
subject,
|
|
1632
|
-
html,
|
|
1633
|
-
text,
|
|
1846
|
+
subject: resolvedSubject,
|
|
1847
|
+
html: resolvedHtml,
|
|
1848
|
+
text: resolvedText,
|
|
1634
1849
|
replyTo: reply_to,
|
|
1635
1850
|
};
|
|
1636
1851
|
let sendResult;
|
|
@@ -1647,13 +1862,13 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1647
1862
|
contact_id: null,
|
|
1648
1863
|
to_email: to,
|
|
1649
1864
|
from_email: site.from_email,
|
|
1650
|
-
subject,
|
|
1865
|
+
subject: resolvedSubject,
|
|
1651
1866
|
status: 'sent',
|
|
1652
1867
|
category: 'transactional',
|
|
1653
1868
|
sent_at: new Date().toISOString(),
|
|
1654
1869
|
provider_message_id: sendResult.messageId ?? null,
|
|
1655
1870
|
idempotency_key: idempotency_key ?? null,
|
|
1656
|
-
body_html:
|
|
1871
|
+
body_html: resolvedHtml ?? null,
|
|
1657
1872
|
}).select('id').single();
|
|
1658
1873
|
return j({
|
|
1659
1874
|
sent: true,
|
|
@@ -1676,8 +1891,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1676
1891
|
inputSchema: {
|
|
1677
1892
|
html: z.string().describe('HTML email content to validate'),
|
|
1678
1893
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
|
1894
|
+
custom_variables: z.array(z.string()).optional().describe('Variable names (without braces) this template legitimately uses beyond the fixed known set.'),
|
|
1679
1895
|
},
|
|
1680
|
-
}, async ({ html, site_id }) => {
|
|
1896
|
+
}, async ({ html, site_id, custom_variables }) => {
|
|
1681
1897
|
let voiceConfig = null;
|
|
1682
1898
|
if (site_id) {
|
|
1683
1899
|
const { data: site } = await db()
|
|
@@ -1688,7 +1904,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1688
1904
|
.maybeSingle();
|
|
1689
1905
|
voiceConfig = site?.brand_voice ?? null;
|
|
1690
1906
|
}
|
|
1691
|
-
const result = validateTemplate(html, voiceConfig);
|
|
1907
|
+
const result = validateTemplate(html, voiceConfig, { customVariables: custom_variables });
|
|
1692
1908
|
return j(result);
|
|
1693
1909
|
});
|
|
1694
1910
|
server.registerTool('check_content_consistency', {
|
|
@@ -2127,7 +2343,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2127
2343
|
idempotentHint: true,
|
|
2128
2344
|
openWorldHint: false,
|
|
2129
2345
|
},
|
|
2130
|
-
description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. Only provided fields are updated.',
|
|
2346
|
+
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
2347
|
inputSchema: {
|
|
2132
2348
|
campaign_id: z.string().uuid(),
|
|
2133
2349
|
name: z.string().min(1).max(200).optional(),
|
|
@@ -2144,13 +2360,28 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2144
2360
|
},
|
|
2145
2361
|
}, 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
2362
|
const projectId = getProjectId();
|
|
2363
|
+
let campaignType;
|
|
2364
|
+
if (send_at !== undefined && send_at !== null) {
|
|
2365
|
+
const { data: existing, error: fetchError } = await db()
|
|
2366
|
+
.from('campaigns')
|
|
2367
|
+
.select('id, type')
|
|
2368
|
+
.eq('id', campaign_id)
|
|
2369
|
+
.eq('project_id', projectId)
|
|
2370
|
+
.maybeSingle();
|
|
2371
|
+
if (fetchError)
|
|
2372
|
+
return j({ error: fetchError.message });
|
|
2373
|
+
if (!existing)
|
|
2374
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
2375
|
+
campaignType = existing.type;
|
|
2376
|
+
}
|
|
2147
2377
|
const updates = { updated_at: new Date().toISOString() };
|
|
2148
2378
|
if (name !== undefined)
|
|
2149
2379
|
updates.name = name;
|
|
2150
2380
|
if (site_id !== undefined)
|
|
2151
2381
|
updates.site_id = site_id;
|
|
2152
|
-
|
|
2153
|
-
|
|
2382
|
+
const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
|
|
2383
|
+
if (scheduleError)
|
|
2384
|
+
return j({ error: scheduleError, code: 'INVALID_FIELD' });
|
|
2154
2385
|
if (topic !== undefined)
|
|
2155
2386
|
updates.topic = topic;
|
|
2156
2387
|
if (sends_per_hour !== undefined)
|
|
@@ -2191,7 +2422,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2191
2422
|
.maybeSingle();
|
|
2192
2423
|
if (campErr || !campaign)
|
|
2193
2424
|
return j({ error: campErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
2194
|
-
const [{ data: steps }, { count: enrollCount }] = await Promise.all([
|
|
2425
|
+
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
2426
|
db()
|
|
2196
2427
|
.from('campaign_steps')
|
|
2197
2428
|
.select('*')
|
|
@@ -2202,8 +2433,66 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2202
2433
|
.select('id', { count: 'exact', head: true })
|
|
2203
2434
|
.eq('campaign_id', campaign_id)
|
|
2204
2435
|
.eq('status', 'active'),
|
|
2436
|
+
db()
|
|
2437
|
+
.from('email_log')
|
|
2438
|
+
.select('id', { count: 'exact', head: true })
|
|
2439
|
+
.eq('project_id', projectId)
|
|
2440
|
+
.eq('campaign_id', campaign_id)
|
|
2441
|
+
.in('status', SENT_EMAIL_STATUSES),
|
|
2442
|
+
db()
|
|
2443
|
+
.from('email_log')
|
|
2444
|
+
.select('id', { count: 'exact', head: true })
|
|
2445
|
+
.eq('project_id', projectId)
|
|
2446
|
+
.eq('campaign_id', campaign_id)
|
|
2447
|
+
.eq('status', 'queued'),
|
|
2448
|
+
db()
|
|
2449
|
+
.from('email_log')
|
|
2450
|
+
.select('id', { count: 'exact', head: true })
|
|
2451
|
+
.eq('project_id', projectId)
|
|
2452
|
+
.eq('campaign_id', campaign_id)
|
|
2453
|
+
.eq('status', 'failed'),
|
|
2454
|
+
db()
|
|
2455
|
+
.from('send_queue')
|
|
2456
|
+
.select('status, next_retry_at')
|
|
2457
|
+
.eq('project_id', projectId)
|
|
2458
|
+
.eq('campaign_id', campaign_id)
|
|
2459
|
+
.in('status', ['pending', 'processing', 'retry']),
|
|
2460
|
+
db()
|
|
2461
|
+
.from('send_dlq')
|
|
2462
|
+
.select('id', { count: 'exact', head: true })
|
|
2463
|
+
.eq('project_id', projectId)
|
|
2464
|
+
.eq('resolved', false)
|
|
2465
|
+
.eq('queue_snapshot->>campaign_id', campaign_id),
|
|
2205
2466
|
]);
|
|
2206
|
-
|
|
2467
|
+
const visibilityError = stepsError ?? enrollError ?? sentError ?? queuedEmailError ?? failedSendError ?? queueError ?? dlqError;
|
|
2468
|
+
if (visibilityError)
|
|
2469
|
+
return j({ error: visibilityError.message, code: 'CAMPAIGN_VISIBILITY_QUERY_FAILED' });
|
|
2470
|
+
const outstandingRows = queueRows ?? [];
|
|
2471
|
+
const pendingOrRetryRows = outstandingRows.filter((row) => row.status !== 'processing');
|
|
2472
|
+
const retryingCount = outstandingRows.filter((row) => row.status === 'retry').length;
|
|
2473
|
+
const processingCount = outstandingRows.filter((row) => row.status === 'processing').length;
|
|
2474
|
+
const nextRetryAt = pendingOrRetryRows
|
|
2475
|
+
.map((row) => row.next_retry_at)
|
|
2476
|
+
.filter((value) => Boolean(value))
|
|
2477
|
+
.sort()[0] ?? null;
|
|
2478
|
+
const queuedCount = queuedEmailCount ?? 0;
|
|
2479
|
+
const failedCount = failedSendCount ?? 0;
|
|
2480
|
+
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
2481
|
+
return j({
|
|
2482
|
+
...campaign,
|
|
2483
|
+
total_sent: sentCount ?? 0,
|
|
2484
|
+
steps: steps ?? [],
|
|
2485
|
+
active_enrollments: enrollCount ?? 0,
|
|
2486
|
+
send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
|
|
2487
|
+
queued_email_count: queuedCount,
|
|
2488
|
+
failed_send_count: failedCount,
|
|
2489
|
+
queue_outstanding_count: outstandingRows.length,
|
|
2490
|
+
queue_pending_count: pendingOrRetryRows.length,
|
|
2491
|
+
queue_retrying_count: retryingCount,
|
|
2492
|
+
queue_processing_count: processingCount,
|
|
2493
|
+
queue_next_retry_at: nextRetryAt,
|
|
2494
|
+
dlq_unresolved_count: unresolvedCount,
|
|
2495
|
+
});
|
|
2207
2496
|
});
|
|
2208
2497
|
server.registerTool('unenroll_contact', {
|
|
2209
2498
|
title: 'Unenroll Contact',
|