@sendinel/mcp-server 1.0.140 → 1.0.142

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.
@@ -0,0 +1,16 @@
1
+ /** Public tool errors must not expose SQL, constraint names or database internals. */
2
+ export declare function dbToolError(error: {
3
+ code?: string;
4
+ message?: string;
5
+ }, message?: string): {
6
+ error: string;
7
+ code: string;
8
+ };
9
+ /** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
10
+ export declare function toolError(body: unknown): {
11
+ isError: true;
12
+ content: {
13
+ type: 'text';
14
+ text: string;
15
+ }[];
16
+ };
@@ -0,0 +1,17 @@
1
+ /** Public tool errors must not expose SQL, constraint names or database internals. */
2
+ export function dbToolError(error, message = 'Unable to complete database operation') {
3
+ if (error.code === '23505')
4
+ return { error: 'A record with these values already exists', code: 'conflict' };
5
+ if (error.code === '23503')
6
+ return { error: 'Referenced record not found', code: 'not_found' };
7
+ if (error.code === '42501')
8
+ return { error: 'Operation forbidden', code: 'forbidden' };
9
+ if (error.code?.startsWith('22') || error.code === '23514' || error.code === '23502') {
10
+ return { error: 'Invalid record values', code: 'validation_failed' };
11
+ }
12
+ return { error: message, code: 'internal_error' };
13
+ }
14
+ /** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
15
+ export function toolError(body) {
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
17
+ }
@@ -13,7 +13,7 @@ export function registerAbTestingTools(server) {
13
13
  idempotentHint: false,
14
14
  openWorldHint: false,
15
15
  },
16
- description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
16
+ description: 'Create an A/B test for a campaign step. Requires a non-blank subject and body on every variant, and at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
17
17
  inputSchema: {
18
18
  campaign_id: z.string().uuid(),
19
19
  step_order: z.number().int().min(0).optional().default(0).describe('Which step to test (default: 0, the first email)'),
@@ -38,13 +38,20 @@ export function registerAbTestingTools(server) {
38
38
  // Check variant steps exist
39
39
  const { data: steps } = await db()
40
40
  .from('campaign_steps')
41
- .select('id, variant_label, subject')
41
+ .select('id, variant_label, subject, body_html, body_text')
42
42
  .eq('campaign_id', campaign_id)
43
43
  .eq('step_order', step_order ?? 0);
44
44
  const variantLabels = (steps ?? []).map((s) => s.variant_label);
45
45
  if (variantLabels.length < 2) {
46
46
  return txt(`Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.`);
47
47
  }
48
+ const emptyVariants = (steps ?? []).filter((step) => !step.subject?.trim() || !(step.body_html?.trim() || step.body_text?.trim()));
49
+ if (emptyVariants.length) {
50
+ return j({
51
+ error: `Variant(s) [${emptyVariants.map((step) => step.variant_label).join(', ')}] for step_order ${step_order ?? 0} need a non-blank subject and body. Add content with add_campaign_step before creating the test.`,
52
+ code: 'validation_failed',
53
+ });
54
+ }
48
55
  // Check no existing test for this step
49
56
  const { data: existing } = await db()
50
57
  .from('ab_tests')
@@ -1,8 +1,9 @@
1
- import { countEmailRows, groupMetricRows, percentRate, isDeliveredRow, isOpenedRow, countPeople, isComplainedRow, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
1
+ import { countEmailRows, groupMetricRows, percentRate, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
2
2
  import { z } from 'zod';
3
3
  import Anthropic from '@anthropic-ai/sdk';
4
4
  import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
+ import { toolError } from '../lib/db-error.js';
6
7
  import { buildVoicePrompt } from '../lib/voice.js';
7
8
  import { getAnthropicModelId } from '../anthropic-model.js';
8
9
  import { dkimRemediationText } from '../lib/email-provider.js';
@@ -103,19 +104,19 @@ export function registerAdvisorTools(server) {
103
104
  // 1. Current period email_log
104
105
  let currentQuery = db()
105
106
  .from('email_log')
106
- .select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
107
+ .select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
107
108
  .eq('project_id', projectId)
108
- .gte('created_at', periodStart.toISOString());
109
+ .gte('sent_at', periodStart.toISOString());
109
110
  if (site_id)
110
111
  currentQuery = currentQuery.eq('site_id', site_id);
111
112
  const currentRows = await readAllRows(currentQuery);
112
113
  // 2. Prior period
113
114
  let priorQuery = db()
114
115
  .from('email_log')
115
- .select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
116
+ .select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
116
117
  .eq('project_id', projectId)
117
- .gte('created_at', priorStart.toISOString())
118
- .lt('created_at', periodStart.toISOString());
118
+ .gte('sent_at', priorStart.toISOString())
119
+ .lt('sent_at', periodStart.toISOString());
119
120
  if (site_id)
120
121
  priorQuery = priorQuery.eq('site_id', site_id);
121
122
  const priorRows = await readAllRows(priorQuery);
@@ -124,8 +125,8 @@ export function registerAdvisorTools(server) {
124
125
  // 3. Domain health (latest per site)
125
126
  let healthQuery = db()
126
127
  .from('domain_health')
127
- .select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day')
128
- .eq('project_id', projectId)
128
+ .select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day, sites!inner(project_id)')
129
+ .eq('sites.project_id', projectId)
129
130
  .order('check_date', { ascending: false })
130
131
  .limit(20);
131
132
  if (site_id)
@@ -595,15 +596,19 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
595
596
  .eq('id', site_id)
596
597
  .eq('project_id', projectId)
597
598
  .maybeSingle();
598
- if (siteErr || !site)
599
- return txt(`Error: Site not found — ${siteErr?.message ?? 'not found'}`);
599
+ if (siteErr)
600
+ return toolError({ error: 'Unable to load site', code: 'internal_error' });
601
+ if (!site)
602
+ return toolError({ error: 'Site not found', code: 'not_found' });
600
603
  // 2. Fetch latest domain_health
601
- const { data: healthRows } = await db()
604
+ const { data: healthRows, error: healthError } = await db()
602
605
  .from('domain_health')
603
606
  .select('spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
604
607
  .eq('site_id', site_id)
605
608
  .order('check_date', { ascending: false })
606
609
  .limit(1);
610
+ if (healthError)
611
+ return toolError({ error: 'Unable to load domain health', code: 'internal_error' });
607
612
  const health = (healthRows ?? [])[0];
608
613
  const spfValid = health?.spf_valid ?? null; // null = not checked yet (#3904)
609
614
  const spfFailed = spfValid === false;
@@ -615,18 +620,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
615
620
  const warmupDailyLimit = health?.warmup_daily_limit ?? null;
616
621
  // 3. Fetch recent 7-day email_log stats
617
622
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
618
- const { data: recentLogs } = await db()
623
+ const { data: recentLogs, error: logsError } = await db()
619
624
  .from('email_log')
620
625
  .select("status, opened_at, clicked_at, bounced_at, contact_id, to_email")
621
626
  .eq('project_id', projectId)
622
627
  .eq('site_id', site_id)
623
628
  .gte('sent_at', sevenDaysAgo);
629
+ if (logsError)
630
+ return toolError({ error: 'Unable to load send history', code: 'internal_error' });
624
631
  const logs = recentLogs ?? [];
625
- const totalSent = logs.length;
626
- const bounced = logs.filter(isBouncedRow).length;
627
- const complained = logs.filter(isComplainedRow).length;
628
- const delivered = logs.filter(isDeliveredRow).length;
629
- const opened = countPeople(logs, isOpenedRow);
632
+ const { sent: totalSent, bounced, complained, delivered, opened } = countEmailRows(logs);
630
633
  const bounceRate = percentRate(bounced, totalSent, 2) ?? 0;
631
634
  const complaintRate = percentRate(complained, totalSent, 2) ?? 0;
632
635
  const openRate = percentRate(opened, delivered, 2) ?? 0;
@@ -745,7 +748,8 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
745
748
  estimatedRecovery = '3-5 days';
746
749
  return j({
747
750
  site: { id: site.id, slug: site.slug, domain: site.sending_domain },
748
- health_score: healthScore,
751
+ health_score: totalSent === 0 ? null : healthScore,
752
+ ...(totalSent === 0 ? { health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
749
753
  recent_7d: {
750
754
  total_sent: totalSent,
751
755
  delivered,
@@ -765,7 +769,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
765
769
  short_term: shortTerm.length > 0 ? shortTerm : ['Continue current sending practices'],
766
770
  monitoring,
767
771
  },
768
- estimated_recovery: issues.length === 0 ? 'No recovery needed — site is healthy' : estimatedRecovery,
772
+ ...(issues.some(issue => issue.type !== 'no_recent_sends') ? { estimated_recovery: estimatedRecovery } : {}),
769
773
  });
770
774
  });
771
775
  }
@@ -28,10 +28,11 @@ export declare function computeAnalyticsMetrics(rows: EmailLogRow[], eventCounts
28
28
  clicked: number;
29
29
  }): AnalyticsMetrics;
30
30
  export declare function computeSenderHealthScore(metrics: {
31
+ sent: number;
31
32
  bounce_rate: number;
32
33
  complaint_rate: number;
33
34
  unsubscribe_rate: number;
34
35
  open_rate: number;
35
- }, trackingEnabled: boolean): number;
36
+ }, trackingEnabled: boolean): number | null;
36
37
  export declare function registerAnalyticsTools(server: McpServer): void;
37
38
  export {};
@@ -1,4 +1,4 @@
1
- import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
1
+ import { readPeriodCampaigns, campaignPeriodFields, filterPlanContacts } from "@sendinel/domain";
2
2
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
3
3
  import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
4
4
  import { z } from 'zod';
@@ -7,6 +7,7 @@ import { getProjectId } from '../project.js';
7
7
  import { resolveProjectClient } from '../byod-client.js';
8
8
  import { decrypt } from '../crypto.js';
9
9
  import { logAudit } from '../audit.js';
10
+ import { dbToolError, toolError } from '../lib/db-error.js';
10
11
  const txt = (text) => ({ content: [{ type: 'text', text }] });
11
12
  const j = (data) => txt(JSON.stringify(data, null, 2));
12
13
  const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
@@ -21,6 +22,8 @@ export function computeAnalyticsMetrics(rows, eventCounts) {
21
22
  complaint_rate: metrics.complaint_rate ?? 0, unsubscribe_rate: metrics.unsubscribe_rate ?? 0 };
22
23
  }
23
24
  export function computeSenderHealthScore(metrics, trackingEnabled) {
25
+ if (metrics.sent === 0)
26
+ return null;
24
27
  let score = 100;
25
28
  if (metrics.bounce_rate > 0.05)
26
29
  score -= 40;
@@ -462,6 +465,11 @@ export function registerAnalyticsTools(server) {
462
465
  }, async ({ site_id, start_date, end_date }) => {
463
466
  const projectId = getProjectId();
464
467
  const end = end_date ?? new Date().toISOString().slice(0, 10);
468
+ const validDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) &&
469
+ Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
470
+ if (!validDate(start_date) || !validDate(end) || start_date > end) {
471
+ return toolError({ error: 'Use valid YYYY-MM-DD dates with start_date on or before end_date', code: 'validation_failed' });
472
+ }
465
473
  const { data: site, error: siteError } = await db()
466
474
  .from('sites')
467
475
  .select('tracking_enabled')
@@ -469,7 +477,9 @@ export function registerAnalyticsTools(server) {
469
477
  .eq('id', site_id)
470
478
  .maybeSingle();
471
479
  if (siteError)
472
- return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
480
+ return toolError({ error: 'Unable to load site', code: 'internal_error' });
481
+ if (!site)
482
+ return toolError({ error: 'Site not found', code: 'not_found' });
473
483
  const trackingEnabled = site?.tracking_enabled ?? false;
474
484
  const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
475
485
  .from('email_log')
@@ -481,7 +491,7 @@ export function registerAnalyticsTools(server) {
481
491
  .order('id', { ascending: true })
482
492
  .range(offset, offset + limit - 1));
483
493
  if (error)
484
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
494
+ return toolError(dbToolError(error));
485
495
  const rows = (data ?? []);
486
496
  let eventCounts = { opened: 0, clicked: 0 };
487
497
  let eventRows = [];
@@ -498,7 +508,7 @@ export function registerAnalyticsTools(server) {
498
508
  .order('id', { ascending: true })
499
509
  .range(offset, offset + limit - 1));
500
510
  if (eventsError)
501
- return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
511
+ return toolError(dbToolError(eventsError));
502
512
  eventRows = events ?? [];
503
513
  eventPageCount = pageCount;
504
514
  const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
@@ -511,14 +521,14 @@ export function registerAnalyticsTools(server) {
511
521
  const periodDays = daysBetween(start_date, end);
512
522
  const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
513
523
  const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
514
- const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
524
+ const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => filterPlanContacts(db()
515
525
  .from('subscribers')
516
526
  .select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
517
- .eq('project_id', projectId)
527
+ .eq('project_id', projectId))
518
528
  .order('id', { ascending: true })
519
529
  .range(offset, offset + limit - 1));
520
530
  if (subscribersError)
521
- return { content: [{ type: 'text', text: `Error: ${subscribersError.message}` }] };
531
+ return toolError(dbToolError(subscribersError));
522
532
  const subscriberRows = (subscribers ?? []);
523
533
  const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
524
534
  const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
@@ -540,6 +550,7 @@ export function registerAnalyticsTools(server) {
540
550
  list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
541
551
  inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
542
552
  sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
553
+ ...(metrics.sent === 0 ? { sender_health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
543
554
  coverage: {
544
555
  log_rows_scanned: rows.length,
545
556
  log_page_count: logPageCount,
@@ -890,7 +901,8 @@ export function registerAnalyticsTools(server) {
890
901
  return txt(`Error: ${attributionError.message}`);
891
902
  const scopedAttributionRows = attributionRows ?? [];
892
903
  const computeMetrics = (set, counts) => {
893
- const total = counts?.sent ?? set.length;
904
+ const sharedCounts = counts ?? countEmailRows(set);
905
+ const total = sharedCounts.sent;
894
906
  const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
895
907
  const opened = counts?.opened ?? countPeople(set, isOpenedRow);
896
908
  const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
+ import { dbToolError, toolError } from '../lib/db-error.js';
4
5
  import { checkApproval } from '../lib/action-approvals.js';
5
6
  import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
6
7
  const txt = (text) => ({ content: [{ type: 'text', text }] });
@@ -42,7 +43,7 @@ export function registerApprovalTools(server, options) {
42
43
  idempotentHint: true,
43
44
  openWorldHint: false,
44
45
  },
45
- description: 'List all pending approval requests for this project. Excludes expired requests without mutating them. Shows what actions are waiting for human approval.',
46
+ description: 'List unexpired pending approval requests for this project. Expired rows are excluded even before the expiry cron runs.',
46
47
  inputSchema: {},
47
48
  }, async () => {
48
49
  const { data, error } = await db()
@@ -54,7 +55,7 @@ export function registerApprovalTools(server, options) {
54
55
  .order('requested_at', { ascending: false })
55
56
  .limit(25);
56
57
  if (error)
57
- return j({ error: error.message, code: 'INTERNAL_ERROR' });
58
+ return toolError(dbToolError(error));
58
59
  return j({ pending: data ?? [], count: data?.length ?? 0 });
59
60
  });
60
61
  }
@@ -13,6 +13,7 @@ import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/d
13
13
  import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
14
14
  import { adminDb, db, requestContext } from '../db.js';
15
15
  import { getProjectId } from '../project.js';
16
+ import { dbToolError, toolError } from '../lib/db-error.js';
16
17
  import { checkPlanLimit } from '../plan-limits.js';
17
18
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
18
19
  import { fireWebhookEvent } from '../lib/webhook-events.js';
@@ -136,6 +137,9 @@ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
136
137
  if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
137
138
  diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
138
139
  }
140
+ if (campaign.type === 'triggered' && campaignTriggerKind(campaign) === 'manual' && !campaign.segment_id) {
141
+ diagnostics.push('campaign type is "triggered" but no trigger is set (it will never fire)');
142
+ }
139
143
  if (campaign.status !== 'active') {
140
144
  diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
141
145
  }
@@ -383,10 +387,12 @@ async function campaignInProject(campaignId) {
383
387
  return Boolean(data);
384
388
  }
385
389
  /** Mirrors validateTarget() in the branches REST route. */
