@sendinel/mcp-server 1.0.166 → 1.0.168
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.
- package/build/lib/domain-health.d.ts +18 -0
- package/build/lib/domain-health.js +34 -0
- package/build/tools/analytics.js +26 -105
- package/build/tools/campaigns.js +37 -21
- package/build/tools/contacts.js +1 -1
- package/build/tools/delivery-ops.js +19 -19
- package/build/tools/sites.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface StoredHealth {
|
|
2
|
+
spf_valid?: boolean | null;
|
|
3
|
+
dkim_valid?: boolean | null;
|
|
4
|
+
dmarc_valid?: boolean | null;
|
|
5
|
+
check_date?: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** DNS observations come only from domain_health, never inferred from registration status. */
|
|
8
|
+
export declare function storedDomainDns(health?: StoredHealth | null): {
|
|
9
|
+
spf: boolean | null;
|
|
10
|
+
dkim: boolean | null;
|
|
11
|
+
dmarc: boolean | null;
|
|
12
|
+
all_verified: boolean | null;
|
|
13
|
+
verification_source: 'stored_domain_health';
|
|
14
|
+
checked_at: string | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function readLatestStoredDomainHealth(domainId: string): any;
|
|
17
|
+
export declare function storedDnsReasons(dns: ReturnType<typeof storedDomainDns>): string[];
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { db } from '../db.js';
|
|
2
|
+
import { getProjectId } from '../project.js';
|
|
3
|
+
/** DNS observations come only from domain_health, never inferred from registration status. */
|
|
4
|
+
export function storedDomainDns(health) {
|
|
5
|
+
const spf = health?.spf_valid ?? null;
|
|
6
|
+
const dkim = health?.dkim_valid ?? null;
|
|
7
|
+
const dmarc = health?.dmarc_valid ?? null;
|
|
8
|
+
return {
|
|
9
|
+
spf, dkim, dmarc,
|
|
10
|
+
all_verified: [spf, dkim, dmarc].every(value => typeof value === 'boolean') ? Boolean(spf && dkim && dmarc) : null,
|
|
11
|
+
verification_source: 'stored_domain_health',
|
|
12
|
+
checked_at: health?.check_date ?? null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function readLatestStoredDomainHealth(domainId) {
|
|
16
|
+
return db().schema('email').from('domain_health')
|
|
17
|
+
.select('spf_valid, dkim_valid, dmarc_valid, check_date, sites!inner(domain_id)')
|
|
18
|
+
.eq('sites.project_id', getProjectId()).eq('sites.domain_id', domainId)
|
|
19
|
+
.order('check_date', { ascending: false }).order('created_at', { ascending: false }).order('id', { ascending: false })
|
|
20
|
+
.limit(1).maybeSingle();
|
|
21
|
+
}
|
|
22
|
+
export function storedDnsReasons(dns) {
|
|
23
|
+
const reasons = [];
|
|
24
|
+
if (dns.spf === false)
|
|
25
|
+
reasons.push('SPF record missing');
|
|
26
|
+
if (dns.dkim === false)
|
|
27
|
+
reasons.push('DKIM not configured');
|
|
28
|
+
if (dns.dmarc === false)
|
|
29
|
+
reasons.push('DMARC record missing');
|
|
30
|
+
if ([dns.spf, dns.dkim, dns.dmarc].some(value => value === null)) {
|
|
31
|
+
reasons.push('DNS authentication has not been checked in stored domain health');
|
|
32
|
+
}
|
|
33
|
+
return reasons;
|
|
34
|
+
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
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
|
|
596
|
-
return
|
|
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:
|
|
704
|
+
openWorldHint: false,
|
|
704
705
|
},
|
|
705
|
-
description: '
|
|
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
|
|
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:
|
|
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)}%` },
|
package/build/tools/campaigns.js
CHANGED
|
@@ -190,27 +190,30 @@ async function campaignTimezoneWarnings(campaign) {
|
|
|
190
190
|
const sendWindow = campaign.send_window ?? (campaign.send_window_inherit !== false ? site?.send_window : null);
|
|
191
191
|
return sendWindow && !validTimezone(site?.timezone) ? [SEND_WINDOW_TIMEZONE_WARNING] : [];
|
|
192
192
|
}
|
|
193
|
-
async function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
seenEvents = null;
|
|
206
|
-
}
|
|
193
|
+
async function readCampaignTriggerHistory(campaigns) {
|
|
194
|
+
if (!campaigns.some(campaign => normalizeTriggerValue(campaign.trigger_event_name)
|
|
195
|
+
|| normalizeTriggerConfig(campaign.trigger_config).events.length))
|
|
196
|
+
return undefined;
|
|
197
|
+
try {
|
|
198
|
+
// /v1/events stores event_type=custom and title=event. Read every page once per response.
|
|
199
|
+
const rows = await readAllRows(db().from('contact_events').select('id, title')
|
|
200
|
+
.eq('project_id', getProjectId()).eq('event_type', 'custom'));
|
|
201
|
+
return new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
|
|
207
202
|
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function buildCampaignTriggerOutput(campaign, seenEvents) {
|
|
208
208
|
return {
|
|
209
209
|
trigger_kind: campaignTriggerKind(campaign),
|
|
210
210
|
fires_on: buildCampaignFiresOn(campaign),
|
|
211
211
|
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
|
+
async function campaignTriggerOutput(campaign) {
|
|
215
|
+
return buildCampaignTriggerOutput(campaign, await readCampaignTriggerHistory([campaign]));
|
|
216
|
+
}
|
|
214
217
|
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
215
218
|
if (sendAt === undefined)
|
|
216
219
|
return null;
|
|
@@ -780,14 +783,14 @@ export function registerCampaignTools(server) {
|
|
|
780
783
|
// counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
|
|
781
784
|
const rows = data ?? [];
|
|
782
785
|
const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
|
|
786
|
+
const seenEvents = await readCampaignTriggerHistory(rows);
|
|
783
787
|
const withPeople = await Promise.all(rows.map(async (row) => {
|
|
784
788
|
const periods = await readCampaignPeriods(db(), getProjectId(), row);
|
|
785
789
|
return {
|
|
786
790
|
...row,
|
|
787
791
|
reentry_mode: row.period_key ? "per_event_period" : "once_ever",
|
|
788
792
|
requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
|
|
789
|
-
|
|
790
|
-
trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
|
|
793
|
+
...buildCampaignTriggerOutput(row, seenEvents),
|
|
791
794
|
...campaignPeriodFields(periods, period),
|
|
792
795
|
total_sent: periods?.totals?.sent ?? (engagement.ok ? engagement.sent.get(row.id) ?? 0 : null),
|
|
793
796
|
unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
|
|
@@ -1734,14 +1737,27 @@ export function registerCampaignTools(server) {
|
|
|
1734
1737
|
}
|
|
1735
1738
|
const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
|
|
1736
1739
|
const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
|
|
1740
|
+
const configurationFields = [
|
|
1741
|
+
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'send_class',
|
|
1742
|
+
'period_key', 'reentry_mode', 'campaign_step_branches', 'segment_id',
|
|
1743
|
+
'unsub_footer_template', 'campaign_goals', 'topic', 'send_at', 'send_time', 'timezone',
|
|
1744
|
+
'goal_type', 'goal_value', 'goal_action', 'goal_target_campaign_id',
|
|
1745
|
+
];
|
|
1746
|
+
// Keep the exhaustive machine-readable inventory, with important settings first.
|
|
1737
1747
|
const fields_not_copied = [...new Set([
|
|
1748
|
+
...configurationFields.filter(key => key in original || [
|
|
1749
|
+
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
|
|
1750
|
+
'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
|
|
1751
|
+
].includes(key)),
|
|
1738
1752
|
...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
|
|
1739
|
-
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
|
|
1740
|
-
'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
|
|
1741
1753
|
])];
|
|
1754
|
+
const counters = fields_not_copied.filter(key => /^(?:total_|unique_)|_rate$/.test(key));
|
|
1755
|
+
const warnings = fields_not_copied.filter(key => configurationFields.includes(key)).map(field => `${field} was not copied${field === 'trigger_config' ? ' (including where filters)' : ''}; review and configure the draft before activation.`);
|
|
1756
|
+
if (counters.length)
|
|
1757
|
+
warnings.push(`Counters and reporting statistics are reset for the draft: ${counters.join(', ')}.`);
|
|
1742
1758
|
await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
|
|
1743
1759
|
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
|
|
1744
|
-
warnings
|
|
1760
|
+
warnings });
|
|
1745
1761
|
});
|
|
1746
1762
|
server.registerTool('list_sequence_templates', {
|
|
1747
1763
|
title: 'List Sequence Templates',
|
|
@@ -2446,7 +2462,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2446
2462
|
idempotentHint: true,
|
|
2447
2463
|
openWorldHint: false,
|
|
2448
2464
|
},
|
|
2449
|
-
description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists remaining tags. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
|
|
2465
|
+
description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists every remaining {{ opener, including malformed and unclosed tags; supported tags keep key names and malformed tags use literal text. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
|
|
2450
2466
|
inputSchema: {
|
|
2451
2467
|
site_id: z.string().uuid().optional().describe('Site UUID for sender tags and footers'),
|
|
2452
2468
|
campaign_id: z.string().uuid().optional().describe('Campaign UUID; previews its first step in send order. Use step_id to choose a specific step.'),
|
|
@@ -2996,7 +3012,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2996
3012
|
idempotentHint: true,
|
|
2997
3013
|
openWorldHint: false,
|
|
2998
3014
|
},
|
|
2999
|
-
description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
|
|
3015
|
+
description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}} in both subject and body as errors, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
|
|
3000
3016
|
inputSchema: {
|
|
3001
3017
|
html: z.string().describe('HTML email content to validate'),
|
|
3002
3018
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
package/build/tools/contacts.js
CHANGED
|
@@ -1711,7 +1711,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1711
1711
|
idempotentHint: true,
|
|
1712
1712
|
openWorldHint: false,
|
|
1713
1713
|
},
|
|
1714
|
-
description: 'List contacts
|
|
1714
|
+
description: 'List contacts using a saved segment_id or inline filters for engagement score, lifecycle stage, site and company. Saved segment_id and inline filters are mutually exclusive. Useful for building re-engagement campaigns or identifying high-value contacts.',
|
|
1715
1715
|
inputSchema: {
|
|
1716
1716
|
segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
|
|
1717
1717
|
lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
|
|
@@ -7,6 +7,7 @@ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestA
|
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
9
|
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
10
|
+
import { readLatestStoredDomainHealth, storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
|
|
10
11
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
11
12
|
function asRecord(value) {
|
|
12
13
|
return value && typeof value === 'object' ? value : {};
|
|
@@ -146,7 +147,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
146
147
|
idempotentHint: true,
|
|
147
148
|
openWorldHint: false,
|
|
148
149
|
},
|
|
149
|
-
description: 'Returns stored SPF/DKIM/DMARC from the latest health check; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
|
|
150
|
+
description: 'Returns stored SPF/DKIM/DMARC from the latest health check, with verification_source=stored_domain_health and checked_at; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
|
|
150
151
|
inputSchema: { domain_id: z.string().uuid() },
|
|
151
152
|
}, async ({ domain_id }) => {
|
|
152
153
|
const { data, error } = await db().from('domains')
|
|
@@ -158,22 +159,10 @@ export function registerDeliveryOpsTools(server) {
|
|
|
158
159
|
return toolError(dbToolError(error, 'Unable to load domain'));
|
|
159
160
|
if (!data)
|
|
160
161
|
return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
161
|
-
const { data: health, error: healthError } = await
|
|
162
|
-
.select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
|
|
163
|
-
.eq('sites.project_id', getProjectId())
|
|
164
|
-
.eq('sites.domain_id', domain_id)
|
|
165
|
-
.order('check_date', { ascending: false })
|
|
166
|
-
.limit(1)
|
|
167
|
-
.maybeSingle();
|
|
162
|
+
const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain_id);
|
|
168
163
|
if (healthError)
|
|
169
164
|
return toolError(dbToolError(healthError, 'Unable to load domain health'));
|
|
170
|
-
|
|
171
|
-
const dkim = health?.dkim_valid ?? null;
|
|
172
|
-
const dmarc = health?.dmarc_valid ?? null;
|
|
173
|
-
const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
|
|
174
|
-
? Boolean(spf && dkim && dmarc)
|
|
175
|
-
: null;
|
|
176
|
-
return j({ spf, dkim, dmarc, all_verified: allVerified });
|
|
165
|
+
return j(storedDomainDns(health));
|
|
177
166
|
});
|
|
178
167
|
server.registerTool('recheck_domain_dns', {
|
|
179
168
|
title: 'Recheck Domain DNS Live',
|
|
@@ -802,7 +791,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
802
791
|
idempotentHint: true,
|
|
803
792
|
openWorldHint: false,
|
|
804
793
|
},
|
|
805
|
-
description: 'List sending domains with Sendinel readiness (ready true/false/null, reasons, provider_detail). Raw provider values, including EE tracking_status_raw, are labelled detail only
|
|
794
|
+
description: 'List sending domains with stored domain_health DNS observations and Sendinel readiness (ready true/false/null, reasons, provider_detail). Registration status, provider and verified_at remain saved metadata; unknown DNS never implies ready. Raw provider values, including EE tracking_status_raw, are labelled detail only.',
|
|
806
795
|
inputSchema: { site_id: z.string().uuid().optional() },
|
|
807
796
|
}, async ({ site_id }) => {
|
|
808
797
|
const projectId = getProjectId();
|
|
@@ -829,9 +818,20 @@ export function registerDeliveryOpsTools(server) {
|
|
|
829
818
|
return j(dbToolError(error));
|
|
830
819
|
if (!data)
|
|
831
820
|
return j({ error: 'Domain query returned no result' });
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
821
|
+
const domains = [];
|
|
822
|
+
for (const domain of data) {
|
|
823
|
+
const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain.id);
|
|
824
|
+
if (healthError)
|
|
825
|
+
return toolError(dbToolError(healthError, 'Unable to load domain health'));
|
|
826
|
+
const dns = storedDomainDns(health);
|
|
827
|
+
const readiness = computeDomainReadiness(domain);
|
|
828
|
+
const auth = [dns.spf, dns.dkim, dns.dmarc];
|
|
829
|
+
readiness.ready = readiness.ready === false || auth.includes(false) ? false
|
|
830
|
+
: readiness.ready === null || auth.includes(null) ? null : true;
|
|
831
|
+
readiness.reasons.push(...storedDnsReasons(dns));
|
|
832
|
+
domains.push({ ...domain, dns, readiness });
|
|
833
|
+
}
|
|
834
|
+
return j({ domains, count: domains.length });
|
|
835
835
|
});
|
|
836
836
|
server.registerTool('register_domain', {
|
|
837
837
|
title: 'Register Domain',
|
package/build/tools/sites.js
CHANGED
|
@@ -111,7 +111,7 @@ async function createSender({ name, slug, domain_id, local_part, sending_domain,
|
|
|
111
111
|
export function registerSiteTools(server) {
|
|
112
112
|
server.registerTool('set_link_domain', {
|
|
113
113
|
title: 'Set Link Domain',
|
|
114
|
-
description: 'Set a branded address for this site’s email links, such as links.example.com. Returns every DNS record, your DNS provider, setup steps and a plain status. Optionally publish all records through your connected Cloudflare account. One approval covers setup and optional DNS publishing.',
|
|
114
|
+
description: 'Set a branded address for this site’s email links, such as links.example.com. Returns every DNS record, your DNS provider, setup steps and a plain status. When the Vercel integration is absent, returns state: not_connected and a reason without saving settings or publishing DNS. Optionally publish all records through your connected Cloudflare account. One approval covers setup and optional DNS publishing.',
|
|
115
115
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
116
116
|
inputSchema: {
|
|
117
117
|
site_id: z.string().uuid(),
|
|
@@ -125,7 +125,7 @@ export function registerSiteTools(server) {
|
|
|
125
125
|
}));
|
|
126
126
|
server.registerTool('check_link_domain', {
|
|
127
127
|
title: 'Check Link Domain',
|
|
128
|
-
description: 'Check whether this site’s branded email links are live. Returns every DNS record, your DNS provider, setup steps and a plain status. Checks DNS, ownership and the secure connection before enabling links.',
|
|
128
|
+
description: 'Check whether this site’s branded email links are live. Returns every DNS record, your DNS provider, setup steps and a plain status. When the Vercel integration is absent, returns state: not_connected and a reason. Checks DNS, ownership and the secure connection before enabling links.',
|
|
129
129
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
130
130
|
inputSchema: { site_id: z.string().uuid() },
|
|
131
131
|
}, async ({ site_id }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.168",
|
|
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",
|