@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.
Files changed (57) hide show
  1. package/README.md +35 -22
  2. package/build/anthropic-model.d.ts +30 -0
  3. package/build/anthropic-model.js +26 -1
  4. package/build/db.d.ts +17 -0
  5. package/build/db.js +15 -1
  6. package/build/index.js +6 -0
  7. package/build/lib/action-approvals.js +28 -0
  8. package/build/lib/brand-asset-persist.d.ts +1 -0
  9. package/build/lib/brand-asset-persist.js +20 -1
  10. package/build/lib/composer-blocks.d.ts +25 -25
  11. package/build/lib/composer-blocks.js +25 -5
  12. package/build/lib/connector-table-discovery.d.ts +12 -0
  13. package/build/lib/connector-table-discovery.js +117 -0
  14. package/build/lib/custom-properties-diff.d.ts +16 -0
  15. package/build/lib/custom-properties-diff.js +22 -0
  16. package/build/lib/schedule.d.ts +7 -2
  17. package/build/lib/schedule.js +8 -0
  18. package/build/lib/v1-client.d.ts +3 -0
  19. package/build/lib/v1-client.js +45 -0
  20. package/build/lib/voice.d.ts +7 -30
  21. package/build/lib/voice.js +6 -138
  22. package/build/prompts.d.ts +6 -0
  23. package/build/prompts.js +18 -0
  24. package/build/scoped-token.d.ts +1 -1
  25. package/build/server.d.ts +1 -0
  26. package/build/server.js +65 -6
  27. package/build/tools/analytics.js +97 -0
  28. package/build/tools/brand-kit.js +29 -30
  29. package/build/tools/campaigns.js +326 -52
  30. package/build/tools/cloud-import.d.ts +2 -0
  31. package/build/tools/cloud-import.js +76 -0
  32. package/build/tools/compound.d.ts +11 -0
  33. package/build/tools/compound.js +37 -7
  34. package/build/tools/connector.d.ts +2 -0
  35. package/build/tools/connector.js +280 -0
  36. package/build/tools/contacts.js +744 -16
  37. package/build/tools/content.js +228 -0
  38. package/build/tools/data.js +441 -0
  39. package/build/tools/delivery-ops.js +508 -1
  40. package/build/tools/forms.js +7 -6
  41. package/build/tools/gateway.d.ts +6 -0
  42. package/build/tools/gateway.js +92 -0
  43. package/build/tools/gdpr.js +185 -0
  44. package/build/tools/landing-pages.d.ts +2 -0
  45. package/build/tools/landing-pages.js +202 -0
  46. package/build/tools/lists.d.ts +2 -0
  47. package/build/tools/lists.js +428 -0
  48. package/build/tools/newsletters.js +1 -8
  49. package/build/tools/org.js +549 -33
  50. package/build/tools/segments.js +35 -184
  51. package/build/tools/signatures.d.ts +2 -0
  52. package/build/tools/signatures.js +168 -0
  53. package/build/tools/sites.js +3 -1
  54. package/build/tools/skills.d.ts +2 -0
  55. package/build/tools/skills.js +29 -0
  56. package/build/tools/templates.js +133 -7
  57. package/package.json +14 -13
@@ -1,20 +1,31 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { db } from '../db.js';
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 apiKey = process.env.SENDINEL_API_KEY;
229
- if (!apiKey)
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
- const body = await res.text();
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 db()
1318
- .from('sites')
1319
- .select('name, sending_domain, from_name, from_email, brand_voice')
1320
- .eq('id', site_id)
1321
- .eq('project_id', projectId)
1322
- .maybeSingle();
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 (!html && !text)
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: html ?? null,
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
- if (send_at !== undefined)
2153
- updates.send_at = send_at;
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
- return j({ ...campaign, steps: steps ?? [], active_enrollments: enrollCount ?? 0 });
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,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerCloudImportTools(server: McpServer): void;
@@ -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 {};