@sendinel/mcp-server 1.0.50 → 1.0.52

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,10 +192,11 @@ export function buildApprovalSummary(action, params, display = {}) {
192
192
  const count = typeof display.campaign_count === 'number' ? display.campaign_count : p.campaign_ids?.length ?? 0;
193
193
  const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
194
194
  const names = display.campaign_names ? `: ${display.campaign_names}` : '';
195
- return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires`;
195
+ const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
196
+ return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
196
197
  },
197
198
  update_campaign_status: (p) => (p.status === 'active'
198
- ? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}`
199
+ ? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}${display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : ''}`
199
200
  : `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
200
201
  rotate_custom_webhook_secret: (p) => `Rotate custom webhook ${p.connection_id} signing secret — immediately invalidates the previous secret with no grace period`,
201
202
  create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
@@ -0,0 +1,62 @@
1
+ import { type ContentIssue } from '@sendinel/domain';
2
+ /** Data keys seen on an event in the project's history, or null when the lookup failed. */
3
+ export declare function eventKeysFromHistory(projectId: string, eventName: string): Promise<string[] | null>;
4
+ /**
5
+ * Contact property keys the project's contacts actually have, from the most recent contacts.
6
+ * complete is true only when every contact was read. Null when the lookup failed.
7
+ */
8
+ export declare function contactPropertyKeys(projectId: string): Promise<{
9
+ keys: string[];
10
+ complete: boolean;
11
+ checked: number;
12
+ } | null>;
13
+ type ContentStep = {
14
+ campaign_id: string;
15
+ step_order: number;
16
+ subject: string | null;
17
+ body_html: string | null;
18
+ body_text: string | null;
19
+ };
20
+ type ContentCampaign = {
21
+ id: string;
22
+ name: string;
23
+ site_id: string | null;
24
+ trigger_event_name: string | null;
25
+ };
26
+ export type CampaignContentFinding = ContentIssue & {
27
+ campaign: string;
28
+ email: number;
29
+ };
30
+ /**
31
+ * Looks for placeholder text, links to the sending domain and merge tags with no data source in
32
+ * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
33
+ * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
34
+ */
35
+ export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[]): Promise<{
36
+ blocking: CampaignContentFinding[];
37
+ warnings: CampaignContentFinding[];
38
+ lookup_errors: string[];
39
+ }>;
40
+ /** The refusal for emails with placeholder text or a link to the sending domain (#3853). */
41
+ export declare function contentNotReadyResponse(blocking: CampaignContentFinding[]): {
42
+ error: string;
43
+ code: string;
44
+ emails: {
45
+ campaign: string;
46
+ email: number;
47
+ kind: import("@sendinel/domain").ContentIssueKind;
48
+ problem: string;
49
+ excerpt: string;
50
+ }[];
51
+ };
52
+ export declare function contentWarningView(warnings: CampaignContentFinding[], lookupErrors: string[]): {
53
+ kind: string;
54
+ warning: string;
55
+ }[];
56
+ /** One line for the approval card: how many warnings and the first few. */
57
+ export declare function summarizeContentWarnings(warnings: Array<{
58
+ campaign?: string;
59
+ email?: number;
60
+ warning: string;
61
+ }>): string;
62
+ export {};
@@ -0,0 +1,111 @@
1
+ import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf } from '@sendinel/domain';
2
+ import { db } from '../db.js';
3
+ /** Data keys seen on an event in the project's history, or null when the lookup failed. */
4
+ export async function eventKeysFromHistory(projectId, eventName) {
5
+ const { data, error } = await db()
6
+ .from('contact_events')
7
+ .select('metadata')
8
+ .eq('project_id', projectId)
9
+ .eq('title', eventName)
10
+ .order('created_at', { ascending: false })
11
+ .limit(200);
12
+ if (error)
13
+ return null;
14
+ const keys = new Set();
15
+ for (const row of (data ?? [])) {
16
+ if (row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata))
17
+ for (const key of Object.keys(row.metadata))
18
+ keys.add(key);
19
+ }
20
+ return Array.from(keys);
21
+ }
22
+ const CONTACT_SAMPLE = 1000;
23
+ /**
24
+ * Contact property keys the project's contacts actually have, from the most recent contacts.
25
+ * complete is true only when every contact was read. Null when the lookup failed.
26
+ */
27
+ export async function contactPropertyKeys(projectId) {
28
+ const { data, count, error } = await db()
29
+ .from('subscribers')
30
+ .select('custom_properties', { count: 'exact' })
31
+ .eq('project_id', projectId)
32
+ .order('created_at', { ascending: false })
33
+ .limit(CONTACT_SAMPLE);
34
+ if (error)
35
+ return null;
36
+ const keys = new Set();
37
+ for (const row of (data ?? [])) {
38
+ const props = row.custom_properties;
39
+ if (props && typeof props === 'object' && !Array.isArray(props)) {
40
+ for (const [key, value] of Object.entries(props))
41
+ if (value !== null && value !== undefined && value !== '')
42
+ keys.add(key);
43
+ }
44
+ }
45
+ const checked = (data ?? []).length;
46
+ return { keys: Array.from(keys), complete: typeof count === 'number' && count <= checked, checked };
47
+ }
48
+ /**
49
+ * Looks for placeholder text, links to the sending domain and merge tags with no data source in
50
+ * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
51
+ * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
52
+ */
53
+ export async function checkCampaignContent(projectId, campaigns, steps) {
54
+ const lookupErrors = [];
55
+ const sendingHostsBySite = new Map();
56
+ const allHosts = new Set();
57
+ const { data: sites, error: sitesErr } = await db().from('sites').select('id, sending_domain, from_email').eq('project_id', projectId);
58
+ if (sitesErr)
59
+ lookupErrors.push(`Could not read your sending domains, so links were not checked against them: ${sitesErr.message}`);
60
+ for (const site of (sites ?? [])) {
61
+ const hosts = [sendingHostOf(site.sending_domain), sendingHostOf(site.from_email)].filter((h) => Boolean(h));
62
+ sendingHostsBySite.set(site.id, hosts);
63
+ for (const host of hosts)
64
+ allHosts.add(host);
65
+ }
66
+ const contactProps = await contactPropertyKeys(projectId);
67
+ if (contactProps === null)
68
+ lookupErrors.push('Could not read your contacts, so merge tags that use contact properties were not confirmed.');
69
+ const eventKeys = new Map();
70
+ for (const eventName of new Set(campaigns.map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
71
+ const keys = await eventKeysFromHistory(projectId, eventName);
72
+ eventKeys.set(eventName, keys);
73
+ if (keys === null)
74
+ lookupErrors.push(`Could not read the event history for "${eventName}", so its merge tags were not confirmed.`);
75
+ }
76
+ const blocking = [];
77
+ const warnings = [];
78
+ for (const campaign of campaigns) {
79
+ const events = campaign.trigger_event_name ? eventKeys.get(campaign.trigger_event_name) : [];
80
+ const sourcedKeys = contactProps === null || events === null || events === undefined
81
+ ? null
82
+ : [...events, ...contactProps.keys.map((key) => `custom.${key}`)];
83
+ const hosts = (campaign.site_id ? sendingHostsBySite.get(campaign.site_id) : undefined) ?? [...allHosts];
84
+ for (const step of steps.filter((s) => s.campaign_id === campaign.id)) {
85
+ for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys })) {
86
+ const finding = { ...issue, campaign: campaign.name, email: step.step_order + 1 };
87
+ (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind) ? blocking : warnings).push(finding);
88
+ }
89
+ }
90
+ }
91
+ return { blocking, warnings, lookup_errors: lookupErrors };
92
+ }
93
+ /** The refusal for emails with placeholder text or a link to the sending domain (#3853). */
94
+ export function contentNotReadyResponse(blocking) {
95
+ return {
96
+ error: 'Some emails are not ready to send. Fix the text or links below, then try again.',
97
+ code: 'CONTENT_NOT_READY',
98
+ emails: blocking.map((f) => ({ campaign: f.campaign, email: f.email, kind: f.kind, problem: f.message, excerpt: f.excerpt })),
99
+ };
100
+ }
101
+ export function contentWarningView(warnings, lookupErrors) {
102
+ return [
103
+ ...warnings.map((f) => ({ campaign: f.campaign, email: f.email, kind: f.kind, warning: f.message })),
104
+ ...lookupErrors.map((message) => ({ kind: 'could_not_check', warning: message })),
105
+ ];
106
+ }
107
+ /** One line for the approval card: how many warnings and the first few. */
108
+ export function summarizeContentWarnings(warnings) {
109
+ const first = warnings.slice(0, 3).map((w) => (w.campaign ? `${w.campaign}, email ${w.email}: ${w.warning}` : w.warning));
110
+ return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}. ${first.join(' | ')}${warnings.length > 3 ? ` | and ${warnings.length - 3} more` : ''}`;
111
+ }
@@ -10,6 +10,7 @@ import { generateEmailFromBrief } from '../lib/email-generation.js';
10
10
  import { fireWebhookEvent } from '../lib/webhook-events.js';
11
11
  import { logAudit } from '../audit.js';
12
12
  import { applyCampaignTargetingFields } from './campaigns.js';
13
+ import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
13
14
  /**
14
15
  * Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
15
16
  * activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
@@ -286,6 +287,10 @@ export function registerCampaignSetTools(server) {
286
287
  if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
287
288
  return j({ error: 'A campaign or email changed (or was deleted) after approval. Nothing was activated. Request a new approval.', code: 'CONTENT_CHANGED_SINCE_APPROVAL' });
288
289
  }
290
+ // Same content gate as the request, so a bad email can't ride through on an old approval.
291
+ const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
292
+ if (readiness.blocking.length)
293
+ return j(contentNotReadyResponse(readiness.blocking));
289
294
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
290
295
  if (!check.approved)
291
296
  return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
@@ -349,6 +354,11 @@ export function registerCampaignSetTools(server) {
349
354
  emails: waitNotSet.map((s) => ({ campaign: set.campaigns.find((c) => c.id === s.campaign_id)?.name ?? s.campaign_id, email: s.step_order + 1, subject: s.subject })),
350
355
  });
351
356
  }
357
+ // Placeholder text and links to the sending domain never go out (#3853). Merge tags with no data source are warnings.
358
+ const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps);
359
+ if (readiness.blocking.length)
360
+ return j(contentNotReadyResponse(readiness.blocking));
361
+ const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
352
362
  const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
353
363
  const channelContext = currentChannelContext();
354
364
  const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
@@ -359,10 +369,12 @@ export function registerCampaignSetTools(server) {
359
369
  campaign_count: set.campaigns.length,
360
370
  email_count: set.steps.length,
361
371
  campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
372
+ ...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
362
373
  });
363
374
  return j({
364
375
  ...approval,
365
376
  campaigns: previewOf(set.campaigns, set.steps),
377
+ ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
366
378
  next: 'Show the person the campaigns above and approve_url. After approval, call activate_campaigns with only approval_id.',
367
379
  });
368
380
  });
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, readAllRows, } from '@sendinel/domain';
3
+ import { 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, readAllRows, } from '@sendinel/domain';
4
4
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
5
5
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
6
6
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -20,6 +20,7 @@ import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } fro
20
20
  import { sendEmailForSite } from '../lib/email-provider.js';
21
21
  import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
22
22
  import { logAudit } from '../audit.js';
23
+ import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
23
24
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
24
25
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
25
26
  import { callSendinelV1 } from '../lib/v1-client.js';
@@ -714,8 +715,9 @@ export function registerCampaignTools(server) {
714
715
  variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
715
716
  survey_id: z.string().uuid().optional(),
716
717
  brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
718
+ skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
717
719
  },
718
- }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
720
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event }) => {
719
721
  const renderVars = {};
720
722
  if (survey_id) {
721
723
  const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
@@ -730,7 +732,7 @@ export function registerCampaignTools(server) {
730
732
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
731
733
  const { data, error } = await db()
732
734
  .from('campaign_steps')
733
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
735
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null })
734
736
  .select()
735
737
  .single();
736
738
  if (error)
@@ -780,6 +782,28 @@ export function registerCampaignTools(server) {
780
782
  emails: unsetSteps.map((s) => ({ email: s.step_order + 1, subject: s.subject })),
781
783
  });
782
784
  }
785
+ // Placeholder text and links to the sending domain never go out (#3853).
786
+ const { data: campRow, error: campErr } = await db()
787
+ .from('campaigns')
788
+ .select('id, name, site_id, trigger_event_name')
789
+ .eq('id', campaign_id)
790
+ .eq('project_id', getProjectId())
791
+ .maybeSingle();
792
+ if (campErr)
793
+ return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
794
+ let contentWarnings = [];
795
+ if (campRow) {
796
+ const { data: contentSteps, error: contentErr } = await db()
797
+ .from('campaign_steps')
798
+ .select('campaign_id, step_order, subject, body_html, body_text')
799
+ .eq('campaign_id', campaign_id);
800
+ if (contentErr)
801
+ return j({ error: `Could not check the campaign's emails: ${contentErr.message}`, code: 'STEP_LOOKUP_FAILED' });
802
+ const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? []);
803
+ if (readiness.blocking.length)
804
+ return j(contentNotReadyResponse(readiness.blocking));
805
+ contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
806
+ }
783
807
  const approvalParams = { campaign_id, status };
