@sendinel/mcp-server 1.0.133 → 1.0.135

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.
@@ -192,6 +192,12 @@ export function buildApprovalSummary(action, params, display = {}) {
192
192
  const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
193
193
  const names = display.campaign_names ? `: ${display.campaign_names}` : '';
194
194
  const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
195
+ const broadcasts = (p.__approval_snapshot?.campaigns ?? [])
196
+ .filter(c => c.type === 'broadcast');
197
+ if (broadcasts.length) {
198
+ const audiences = broadcasts.map(c => `${c.name ?? c.id}: ${typeof c.recipient_count === 'number' ? c.recipient_count : 'unknown'} eligible recipients${c.audience ? ` (matched ${c.audience.matched}, not signed up for sender ${c.audience.not_signed_up_for_sender}, held back by consent ${JSON.stringify(c.audience.held_back_by_consent)})` : ''}`).join('; ');
199
+ return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — broadcasts enroll nobody by design; total_enrolled stays unchanged (0 until enrollment). Use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending. Triggered campaigns send when their triggers fire. ${audiences}${warnings}`;
200
+ }
195
201
  return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
196
202
  },
197
203
  set_campaign_trigger: (p) => `Change what starts campaign \"${display.campaign_name ?? p.campaign_id}\" from ${display.from_trigger ?? 'its current trigger'} to ${display.to_trigger ?? 'the requested trigger'}; ${typeof display.recipient_count === 'number' ? `up to ${display.recipient_count.toLocaleString('en-US')} people` : 'an unknown number of people'} may start receiving it`,
@@ -1,4 +1,20 @@
1
1
  type Params = Record<string, any>;
2
+ /** The same segment scope, sender subscriptions and consent gate used by the send tools. */
3
+ export declare function captureBroadcastAudience(projectId: string, campaign: Params): Promise<{
4
+ recipient_count: null;
5
+ audience: null;
6
+ segment?: undefined;
7
+ contact_ids?: undefined;
8
+ } | {
9
+ segment: any;
10
+ contact_ids: string[];
11
+ recipient_count: number;
12
+ audience: {
13
+ matched: number;
14
+ not_signed_up_for_sender: number;
15
+ held_back_by_consent: Record<string, number>;
16
+ };
17
+ }>;
2
18
  /** Optional display facts only. Failures must never prevent requesting or using an approval. */
3
19
  export declare function captureApprovalDisplay(action: string, params: Params, projectId: string): Promise<{
4
20
  target_name: string;
@@ -4,6 +4,28 @@ import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, pa
4
4
  import { callSendinelV1 } from './v1-client.js';
5
5
  import { adminDb, db } from '../db.js';
6
6
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
7
+ /** The same segment scope, sender subscriptions and consent gate used by the send tools. */
8
+ export async function captureBroadcastAudience(projectId, campaign) {
9
+ if (!campaign.segment_id || !campaign.site_id)
10
+ return { recipient_count: null, audience: null };
11
+ const source = db();
12
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
13
+ const { data: segment, error } = await client.from('segments').select('id, name, rules, site_id')
14
+ .eq('id', campaign.segment_id).eq('project_id', projectId).maybeSingle();
15
+ if (error || !segment)
16
+ throw new Error('Approval segment unavailable');
17
+ const resolved = await resolveSendAudience(projectId, campaign.site_id, scopeSegmentRulesToSite(segment.rules, segment.site_id), campaign.send_class === 'product' ? 'product' : 'broadcast');
18
+ return {
19
+ segment,
20
+ contact_ids: [...resolved.contactIds].sort(),
21
+ recipient_count: resolved.contactIds.length,
22
+ audience: {
23
+ matched: resolved.matched,
24
+ not_signed_up_for_sender: resolved.notOnSender,
25
+ held_back_by_consent: resolved.heldBack,
26
+ },
27
+ };
28
+ }
7
29
  const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
8
30
  'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
9
31
  'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
@@ -159,7 +181,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
159
181
  typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
160
182
  if (ids.length) {
161
183
  snapshot.campaigns = await readAllRows(client.from('campaigns')
162
- .select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
184
+ .select('id, name, site_id, type, segment_id, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
163
185
  .eq('project_id', projectId).in('id', ids));
164
186
  if (snapshot.campaigns.length !== ids.length)
165
187
  throw new Error('Approval target not found');
@@ -167,11 +189,17 @@ export async function captureApprovalSnapshot(action, params, projectId) {
167
189
  .select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
168
190
  .in('campaign_id', ids));
169
191
  let count = 0;
170
- for (const id of ids) {
171
- const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
192
+ for (const campaign of snapshot.campaigns) {
193
+ if (campaign.type === 'broadcast') {
194
+ Object.assign(campaign, await captureBroadcastAudience(projectId, campaign));
195
+ count = count == null || campaign.recipient_count == null ? null : count + campaign.recipient_count;
196
+ continue;
197
+ }
198
+ const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', campaign.id);
172
199
  if (result.error || result.count == null)
173
200
  throw new Error('Approval audience count unavailable');
174
- count += result.count;
201
+ if (count != null)
202
+ count += result.count;
175
203
  }
176
204
  snapshot.recipient_count = count;
177
205
  }
@@ -10,12 +10,12 @@ export declare function countUniqueEngagementByCampaign(sb: {
10
10
  delivered: Map<string, number>;
11
11
  error?: undefined;
12
12
  } | {
13
- ok: false;
14
- error: {
15
- message: string;
16
- };
17
13
  opened?: undefined;
18
14
  clicked?: undefined;
19
15
  sent?: undefined;
20
16
  delivered?: undefined;
17
+ ok: false;
18
+ error: {
19
+ message: string;
20
+ };
21
21
  }>;
@@ -1,4 +1,4 @@
1
- import { checkProjectSendAllowlist } from "@sendinel/domain";
1
+ import { checkProjectSendAllowlist, stripActiveEmailContent } from "@sendinel/domain";
2
2
  import { Resend } from "resend";
3
3
  import { adminDb, db } from "../db.js";
4
4
  import { decrypt } from "../crypto.js";
@@ -251,6 +251,7 @@ export async function sendEmailForSite(projectId, siteId, args) {
251
251
  const refusal = await checkProjectSendAllowlist(adminDb(), projectId, args, "mcp");
252
252
  if (refusal)
253
253
  return refusal;
254
+ args = { ...args, ...(args.html !== undefined ? { html: stripActiveEmailContent(args.html).html } : {}) };
254
255
  const config = await resolveProviderConfig(projectId, siteId);
255
256
  switch (config.name) {
256
257
  case "resend":
@@ -8,6 +8,11 @@ type CampaignSetRow = {
8
8
  trigger_config?: unknown;
9
9
  send_class: string | null;
10
10
  site_id: string;
11
+ type?: string;
12
+ segment_id?: string | null;
13
+ recipient_count?: number | null;
14
+ audience?: Record<string, unknown> | null;
15
+ audience_error?: string;
11
16
  };
12
17
  type StepRow = {
13
18
  campaign_id: string;
@@ -33,6 +38,10 @@ export declare function previewOf(campaigns: CampaignSetRow[], steps: StepRow[])
33
38
  tag: string;
34
39
  } | null;
35
40
  send_class: string | null;
41
+ recipient_count?: number | null | undefined;
42
+ audience?: Record<string, unknown> | null | undefined;
43
+ audience_error?: string | undefined;
44
+ activation?: string | undefined;
36
45
  emails: {
37
46
  step_order: number;
38
47
  wait: string;
@@ -11,6 +11,7 @@ import { fireWebhookEvent } from '../lib/webhook-events.js';
11
11
  import { logAudit } from '../audit.js';
12
12
  import { applyCampaignTargetingFields } from './campaigns.js';
13
13
  import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
14
+ import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
14
15
  /**
15
16
  * Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
16
17
  * activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
@@ -38,6 +39,7 @@ const CampaignInput = z.object({
38
39
  description: z.string().max(1000).optional(),
39
40
  emails: z.array(EmailInput).min(1).max(10),
40
41
  });
42
+ const BROADCAST_ACTIVATION = 'Activating a broadcast enrolls nobody by design; total_enrolled stays unchanged (0 until enrollment). Use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending.';
41
43
  /** The event-data filter a campaign starts on, if any (#3689). */
42
44
  function triggerWhere(config) {
43
45
  if (!config || typeof config !== 'object' || Array.isArray(config))
@@ -71,6 +73,12 @@ export function previewOf(campaigns, steps) {
71
73
  ? { event: c.trigger_event_name, ...(triggerWhere(c.trigger_config) ? { only_when: triggerWhere(c.trigger_config) } : {}) }
72
74
  : c.trigger_tag ? { tag: c.trigger_tag } : null,
73
75
  send_class: c.send_class,
76
+ ...(c.type === 'broadcast' ? {
77
+ recipient_count: c.recipient_count ?? null,
78
+ audience: c.audience ?? null,
79
+ ...(c.audience_error ? { audience_error: c.audience_error } : {}),
80
+ activation: BROADCAST_ACTIVATION,
81
+ } : {}),
74
82
  emails: steps
75
83
  .filter((s) => s.campaign_id === c.id)
76
84
  .sort((a, b) => a.step_order - b.step_order)
@@ -85,7 +93,7 @@ export function previewOf(campaigns, steps) {
85
93
  export async function loadSet(projectId, campaignIds) {
86
94
  const { data: campaigns, error } = await db()
87
95
  .from('campaigns')
88
- .select('id, name, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
96
+ .select('id, name, type, segment_id, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
89
97
  .eq('project_id', projectId)
90
98
  .in('id', campaignIds);
91
99
  if (error)
@@ -96,7 +104,20 @@ export async function loadSet(projectId, campaignIds) {
96
104
  .in('campaign_id', campaignIds);
97
105
  if (stepsError)
98
106
  throw stepsError;
99
- return { campaigns: (campaigns ?? []), steps: (steps ?? []) };
107
+ const loaded = (campaigns ?? []).map((campaign) => ({ ...campaign }));
108
+ for (const campaign of loaded) {
109
+ if (campaign.type !== 'broadcast')
110
+ continue;
111
+ try {
112
+ const { recipient_count, audience } = await captureBroadcastAudience(projectId, campaign);
113
+ Object.assign(campaign, { recipient_count, audience });
114
+ }
115
+ catch (error) {
116
+ Object.assign(campaign, { recipient_count: null, audience: null,
117
+ audience_error: error instanceof Error ? error.message : String(error) });
118
+ }
119
+ }
120
+ return { campaigns: loaded, steps: (steps ?? []) };
100
121
  }
101
122
  /**
102
123
  * Writes checked drafts as one campaign set. Shared by create_campaign_set and
@@ -256,7 +277,7 @@ export function registerCampaignSetTools(server) {
256
277
  server.registerTool('activate_campaigns', {
257
278
  title: 'Activate Campaigns',
258
279
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
259
- description: 'Turn on a set of draft or paused campaigns behind one human approval. Call 1: give campaign_set_id or campaign_ids; it returns one approval_id with a summary of every campaign, and activates nothing. After a person approves (approve_url works on a phone), call again with only approval_id. It refuses if any campaign or email changed after approval.',
280
+ description: 'Turn on a set of draft or paused campaigns behind one human approval. Call 1: give campaign_set_id or campaign_ids; it returns one approval_id with a summary of every campaign, and activates nothing. After a person approves (approve_url works on a phone), call again with only approval_id. It refuses if any campaign or email changed after approval. Activating a broadcast enrolls nobody; use send_broadcast or broadcast_to_segment to create, enroll and activate a broadcast for sending.',
260
281
  inputSchema: {
261
282
  campaign_set_id: z.string().uuid().optional(),
262
283
  campaign_ids: z.array(z.string().uuid()).min(1).max(50).optional(),
@@ -273,7 +294,7 @@ export function registerCampaignSetTools(server) {
273
294
  if (rowErr)
274
295
  return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
275
296
  if (!row)
276
- return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
297
+ return j({ error: `Approval ${approval_id} is not_found. Request a new approval.`, code: 'not_found' });
277
298
  const stored = (row.params ?? {});
278
299
  const ids = Array.isArray(stored.campaign_ids) ? stored.campaign_ids : [];
279
300
  // Check before consuming: what is live now must be exactly what was approved.
@@ -293,7 +314,7 @@ export function registerCampaignSetTools(server) {
293
314
  return j(contentNotReadyResponse(readiness.blocking));
294
315
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
295
316
  if (!check.approved)
296
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
317
+ return j({ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`, code: 'validation_failed', approval_status: check.status });
297
318
  const activated = [];
298
319
  const failed = [];
299
320
  for (const c of current.campaigns) {
@@ -314,9 +335,15 @@ export function registerCampaignSetTools(server) {
314
335
  return j({
315
336
  activated,
316
337
  failed,
338
+ ...(current.campaigns.some(c => c.type === 'broadcast') ? {
339
+ campaigns: previewOf(current.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), current.steps),
340
+ broadcast_activation: BROADCAST_ACTIVATION,
341
+ } : {}),
317
342
  message: failed.length
318
343
  ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
319
- : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
344
+ : current.campaigns.some(c => c.type === 'broadcast')
345
+ ? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
346
+ : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
320
347
  });
321
348
  }
322
349
  // Call 1: build one approval for the exact set.
@@ -363,7 +390,7 @@ export function registerCampaignSetTools(server) {
363
390
  const channelContext = currentChannelContext();
364
391
  const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
365
392
  if (!decision.allowed)
366
- return txt(decision.reason ?? 'This channel cannot request this action.');
393
+ return j({ error: decision.reason ?? 'This channel cannot request this action.', code: 'forbidden' });
367
394
  const names = set.campaigns.map((c) => c.name);
368
395
  const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
369
396
  campaign_count: set.campaigns.length,
@@ -2,7 +2,7 @@ import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } fro
2
2
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
3
3
  import { z } from 'zod';
4
4
  import Anthropic from '@anthropic-ai/sdk';
5
- import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
5
+ import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
6
6
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
7
7
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
8
8
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -1510,7 +1510,7 @@ export function registerCampaignTools(server) {
1510
1510
  });
1511
1511
  return j({
1512
1512
  ...approval,
1513
- rendered: { subject: content.subject, body_html: content.body_html, body_text: content.body_text },
1513
+ rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
1514
1514
  recipient_count: resolved.contactIds.length,
1515
1515
  audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
1516
1516
  site: { id: site.id, name: site.name, from_email: site.from_email },
@@ -2461,6 +2461,8 @@ Do not include body_html in your response; Sendinel renders body_html from block
2461
2461
  }
2462
2462
  resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
2463
2463
  }
2464
+ if (resolvedHtml !== undefined)
2465
+ resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
2464
2466
  if (!resolvedSubject?.trim())
2465
2467
  return txt('Error: subject is required');
2466
2468
  const payload = {
@@ -2653,6 +2655,8 @@ Do not include body_html in your response; Sendinel renders body_html from block
2653
2655
  }
2654
2656
  resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
2655
2657
  }
2658
+ if (resolvedHtml !== undefined)
2659
+ resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
2656
2660
  if (!resolvedSubject?.trim())
2657
2661
  return txt('Error: subject is required');
2658
2662
  const { data: projectContext, error: projectContextError } = await adminDb()
@@ -2871,6 +2875,17 @@ Do not include body_html in your response; Sendinel renders body_html from block
2871
2875
  bodyText: body_text,
2872
2876
  brandSettings: projectRow?.brand_settings ?? null,
2873
2877
  });
2878
+ const active = stripActiveEmailContent(html).removed;
2879
+ if (active.includes('script'))
2880
+ result.warnings.push('The script tag is removed at send time.');
2881
+ if (active.includes('event-handler attribute'))
2882
+ result.warnings.push('Each on* event-handler attribute is removed at send time.');
2883
+ const schemes = active.filter(finding => finding.endsWith(' URL'));
2884
+ if (schemes.length)
2885
+ result.warnings.push(`Active links (${schemes.join(', ')}) are removed at send time.`);
2886
+ const tags = active.filter(finding => finding !== 'script' && finding !== 'event-handler attribute' && !finding.endsWith(' URL'));
2887
+ if (tags.length)
2888
+ result.warnings.push(`Other active tags (${tags.join(', ')}) are removed at send time.`);
2874
2889
  for (const issue of findContentIssues({ subject, body_html: html, body_text }, { sendingHosts, sourcedKeys })) {
2875
2890
  if (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind))
2876
2891
  result.errors.push(`${issue.message} (${issue.excerpt})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.133",
3
+ "version": "1.0.135",
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",