@sendinel/mcp-server 1.0.165 → 1.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,9 @@ import { z } from 'zod';
6
6
  import { db, adminDb, requestContext } from '../db.js';
7
7
  import { getProjectId } from '../project.js';
8
8
  import { resolveProjectClient } from '../byod-client.js';
9
- import { decrypt } from '../crypto.js';
10
9
  import { logAudit } from '../audit.js';
11
10
  import { dbToolError, toolError } from '../lib/db-error.js';
11
+ import { storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
12
12
  const txt = (text) => ({ content: [{ type: 'text', text }] });
13
13
  const j = (data) => txt(JSON.stringify(data, null, 2));
14
14
  const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
@@ -579,21 +579,22 @@ export function registerAnalyticsTools(server) {
579
579
  idempotentHint: true,
580
580
  openWorldHint: false,
581
581
  },
582
- description: 'SPF/DKIM/DMARC status and warmup progress for a site',
582
+ description: 'Stored domain_health SPF/DKIM/DMARC history and warmup progress for a site. Each row adds a DNS summary with verification_source and checked_at; null means unchecked, never verified.',
583
583
  inputSchema: {
584
584
  site_id: z.string().uuid().describe('Site UUID'),
585
585
  days: z.number().int().min(1).max(90).optional().describe('Recent days to return, default 7'),
586
586
  },
587
587
  }, async ({ site_id, days = 7 }) => {
588
- const { data, error } = await db()
588
+ const { data, error } = await db().schema('email')
589
589
  .from('domain_health')
590
- .select('*')
590
+ .select('*, sites!inner(project_id)')
591
591
  .eq('site_id', site_id)
592
- .order('check_date', { ascending: false })
592
+ .eq('sites.project_id', getProjectId())
593
+ .order('check_date', { ascending: false }).order('created_at', { ascending: false }).order('id', { ascending: false })
593
594
  .limit(days);
594
595
  if (error)
595
- return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
596
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
596
+ return toolError(dbToolError(error, 'Unable to load domain health'));
597
+ return j((data ?? []).map(({ sites: _sites, ...row }) => ({ ...row, dns: storedDomainDns(row) })));
597
598
  });