386
- async function validateBranchTarget(campaignId, action, targetStepId, targetCampaignId) {
390
+ async function validateBranchTarget(campaignId, fromStepId, action, targetStepId, targetCampaignId) {
387
391
  if (action === 'goto_step') {
388
392
  if (!targetStepId)
389
393
  return 'target_step_id is required for goto_step';
394
+ if (targetStepId === fromStepId)
395
+ return 'goto_step cannot target its own source step';
390
396
  const { data } = await db()
391
397
  .from('campaign_steps')
392
398
  .select('id')
@@ -399,6 +405,8 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
399
405
  if (action === 'goto_campaign') {
400
406
  if (!targetCampaignId)
401
407
  return 'target_campaign_id is required for goto_campaign';
408
+ if (targetCampaignId === campaignId)
409
+ return 'goto_campaign cannot target its own campaign';
402
410
  if (!(await campaignInProject(targetCampaignId)))
403
411
  return 'target_campaign_id must be a campaign in this project';
404
412
  }
@@ -850,7 +858,7 @@ export function registerCampaignTools(server) {
850
858
  .single();
851
859
  if (error)
852
860
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
853
- return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION }, null, 2) }] };
861
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) }, null, 2) }] };
854
862
  });
855
863
  const propertyConditions = z.array(z.union([
856
864
  z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
@@ -864,7 +872,7 @@ export function registerCampaignTools(server) {
864
872
  idempotentHint: false,
865
873
  openWorldHint: false,
866
874
  },
867
- description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
875
+ description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
868
876
  inputSchema: {
869
877
  campaign_id: z.string().uuid(),
870
878
  step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
@@ -887,6 +895,16 @@ export function registerCampaignTools(server) {
887
895
  const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
888
896
  if (!properties.ok)
889
897
  return j({ error: properties.error, code: "INVALID_INPUT" });
898
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
899
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
900
+ if (!subject.trim())
901
+ return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
902
+ const hasBody = body_html?.trim() || body_text?.trim()
903
+ || htmlFromBlocks?.replace(/<[^>]*>/g, '').trim()
904
+ || normalizedBlocks?.some((block) => block.type === 'image' && block.url);
905
+ if (!hasBody) {
906
+ return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
907
+ }
890
908
  const { data: campaign, error: campaignError } = await db()
891
909
  .schema('email')
892
910
  .from('campaigns')
@@ -906,8 +924,6 @@ export function registerCampaignTools(server) {
906
924
  renderVars.survey_link = surveyLink;
907
925
  }
908
926
  const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
909
- const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
910
- const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
911
927
  const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
912
928
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
913
929
  const { data, error } = await db()
@@ -970,12 +986,15 @@ export function registerCampaignTools(server) {
970
986
  // Placeholder text and links to the sending domain never go out (#3853).
971
987
  const { data: campRow, error: campErr } = await db()
972
988
  .from('campaigns')
973
- .select('id, name, site_id, trigger_event_name')
989
+ .select('id, name, site_id, type, trigger_type, trigger_event_name, trigger_tag, trigger_config, segment_id')
974
990
  .eq('id', campaign_id)
975
991
  .eq('project_id', getProjectId())
976
992
  .maybeSingle();
977
993
  if (campErr)
978
994
  return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
995
+ if (campRow?.type === 'triggered' && campaignTriggerKind(campRow) === 'manual' && !campRow.segment_id) {
996
+ return j({ error: 'This triggered campaign has no trigger. Set an event, tag, date, contact event, or segment before activating it.', code: 'validation_failed' });
997
+ }
979
998
  let contentWarnings = [];
980
999
  if (campRow) {
981
1000
  const { data: contentSteps, error: contentErr } = await db()
@@ -1606,7 +1625,7 @@ export function registerCampaignTools(server) {
1606
1625
  idempotentHint: false,
1607
1626
  openWorldHint: false,
1608
1627
  },
1609
- description: 'Clone an existing campaign (with all steps) into a new draft campaign',
1628
+ description: 'Clone campaign steps into a draft. Trigger, audience, send class, re-entry, goals and branches are NOT copied; fields_not_copied and warnings explicitly identify these omissions. Review and configure before activation.',
1610
1629
  inputSchema: {
1611
1630
  campaign_id: z.string().uuid().describe('Source campaign to clone'),
1612
1631
  new_name: z.string().min(1).describe('Name for the cloned campaign'),
@@ -1620,11 +1639,13 @@ export function registerCampaignTools(server) {
1620
1639
  .eq('id', campaign_id)
1621
1640
  .eq('project_id', projectId)
1622
1641
  .maybeSingle();
1623
- if (fetchErr || !original)
1624
- return txt(`Error: Campaign not found — ${fetchErr?.message ?? 'not found'}`);
1642
+ if (fetchErr)
1643
+ return toolError(dbToolError(fetchErr));
1644
+ if (!original)
1645
+ return toolError({ error: 'Campaign not found', code: 'not_found' });
1625
1646
  const limit = await checkPlanLimit('active_campaigns');
1626
1647
  if (!limit.allowed)
1627
- return txt(`Active campaign limit reached (${limit.current}/${limit.limit}).`);
1648
+ return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
1628
1649
  const { data: newCampaign, error: createErr } = await db()
1629
1650
  .from('campaigns')
1630
1651
  .insert({
@@ -1640,7 +1661,7 @@ export function registerCampaignTools(server) {
1640
1661
  .select()
1641
1662
  .single();
1642
1663
  if (createErr)
1643
- return txt(`Error creating campaign: ${createErr.message}`);
1664
+ return toolError(dbToolError(createErr));
1644
1665
  const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
1645
1666
  if (steps.length > 0) {
1646
1667
  const stepRows = steps.map((s) => ({
@@ -1655,9 +1676,18 @@ export function registerCampaignTools(server) {
1655
1676
  }));
1656
1677
  const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
1657
1678
  if (stepErr)
1658
- return txt(`Campaign created but steps failed: ${stepErr.message}`);
1659
- }
1660
- return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
1679
+ return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
1680
+ }
1681
+ const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
1682
+ const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
1683
+ const fields_not_copied = [...new Set([
1684
+ ...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
1685
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1686
+ 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1687
+ ])];
1688
+ await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
1689
+ return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
1690
+ warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
1661
1691
  });
1662
1692
  server.registerTool('list_sequence_templates', {
1663
1693
  title: 'List Sequence Templates',
@@ -3848,14 +3878,20 @@ Return ONLY valid JSON with this exact shape:
3848
3878
  }, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
3849
3879
  const conditionValue = campaignBranchConditionValue(condition_type, condition_value);
3850
3880
  if (!conditionValue.ok)
3851
- return { ...j({ error: conditionValue.error, code: 'INVALID_CONDITION_VALUE' }), isError: true };
3881
+ return { ...j({ error: conditionValue.error, code: 'validation_failed' }), isError: true };
3852
3882
  if (!(await campaignInProject(campaign_id)))
3853
- return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
3883
+ return toolError({ error: 'Campaign not found in this project', code: 'not_found' });
3884
+ const { data: sourceStep, error: sourceError } = await db().from('campaign_steps').select('id')
3885
+ .eq('id', from_step_id).eq('campaign_id', campaign_id).maybeSingle();
3886
+ if (sourceError)
3887
+ return toolError(dbToolError(sourceError));
3888
+ if (!sourceStep)
3889
+ return toolError({ error: 'from_step_id must be a step in this campaign', code: 'validation_failed' });
3854
3890
  // Same requirement the REST route enforces: an action naming a target must have one,
3855
3891
  // and that target must belong to this project.
3856
- const targetError = await validateBranchTarget(campaign_id, action, target_step_id ?? null, target_campaign_id ?? null);
3892
+ const targetError = await validateBranchTarget(campaign_id, from_step_id, action, target_step_id ?? null, target_campaign_id ?? null);
3857
3893
  if (targetError)
3858
- return j({ error: targetError, code: 'INVALID_TARGET' });
3894
+ return toolError({ error: targetError, code: 'validation_failed' });
3859
3895
  const { data, error } = await db()
3860
3896
  .from('campaign_step_branches')
3861
3897
  .insert({
@@ -3871,9 +3907,14 @@ Return ONLY valid JSON with this exact shape:
3871
3907
  .select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority')
3872
3908
  .single();
3873
3909
  if (error)
3874
- return j({ error: error.message });
3910
+ return toolError(dbToolError(error));
3875
3911
  await logAudit({ action: 'add_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id: data?.id, condition_type, branch_action: action } }).catch(() => { });
3876
- return j({ success: true, branch: data });
3912
+ // Read after insertion so a tie already committed by a concurrent request is visible.
3913
+ const { data: peers, error: peerError } = await db().from('campaign_step_branches').select('id')
3914
+ .eq('campaign_id', campaign_id).eq('from_step_id', from_step_id).eq('priority', priority ?? 0).neq('id', data.id);
3915
+ const warnings = peerError ? ['Branch saved, but priority ties could not be checked.']
3916
+ : peers?.length ? ['Another branch on this step has the same priority; evaluation order may be ambiguous.'] : [];
3917
+ return j({ success: true, branch: data, warnings });
3877
3918
  });
3878
3919
  server.registerTool('remove_campaign_branch', {
3879
3920
  title: 'Remove Campaign Branch',
@@ -7,6 +7,8 @@ import { z } from 'zod';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { db, projectClientForWriteback } from '../db.js';
9
9
  import { getProjectId } from '../project.js';
10
+ import { dbToolError, toolError } from '../lib/db-error.js';
11
+ import { resolveSegmentContactIds } from '../lib/send-audience.js';
10
12
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
11
13
  import { logConsent, logAudit } from '../audit.js';
12
14
  import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
@@ -238,6 +240,49 @@ function normalizePhoneE164(value) {
238
240
  function isPlainObject(value) {
239
241
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
240
242
  }
243
+ const SUBSCRIBER_VIEW_COLUMNS = new Set([
244
+ 'contact', 'status_strip', 'quick_send', 'site', 'company', 'status', 'last_activity',
245
+ 'sms_opt_in', 'tags', 'score', 'stage', 'opens', 'clicks', 'joined', 'embed_actions',
246
+ 'email', 'first_name', 'last_name', 'introduced_by',
247
+ ]);
248
+ const SUBSCRIBER_VIEW_STAGES = new Set(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']);
249
+ function validateSubscriberViewInput(columns, filters) {
250
+ if (isPlainObject(filters)) {
251
+ for (const [key, value] of Object.entries(filters)) {
252
+ if (!SUBSCRIBER_VIEW_FILTER_KEYS.has(key))
253
+ return `Unknown filter "${key}". Valid keys: ${[...SUBSCRIBER_VIEW_FILTER_KEYS].join(', ')}`;
254
+ if (key === 'show_test' ? typeof value !== 'boolean' : typeof value !== 'string' || value.length > 500)
255
+ return `Invalid value for filter "${key}"`;
256
+ if (key === 'stage' && value !== '' && !SUBSCRIBER_VIEW_STAGES.has(value))
257
+ return `Invalid stage. Valid stages: ${[...SUBSCRIBER_VIEW_STAGES].join(', ')}`;
258
+ if (key === 'dir' && value !== 'asc' && value !== 'desc')
259
+ return 'dir must be asc or desc';
260
+ if (key === 'sort' && value !== '' && !SUBSCRIBER_VIEW_SORT_FIELDS.has(value))
261
+ return 'Invalid sort field';
262
+ if (key === 'status' && !['', 'all', 'active', 'unsubscribed', 'not_subscribed'].includes(value))
263
+ return 'Invalid subscription status';
264
+ if (key === 'cp_key' && value !== '' && !SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(value))
265
+ return 'Invalid custom property key';
266
+ }
267
+ }
268
+ if (isPlainObject(filters) && Boolean(filters.cp_key) !== Boolean(filters.cp_value))
269
+ return 'cp_key and cp_value must be provided together';
270
+ if (isPlainObject(columns)) {
271
+ for (const list of [columns.order, columns.hidden]) {
272
+ if (list === undefined)
273
+ continue;
274
+ if (!Array.isArray(list) || list.length > SUBSCRIBER_VIEW_MAX_COLUMNS)
275
+ return 'Invalid column list';
276
+ for (const key of list) {
277
+ if (typeof key !== 'string' || !SUBSCRIBER_VIEW_KEY_RE.test(key) ||
278
+ (!SUBSCRIBER_VIEW_COLUMNS.has(key) && !(key.startsWith('prop:') && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(key.slice(5))))) {
279
+ return `Unknown column "${String(key)}". Use a subscriber column or prop:<custom property key>`;
280
+ }
281
+ }
282
+ }
283
+ }
284
+ return null;
285
+ }
241
286
  function normalizeSubscriberViewKeys(value) {
242
287
  if (!Array.isArray(value))
243
288
  return [];
@@ -1075,9 +1120,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1075
1120
  filters: z.record(z.string(), z.unknown()).optional(),
1076
1121
  },
1077
1122
  }, async ({ name, view_mode, columns, filters }) => {
1123
+ const inputError = validateSubscriberViewInput(columns, filters);
1124
+ if (inputError)
1125
+ return toolError({ error: inputError, code: 'validation_failed' });
1078
1126
  const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
1079
1127
  if (!normalizedName)
1080
- return txt('Error: name is required');
1128
+ return toolError({ error: 'name is required', code: 'validation_failed' });
1081
1129
  const { data, error } = await db()
1082
1130
  .from('subscriber_list_views')
1083
1131
  .insert({
@@ -1091,9 +1139,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1091
1139
  .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1092
1140
  .maybeSingle();
1093
1141
  if (error?.code === '23505')
1094
- return txt(`Error: A view named "${normalizedName}" already exists`);
1142
+ return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
1095
1143
  if (error)
1096
- return txt(`Error: ${error.message}`);
1144
+ return toolError(dbToolError(error));
1145
+ await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
1097
1146
  return j({ view: data });
1098
1147
  });
1099
1148
  server.registerTool('update_subscriber_view', {
@@ -1116,11 +1165,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1116
1165
  if (name === undefined && view_mode === undefined && columns === undefined && filters === undefined) {
1117
1166
  return txt('Nothing to update');
1118
1167
  }
1168
+ const inputError = validateSubscriberViewInput(columns, filters);
1169
+ if (inputError)
1170
+ return toolError({ error: inputError, code: 'validation_failed' });
1119
1171
  const updates = {};
1120
1172
  if (name !== undefined) {
1121
1173
  const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
1122
1174
  if (!normalizedName)
1123
- return txt('Error: name is required');
1175
+ return toolError({ error: 'name is required', code: 'validation_failed' });
1124
1176
  updates.name = normalizedName;
1125
1177
  }
1126
1178
  if (view_mode !== undefined)
@@ -1137,11 +1189,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1137
1189
  .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1138
1190
  .maybeSingle();
1139
1191
  if (error?.code === '23505')
1140
- return txt('Error: A view with that name already exists');
1192
+ return toolError({ error: 'A view with that name already exists', code: 'conflict' });
1141
1193
  if (error)
1142
- return txt(`Error: ${error.message}`);
1194
+ return toolError(dbToolError(error));
1143
1195
  if (!data)
1144
- return txt('View not found');
1196
+ return toolError({ error: 'View not found', code: 'not_found' });
1197
+ await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
1145
1198
  return j({ view: data });
1146
1199
  });
1147
1200
  server.registerTool('delete_subscriber_view', {
@@ -1649,6 +1702,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1649
1702
  },
1650
1703
  description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
1651
1704
  inputSchema: {
1705
+ segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
1652
1706
  lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
1653
1707
  min_score: z.number().int().min(0).max(100).optional(),
1654
1708
  max_score: z.number().int().min(0).max(100).optional(),
@@ -1657,8 +1711,28 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1657
1711
  company_domain: z.string().optional(),
1658
1712
  limit: z.number().int().min(1).max(200).optional().default(50),
1659
1713
  },
1660
- }, async ({ lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
1714
+ }, async ({ segment_id, lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
1661
1715
  const projectId = getProjectId();
1716
+ let segmentIds;
1717
+ if (segment_id) {
1718
+ if ([lifecycle_stage, min_score, max_score, site_id, company_id, company_domain].some(value => value !== undefined)) {
1719
+ return toolError({ error: 'Provide segment_id or inline filters, not both', code: 'validation_failed' });
1720
+ }
1721
+ const { data: segment, error } = await db().from('segments').select('rules')
1722
+ .eq('project_id', projectId).eq('id', segment_id).maybeSingle();
1723
+ if (error)
1724
+ return toolError(dbToolError(error));
1725
+ if (!segment)
1726
+ return toolError({ error: 'Segment not found', code: 'not_found' });
1727
+ try {
1728
+ segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
1729
+ }
1730
+ catch (error) {
1731
+ return toolError(isSegmentRulesError(error) ? { error: error.message, code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
1732
+ }
1733
+ if (!segmentIds.length)
1734
+ return j([]);
1735
+ }
1662
1736
  const embedOk = await hasCompaniesEmbed(projectId);
1663
1737
  let q = db()
1664
1738
  .from('subscribers')
@@ -1697,9 +1771,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1697
1771
  return { content: [{ type: 'text', text: '[]' }] };
1698
1772
  q = q.in('company_id', ids);
1699
1773
  }
1774
+ if (segmentIds) {
1775
+ const rows = [];
1776
+ for (const ids of chunkIds(segmentIds)) {
1777
+ // A Supabase builder is mutable; build a fresh query for each chunk.
1778
+ const { data, error } = await db().from('subscribers')
1779
+ .select(embedOk ? SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY : SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY)
1780
+ .eq('project_id', projectId).eq('global_unsubscribed', false).in('id', ids)
1781
+ .order('engagement_score', { ascending: false }).limit(limit ?? 50);
1782
+ if (error)
1783
+ return toolError(dbToolError(error));
1784
+ rows.push(...(data ?? []));
1785
+ }
1786
+ return j(rows.sort((a, b) => b.engagement_score - a.engagement_score).slice(0, limit ?? 50));
1787
+ }
1700
1788
  const { data, error } = await q;
1701
1789
  if (error)
1702
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1790
+ return toolError(dbToolError(error));
1703
1791
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1704
1792
  });
1705
1793
  server.registerTool('update_subscriber', {
@@ -2056,18 +2144,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2056
2144
  },
2057
2145
  }, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
2058
2146
  if (!add_tags.length && !remove_tags.length)
2059
- return txt('No tags to add or remove.');
2147
+ return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
2060
2148
  const projectId = getProjectId();
2061
2149
  let updated = 0;
2150
+ const missing_contact_ids = [];
2062
2151
  for (const cid of contact_ids) {
2063
- const { data: contact } = await db()
2152
+ const { data: contact, error: readError } = await db()
2064
2153
  .from('subscribers')
2065
2154
  .select('id, email, first_name, last_name, custom_properties, tags')
2066
2155
  .eq('id', cid)
2067
2156
  .eq('project_id', projectId)
2068
2157
  .maybeSingle();
2069
- if (!contact)
2158
+ if (readError)
2159
+ return toolError({ ...dbToolError(readError), updated });
2160
+ if (!contact) {
2161
+ missing_contact_ids.push(cid);
2070
2162
  continue;
2163
+ }
2071
2164
  const currentTags = contact.tags ?? [];
2072
2165
  const removeSet = new Set(remove_tags);
2073
2166
  const merged = currentTags.filter((t) => !removeSet.has(t));
@@ -2075,17 +2168,25 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2075
2168
  if (!merged.includes(t))
2076
2169
  merged.push(t);
2077
2170
  }
2078
- const { error } = await db()
2171
+ const { data: saved, error } = await db()
2079
2172
  .from('subscribers')
2080
2173
  .update({ tags: merged })
2081
2174
  .eq('id', cid)
2082
- .eq('project_id', projectId);
2083
- if (!error) {
2084
- updated++;
2085
- await writeBackMcpContact(projectId, contact, { tags: merged });
2175
+ .eq('project_id', projectId)
2176
+ .select('id').maybeSingle();
2177
+ if (error)
2178
+ return toolError({ ...dbToolError(error), updated });
2179
+ if (!saved) {
2180
+ missing_contact_ids.push(cid);
2181
+ continue;
2086
2182
  }
2183
+ updated++;
2184
+ await writeBackMcpContact(projectId, contact, { tags: merged });
2185
+ await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2087
2186
  }
2088
- return j({ updated, total: contact_ids.length, add_tags, remove_tags });
2187
+ if (!updated)
2188
+ return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
2189
+ return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
2089
2190
  });
2090
2191
  server.registerTool('bulk_update_subscribers', {
2091
2192
  title: 'Bulk Update Subscribers',
@@ -2,6 +2,9 @@ import { withApproval } from '../lib/action-approvals.js';
2
2
  import { z } from 'zod';
3
3
  import { adminDb, db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
+ import { stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
6
+ import { dbToolError, toolError } from '../lib/db-error.js';
7
+ import { logAudit } from '../audit.js';
5
8
  import { assertHttpUrl } from '../lib/url-validation.js';
6
9
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
7
10
  const BLOG_IMAGE_FORMATS = ['square_image', 'story_image', 'animated_gif', 'short_video', 'carousel'];
@@ -219,7 +222,7 @@ export function registerContentTools(server) {
219
222
  idempotentHint: false,
220
223
  openWorldHint: false,
221
224
  },
222
- description: 'Create a reusable content block.',
225
+ description: 'Create a reusable content block. Active HTML is rejected; footer blocks require an unsubscribe link or merge tag.',
223
226
  inputSchema: {
224
227
  name: z.string().min(1),
225
228
  category: z.enum(['header', 'footer', 'cta', 'legal', 'general']).optional().default('general'),
@@ -227,12 +230,19 @@ export function registerContentTools(server) {
227
230
  description: z.string().optional(),
228
231
  },
229
232
  }, async ({ name, category = 'general', html, description }) => {
233
+ const active = stripActiveEmailContent(html);
234
+ if (active.removed.length)
235
+ return toolError({ error: 'Active HTML is not allowed in content blocks', code: 'validation_failed' });
236
+ if (category === 'footer' && !/href\s*=\s*["']\s*\{\{\s*unsubscribe_url\s*\}\}/i.test(html) && !validateUnsubscribeFooterTemplate(html).ok) {
237
+ return toolError({ error: 'Footer blocks require {{unsubscribe_url}} in a link or <unsubscribe>link text</unsubscribe>', code: 'validation_failed' });
238
+ }
230
239
  const { data, error } = await db().from('content_blocks')
231
240
  .insert({ project_id: getProjectId(), name, category, html, description: description ?? null })
232
241
  .select('id, name, category, html, description, created_at')
233
242
  .single();
234
243
  if (error)
235
- return j({ error: error.message });
244
+ return toolError(dbToolError(error));
245
+ await logAudit({ action: 'content_block.created', resourceType: 'content_block', resourceId: data.id });
236
246
  return j({ block: data });
237
247
  });
238
248
  server.registerTool('delete_content_block', {
@@ -6,6 +6,7 @@ import { adminDb, db } from '../db.js';
6
6
  import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { getProjectId } from '../project.js';
9
+ import { dbToolError, toolError } from '../lib/db-error.js';
9
10
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
10
11
  function asRecord(value) {
11
12
  return value && typeof value === 'object' ? value : {};
@@ -154,18 +155,18 @@ export function registerDeliveryOpsTools(server) {
154
155
  .eq('id', domain_id)
155
156
  .maybeSingle();
156
157
  if (error)
157
- return j({ error: error.message });
158
+ return toolError(dbToolError(error, 'Unable to load domain'));
158
159
  if (!data)
159
- return j({ error: 'Domain not found', code: 'NOT_FOUND' });
160
+ return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
160
161
  const { data: health, error: healthError } = await db().from('domain_health')
161
162
  .select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
162
- .eq('project_id', getProjectId())
163
+ .eq('sites.project_id', getProjectId())
163
164
  .eq('sites.domain_id', domain_id)
164
165
  .order('check_date', { ascending: false })
165
166
  .limit(1)
166
167
  .maybeSingle();
167
168
  if (healthError)
168
- return j({ error: healthError.message });
169
+ return toolError(dbToolError(healthError, 'Unable to load domain health'));
169
170
  const spf = health?.spf_valid ?? null;
170
171
  const dkim = health?.dkim_valid ?? null;
171
172
  const dmarc = health?.dmarc_valid ?? null;
@@ -3,10 +3,55 @@ import { z } from 'zod';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { logAudit } from '../audit.js';
6
+ import { stripActiveEmailContent } from '@sendinel/domain';
7
+ import { dbToolError, toolError } from '../lib/db-error.js';
6
8
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
7
9
  const SLUG_RE = /^[a-z0-9][a-z0-9\-]{0,62}$/;
8
10
  const VALID_STATUSES = ['draft', 'published', 'archived'];
9
11
  const LIST_COLUMNS = 'id, title, slug, site_id, status, form_id, blocks, meta_title, meta_description, conversion_event, views, conversions, background_color, accent_color, custom_css, created_at, updated_at, published_at';
12
+ // Types rendered by LandingPageRenderer. Reuse the domain active-content pass.
13
+ const BLOCK_TYPES = new Set(['hero', 'text', 'image', 'form', 'features', 'testimonial', 'cta', 'spacer']);
14
+ const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
15
+ function validatePageContent(input) {
16
+ for (const key of ['background_color', 'accent_color']) {
17
+ if (input[key] !== undefined && (typeof input[key] !== 'string' || !COLOR_RE.test(input[key]))) {
18
+ return `${key} must be a three- or six-digit hex color`;
19
+ }
20
+ }
21
+ if (input.custom_css != null && (typeof input.custom_css !== 'string' || /<\s*\/\s*style|<\s*script|javascript\s*:|expression\s*\(|@import/i.test(input.custom_css))) {
22
+ return 'custom_css contains unsafe content';
23
+ }
24
+ if (input.blocks !== undefined && (!Array.isArray(input.blocks) || input.blocks.some(block => !block || typeof block !== 'object' || !BLOCK_TYPES.has(block.type)))) {
25
+ return `Each block must have a recognized type: ${[...BLOCK_TYPES].join(', ')}`;
26
+ }
27
+ return null;
28
+ }
29
+ function sanitizeBlockValue(value) {
30
+ if (typeof value === 'string') {
31
+ // Also inspect bare URL fields (ctaUrl/url/src), using the same URL decoder
32
+ // as HTML attributes rather than a second scheme blacklist.
33
+ const url = stripActiveEmailContent(`<a href="${value.replace(/"/g, '&quot;')}">`);
34
+ if (url.removed.some(finding => finding.endsWith(' URL')))
35
+ return '';
36
+ return stripActiveEmailContent(value).html;
37
+ }
38
+ if (Array.isArray(value))
39
+ return value.map(sanitizeBlockValue);
40
+ if (value && typeof value === 'object')
41
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeBlockValue(item)]));
42
+ return value;
43
+ }
44
+ async function validatePageForm(formId) {
45
+ if (!formId)
46
+ return null;
47
+ const { data, error } = await db().from('signup_forms').select('id')
48
+ .eq('project_id', getProjectId()).eq('id', formId).maybeSingle();
49
+ if (error)
50
+ return dbToolError(error, 'Unable to load form');
51
+ if (!data)
52
+ return { error: 'form_id not found in this project', code: 'not_found' };
53
+ return null;
54
+ }
10
55
  function slugify(text) {
11
56
  return text
12
57
  .toLowerCase()
@@ -53,7 +98,7 @@ export function registerLandingPageTools(server) {
53
98
  server.registerTool('create_landing_page', {
54
99
  title: 'Create Landing Page',
55
100
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
56
- description: 'Create a draft landing page. Publish it with update_landing_page by setting status to published.',
101
+ description: 'Create a draft landing page. Blocks require a renderer type and have active content stripped; colors must be hex and custom_css must be safe. Publish with update_landing_page.',
57
102
  inputSchema: {
58
103
  title: z.string().trim().min(1).max(200),
59
104
  slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
@@ -72,7 +117,13 @@ export function registerLandingPageTools(server) {
72
117
  const normalizedTitle = title.trim();
73
118
  const normalizedSlug = (slug ?? slugify(normalizedTitle)).trim();
74
119
  if (!SLUG_RE.test(normalizedSlug))
75
- return j({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)' });
120
+ return toolError({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)', code: 'validation_failed' });
121
+ const contentError = validatePageContent({ blocks, background_color, accent_color, custom_css });
122
+ if (contentError)
123
+ return toolError({ error: contentError, code: 'validation_failed' });
124
+ const formError = await validatePageForm(form_id);
125
+ if (formError)
126
+ return toolError(formError);
76
127
  const insert = {
77
128
  project_id: projectId,
78
129
  title: normalizedTitle,
@@ -83,7 +134,7 @@ export function registerLandingPageTools(server) {
83
134
  if (form_id)
84
135
  insert.form_id = form_id;
85
136
  if (blocks)
86
- insert.blocks = blocks;
137
+ insert.blocks = sanitizeBlockValue(blocks);
87
138
  if (meta_title)
88
139
  insert.meta_title = meta_title;
89
140
  if (meta_description)
@@ -103,8 +154,10 @@ export function registerLandingPageTools(server) {
103
154
  .single();
104
155
  if (error) {
105
156
  if (error.code === '23505')
106
- return j({ error: 'A page with this slug already exists' });
107
- return j({ error: error.message });
157
+ return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
158
+ if (error.code === '23503')
159
+ return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
160
+ return toolError(dbToolError(error));
108
161
  }
109
162
  await logAudit({
110
163
  action: 'landing_page.created',
@@ -136,6 +189,24 @@ export function registerLandingPageTools(server) {
136
189
  },
137
190
  }, withApproval('update_landing_page', async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
138
191
  const projectId = getProjectId();
192
+ // Validate the effective document on publish, including unsafe older drafts.
193
+ let content = { blocks, background_color, accent_color, custom_css };
194
+ if (status === 'published') {
195
+ const { data: existing, error: readError } = await db().from('landing_pages')
196
+ .select('blocks, background_color, accent_color, custom_css, form_id')
197
+ .eq('id', page_id).eq('project_id', projectId).maybeSingle();
198
+ if (readError)
199
+ return toolError(dbToolError(readError));
200
+ if (!existing)
201
+ return toolError({ error: 'Landing page not found', code: 'not_found' });
202
+ content = { ...existing, ...Object.fromEntries(Object.entries(content).filter(([, value]) => value !== undefined)) };
203
+ }
204
+ const contentError = validatePageContent(content);
205
+ if (contentError)
206
+ return toolError({ error: contentError, code: 'validation_failed' });
207
+ const formError = await validatePageForm(form_id === undefined ? content.form_id : form_id);
208
+ if (formError)
209
+ return toolError(formError);
139
210
  const updates = { updated_at: new Date().toISOString() };
140
211
  if (title !== undefined)
141
212
  updates.title = title.trim();
@@ -151,7 +222,7 @@ export function registerLandingPageTools(server) {
151
222
  if (form_id !== undefined)
152
223
  updates.form_id = form_id;
153
224
  if (blocks !== undefined)
154
- updates.blocks = blocks;
225
+ updates.blocks = sanitizeBlockValue(blocks);
155
226
  if (meta_title !== undefined)
156
227
  updates.meta_title = meta_title;
157
228
  if (meta_description !== undefined)
@@ -164,18 +235,24 @@ export function registerLandingPageTools(server) {
164
235
  updates.accent_color = accent_color;
165
236
  if (custom_css !== undefined)
166
237
  updates.custom_css = custom_css;
238
+ if (status === 'published' && content.blocks !== undefined)
239
+ updates.blocks = sanitizeBlockValue(content.blocks);
167
240
  const { data, error } = await db()
168
241
  .from('landing_pages')
169
242
  .update(updates)
170
243
  .eq('id', page_id)
171
244
  .eq('project_id', projectId)
172
245
  .select()
173
- .single();
246
+ .maybeSingle();
174
247
  if (error) {
175
248
  if (error.code === '23505')
176
- return j({ error: 'A page with this slug already exists' });
177
- return j({ error: error.message });
249
+ return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
250
+ if (error.code === '23503')
251
+ return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
252
+ return toolError(dbToolError(error));
178
253
  }
254
+ if (!data)
255
+ return toolError({ error: 'Landing page not found', code: 'not_found' });
179
256
  await logAudit({
180
257
  action: 'landing_page.updated',
181
258
  resourceType: 'landing_page',
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import Anthropic from '@anthropic-ai/sdk';
5
5
  import { db } from '../db.js';
6
6
  import { getProjectId } from '../project.js';
7
+ import { dbToolError, toolError } from '../lib/db-error.js';
7
8
  import { getAnthropicModelId } from '../anthropic-model.js';
8
9
  import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
9
10
  import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
@@ -89,7 +90,7 @@ export function registerSegmentTools(server) {
89
90
  catch (rulesError) {
90
91
  const invalid = invalidRules(rulesError);
91
92
  if (invalid)
92
- return invalid;
93
+ return toolError({ error: rulesError instanceof Error ? rulesError.message : 'Invalid segment rules', code: 'validation_failed' });
93
94
  throw rulesError;
94
95
  }
95
96
  const { data, error } = await db()
@@ -97,8 +98,10 @@ export function registerSegmentTools(server) {
97
98
  .insert({ project_id: getProjectId(), name, description, rules: storedRules })
98
99
  .select('id, name, description, rules, created_at')
99
100
  .single();
101
+ if (error?.code === '23505')
102
+ return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
100
103
  if (error)
101
- return dbErrorResult(error);
104
+ return toolError(dbToolError(error));
102
105
  // Count against the stored (normalized) tree, not the raw input: a tree skips
103
106
  // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
104
107
  // does for this same segment a moment later. Pushing the flat rules down instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.140",
3
+ "version": "1.0.142",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",