@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.
- package/build/lib/action-approvals.d.ts +13 -1
- package/build/lib/action-approvals.js +92 -13
- package/build/lib/approval-chat-confirm.js +2 -1
- package/build/lib/approval-tool-decisions.js +4 -2
- package/build/lib/domain-health.d.ts +18 -0
- package/build/lib/domain-health.js +34 -0
- package/build/lib/how-to-recipes.js +8 -8
- package/build/lib/sandbox-audience.js +3 -2
- package/build/server.js +11 -1
- package/build/tools/ab-testing.js +1 -1
- package/build/tools/agent-mailboxes.js +2 -2
- package/build/tools/analytics.js +26 -105
- package/build/tools/approvals.js +6 -5
- package/build/tools/automations.js +2 -2
- package/build/tools/brand-kit.js +1 -1
- package/build/tools/campaign-sets.d.ts +1 -0
- package/build/tools/campaign-sets.js +11 -4
- package/build/tools/campaigns.js +70 -49
- package/build/tools/contacts.js +9 -9
- package/build/tools/content.js +2 -2
- package/build/tools/data.js +4 -4
- package/build/tools/delivery-ops.js +20 -20
- package/build/tools/landing-pages.js +2 -2
- package/build/tools/lists.js +1 -1
- package/build/tools/newsletters.js +3 -3
- package/build/tools/org.js +4 -4
- package/build/tools/segments.js +1 -1
- package/build/tools/signatures.js +1 -1
- package/build/tools/sms.js +4 -4
- package/build/tools/social-posts.js +2 -2
- package/build/tools/social-replies.js +2 -2
- package/build/tools/templates.js +1 -1
- package/build/tools/webhooks.js +3 -3
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -19,7 +19,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
|
|
|
19
19
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
20
20
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
21
21
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
22
|
-
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
22
|
+
import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
23
23
|
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
24
24
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
25
25
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
@@ -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),
|
|
@@ -877,15 +880,15 @@ export function registerCampaignTools(server) {
|
|
|
877
880
|
};
|
|
878
881
|
const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
|
|
879
882
|
if (!reentry.ok)
|
|
880
|
-
return j({ error: reentry.error, code: '
|
|
883
|
+
return j({ error: reentry.error, code: 'validation_failed' });
|
|
881
884
|
if (reentry.period_key !== undefined)
|
|
882
885
|
insertPayload.period_key = reentry.period_key;
|
|
883
886
|
const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
|
|
884
887
|
if (scheduleError)
|
|
885
|
-
return j({ error: scheduleError, code: '
|
|
888
|
+
return j({ error: scheduleError, code: 'validation_failed' });
|
|
886
889
|
const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
887
890
|
if (targetingError)
|
|
888
|
-
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : '
|
|
891
|
+
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
|
|
889
892
|
const { data, error } = await db()
|
|
890
893
|
.from('campaigns')
|
|
891
894
|
.insert(insertPayload)
|
|
@@ -1110,7 +1113,7 @@ export function registerCampaignTools(server) {
|
|
|
1110
1113
|
},
|
|
1111
1114
|
description: 'Enroll a single contact in a campaign',
|
|
1112
1115
|
inputSchema: {
|
|
1113
|
-
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
|
|
1116
|
+
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.'),
|
|
1114
1117
|
campaign_id: z.string().uuid(),
|
|
1115
1118
|
contact_id: z.string().uuid(),
|
|
1116
1119
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
@@ -1233,7 +1236,7 @@ export function registerCampaignTools(server) {
|
|
|
1233
1236
|
},
|
|
1234
1237
|
description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
|
|
1235
1238
|
inputSchema: {
|
|
1236
|
-
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
|
|
1239
|
+
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.'),
|
|
1237
1240
|
campaign_id: z.string().uuid(),
|
|
1238
1241
|
site_id: z.string().uuid(),
|
|
1239
1242
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
@@ -1625,6 +1628,11 @@ export function registerCampaignTools(server) {
|
|
|
1625
1628
|
content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
|
|
1626
1629
|
}
|
|
1627
1630
|
const delivery = await sandboxAudience(projectId, resolved.contactIds);
|
|
1631
|
+
if (delivery.recipient_count === 0) {
|
|
1632
|
+
return j({ error: 'No recipients can be delivered under the sandbox policy. Choose an allowed audience before requesting approval.',
|
|
1633
|
+
code: 'validation_failed', ...delivery,
|
|
1634
|
+
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) } });
|
|
1635
|
+
}
|
|
1628
1636
|
const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
|
|
1629
1637
|
const payload = {
|
|
1630
1638
|
site_id: site.id,
|
|
@@ -1729,14 +1737,27 @@ export function registerCampaignTools(server) {
|
|
|
1729
1737
|
}
|
|
1730
1738
|
const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
|
|
1731
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.
|
|
1732
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)),
|
|
1733
1752
|
...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
|
|
1734
|
-
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
|
|
1735
|
-
'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
|
|
1736
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(', ')}.`);
|
|
1737
1758
|
await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
|
|
1738
1759
|
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
|
|
1739
|
-
warnings
|
|
1760
|
+
warnings });
|
|
1740
1761
|
});
|
|
1741
1762
|
server.registerTool('list_sequence_templates', {
|
|
1742
1763
|
title: 'List Sequence Templates',
|
|
@@ -1995,7 +2016,7 @@ Adapt your tone accordingly:
|
|
|
1995
2016
|
idempotentHint: false,
|
|
1996
2017
|
openWorldHint: true,
|
|
1997
2018
|
},
|
|
1998
|
-
description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with
|
|
2019
|
+
description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with only approval_id to launch them. Nothing is created or sent before approval.',
|
|
1999
2020
|
inputSchema: {
|
|
2000
2021
|
name: z.string().min(1).describe('Campaign name'),
|
|
2001
2022
|
site_id: z.string().uuid(),
|
|
@@ -2428,7 +2449,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2428
2449
|
emails: plan.steps.map((step, i) => ({
|
|
2429
2450
|
step: i, subject: step.subject, body_html: step.body_html, body_text: step.body_text, delay_days: step.delay_days, delay_hours: step.delay_hours,
|
|
2430
2451
|
})),
|
|
2431
|
-
next: '
|
|
2452
|
+
next: approvalNext('launch_campaign', approval) + ' These exact emails are sent; nothing is rewritten. Do not say it launched before that call succeeds.',
|
|
2432
2453
|
});
|
|
2433
2454
|
}
|
|
2434
2455
|
return launchFromPlan(plan);
|
|
@@ -2441,7 +2462,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2441
2462
|
idempotentHint: true,
|
|
2442
2463
|
openWorldHint: false,
|
|
2443
2464
|
},
|
|
2444
|
-
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.',
|
|
2445
2466
|
inputSchema: {
|
|
2446
2467
|
site_id: z.string().uuid().optional().describe('Site UUID for sender tags and footers'),
|
|
2447
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.'),
|
|
@@ -2466,7 +2487,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2466
2487
|
},
|
|
2467
2488
|
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending; suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
|
|
2468
2489
|
inputSchema: {
|
|
2469
|
-
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
|
|
2490
|
+
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.'),
|
|
2470
2491
|
to_email: z.string().email().describe('Email address to send the test to'),
|
|
2471
2492
|
site_id: z.string().uuid().describe('Site UUID (determines from address)'),
|
|
2472
2493
|
step_id: z.string().uuid().optional(),
|
|
@@ -2512,7 +2533,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2512
2533
|
},
|
|
2513
2534
|
description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
|
|
2514
2535
|
inputSchema: {
|
|
2515
|
-
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
|
|
2536
|
+
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.'),
|
|
2516
2537
|
to: z.string().email().describe('Recipient email address'),
|
|
2517
2538
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
2518
2539
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2696,9 +2717,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2696
2717
|
idempotentHint: false,
|
|
2697
2718
|
openWorldHint: true,
|
|
2698
2719
|
},
|
|
2699
|
-
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with
|
|
2720
|
+
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with only approval_id once approved.',
|
|
2700
2721
|
inputSchema: {
|
|
2701
|
-
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
|
|
2722
|
+
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.'),
|
|
2702
2723
|
to: z.string().email().describe('Recipient email address'),
|
|
2703
2724
|
site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
|
|
2704
2725
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2991,7 +3012,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2991
3012
|
idempotentHint: true,
|
|
2992
3013
|
openWorldHint: false,
|
|
2993
3014
|
},
|
|
2994
|
-
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.',
|
|
2995
3016
|
inputSchema: {
|
|
2996
3017
|
html: z.string().describe('HTML email content to validate'),
|
|
2997
3018
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
|
@@ -3188,20 +3209,20 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3188
3209
|
if (footerTemplate !== null) {
|
|
3189
3210
|
const footerError = validateUnsubFooterTemplate(footerTemplate);
|
|
3190
3211
|
if (footerError)
|
|
3191
|
-
return j({ error: footerError, code: '
|
|
3212
|
+
return j({ error: footerError, code: 'validation_failed' });
|
|
3192
3213
|
}
|
|
3193
3214
|
const sendClass = send_class ?? 'broadcast';
|
|
3194
3215
|
const targeting = {};
|
|
3195
3216
|
const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
|
|
3196
3217
|
const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
|
|
3197
3218
|
if (targetingError)
|
|
3198
|
-
return j({ error: targetingError, code: '
|
|
3219
|
+
return j({ error: targetingError, code: 'validation_failed' });
|
|
3199
3220
|
const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
|
|
3200
3221
|
const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
|
|
3201
3222
|
const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
|
|
3202
3223
|
const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type: campaignType, trigger_type: targeting.trigger_type });
|
|
3203
3224
|
if (!reentry.ok)
|
|
3204
|
-
return j({ error: reentry.error, code: '
|
|
3225
|
+
return j({ error: reentry.error, code: 'validation_failed' });
|
|
3205
3226
|
if (reentry.period_key !== undefined)
|
|
3206
3227
|
targeting.period_key = reentry.period_key;
|
|
3207
3228
|
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
@@ -3512,7 +3533,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3512
3533
|
openWorldHint: false,
|
|
3513
3534
|
},
|
|
3514
3535
|
description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
|
|
3515
|
-
inputSchema: { 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
|
|
3536
|
+
inputSchema: { 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.'), step_id: z.string().uuid() },
|
|
3516
3537
|
}, withApproval('delete_campaign_step', async ({ step_id }) => {
|
|
3517
3538
|
const projectId = getProjectId();
|
|
3518
3539
|
const { data: step } = await db()
|
|
@@ -3550,7 +3571,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3550
3571
|
openWorldHint: false,
|
|
3551
3572
|
},
|
|
3552
3573
|
description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
|
|
3553
|
-
inputSchema: { 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
|
|
3574
|
+
inputSchema: { 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.'), campaign_id: z.string().uuid() },
|
|
3554
3575
|
}, withApproval('delete_campaign', async ({ campaign_id }) => {
|
|
3555
3576
|
const projectId = getProjectId();
|
|
3556
3577
|
const { data: campaign, error: fetchErr } = await db()
|
|
@@ -3593,7 +3614,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3593
3614
|
return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
|
|
3594
3615
|
}
|
|
3595
3616
|
if (!args.campaign_id)
|
|
3596
|
-
return j({ error: 'campaign_id is required', code: '
|
|
3617
|
+
return j({ error: 'campaign_id is required', code: 'validation_failed' });
|
|
3597
3618
|
const projectId = getProjectId();
|
|
3598
3619
|
const { data: existing, error: loadError } = await db().from('campaigns')
|
|
3599
3620
|
.select(TRIGGER_EDIT_SELECT)
|
|
@@ -3608,14 +3629,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3608
3629
|
const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config', 'reentry_mode', 'period_key']
|
|
3609
3630
|
.some((key) => args[key] !== undefined);
|
|
3610
3631
|
if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
|
|
3611
|
-
return j({ error: 'Provide trigger fields or clear=true, exclusively', code: '
|
|
3632
|
+
return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'validation_failed' });
|
|
3612
3633
|
}
|
|
3613
3634
|
const updates = {};
|
|
3614
3635
|
const targetingError = await applyCampaignTargetingFields(updates, args.clear
|
|
3615
3636
|
? { trigger_type: 'manual', trigger_event_name: null, trigger_tag: null, trigger_config: null }
|
|
3616
3637
|
: args, projectId, existing);
|
|
3617
3638
|
if (targetingError)
|
|
3618
|
-
return j({ error: targetingError, code: '
|
|
3639
|
+
return j({ error: targetingError, code: 'validation_failed' });
|
|
3619
3640
|
const normalized = normalizeTriggerConfig(updates.trigger_config);
|
|
3620
3641
|
if (existing.status === 'draft' && existing.type === 'sequence'
|
|
3621
3642
|
&& (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
|
|
@@ -3626,7 +3647,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3626
3647
|
trigger_type: (updates.trigger_type ?? existing.trigger_type),
|
|
3627
3648
|
});
|
|
3628
3649
|
if (!reentry.ok)
|
|
3629
|
-
return j({ error: reentry.error, code: '
|
|
3650
|
+
return j({ error: reentry.error, code: 'validation_failed' });
|
|
3630
3651
|
if (reentry.period_key !== undefined)
|
|
3631
3652
|
updates.period_key = reentry.period_key;
|
|
3632
3653
|
const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
|
|
@@ -3670,11 +3691,11 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3670
3691
|
}
|
|
3671
3692
|
const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
|
|
3672
3693
|
if (!campaign_id)
|
|
3673
|
-
return j({ error: 'campaign_id is required', code: '
|
|
3694
|
+
return j({ error: 'campaign_id is required', code: 'validation_failed' });
|
|
3674
3695
|
if (unsub_footer_template !== undefined && unsub_footer_template !== null) {
|
|
3675
3696
|
const footerError = validateUnsubFooterTemplate(unsub_footer_template);
|
|
3676
3697
|
if (footerError)
|
|
3677
|
-
return j({ error: footerError, code: '
|
|
3698
|
+
return j({ error: footerError, code: 'validation_failed' });
|
|
3678
3699
|
}
|
|
3679
3700
|
const projectId = getProjectId();
|
|
3680
3701
|
let campaignType;
|
|
@@ -3709,9 +3730,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3709
3730
|
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3710
3731
|
if (type !== existing.type) {
|
|
3711
3732
|
if (existing.status !== 'draft')
|
|
3712
|
-
return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: '
|
|
3733
|
+
return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: 'validation_failed' });
|
|
3713
3734
|
if (existing.type !== 'sequence' && existing.type !== 'triggered')
|
|
3714
|
-
return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: '
|
|
3735
|
+
return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'validation_failed' });
|
|
3715
3736
|
}
|
|
3716
3737
|
}
|
|
3717
3738
|
const hasTriggerUpdate = reentry_mode !== undefined || period_key !== undefined || trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
|
|
@@ -3733,7 +3754,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3733
3754
|
updates.type = type;
|
|
3734
3755
|
const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
|
|
3735
3756
|
if (scheduleError)
|
|
3736
|
-
return j({ error: scheduleError, code: '
|
|
3757
|
+
return j({ error: scheduleError, code: 'validation_failed' });
|
|
3737
3758
|
if (topic !== undefined)
|
|
3738
3759
|
updates.topic = topic;
|
|
3739
3760
|
if (sends_per_hour !== undefined)
|
|
@@ -3746,7 +3767,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3746
3767
|
updates.unsub_footer_template = unsub_footer_template?.trim() ?? null;
|
|
3747
3768
|
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
|
|
3748
3769
|
if (targetingError)
|
|
3749
|
-
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : '
|
|
3770
|
+
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
|
|
3750
3771
|
// If type was not explicitly changed, but this update sets an event trigger on a draft sequence,
|
|
3751
3772
|
// normalize type to 'triggered' so that event intake matches and enrolls it (#3852).
|
|
3752
3773
|
if (type === undefined && existingCampaignForTargeting?.type === 'sequence' && existingCampaignForTargeting?.status === 'draft') {
|
|
@@ -3762,7 +3783,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3762
3783
|
trigger_type: (updates.trigger_type ?? existingCampaignForTargeting?.trigger_type),
|
|
3763
3784
|
});
|
|
3764
3785
|
if (!reentry.ok)
|
|
3765
|
-
return j({ error: reentry.error, code: '
|
|
3786
|
+
return j({ error: reentry.error, code: 'validation_failed' });
|
|
3766
3787
|
if (reentry.period_key !== undefined)
|
|
3767
3788
|
updates.period_key = reentry.period_key;
|
|
3768
3789
|
let data;
|
package/build/tools/contacts.js
CHANGED
|
@@ -607,7 +607,7 @@ export function registerContactTools(server) {
|
|
|
607
607
|
description: 'Record a contact event through POST /api/v1/events and enroll matching active triggered campaigns. Can start real sends; requires standard approval. Returns the REST outcome and sendability unchanged. Does not subscribe the contact or update contact properties.',
|
|
608
608
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
609
609
|
inputSchema: {
|
|
610
|
-
approval_id: z.string().uuid().optional().describe('Call once to request approval, then
|
|
610
|
+
approval_id: z.string().uuid().optional().describe('Call once to request approval, then call again with only approval_id.'),
|
|
611
611
|
email: z.string().email(),
|
|
612
612
|
event: z.string().trim().min(1),
|
|
613
613
|
data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
@@ -659,7 +659,7 @@ export function registerContactTools(server) {
|
|
|
659
659
|
description: 'Identify or update a contact through the v1 identify API. Optional signup/customer consent subscribes new or existing contacts without overriding opt-outs. Customer consent requires a topic; consent requires site_id. Without consent, site_id retains legacy create-only subscription behavior and returns a warning.',
|
|
660
660
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
661
661
|
inputSchema: {
|
|
662
|
-
approval_id: z.string().uuid().optional().describe('Call once to request approval, then
|
|
662
|
+
approval_id: z.string().uuid().optional().describe('Call once to request approval, then call again with only approval_id.'),
|
|
663
663
|
email: z.string().email(),
|
|
664
664
|
first_name: z.string().optional(),
|
|
665
665
|
last_name: z.string().optional(),
|
|
@@ -1212,7 +1212,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1212
1212
|
},
|
|
1213
1213
|
description: 'Delete a saved subscriber view owned by the current project.',
|
|
1214
1214
|
inputSchema: {
|
|
1215
|
-
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
|
|
1215
|
+
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.'),
|
|
1216
1216
|
view_id: z.string().uuid(),
|
|
1217
1217
|
},
|
|
1218
1218
|
}, withApproval('delete_subscriber_view', async ({ view_id }) => {
|
|
@@ -1461,7 +1461,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1461
1461
|
},
|
|
1462
1462
|
description: 'Bulk import contacts and subscribe them to a site. Max 200 per call.',
|
|
1463
1463
|
inputSchema: {
|
|
1464
|
-
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
|
|
1464
|
+
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.'),
|
|
1465
1465
|
contacts: z.array(z.object({
|
|
1466
1466
|
email: z.string().email(),
|
|
1467
1467
|
first_name: z.string().optional(),
|
|
@@ -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(),
|
|
@@ -2222,7 +2222,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2222
2222
|
},
|
|
2223
2223
|
description: 'Merge custom_properties into many contacts at once. Same merge semantics as update_subscriber, applied across a batch.',
|
|
2224
2224
|
inputSchema: {
|
|
2225
|
-
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
|
|
2225
|
+
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.'),
|
|
2226
2226
|
contact_ids: z.array(z.string().uuid()).min(1),
|
|
2227
2227
|
custom_properties: z.record(z.string(), z.unknown()).describe('Merged into each contact\'s existing custom_properties; a key set to null deletes that key'),
|
|
2228
2228
|
},
|
|
@@ -2521,9 +2521,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2521
2521
|
idempotentHint: false,
|
|
2522
2522
|
openWorldHint: false,
|
|
2523
2523
|
},
|
|
2524
|
-
description: 'Remove an email address from the suppression list. Requires human approval: the first call returns an approve_url and does nothing; call again with
|
|
2524
|
+
description: 'Remove an email address from the suppression list. Requires human approval: the first call returns an approve_url and does nothing; call again with only approval_id once approved.',
|
|
2525
2525
|
inputSchema: {
|
|
2526
|
-
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
|
|
2526
|
+
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.'),
|
|
2527
2527
|
email: z.string().email(),
|
|
2528
2528
|
},
|
|
2529
2529
|
}, withApproval('remove_suppression', async ({ email }) => {
|
|
@@ -2907,7 +2907,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2907
2907
|
title: 'Delete Custom Field',
|
|
2908
2908
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
2909
2909
|
description: 'Delete a custom field definition without deleting existing contact values stored under that key.',
|
|
2910
|
-
inputSchema: { 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
|
|
2910
|
+
inputSchema: { 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.'), id: z.string().uuid() },
|
|
2911
2911
|
}, withApproval('delete_custom_field', async ({ id }) => {
|
|
2912
2912
|
const projectId = getProjectId();
|
|
2913
2913
|
const { data, error } = await db()
|
package/build/tools/content.js
CHANGED
|
@@ -259,7 +259,7 @@ export function registerContentTools(server) {
|
|
|
259
259
|
openWorldHint: false,
|
|
260
260
|
},
|
|
261
261
|
description: 'Delete a reusable content block.',
|
|
262
|
-
inputSchema: { 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
|
|
262
|
+
inputSchema: { 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.'), block_id: z.string().uuid() },
|
|
263
263
|
}, withApproval('delete_content_block', async ({ block_id }) => {
|
|
264
264
|
const { error } = await db().from('content_blocks').delete().eq('project_id', getProjectId()).eq('id', block_id);
|
|
265
265
|
if (error)
|
|
@@ -319,7 +319,7 @@ export function registerContentTools(server) {
|
|
|
319
319
|
},
|
|
320
320
|
description: 'Ingest a blog post (written by your AI/agent) into Sendinel. By default it lands as a draft in the review queue. For a trusted, hands-off flow, set auto_publish=true with publish_to (a destination name or id from list_blog_destinations) to skip review and publish to the user\'s own site — now, or at scheduled_at. Only title is required; everything else is optional.',
|
|
321
321
|
inputSchema: {
|
|
322
|
-
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
|
|
322
|
+
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.'),
|
|
323
323
|
title: z.string().min(1).max(300),
|
|
324
324
|
body: z.string().optional(),
|
|
325
325
|
excerpt: z.string().max(1000).optional(),
|
package/build/tools/data.js
CHANGED
|
@@ -563,7 +563,7 @@ export function registerDataTools(server) {
|
|
|
563
563
|
},
|
|
564
564
|
description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
|
|
565
565
|
inputSchema: {
|
|
566
|
-
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
|
|
566
|
+
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.'),
|
|
567
567
|
org_id: z.string().uuid().optional(),
|
|
568
568
|
platform: z.enum(CONNECTED_PLATFORMS).optional(),
|
|
569
569
|
connection_id: z.string().uuid().optional(),
|
|
@@ -980,7 +980,7 @@ export function registerDataTools(server) {
|
|
|
980
980
|
},
|
|
981
981
|
description: 'Create an API key and return the secret once.',
|
|
982
982
|
inputSchema: {
|
|
983
|
-
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
|
|
983
|
+
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.'),
|
|
984
984
|
name: z.string().min(1),
|
|
985
985
|
slug: z.string().optional(),
|
|
986
986
|
scopes: z.array(z.string()).optional().default(['read']),
|
|
@@ -1010,7 +1010,7 @@ export function registerDataTools(server) {
|
|
|
1010
1010
|
openWorldHint: false,
|
|
1011
1011
|
},
|
|
1012
1012
|
description: 'Revoke an API key.',
|
|
1013
|
-
inputSchema: { 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
|
|
1013
|
+
inputSchema: { 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.'), key_id: z.string().uuid() },
|
|
1014
1014
|
}, withApproval('revoke_api_key', async ({ key_id }) => {
|
|
1015
1015
|
const { error } = await db().from('api_keys').update({ revoked_at: new Date().toISOString() }).eq('project_id', getProjectId()).eq('id', key_id);
|
|
1016
1016
|
if (error)
|
|
@@ -1198,7 +1198,7 @@ export function registerDataTools(server) {
|
|
|
1198
1198
|
},
|
|
1199
1199
|
description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
|
|
1200
1200
|
inputSchema: {
|
|
1201
|
-
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
|
|
1201
|
+
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.'),
|
|
1202
1202
|
siteId: z.string().uuid(),
|
|
1203
1203
|
format: z.enum(['json', 'csv']).optional().default('json'),
|
|
1204
1204
|
},
|
|
@@ -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',
|
|
@@ -546,7 +535,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
546
535
|
},
|
|
547
536
|
description: 'Re-enter dead-lettered campaign enrollments into active sending, which can cause real emails to send on the next queue tick. Call once without confirmed to preview the matched count, then call again with confirmed=true to replay them.',
|
|
548
537
|
inputSchema: {
|
|
549
|
-
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
|
|
538
|
+
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.'),
|
|
550
539
|
site_id: z.string().uuid(),
|
|
551
540
|
max_count: z.number().int().min(1).max(1000).default(100).optional(),
|
|
552
541
|
failed_after: z.string().datetime().optional(),
|
|
@@ -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',
|
|
@@ -172,7 +172,7 @@ export function registerLandingPageTools(server) {
|
|
|
172
172
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
173
173
|
description: 'Update a landing page. Setting status to published stamps published_at; pass site_id or form_id as null to clear it.',
|
|
174
174
|
inputSchema: {
|
|
175
|
-
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
|
|
175
|
+
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.'),
|
|
176
176
|
page_id: z.string().uuid(),
|
|
177
177
|
title: z.string().trim().min(1).max(200).optional(),
|
|
178
178
|
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
@@ -265,7 +265,7 @@ export function registerLandingPageTools(server) {
|
|
|
265
265
|
title: 'Delete Landing Page',
|
|
266
266
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
267
267
|
description: 'Permanently delete a landing page. Set status to archived instead to unpublish without deleting.',
|
|
268
|
-
inputSchema: { 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
|
|
268
|
+
inputSchema: { 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.'), page_id: z.string().uuid() },
|
|
269
269
|
}, withApproval('delete_landing_page', async ({ page_id }) => {
|
|
270
270
|
const projectId = getProjectId();
|
|
271
271
|
const { error } = await db()
|
package/build/tools/lists.js
CHANGED
|
@@ -186,7 +186,7 @@ export function registerListTools(server) {
|
|
|
186
186
|
title: 'Delete Contact List',
|
|
187
187
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
188
188
|
description: 'Permanently delete a static contact list and its memberships.',
|
|
189
|
-
inputSchema: { 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
|
|
189
|
+
inputSchema: { 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.'), list_id: z.string().uuid() },
|
|
190
190
|
}, withApproval('delete_list', async ({ list_id }) => {
|
|
191
191
|
const projectId = getProjectId();
|
|
192
192
|
const { error } = await db()
|