@sendinel/mcp-server 1.0.165 → 1.0.167

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.
@@ -19,7 +19,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
19
19
  import { checkPlanLimit } from '../plan-limits.js';
20
20
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
21
21
  import { fireWebhookEvent } from '../lib/webhook-events.js';
22
- import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
22
+ import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
23
23
  import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
24
24
  import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
25
25
  import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
@@ -190,27 +190,30 @@ async function campaignTimezoneWarnings(campaign) {
190
190
  const sendWindow = campaign.send_window ?? (campaign.send_window_inherit !== false ? site?.send_window : null);
191
191
  return sendWindow && !validTimezone(site?.timezone) ? [SEND_WINDOW_TIMEZONE_WARNING] : [];
192
192
  }
193
- async function campaignTriggerOutput(campaign) {
194
- let seenEvents;
195
- const config = normalizeTriggerConfig(campaign.trigger_config);
196
- if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length) {
197
- try {
198
- // /v1/events -> intakeContactEvent stores event_type=custom and title=event.
199
- // Read all pages: a capped sample cannot establish that an event was never seen.
200
- const rows = await readAllRows(db().from('contact_events').select('id, title')
201
- .eq('project_id', getProjectId()).eq('event_type', 'custom'));
202
- seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
203
- }
204
- catch {
205
- seenEvents = null;
206
- }
193
+ async function readCampaignTriggerHistory(campaigns) {
194
+ if (!campaigns.some(campaign => normalizeTriggerValue(campaign.trigger_event_name)
195
+ || normalizeTriggerConfig(campaign.trigger_config).events.length))
196
+ return undefined;
197
+ try {
198
+ // /v1/events stores event_type=custom and title=event. Read every page once per response.
199
+ const rows = await readAllRows(db().from('contact_events').select('id, title')
200
+ .eq('project_id', getProjectId()).eq('event_type', 'custom'));
201
+ return new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
207
202
  }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function buildCampaignTriggerOutput(campaign, seenEvents) {
208
208
  return {
209
209
  trigger_kind: campaignTriggerKind(campaign),
210
210
  fires_on: buildCampaignFiresOn(campaign),
211
211
  trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
212
212
  };
213
213
  }
214
+ async function campaignTriggerOutput(campaign) {
215
+ return buildCampaignTriggerOutput(campaign, await readCampaignTriggerHistory([campaign]));
216
+ }
214
217
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
215
218
  if (sendAt === undefined)
216
219
  return null;
@@ -780,14 +783,14 @@ export function registerCampaignTools(server) {
780
783
  // counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
781
784
  const rows = data ?? [];
782
785
  const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
786
+ const seenEvents = await readCampaignTriggerHistory(rows);
783
787
  const withPeople = await Promise.all(rows.map(async (row) => {
784
788
  const periods = await readCampaignPeriods(db(), getProjectId(), row);
785
789
  return {
786
790
  ...row,
787
791
  reentry_mode: row.period_key ? "per_event_period" : "once_ever",
788
792
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
789
- fires_on: buildCampaignFiresOn(row),
790
- trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
793
+ ...buildCampaignTriggerOutput(row, seenEvents),
791
794
  ...campaignPeriodFields(periods, period),
792
795
  total_sent: periods?.totals?.sent ?? (engagement.ok ? engagement.sent.get(row.id) ?? 0 : null),
793
796
  unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
@@ -877,15 +880,15 @@ export function registerCampaignTools(server) {
877
880
  };
878
881
  const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
879
882
  if (!reentry.ok)
880
- return j({ error: reentry.error, code: 'INVALID_FIELD' });
883
+ return j({ error: reentry.error, code: 'validation_failed' });
881
884
  if (reentry.period_key !== undefined)
882
885
  insertPayload.period_key = reentry.period_key;
883
886
  const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
884
887
  if (scheduleError)
885
- return j({ error: scheduleError, code: 'INVALID_FIELD' });
888
+ return j({ error: scheduleError, code: 'validation_failed' });
886
889
  const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
887
890
  if (targetingError)
888
- return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
891
+ return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
889
892
  const { data, error } = await db()
890
893
  .from('campaigns')
891
894
  .insert(insertPayload)
@@ -1110,7 +1113,7 @@ export function registerCampaignTools(server) {
1110
1113
  },
1111
1114
  description: 'Enroll a single contact in a campaign',
1112
1115
  inputSchema: {
1113
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1116
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
1114
1117
  campaign_id: z.string().uuid(),
1115
1118
  contact_id: z.string().uuid(),
1116
1119
  next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
@@ -1233,7 +1236,7 @@ export function registerCampaignTools(server) {
1233
1236
  },
1234
1237
  description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
1235
1238
  inputSchema: {
1236
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1239
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
1237
1240
  campaign_id: z.string().uuid(),
1238
1241
  site_id: z.string().uuid(),
1239
1242
  next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
@@ -1625,6 +1628,11 @@ export function registerCampaignTools(server) {
1625
1628
  content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
1626
1629
  }
1627
1630
  const delivery = await sandboxAudience(projectId, resolved.contactIds);
1631
+ if (delivery.recipient_count === 0) {
1632
+ return j({ error: 'No recipients can be delivered under the sandbox policy. Choose an allowed audience before requesting approval.',
1633
+ code: 'validation_failed', ...delivery,
1634
+ audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) } });
1635
+ }
1628
1636
  const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
1629
1637
  const payload = {
1630
1638
  site_id: site.id,
@@ -1729,14 +1737,27 @@ export function registerCampaignTools(server) {
1729
1737
  }
1730
1738
  const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
1731
1739
  const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
1740
+ const configurationFields = [
1741
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'send_class',
1742
+ 'period_key', 'reentry_mode', 'campaign_step_branches', 'segment_id',
1743
+ 'unsub_footer_template', 'campaign_goals', 'topic', 'send_at', 'send_time', 'timezone',
1744
+ 'goal_type', 'goal_value', 'goal_action', 'goal_target_campaign_id',
1745
+ ];
1746
+ // Keep the exhaustive machine-readable inventory, with important settings first.
1732
1747
  const fields_not_copied = [...new Set([
1748
+ ...configurationFields.filter(key => key in original || [
1749
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1750
+ 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1751
+ ].includes(key)),
1733
1752
  ...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
1734
- 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1735
- 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1736
1753
  ])];
1754
+ const counters = fields_not_copied.filter(key => /^(?:total_|unique_)|_rate$/.test(key));
1755
+ const warnings = fields_not_copied.filter(key => configurationFields.includes(key)).map(field => `${field} was not copied${field === 'trigger_config' ? ' (including where filters)' : ''}; review and configure the draft before activation.`);
1756
+ if (counters.length)
1757
+ warnings.push(`Counters and reporting statistics are reset for the draft: ${counters.join(', ')}.`);
1737
1758
  await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
1738
1759
  return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
1739
- warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
1760
+ warnings });
1740
1761
  });
1741
1762
  server.registerTool('list_sequence_templates', {
1742
1763
  title: 'List Sequence Templates',
@@ -1995,7 +2016,7 @@ Adapt your tone accordingly:
1995
2016
  idempotentHint: false,
1996
2017
  openWorldHint: true,
1997
2018
  },
1998
- description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with the same arguments plus approval_id to launch them. Nothing is created or sent before approval.',
2019
+ description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with only approval_id to launch them. Nothing is created or sent before approval.',
1999
2020
  inputSchema: {
2000
2021
  name: z.string().min(1).describe('Campaign name'),
2001
2022
  site_id: z.string().uuid(),
@@ -2428,7 +2449,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2428
2449
  emails: plan.steps.map((step, i) => ({
2429
2450
  step: i, subject: step.subject, body_html: step.body_html, body_text: step.body_text, delay_days: step.delay_days, delay_hours: step.delay_hours,
2430
2451
  })),
2431
- next: 'Show the user each subject, the recipient count and approve_url. After a person approves, call launch_campaign again with the same arguments plus approval_id. These exact emails are sent; nothing is rewritten. Do not say it launched before that call succeeds.',
2452
+ next: approvalNext('launch_campaign', approval) + ' These exact emails are sent; nothing is rewritten. Do not say it launched before that call succeeds.',
2432
2453
  });
2433
2454
  }
2434
2455
  return launchFromPlan(plan);
@@ -2441,7 +2462,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2441
2462
  idempotentHint: true,
2442
2463
  openWorldHint: false,
2443
2464
  },
2444
- description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists remaining tags. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
2465
+ description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists every remaining {{ opener, including malformed and unclosed tags; supported tags keep key names and malformed tags use literal text. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
2445
2466
  inputSchema: {
2446
2467
  site_id: z.string().uuid().optional().describe('Site UUID for sender tags and footers'),
2447
2468
  campaign_id: z.string().uuid().optional().describe('Campaign UUID; previews its first step in send order. Use step_id to choose a specific step.'),
@@ -2466,7 +2487,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2466
2487
  },
2467
2488
  description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending; suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
2468
2489
  inputSchema: {
2469
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2490
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2470
2491
  to_email: z.string().email().describe('Email address to send the test to'),
2471
2492
  site_id: z.string().uuid().describe('Site UUID (determines from address)'),
2472
2493
  step_id: z.string().uuid().optional(),
@@ -2512,7 +2533,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2512
2533
  },
2513
2534
  description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
2514
2535
  inputSchema: {
2515
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2536
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2516
2537
  to: z.string().email().describe('Recipient email address'),
2517
2538
  site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
2518
2539
  subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
@@ -2696,9 +2717,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
2696
2717
  idempotentHint: false,
2697
2718
  openWorldHint: true,
2698
2719
  },
2699
- description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
2720
+ description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with only approval_id once approved.',
2700
2721
  inputSchema: {
2701
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2722
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2702
2723
  to: z.string().email().describe('Recipient email address'),
2703
2724
  site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
2704
2725
  subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
@@ -2991,7 +3012,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2991
3012
  idempotentHint: true,
2992
3013
  openWorldHint: false,
2993
3014
  },
2994
- description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
3015
+ description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}} in both subject and body as errors, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
2995
3016
  inputSchema: {
2996
3017
  html: z.string().describe('HTML email content to validate'),
2997
3018
  site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
@@ -3188,20 +3209,20 @@ Return ONLY valid JSON with this exact shape:
3188
3209
  if (footerTemplate !== null) {
3189
3210
  const footerError = validateUnsubFooterTemplate(footerTemplate);
3190
3211
  if (footerError)
3191
- return j({ error: footerError, code: 'INVALID_FIELD' });
3212
+ return j({ error: footerError, code: 'validation_failed' });
3192
3213
  }
3193
3214
  const sendClass = send_class ?? 'broadcast';
3194
3215
  const targeting = {};
3195
3216
  const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
3196
3217
  const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
3197
3218
  if (targetingError)
3198
- return j({ error: targetingError, code: 'INVALID_FIELD' });
3219
+ return j({ error: targetingError, code: 'validation_failed' });
3199
3220
  const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
3200
3221
  const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
3201
3222
  const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
3202
3223
  const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type: campaignType, trigger_type: targeting.trigger_type });
3203
3224
  if (!reentry.ok)
3204
- return j({ error: reentry.error, code: 'INVALID_FIELD' });
3225
+ return j({ error: reentry.error, code: 'validation_failed' });
3205
3226
  if (reentry.period_key !== undefined)
3206
3227
  targeting.period_key = reentry.period_key;
3207
3228
  const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
@@ -3512,7 +3533,7 @@ Return ONLY valid JSON with this exact shape:
3512
3533
  openWorldHint: false,
3513
3534
  },