784
808
  if (!approval_id) {
785
809
  const channelContext = currentChannelContext();
@@ -797,8 +821,9 @@ export function registerCampaignTools(server) {
797
821
  campaign_name: camp?.name ?? null,
798
822
  enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
799
823
  trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
824
+ ...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
800
825
  });
801
- return j(approval);
826
+ return j(contentWarnings.length ? { ...approval, content_warnings: contentWarnings } : approval);
802
827
  }
803
828
  }
804
829
  else {
@@ -2733,25 +2758,57 @@ Do not include body_html in your response; Sendinel renders body_html from block
2733
2758
  idempotentHint: true,
2734
2759
  openWorldHint: false,
2735
2760
  },
2736
- description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, stock phrases, excessive CTAs, missing sign-off, and more.',
2761
+ 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.',
2737
2762
  inputSchema: {
2738
2763
  html: z.string().describe('HTML email content to validate'),
2739
2764
  site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
2740
2765
  custom_variables: z.array(z.string()).optional().describe('Variable names (without braces) this template legitimately uses beyond the fixed known set.'),
2766
+ subject: z.string().optional().describe('Subject line, checked for placeholder text and merge tags.'),
2767
+ body_text: z.string().optional().describe('Plain text version, checked for placeholder text and links.'),
2768
+ trigger_event_name: z.string().min(1).optional().describe('Event that starts the campaign. Its past data keys and your contact properties are used to check merge tags.'),
2769
+ event_keys: z.array(z.string()).optional().describe('Data keys the trigger event sends. Merge tags not in this list, your contact properties or the built-in set are flagged.'),
2741
2770
  },