598
599
  server.registerTool('get_engagement_insights', {
599
600
  title: 'Get Engagement Insights',
@@ -700,9 +701,9 @@ export function registerAnalyticsTools(server) {
700
701
  readOnlyHint: true,
701
702
  destructiveHint: false,
702
703
  idempotentHint: true,
703
- openWorldHint: true,
704
+ openWorldHint: false,
704
705
  },
705
- description: 'Run a deliverability check for a site: DNS records (SPF/DKIM/DMARC), warmup status, bounce/complaint rates, and risk assessment',
706
+ description: 'Read stored domain_health DNS observations (SPF/DKIM/DMARC), warmup status, bounce/complaint rates and risk assessment. Uses the same DNS source as refresh_domain_dns, get_domain_health and list_domains; null means unchecked. No live provider or DNS calls.',
706
707
  inputSchema: {
707
708
  site_id: z.string().uuid(),
708
709
  },
@@ -717,95 +718,25 @@ export function registerAnalyticsTools(server) {
717
718
  if (siteErr || !site)
718
719
  return txt(`Error: Site not found — ${(siteErr ? dbToolError(siteErr).error : undefined) ?? 'not found'}`);
719
720
  const domain = site.sending_domain;
720
- const dnsResults = { spf: null, dkim: null, dmarc: null };
721
- let verificationSource = 'dns_lookup';
722
- let resendDomainStatus = null;
723
- // Try Resend domain verification API first — avoids false negatives from
724
- // raw DNS lookups (Resend uses varying DKIM selectors per domain vintage).
725
- try {
726
- // Resolve Resend API key: site key → project key → env
727
- let apiKey = null;
728
- const { data: siteRow } = await adminDb()
729
- .from('sites')
730
- .select('provider_api_key_enc')
731
- .eq('id', site_id)
732
- .eq('project_id', projectId)
733
- .maybeSingle();
734
- if (siteRow?.provider_api_key_enc) {
735
- apiKey = decrypt(siteRow.provider_api_key_enc);
736
- }
737
- else {
738
- const { data: projRow } = await adminDb()
739
- .from('projects')
740
- .select('provider_api_key_enc')
741
- .eq('id', projectId)
742
- .maybeSingle();
743
- if (projRow?.provider_api_key_enc) {
744
- apiKey = decrypt(projRow.provider_api_key_enc);
745
- }
746
- else if (process.env.RESEND_API_KEY) {
747
- apiKey = process.env.RESEND_API_KEY;
748
- }
749
- }
750
- if (apiKey) {
751
- const { Resend } = await import('resend');
752
- const resend = new Resend(apiKey);
753
- const { data: domainList } = await resend.domains.list();
754
- const domains = domainList?.data ?? [];
755
- const match = domains.find((d) => d.name === domain);
756
- if (match) {
757
- resendDomainStatus = match.status;
758
- if (match.status === 'verified') {
759
- verificationSource = 'resend_api';
760
- dnsResults.spf = { found: true, verified_by: 'resend_api', note: 'Resend handles SPF authentication at infrastructure level' };
761
- dnsResults.dkim = { found: true, verified_by: 'resend_api', note: 'DKIM signing managed by Resend (selector may vary)' };
762
- dnsResults.dmarc = { found: true, verified_by: 'resend_api', note: 'Domain verified by Resend — DMARC alignment confirmed' };
763
- }
764
- }
765
- }
766
- }
767
- catch {
768
- // Resend API unavailable or key missing — fall back to DNS lookup
769
- }
770
- // Fall back to raw DNS checks if Resend API didn't confirm verification
771
- if (verificationSource === 'dns_lookup') {
772
- try {
773
- const dns = await import('dns/promises');
774
- try {
775
- const txtRecords = await dns.resolveTxt(domain);
776
- const spfRecord = txtRecords.flat().find((r) => r.startsWith('v=spf1'));
777
- dnsResults.spf = { found: !!spfRecord, record: spfRecord ?? null, includes_resend: spfRecord?.includes('include:amazonses.com') || spfRecord?.includes('include:_spf.resend.com') || false };
778
- }
779
- catch {
780
- dnsResults.spf = { found: false, error: 'No SPF record found' };
781
- }
782
- try {
783
- const dmarcRecords = await dns.resolveTxt(`_dmarc.${domain}`);
784
- const dmarcRecord = dmarcRecords.flat().find((r) => r.startsWith('v=DMARC1'));
785
- const policy = dmarcRecord?.match(/p=(\w+)/)?.[1] ?? 'unknown';
786
- dnsResults.dmarc = { found: !!dmarcRecord, record: dmarcRecord ?? null, policy, strict: policy === 'quarantine' || policy === 'reject' };
787
- }
788
- catch {
789
- dnsResults.dmarc = { found: false, error: 'No DMARC record found' };
790
- }
791
- try {
792
- const cnames = await dns.resolveCname(`resend._domainkey.${domain}`);
793
- dnsResults.dkim = { found: cnames.length > 0, cname: cnames[0] ?? null };
794
- }
795
- catch {
796
- dnsResults.dkim = { found: false, error: 'No DKIM CNAME found for resend._domainkey' };
797
- }
798
- }
799
- catch { /* dns module unavailable */ }
800
- }
801
- const { data: healthRows } = await db()
721
+ const { data: healthRows, error: healthError } = await db().schema('email')
802
722
  .from('domain_health')
803
- .select('*')
723
+ .select('*, sites!inner(project_id)')
804
724
  .eq('site_id', site_id)
725
+ .eq('sites.project_id', projectId)
805
726
  .order('check_date', { ascending: false })
727
+ .order('created_at', { ascending: false }).order('id', { ascending: false })
806
728
  .limit(14);
729
+ if (healthError)
730
+ return toolError(dbToolError(healthError, 'Unable to load domain health'));
807
731
  const health = healthRows ?? [];
808
732
  const latest = health[0];
733
+ const storedDns = storedDomainDns(latest);
734
+ const verificationSource = storedDns.verification_source;
735
+ const dnsResults = {
736
+ spf: { found: storedDns.spf, verified_by: storedDns.spf === true ? verificationSource : null, record: null, includes_resend: null, error: null, note: 'Stored SPF observation; raw DNS was not queried.' },
737
+ dkim: { found: storedDns.dkim, verified_by: storedDns.dkim === true ? verificationSource : null, cname: null, error: null, note: 'Stored DKIM observation; raw DNS was not queried.' },
738
+ dmarc: { found: storedDns.dmarc, verified_by: storedDns.dmarc === true ? verificationSource : null, record: null, policy: null, strict: null, error: null, note: 'Stored DMARC observation; raw DNS was not queried.' },
739
+ };
809
740
  const warmupDay = latest?.warmup_day ?? null;
810
741
  const warmupLimit = latest?.warmup_daily_limit ?? null;
811
742
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
@@ -821,18 +752,7 @@ export function registerAnalyticsTools(server) {
821
752
  const complained = logs.filter(isComplainedRow).length;
822
753
  const bounceRate = percentRate(bounced, totalSent, 8) ?? 0;
823
754
  const complaintRate = percentRate(complained, totalSent, 8) ?? 0;
824
- const risks = [];
825
- if (verificationSource === 'dns_lookup') {
826
- if (!dnsResults.spf?.found)
827
- risks.push('SPF record missing');
828
- if (!dnsResults.dkim?.found)
829
- risks.push('DKIM not configured');
830
- if (!dnsResults.dmarc?.found)
831
- risks.push('DMARC record missing');
832
- }
833
- if (resendDomainStatus && resendDomainStatus !== 'verified') {
834
- risks.push(`Resend domain status is '${resendDomainStatus}' — domain verification incomplete`);
835
- }
755
+ const risks = storedDnsReasons(storedDns);
836
756
  if (bounceRate > 5)
837
757
  risks.push(`HIGH bounce rate: ${bounceRate.toFixed(1)}%`);
838
758
  else if (bounceRate > 2)
@@ -846,7 +766,8 @@ export function registerAnalyticsTools(server) {
846
766
  return j({
847
767
  site: { slug: site.slug, domain, from_email: site.from_email },
848
768
  verification_source: verificationSource,
849
- resend_domain_status: resendDomainStatus,
769
+ resend_domain_status: null, // Retained for compatibility; no live Resend query is made.
770
+ checked_at: storedDns.checked_at,
850
771
  dns: dnsResults,
851
772
  warmup: { day: warmupDay, daily_limit: warmupLimit, completed: warmupDay ? warmupDay >= 43 : null },
852
773
  recent_7d: { total_sent: totalSent, bounced, complained, bounce_rate: `${bounceRate.toFixed(2)}%`, complaint_rate: `${complaintRate.toFixed(3)}%` },
@@ -23,7 +23,7 @@ export function registerApprovalTools(server, options) {
23
23
  });
24
24
  server.registerTool('cancel_approval', {
25
25
  title: 'Cancel Approval',
26
- description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected; cannot approve or undo cancellation.',
26
+ description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
27
27
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
28
28
  inputSchema: { approval_id: z.string().uuid() },
29
29
  }, async ({ approval_id }) => {
@@ -77,14 +77,15 @@ export function registerApprovalTools(server, options) {
77
77
  idempotentHint: true,
78
78
  openWorldHint: false,
79
79
  },
80
- description: 'List unexpired pending approval requests for this project. Expired rows are excluded even before the expiry cron runs.',
81
- inputSchema: {},
82
- }, async () => {
80
+ description: 'List unexpired, unused approval requests for this project. Defaults to pending; pass status: approved to find approved-but-unused requests, including sandbox auto-approvals, to execute or cancel. Expired and used rows are excluded.',
81
+ inputSchema: { status: z.enum(['pending', 'approved']).optional().describe('Default pending. Use approved to find unused approvals, including sandbox auto-approvals.') },
82
+ }, async ({ status }) => {
83
83
  const { data, error } = await db()
84
84
  .from('action_approvals')
85
85
  .select('id, action, tier, channel, summary, status, requested_at, expires_at, decided_via, decided_by')
86
86
  .eq('project_id', getProjectId())
87
- .eq('status', 'pending')
87
+ .eq('status', status ?? 'pending')
88
+ .is('used_at', null)
88
89
  .gt('expires_at', new Date().toISOString())
89
90
  .order('requested_at', { ascending: false })
90
91
  .limit(25);
@@ -135,7 +135,7 @@ export function registerAutomationTools(server) {
135
135
  },
136
136
  description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
137
137
  inputSchema: {
138
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
138
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
139
139
  subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
140
140
  campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
141
141
  site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
@@ -217,7 +217,7 @@ export function registerAutomationTools(server) {
217
217
  },
218
218
  description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
219
219
  inputSchema: {
220
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
220
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
221
221
  subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
222
222
  campaign_id: z.string().uuid().describe('Triggered campaign UUID with ladder steps'),
223
223
  next_send_at: z.string().datetime().optional().describe('First eligible send time. Defaults to now.'),
@@ -39,7 +39,7 @@ export function registerBrandKitTools(server) {
39
39
  },
40
40
  description: 'Import raw Brand Core / Brand Brain / Content Library / Brand Kit markdown text into the current project.',
41
41
  inputSchema: {
42
- approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
42
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
43
43
  text: z.string().min(1).describe('Raw markdown text from the Brand Core / Brand Kit companion output.'),
44
44
  },
45
45
  }, withApproval('import_brand_kit_text', async ({ text }) => {
@@ -6,6 +6,7 @@ type CampaignSetRow = {
6
6
  trigger_event_name: string | null;
7
7
  trigger_tag: string | null;
8
8
  trigger_config?: unknown;
9
+ trigger_type?: string | null;
9
10
  send_class: string | null;
10
11
  site_id: string;
11
12
  type?: string;
@@ -5,11 +5,11 @@ import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { checkPlanLimit } from '../plan-limits.js';
7
7
  import { validateTemplate } from '../lib/voice.js';
8
- import { approvalExcerpt, assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
8
+ import { approvalNext, approvalExcerpt, assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
9
9
  import { generateEmailFromBrief } from '../lib/email-generation.js';
10
10
  import { fireWebhookEvent } from '../lib/webhook-events.js';
11
11
  import { logAudit } from '../audit.js';
12
- import { applyCampaignTargetingFields } from './campaigns.js';
12
+ import { applyCampaignTargetingFields, campaignTriggerKind } from './campaigns.js';
13
13
  import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
14
14
  import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
15
15
  import { dbToolError } from '../lib/db-error.js';
@@ -94,7 +94,7 @@ export function previewOf(campaigns, steps) {
94
94
  export async function loadSet(projectId, campaignIds) {
95
95
  const { data: campaigns, error } = await db()
96
96
  .from('campaigns')
97
- .select('id, name, type, segment_id, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
97
+ .select('id, name, type, segment_id, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
98
98
  .eq('project_id', projectId)
99
99
  .in('id', campaignIds);
100
100
  if (error)
@@ -343,6 +343,9 @@ export function registerCampaignSetTools(server) {
343
343
  if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
344
344
  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' });
345
345
  }
346
+ if (current.campaigns.some(c => c.type === 'triggered' && campaignTriggerKind(c) === 'manual' && !c.segment_id)) {
347
+ return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed' });
348
+ }
346
349
  // Same content gate as the request, so a bad email can't ride through on an old approval.
347
350
  const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
348
351
  if (readiness.blocking.length)
@@ -375,6 +378,10 @@ export function registerCampaignSetTools(server) {
375
378
  const notReady = set.campaigns.filter((c) => !['draft', 'paused'].includes(c.status));
376
379
  if (notReady.length)
377
380
  return j({ error: 'Only draft or paused campaigns can be activated here.', code: 'NOT_ACTIVATABLE', campaigns: notReady.map((c) => ({ id: c.id, name: c.name, status: c.status })) });
381
+ const triggerless = set.campaigns.filter(c => c.type === 'triggered' && campaignTriggerKind(c) === 'manual' && !c.segment_id);
382
+ if (triggerless.length)
383
+ return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed',
384
+ campaigns: triggerless.map(c => ({ id: c.id, name: c.name })) });
378
385
  const empty = set.campaigns.filter((c) => !set.steps.some((s) => s.campaign_id === c.id));
379
386
  if (empty.length)
380
387
  return j({ error: 'Some campaigns have no emails yet.', code: 'NO_EMAILS', campaigns: empty.map((c) => ({ id: c.id, name: c.name })) });
@@ -410,7 +417,7 @@ export function registerCampaignSetTools(server) {
410
417
  ...approval,
411
418
  campaigns: previewOf(set.campaigns, set.steps),
412
419
  ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
413
- next: 'Show the person the campaigns above and approve_url. After approval, call activate_campaigns with only approval_id.',
420
+ next: approvalNext('activate_campaigns', approval),
414
421
  });
415
422
  });
416
423
  }