3514
3535
  description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
3515
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), step_id: z.string().uuid() },
3536
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), step_id: z.string().uuid() },
3516
3537
  }, withApproval('delete_campaign_step', async ({ step_id }) => {
3517
3538
  const projectId = getProjectId();
3518
3539
  const { data: step } = await db()
@@ -3550,7 +3571,7 @@ Return ONLY valid JSON with this exact shape:
3550
3571
  openWorldHint: false,
3551
3572
  },
3552
3573
  description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
3553
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), campaign_id: z.string().uuid() },
3574
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), campaign_id: z.string().uuid() },
3554
3575
  }, withApproval('delete_campaign', async ({ campaign_id }) => {
3555
3576
  const projectId = getProjectId();
3556
3577
  const { data: campaign, error: fetchErr } = await db()
@@ -3593,7 +3614,7 @@ Return ONLY valid JSON with this exact shape:
3593
3614
  return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3594
3615
  }
3595
3616
  if (!args.campaign_id)
3596
- return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3617
+ return j({ error: 'campaign_id is required', code: 'validation_failed' });
3597
3618
  const projectId = getProjectId();
3598
3619
  const { data: existing, error: loadError } = await db().from('campaigns')
3599
3620
  .select(TRIGGER_EDIT_SELECT)
@@ -3608,14 +3629,14 @@ Return ONLY valid JSON with this exact shape:
3608
3629
  const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config', 'reentry_mode', 'period_key']
3609
3630
  .some((key) => args[key] !== undefined);
3610
3631
  if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
3611
- return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'INVALID_FIELD' });
3632
+ return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'validation_failed' });
3612
3633
  }