2742
- }, async ({ html, site_id, custom_variables }) => {
2771
+ }, async ({ html, site_id, custom_variables, subject, body_text, trigger_event_name, event_keys }) => {
2772
+ const projectId = getProjectId();
2743
2773
  let voiceConfig = null;
2744
2774
  if (site_id) {
2745
2775
  const { data: site } = await db()
2746
2776
  .from('sites')
2747
2777
  .select('brand_voice')
2748
2778
  .eq('id', site_id)
2749
- .eq('project_id', getProjectId())
2779
+ .eq('project_id', projectId)
2750
2780
  .maybeSingle();
2751
2781
  voiceConfig = site?.brand_voice ?? null;
2752
2782
  }
2753
- const result = validateTemplate(html, voiceConfig, { customVariables: custom_variables });
2754
- return j(result);
2783
+ // The brand kit's sign-off name applies to every site in the project (#3854).
2784
+ const { data: projectRow } = await db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
2785
+ // #3853: placeholder text, links to the sending domain, merge tags with no data source.
2786
+ const { data: siteRows } = await db().from('sites').select('id, sending_domain, from_email').eq('project_id', projectId);
2787
+ const sendingHosts = (siteRows ?? [])
2788
+ .filter((row) => !site_id || row.id === site_id)
2789
+ .flatMap((row) => [sendingHostOf(row.sending_domain), sendingHostOf(row.from_email)])
2790
+ .filter((host) => Boolean(host));
2791
+ let sourcedKeys;
2792
+ if (trigger_event_name || event_keys) {
2793
+ const fromHistory = trigger_event_name ? await eventKeysFromHistory(projectId, trigger_event_name) : [];
2794
+ const contacts = await contactPropertyKeys(projectId);
2795
+ sourcedKeys = fromHistory === null || contacts === null
2796
+ ? null
2797
+ : [...(event_keys ?? []), ...fromHistory, ...contacts.keys.map((key) => `custom.${key}`)];
2798
+ }
2799
+ const result = validateTemplate(html, voiceConfig, {
2800
+ customVariables: [...(custom_variables ?? []), ...(sourcedKeys ?? [])],
2801
+ subject,
2802
+ bodyText: body_text,
2803
+ brandSettings: projectRow?.brand_settings ?? null,
2804
+ });
2805
+ for (const issue of findContentIssues({ subject, body_html: html, body_text }, { sendingHosts, sourcedKeys })) {
2806
+ if (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind))
2807
+ result.errors.push(`${issue.message} (${issue.excerpt})`);
2808
+ else if (!result.errors.some((error) => error.includes(issue.excerpt)))
2809
+ result.warnings.push(issue.message);
2810
+ }
2811
+ return j({ ...result, valid: result.errors.length === 0 });
2755
2812
  });