3613
3634
  const updates = {};
3614
3635
  const targetingError = await applyCampaignTargetingFields(updates, args.clear
3615
3636
  ? { trigger_type: 'manual', trigger_event_name: null, trigger_tag: null, trigger_config: null }
3616
3637
  : args, projectId, existing);
3617
3638
  if (targetingError)
3618
- return j({ error: targetingError, code: 'INVALID_FIELD' });
3639
+ return j({ error: targetingError, code: 'validation_failed' });
3619
3640
  const normalized = normalizeTriggerConfig(updates.trigger_config);
3620
3641
  if (existing.status === 'draft' && existing.type === 'sequence'
3621
3642
  && (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
@@ -3626,7 +3647,7 @@ Return ONLY valid JSON with this exact shape:
3626
3647
  trigger_type: (updates.trigger_type ?? existing.trigger_type),
3627
3648
  });
3628
3649
  if (!reentry.ok)
3629
- return j({ error: reentry.error, code: 'INVALID_FIELD' });
3650
+ return j({ error: reentry.error, code: 'validation_failed' });
3630
3651
  if (reentry.period_key !== undefined)
3631
3652
  updates.period_key = reentry.period_key;
3632
3653
  const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
@@ -3670,11 +3691,11 @@ Return ONLY valid JSON with this exact shape:
3670
3691
  }