2756
2813
  server.registerTool('check_content_consistency', {
2757
2814
  title: 'Check Content Consistency',
@@ -3039,7 +3096,7 @@ Return ONLY valid JSON with this exact shape:
3039
3096
  return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
3040
3097
  const { data, error } = await db()
3041
3098
  .from('campaign_steps')
3042
- .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, created_at, updated_at')
3099
+ .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, created_at, updated_at')
3043
3100
  .eq('campaign_id', campaign_id)
3044
3101
  .order('step_order', { ascending: true });
3045
3102
  if (error)
@@ -3065,6 +3122,7 @@ Return ONLY valid JSON with this exact shape:
3065
3122
  delay_hours: z.number().int().min(0).optional(),
3066
3123
  step_order: z.number().int().min(0).optional(),
3067
3124
  survey_id: z.string().uuid().optional(),
3125
+ skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
3068
3126
  },
3069
3127
  }, async ({ step_id, ...updates }) => {
3070
3128
  const projectId = getProjectId();
@@ -3105,7 +3163,7 @@ Return ONLY valid JSON with this exact shape:
3105
3163
  .from('campaign_steps')
3106
3164
  .update({ ...renderedUpdates, updated_at: new Date().toISOString() })
3107
3165
  .eq('id', step_id)
3108
- .select('id, step_order, subject, delay_days, delay_hours, updated_at')
3166
+ .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, updated_at')
3109
3167
  .single();
3110
3168
  if (error)
3111
3169
  return j({ error: error.message });
@@ -1359,30 +1359,67 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1359
1359
  idempotentHint: true,
1360
1360
  openWorldHint: false,
1361
1361
  },