3671
3692
  const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
3672
3693
  if (!campaign_id)
3673
- return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3694
+ return j({ error: 'campaign_id is required', code: 'validation_failed' });
3674
3695
  if (unsub_footer_template !== undefined && unsub_footer_template !== null) {
3675
3696
  const footerError = validateUnsubFooterTemplate(unsub_footer_template);
3676
3697
  if (footerError)
3677
- return j({ error: footerError, code: 'INVALID_FIELD' });
3698
+ return j({ error: footerError, code: 'validation_failed' });
3678
3699
  }
3679
3700
  const projectId = getProjectId();
3680
3701
  let campaignType;
@@ -3709,9 +3730,9 @@ Return ONLY valid JSON with this exact shape:
3709
3730
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3710
3731
  if (type !== existing.type) {
3711
3732
  if (existing.status !== 'draft')
3712
- return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: 'INVALID_FIELD' });
3733
+ return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: 'validation_failed' });
3713
3734
  if (existing.type !== 'sequence' && existing.type !== 'triggered')
3714
- return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
3735
+ return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'validation_failed' });
3715
3736
  }
3716
3737
  }
3717
3738
  const hasTriggerUpdate = reentry_mode !== undefined || period_key !== undefined || trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
@@ -3733,7 +3754,7 @@ Return ONLY valid JSON with this exact shape:
3733
3754
  updates.type = type;
3734
3755
  const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
3735
3756
  if (scheduleError)
3736
- return j({ error: scheduleError, code: 'INVALID_FIELD' });
3757
+ return j({ error: scheduleError, code: 'validation_failed' });
3737
3758
  if (topic !== undefined)
3738
3759
  updates.topic = topic;
3739
3760
  if (sends_per_hour !== undefined)
@@ -3746,7 +3767,7 @@ Return ONLY valid JSON with this exact shape:
3746
3767
  updates.unsub_footer_template = unsub_footer_template?.trim() ?? null;
3747
3768
  const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
3748
3769
  if (targetingError)
3749
- return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
3770
+ return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
3750
3771
  // If type was not explicitly changed, but this update sets an event trigger on a draft sequence,
3751
3772
  // normalize type to 'triggered' so that event intake matches and enrolls it (#3852).
3752
3773
  if (type === undefined && existingCampaignForTargeting?.type === 'sequence' && existingCampaignForTargeting?.status === 'draft') {
@@ -3762,7 +3783,7 @@ Return ONLY valid JSON with this exact shape:
3762
3783
  trigger_type: (updates.trigger_type ?? existingCampaignForTargeting?.trigger_type),
3763
3784
  });
3764
3785
  if (!reentry.ok)
3765
- return j({ error: reentry.error, code: 'INVALID_FIELD' });
3786
+ return j({ error: reentry.error, code: 'validation_failed' });
3766
3787
  if (reentry.period_key !== undefined)
3767
3788
  updates.period_key = reentry.period_key;
3768
3789
  let data;
@@ -607,7 +607,7 @@ export function registerContactTools(server) {
607
607
  description: 'Record a contact event through POST /api/v1/events and enroll matching active triggered campaigns. Can start real sends; requires standard approval. Returns the REST outcome and sendability unchanged. Does not subscribe the contact or update contact properties.',
608
608
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
609
609
  inputSchema: {
610
- approval_id: z.string().uuid().optional().describe('Call once to request approval, then repeat the same arguments with the approved approval_id.'),
610
+ approval_id: z.string().uuid().optional().describe('Call once to request approval, then call again with only approval_id.'),
611
611
  email: z.string().email(),
612
612
  event: z.string().trim().min(1),
613
613
  data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
@@ -659,7 +659,7 @@ export function registerContactTools(server) {
659
659
  description: 'Identify or update a contact through the v1 identify API. Optional signup/customer consent subscribes new or existing contacts without overriding opt-outs. Customer consent requires a topic; consent requires site_id. Without consent, site_id retains legacy create-only subscription behavior and returns a warning.',
660
660
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
661
661
  inputSchema: {
662
- approval_id: z.string().uuid().optional().describe('Call once to request approval, then repeat the same arguments with the approved approval_id.'),
662
+ approval_id: z.string().uuid().optional().describe('Call once to request approval, then call again with only approval_id.'),
663
663
  email: z.string().email(),
664
664
  first_name: z.string().optional(),
665
665
  last_name: z.string().optional(),
@@ -1212,7 +1212,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1212
1212
  },
1213
1213
  description: 'Delete a saved subscriber view owned by the current project.',
1214
1214
  inputSchema: {
1215
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1215
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
1216
1216
  view_id: z.string().uuid(),
1217
1217
  },
1218
1218
  }, withApproval('delete_subscriber_view', async ({ view_id }) => {
@@ -1461,7 +1461,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1461
1461
  },
1462
1462
  description: 'Bulk import contacts and subscribe them to a site. Max 200 per call.',
1463
1463
  inputSchema: {
1464
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1464
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
1465
1465
  contacts: z.array(z.object({
1466
1466
  email: z.string().email(),
1467
1467
  first_name: z.string().optional(),
@@ -1711,7 +1711,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1711
1711
  idempotentHint: true,
1712
1712
  openWorldHint: false,
1713
1713
  },
1714
- description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
1714
+ description: 'List contacts using a saved segment_id or inline filters for engagement score, lifecycle stage, site and company. Saved segment_id and inline filters are mutually exclusive. Useful for building re-engagement campaigns or identifying high-value contacts.',
1715
1715
  inputSchema: {
1716
1716
  segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
1717
1717
  lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
@@ -2222,7 +2222,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2222
2222
  },
2223
2223
  description: 'Merge custom_properties into many contacts at once. Same merge semantics as update_subscriber, applied across a batch.',
2224
2224
  inputSchema: {
2225
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2225
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2226
2226
  contact_ids: z.array(z.string().uuid()).min(1),
2227
2227
  custom_properties: z.record(z.string(), z.unknown()).describe('Merged into each contact\'s existing custom_properties; a key set to null deletes that key'),
2228
2228
  },
@@ -2521,9 +2521,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2521
2521
  idempotentHint: false,
2522
2522
  openWorldHint: false,
2523
2523
  },
2524
- description: 'Remove an email address from the suppression list. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
2524
+ description: 'Remove an email address from the suppression list. Requires human approval: the first call returns an approve_url and does nothing; call again with only approval_id once approved.',
2525
2525
  inputSchema: {
2526
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2526
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2527
2527
  email: z.string().email(),
2528
2528
  },
2529
2529
  }, withApproval('remove_suppression', async ({ email }) => {
@@ -2907,7 +2907,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2907
2907
  title: 'Delete Custom Field',
2908
2908
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
2909
2909
  description: 'Delete a custom field definition without deleting existing contact values stored under that key.',
2910
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), id: z.string().uuid() },
2910
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), id: z.string().uuid() },
2911
2911
  }, withApproval('delete_custom_field', async ({ id }) => {
2912
2912
  const projectId = getProjectId();
2913
2913
  const { data, error } = await db()
@@ -259,7 +259,7 @@ export function registerContentTools(server) {
259
259
  openWorldHint: false,
260
260
  },
261
261
  description: 'Delete a reusable content block.',
262
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), block_id: z.string().uuid() },
262
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), block_id: z.string().uuid() },
263
263
  }, withApproval('delete_content_block', async ({ block_id }) => {
264
264
  const { error } = await db().from('content_blocks').delete().eq('project_id', getProjectId()).eq('id', block_id);
265
265
  if (error)
@@ -319,7 +319,7 @@ export function registerContentTools(server) {
319
319
  },
320
320
  description: 'Ingest a blog post (written by your AI/agent) into Sendinel. By default it lands as a draft in the review queue. For a trusted, hands-off flow, set auto_publish=true with publish_to (a destination name or id from list_blog_destinations) to skip review and publish to the user\'s own site — now, or at scheduled_at. Only title is required; everything else is optional.',
321
321
  inputSchema: {
322
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
322
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
323
323
  title: z.string().min(1).max(300),
324
324
  body: z.string().optional(),
325
325
  excerpt: z.string().max(1000).optional(),
@@ -563,7 +563,7 @@ export function registerDataTools(server) {
563
563
  },
564
564
  description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
565
565
  inputSchema: {
566
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
566
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
567
567
  org_id: z.string().uuid().optional(),
568
568
  platform: z.enum(CONNECTED_PLATFORMS).optional(),
569
569
  connection_id: z.string().uuid().optional(),
@@ -980,7 +980,7 @@ export function registerDataTools(server) {
980
980
  },
981
981
  description: 'Create an API key and return the secret once.',
982
982
  inputSchema: {
983
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
983
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
984
984
  name: z.string().min(1),
985
985
  slug: z.string().optional(),
986
986
  scopes: z.array(z.string()).optional().default(['read']),
@@ -1010,7 +1010,7 @@ export function registerDataTools(server) {
1010
1010
  openWorldHint: false,
1011
1011
  },
1012
1012
  description: 'Revoke an API key.',
1013
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), key_id: z.string().uuid() },
1013
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), key_id: z.string().uuid() },
1014
1014
  }, withApproval('revoke_api_key', async ({ key_id }) => {
1015
1015
  const { error } = await db().from('api_keys').update({ revoked_at: new Date().toISOString() }).eq('project_id', getProjectId()).eq('id', key_id);
1016
1016
  if (error)
@@ -1198,7 +1198,7 @@ export function registerDataTools(server) {
1198
1198
  },
1199
1199
  description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
1200
1200
  inputSchema: {
1201
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1201
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
1202
1202
  siteId: z.string().uuid(),
1203
1203
  format: z.enum(['json', 'csv']).optional().default('json'),
1204
1204
  },
@@ -7,6 +7,7 @@ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestA
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { getProjectId } from '../project.js';
9
9
  import { dbToolError, toolError } from '../lib/db-error.js';
10
+ import { readLatestStoredDomainHealth, storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
10
11
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
11
12
  function asRecord(value) {
12
13
  return value && typeof value === 'object' ? value : {};
@@ -146,7 +147,7 @@ export function registerDeliveryOpsTools(server) {
146
147
  idempotentHint: true,
147
148
  openWorldHint: false,
148
149
  },
149
- description: 'Returns stored SPF/DKIM/DMARC from the latest health check; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
150
+ description: 'Returns stored SPF/DKIM/DMARC from the latest health check, with verification_source=stored_domain_health and checked_at; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
150
151
  inputSchema: { domain_id: z.string().uuid() },
151
152
  }, async ({ domain_id }) => {
152
153
  const { data, error } = await db().from('domains')
@@ -158,22 +159,10 @@ export function registerDeliveryOpsTools(server) {
158
159
  return toolError(dbToolError(error, 'Unable to load domain'));
159
160
  if (!data)
160
161
  return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
161
- const { data: health, error: healthError } = await db().from('domain_health')
162
- .select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
163
- .eq('sites.project_id', getProjectId())
164
- .eq('sites.domain_id', domain_id)
165
- .order('check_date', { ascending: false })
166
- .limit(1)
167
- .maybeSingle();
162
+ const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain_id);
168
163
  if (healthError)
169
164
  return toolError(dbToolError(healthError, 'Unable to load domain health'));
170
- const spf = health?.spf_valid ?? null;
171
- const dkim = health?.dkim_valid ?? null;
172
- const dmarc = health?.dmarc_valid ?? null;
173
- const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
174
- ? Boolean(spf && dkim && dmarc)
175
- : null;
176
- return j({ spf, dkim, dmarc, all_verified: allVerified });
165
+ return j(storedDomainDns(health));
177
166
  });
178
167
  server.registerTool('recheck_domain_dns', {
179
168
  title: 'Recheck Domain DNS Live',
@@ -546,7 +535,7 @@ export function registerDeliveryOpsTools(server) {
546
535
  },
547
536
  description: 'Re-enter dead-lettered campaign enrollments into active sending, which can cause real emails to send on the next queue tick. Call once without confirmed to preview the matched count, then call again with confirmed=true to replay them.',
548
537
  inputSchema: {
549
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
538
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
550
539
  site_id: z.string().uuid(),
551
540
  max_count: z.number().int().min(1).max(1000).default(100).optional(),
552
541
  failed_after: z.string().datetime().optional(),
@@ -802,7 +791,7 @@ export function registerDeliveryOpsTools(server) {
802
791
  idempotentHint: true,
803
792
  openWorldHint: false,
804
793
  },
805
- description: 'List sending domains with Sendinel readiness (ready true/false/null, reasons, provider_detail). Raw provider values, including EE tracking_status_raw, are labelled detail only and do not drive readiness.',
794
+ description: 'List sending domains with stored domain_health DNS observations and Sendinel readiness (ready true/false/null, reasons, provider_detail). Registration status, provider and verified_at remain saved metadata; unknown DNS never implies ready. Raw provider values, including EE tracking_status_raw, are labelled detail only.',
806
795
  inputSchema: { site_id: z.string().uuid().optional() },
807
796
  }, async ({ site_id }) => {
808
797
  const projectId = getProjectId();
@@ -829,9 +818,20 @@ export function registerDeliveryOpsTools(server) {
829
818
  return j(dbToolError(error));
830
819
  if (!data)
831
820
  return j({ error: 'Domain query returned no result' });
832
- return j({ domains: data.map((domain) => ({
833
- ...domain, readiness: computeDomainReadiness(domain),
834
- })), count: data.length });
821
+ const domains = [];
822
+ for (const domain of data) {
823
+ const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain.id);
824
+ if (healthError)
825
+ return toolError(dbToolError(healthError, 'Unable to load domain health'));
826
+ const dns = storedDomainDns(health);
827
+ const readiness = computeDomainReadiness(domain);
828
+ const auth = [dns.spf, dns.dkim, dns.dmarc];
829
+ readiness.ready = readiness.ready === false || auth.includes(false) ? false
830
+ : readiness.ready === null || auth.includes(null) ? null : true;
831
+ readiness.reasons.push(...storedDnsReasons(dns));
832
+ domains.push({ ...domain, dns, readiness });
833
+ }
834
+ return j({ domains, count: domains.length });
835
835
  });
836
836
  server.registerTool('register_domain', {
837
837
  title: 'Register Domain',
@@ -172,7 +172,7 @@ export function registerLandingPageTools(server) {
172
172
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
173
173
  description: 'Update a landing page. Setting status to published stamps published_at; pass site_id or form_id as null to clear it.',
174
174
  inputSchema: {
175
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
175
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
176
176
  page_id: z.string().uuid(),
177
177
  title: z.string().trim().min(1).max(200).optional(),
178
178
  slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
@@ -265,7 +265,7 @@ export function registerLandingPageTools(server) {
265
265
  title: 'Delete Landing Page',
266
266
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
267
267
  description: 'Permanently delete a landing page. Set status to archived instead to unpublish without deleting.',
268
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), page_id: z.string().uuid() },
268
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), page_id: z.string().uuid() },
269
269
  }, withApproval('delete_landing_page', async ({ page_id }) => {
270
270
  const projectId = getProjectId();
271
271
  const { error } = await db()
@@ -186,7 +186,7 @@ export function registerListTools(server) {
186
186
  title: 'Delete Contact List',
187
187
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
188
188
  description: 'Permanently delete a static contact list and its memberships.',
189
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), list_id: z.string().uuid() },
189
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), list_id: z.string().uuid() },
190
190
  }, withApproval('delete_list', async ({ list_id }) => {
191
191
  const projectId = getProjectId();
192
192
  const { error } = await db()