1362
- description: 'Recent email sends with status, filterable by site and/or recipient',
1362
+ description: 'See sent emails and their delivery status: the email history for your project, one row per message with recipient, subject, send time, status (sent, delivered, opened, clicked, bounced, complained, failed) and where it came from (source: campaign or transactional). Filter by recipient email, subject words, campaign, site, status, and a sent-between date range (sent_after / sent_before, ISO dates; a date alone covers the whole day, UTC). Newest first. Page past 200 with offset: when a filter or offset is used the answer is { emails, has_more, next_offset }; with no filters it is the plain list.',
1363
1363
  inputSchema: {
1364
1364
  site_id: z.string().uuid().optional(),
1365
- contact_email: z.string().optional(),
1366
- status: z.enum(['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed']).optional(),
1365
+ contact_email: z.string().optional().describe('Exact recipient email address.'),
1366
+ status: z.enum(['queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed', 'failed']).optional(),
1367
+ sent_after: z.string().optional().describe('Only emails sent at or after this time. ISO 8601; a bare date like 2026-10-15 starts at 00:00 UTC.'),
1368
+ sent_before: z.string().optional().describe('Only emails sent at or before this time. ISO 8601; a bare date like 2026-10-15 covers that whole day (UTC).'),
1369
+ campaign_id: z.string().uuid().optional().describe('Only emails sent by this campaign.'),
1370
+ subject_contains: z.string().min(1).max(200).optional().describe('Only emails whose subject contains these words (not case sensitive).'),
1371
+ offset: z.number().int().min(0).max(100000).optional().describe('How many rows to skip, to page past the first 200. Use next_offset from the last answer.'),
1367
1372
  limit: z.number().int().min(1).max(200).optional().default(50),
1368
1373
  },
1369
- }, async ({ site_id, contact_email, status, limit }) => {
1374
+ }, async ({ site_id, contact_email, status, sent_after, sent_before, campaign_id, subject_contains, offset, limit }) => {
1375
+ const parseBound = (raw, edge) => {
1376
+ const value = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}${edge === 'start' ? 'T00:00:00.000Z' : 'T23:59:59.999Z'}` : raw;
1377
+ const date = new Date(value);
1378
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
1379
+ };
1380
+ const after = sent_after === undefined ? undefined : parseBound(sent_after, 'start');
1381
+ const before = sent_before === undefined ? undefined : parseBound(sent_before, 'end');
1382
+ if (after === null)
1383
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `sent_after is not a date: ${sent_after}. Use ISO 8601, e.g. 2026-10-15.`, code: 'BAD_REQUEST' }, null, 2) }] };
1384
+ if (before === null)
1385
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `sent_before is not a date: ${sent_before}. Use ISO 8601, e.g. 2026-10-15.`, code: 'BAD_REQUEST' }, null, 2) }] };
1386
+ const pageSize = limit ?? 50;
1387
+ const start = offset ?? 0;
1388
+ const paged = offset !== undefined || after !== undefined || before !== undefined || campaign_id !== undefined || subject_contains !== undefined;
1389
+ // Fetch one extra row to know whether another page exists.
1370
1390
  let q = db()
1371
1391
  .from('email_log')
1372
- .select('id, to_email, subject, status, sent_at, opened_at, clicked_at, bounced_at, site_id, campaign_id')
1392
+ .select('id, to_email, from_email, subject, status, category, sent_at, delivered_at, opened_at, clicked_at, bounced_at, bounce_type, complained_at, error_message, site_id, campaign_id, created_at')
1373
1393
  .eq('project_id', getProjectId())
1374
1394
  .order('sent_at', { ascending: false })
1375
- .limit(limit ?? 50);
1395
+ .order('id', { ascending: false })
1396
+ .range(start, start + pageSize);
1376
1397
  if (site_id)
1377
1398
  q = q.eq('site_id', site_id);
1378
1399
  if (status)
1379
1400
  q = q.eq('status', status);
1380
1401
  if (contact_email)
1381
1402
  q = q.eq('to_email', contact_email);
1403
+ if (campaign_id)
1404
+ q = q.eq('campaign_id', campaign_id);
1405
+ if (after)
1406
+ q = q.gte('sent_at', after);
1407
+ if (before)
1408
+ q = q.lte('sent_at', before);
1409
+ if (subject_contains)
1410
+ q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
1382
1411
  const { data, error } = await q;
1383
1412
  if (error)
1384
1413
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1385
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1414
+ const fetched = (data ?? []);
1415
+ const hasMore = fetched.length > pageSize;
1416
+ // source: where the email came from. A campaign id wins; otherwise the stored category (transactional, test, marketing).
1417
+ const rows = fetched.slice(0, pageSize).map((row) => ({
1418
+ ...row,
1419
+ source: row.campaign_id ? 'campaign' : (row.category === 'transactional' ? 'transactional' : (row.category ?? 'other')),
1420
+ }));
1421
+ const body = paged ? { emails: rows, has_more: hasMore, next_offset: hasMore ? start + pageSize : null } : rows;
1422
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
1386
1423
  });
1387
1424
  server.registerTool('list_suppressions', {
1388
1425
  title: 'List Suppressions',
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
5
5
  import { checkPlanLimit } from '../plan-limits.js';
6
6
  import { validateTemplate } from '../lib/voice.js';
7
7
  import { logAudit } from '../audit.js';
8
+ import { eventKeysFromHistory, contactPropertyKeys } from '../lib/content-sources.js';
8
9
  import { loadSet, previewOf, writeCampaignDrafts } from './campaign-sets.js';
9
10
  /**
10
11
  * convert_imported_campaigns (#3689): turns campaigns imported from another platform (Customer.io
@@ -30,51 +31,6 @@ const Conversion = z.object({
30
31
  event_keys: z.array(z.string()).optional().describe('Data keys the trigger event sends. Used to catch placeholders that would come out blank.'),
31
32
  send_class: z.enum(['broadcast', 'product']).optional().describe('product for account, trial and onboarding email; broadcast for marketing.'),
32
33
  });
33
- /** Data keys seen on an event in the project's history, or null when the lookup failed. */
34
- async function eventKeysFromHistory(projectId, eventName) {
35
- const { data, error } = await db()
36
- .from('contact_events')
37
- .select('metadata')
38
- .eq('project_id', projectId)
39
- .eq('title', eventName)
40
- .order('created_at', { ascending: false })
41
- .limit(200);
42
- if (error)
43
- return null;
44
- const keys = new Set();
45
- for (const row of (data ?? [])) {
46
- if (row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata))
47
- for (const key of Object.keys(row.metadata))
48
- keys.add(key);
49
- }
50
- return Array.from(keys);
51
- }
52
- const CONTACT_SAMPLE = 1000;
53
- /**
54
- * Contact property keys the project's contacts actually have, from the most recent contacts.
55
- * complete is true only when every contact was read. Null when the lookup failed.
56
- */
57
- async function contactPropertyKeys(projectId) {
58
- const { data, count, error } = await db()
59
- .from('subscribers')
60
- .select('custom_properties', { count: 'exact' })
61
- .eq('project_id', projectId)
62
- .order('created_at', { ascending: false })
63
- .limit(CONTACT_SAMPLE);
64
- if (error)
65
- return null;
66
- const keys = new Set();
67
- for (const row of (data ?? [])) {
68
- const props = row.custom_properties;
69
- if (props && typeof props === 'object' && !Array.isArray(props)) {
70
- for (const [key, value] of Object.entries(props))
71
- if (value !== null && value !== undefined && value !== '')
72
- keys.add(key);
73
- }
74
- }
75
- const checked = (data ?? []).length;
76
- return { keys: Array.from(keys), complete: typeof count === 'number' && count <= checked, checked };
77
- }
78
34
  function reviewView(plan) {
79
35
  return {
80
36
  source: { campaign_id: plan.source_campaign_id, name: plan.source_name },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
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",