@sendinel/mcp-server 1.0.10 → 1.0.12
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/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/advisor.js +3 -3
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +105 -8
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +349 -60
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data-proposals.js +1 -1
- package/build/tools/data.js +448 -4
- package/build/tools/delivery-ops.js +525 -7
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +565 -35
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +13 -12
|
@@ -1,8 +1,138 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import { cancelSendQueueRows, findReplayableEnrollmentDlqRows, listSendQueueRows, replayEnrollmentDlqRows, retrySendQueueRows, } from '@sendinel/domain';
|
|
4
|
+
import { logAudit } from '../audit.js';
|
|
5
|
+
import { adminDb, db } from '../db.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
3
8
|
import { getProjectId } from '../project.js';
|
|
4
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return value && typeof value === 'object' ? value : {};
|
|
12
|
+
}
|
|
13
|
+
function v1ErrorMessage(value) {
|
|
14
|
+
const body = asRecord(value);
|
|
15
|
+
return asRecord(body.error).message ?? body.error ?? value;
|
|
16
|
+
}
|
|
17
|
+
async function resolveNameservers(domain) {
|
|
18
|
+
const labels = domain.toLowerCase().split('.').filter(Boolean);
|
|
19
|
+
for (let index = 0; index < labels.length - 1; index += 1) {
|
|
20
|
+
const candidate = labels.slice(index).join('.');
|
|
21
|
+
try {
|
|
22
|
+
return { nameservers: await dns.resolveNs(candidate), zone: candidate };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Try the next parent domain.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { nameservers: [], zone: null };
|
|
29
|
+
}
|
|
30
|
+
function detectDnsProvider(nameservers) {
|
|
31
|
+
const normalized = nameservers.map((value) => value.toLowerCase());
|
|
32
|
+
if (normalized.some((value) => value.endsWith('.ns.cloudflare.com')))
|
|
33
|
+
return 'cloudflare';
|
|
34
|
+
if (normalized.some((value) => value.includes('awsdns')))
|
|
35
|
+
return 'route53';
|
|
36
|
+
if (normalized.some((value) => value.includes('googledomains') || value.includes('google')))
|
|
37
|
+
return 'google';
|
|
38
|
+
if (normalized.some((value) => value.includes('domaincontrol.com')))
|
|
39
|
+
return 'godaddy';
|
|
40
|
+
if (normalized.some((value) => value.includes('registrar-servers.com') || value.includes('namecheap')))
|
|
41
|
+
return 'namecheap';
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
async function getOrProvisionDmarcInboundToken(projectId) {
|
|
45
|
+
const sb = adminDb();
|
|
46
|
+
const { data, error } = await sb.schema('email').from('projects')
|
|
47
|
+
.select('dmarc_inbound_token').eq('id', projectId).single();
|
|
48
|
+
if (error)
|
|
49
|
+
throw new Error(error.message);
|
|
50
|
+
let token = data?.dmarc_inbound_token;
|
|
51
|
+
if (!token) {
|
|
52
|
+
token = crypto.randomUUID().replace(/-/g, '').slice(0, 24);
|
|
53
|
+
const { error: updateError } = await sb.schema('email').from('projects')
|
|
54
|
+
.update({ dmarc_inbound_token: token }).eq('id', projectId);
|
|
55
|
+
if (updateError) {
|
|
56
|
+
const { data: retry } = await sb.schema('email').from('projects')
|
|
57
|
+
.select('dmarc_inbound_token').eq('id', projectId).single();
|
|
58
|
+
token = retry?.dmarc_inbound_token ?? token;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!token)
|
|
62
|
+
throw new Error('Unable to provision DMARC inbound token');
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
5
65
|
export function registerDeliveryOpsTools(server) {
|
|
66
|
+
server.registerTool('list_blackout_dates', {
|
|
67
|
+
title: 'List Blackout Dates',
|
|
68
|
+
annotations: {
|
|
69
|
+
readOnlyHint: true,
|
|
70
|
+
destructiveHint: false,
|
|
71
|
+
idempotentHint: true,
|
|
72
|
+
openWorldHint: false,
|
|
73
|
+
},
|
|
74
|
+
description: 'List sending blackout dates for this project. The send-enqueue pipeline skips a due send when the contact-local date matches one of these dates.',
|
|
75
|
+
inputSchema: {},
|
|
76
|
+
}, async () => {
|
|
77
|
+
const { data, error } = await db()
|
|
78
|
+
.from('blackout_dates')
|
|
79
|
+
.select('id, date, reason, created_at')
|
|
80
|
+
.eq('project_id', getProjectId())
|
|
81
|
+
.order('date', { ascending: true });
|
|
82
|
+
if (error)
|
|
83
|
+
return j({ error: error.message });
|
|
84
|
+
return j({ blackout_dates: data ?? [] });
|
|
85
|
+
});
|
|
86
|
+
server.registerTool('add_blackout_date', {
|
|
87
|
+
title: 'Add Blackout Date',
|
|
88
|
+
annotations: {
|
|
89
|
+
readOnlyHint: false,
|
|
90
|
+
destructiveHint: false,
|
|
91
|
+
idempotentHint: true,
|
|
92
|
+
openWorldHint: false,
|
|
93
|
+
},
|
|
94
|
+
description: 'Mark a date as a sending blackout for this project. Calling this again for the same date updates its reason. Due sends are skipped for the contact-local blackout date, not cancelled.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
date: z.string().describe('Date in YYYY-MM-DD format, for example 2026-12-25'),
|
|
97
|
+
reason: z.string().optional().describe('Optional human-readable reason'),
|
|
98
|
+
},
|
|
99
|
+
}, async ({ date, reason }) => {
|
|
100
|
+
if (Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) {
|
|
101
|
+
return j({ error: 'date must be a valid YYYY-MM-DD date' });
|
|
102
|
+
}
|
|
103
|
+
const { data, error } = await db()
|
|
104
|
+
.from('blackout_dates')
|
|
105
|
+
.upsert({ project_id: getProjectId(), date, reason: reason?.trim() || null }, { onConflict: 'project_id,date' })
|
|
106
|
+
.select('id, date, reason, created_at')
|
|
107
|
+
.single();
|
|
108
|
+
if (error)
|
|
109
|
+
return j({ error: error.message });
|
|
110
|
+
return j({ blackout_date: data });
|
|
111
|
+
});
|
|
112
|
+
server.registerTool('remove_blackout_date', {
|
|
113
|
+
title: 'Remove Blackout Date',
|
|
114
|
+
annotations: {
|
|
115
|
+
readOnlyHint: false,
|
|
116
|
+
destructiveHint: true,
|
|
117
|
+
idempotentHint: false,
|
|
118
|
+
openWorldHint: false,
|
|
119
|
+
},
|
|
120
|
+
description: 'Remove a sending blackout date for this project by id. Use list_blackout_dates first to find the id.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
id: z.string().uuid(),
|
|
123
|
+
},
|
|
124
|
+
}, async ({ id }) => {
|
|
125
|
+
const { error, count } = await db()
|
|
126
|
+
.from('blackout_dates')
|
|
127
|
+
.delete({ count: 'exact' })
|
|
128
|
+
.eq('id', id)
|
|
129
|
+
.eq('project_id', getProjectId());
|
|
130
|
+
if (error)
|
|
131
|
+
return j({ error: error.message });
|
|
132
|
+
if (!count)
|
|
133
|
+
return j({ error: 'Blackout date not found', code: 'NOT_FOUND' });
|
|
134
|
+
return j({ removed: true, id });
|
|
135
|
+
});
|
|
6
136
|
server.registerTool('refresh_domain_dns', {
|
|
7
137
|
title: 'Refresh Domain DNS',
|
|
8
138
|
annotations: {
|
|
@@ -15,7 +145,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
15
145
|
inputSchema: { domain_id: z.string().uuid() },
|
|
16
146
|
}, async ({ domain_id }) => {
|
|
17
147
|
const { data, error } = await db().from('domains')
|
|
18
|
-
.select('id
|
|
148
|
+
.select('id')
|
|
19
149
|
.eq('project_id', getProjectId())
|
|
20
150
|
.eq('id', domain_id)
|
|
21
151
|
.maybeSingle();
|
|
@@ -23,11 +153,22 @@ export function registerDeliveryOpsTools(server) {
|
|
|
23
153
|
return j({ error: error.message });
|
|
24
154
|
if (!data)
|
|
25
155
|
return j({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
156
|
+
const { data: health, error: healthError } = await db().from('domain_health')
|
|
157
|
+
.select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
|
|
158
|
+
.eq('project_id', getProjectId())
|
|
159
|
+
.eq('sites.domain_id', domain_id)
|
|
160
|
+
.order('check_date', { ascending: false })
|
|
161
|
+
.limit(1)
|
|
162
|
+
.maybeSingle();
|
|
163
|
+
if (healthError)
|
|
164
|
+
return j({ error: healthError.message });
|
|
165
|
+
const spf = health?.spf_valid ?? null;
|
|
166
|
+
const dkim = health?.dkim_valid ?? null;
|
|
167
|
+
const dmarc = health?.dmarc_valid ?? null;
|
|
168
|
+
const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
|
|
169
|
+
? Boolean(spf && dkim && dmarc)
|
|
170
|
+
: null;
|
|
171
|
+
return j({ spf, dkim, dmarc, all_verified: allVerified });
|
|
31
172
|
});
|
|
32
173
|
server.registerTool('dnsbl_check', {
|
|
33
174
|
title: 'DNSBL Check',
|
|
@@ -228,6 +369,199 @@ export function registerDeliveryOpsTools(server) {
|
|
|
228
369
|
return j({ error: error.message });
|
|
229
370
|
return j({ dlq: data ?? [], count: data?.length ?? 0 });
|
|
230
371
|
});
|
|
372
|
+
server.registerTool('get_enrollment_dlq', {
|
|
373
|
+
title: 'Get Enrollment DLQ',
|
|
374
|
+
annotations: {
|
|
375
|
+
readOnlyHint: true,
|
|
376
|
+
destructiveHint: false,
|
|
377
|
+
idempotentHint: true,
|
|
378
|
+
openWorldHint: false,
|
|
379
|
+
},
|
|
380
|
+
description: 'List campaign enrollments that failed every retry and stopped sending. Use replay_enrollment_dlq_rows to preview and re-enter them into active sending.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
site_id: z.string().uuid().optional(),
|
|
383
|
+
limit: z.number().int().min(1).max(50).default(20).optional(),
|
|
384
|
+
unresolved_only: z.boolean().default(true).optional(),
|
|
385
|
+
},
|
|
386
|
+
}, async ({ site_id, limit = 20, unresolved_only = true }) => {
|
|
387
|
+
const projectId = getProjectId();
|
|
388
|
+
let query = db().from('enrollment_dlq')
|
|
389
|
+
.select('id, enrollment_id, campaign_id, contact_id, enrollment_snapshot, final_error, attempts, created_at, resolved, resolved_at')
|
|
390
|
+
.eq('project_id', projectId)
|
|
391
|
+
.order('created_at', { ascending: false })
|
|
392
|
+
.limit(limit);
|
|
393
|
+
if (site_id)
|
|
394
|
+
query = query.eq('site_id', site_id);
|
|
395
|
+
if (unresolved_only)
|
|
396
|
+
query = query.eq('resolved', false);
|
|
397
|
+
const { data, error } = await query;
|
|
398
|
+
if (error)
|
|
399
|
+
return j({ error: error.message });
|
|
400
|
+
return j({ dlq: data ?? [], count: data?.length ?? 0 });
|
|
401
|
+
});
|
|
402
|
+
server.registerTool('get_send_queue_rows', {
|
|
403
|
+
title: 'Get Send Queue Rows',
|
|
404
|
+
annotations: {
|
|
405
|
+
readOnlyHint: true,
|
|
406
|
+
destructiveHint: false,
|
|
407
|
+
idempotentHint: true,
|
|
408
|
+
openWorldHint: false,
|
|
409
|
+
},
|
|
410
|
+
description: 'List individual queued sends with status, attempts, the latest error, and next retry time. Use this when aggregate queue counts do not explain which recipients are still waiting.',
|
|
411
|
+
inputSchema: {
|
|
412
|
+
site_id: z.string().uuid().optional(),
|
|
413
|
+
campaign_id: z.string().uuid().optional(),
|
|
414
|
+
status: z.array(z.enum(['pending', 'processing', 'retry'])).optional(),
|
|
415
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
416
|
+
},
|
|
417
|
+
}, async ({ site_id, campaign_id, status, limit = 50 }) => {
|
|
418
|
+
try {
|
|
419
|
+
const { rows } = await listSendQueueRows({
|
|
420
|
+
projectId: getProjectId(),
|
|
421
|
+
siteId: site_id,
|
|
422
|
+
campaignId: campaign_id,
|
|
423
|
+
status,
|
|
424
|
+
limit,
|
|
425
|
+
}, db());
|
|
426
|
+
return j({ rows, count: rows.length });
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
return j({ error: error instanceof Error ? error.message : 'Queue query failed' });
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
server.registerTool('retry_send_queue_rows', {
|
|
433
|
+
title: 'Retry Send Queue Rows',
|
|
434
|
+
annotations: {
|
|
435
|
+
readOnlyHint: false,
|
|
436
|
+
destructiveHint: false,
|
|
437
|
+
idempotentHint: true,
|
|
438
|
+
openWorldHint: false,
|
|
439
|
+
},
|
|
440
|
+
description: 'Make selected unclaimed retry rows eligible for the next processing tick without inserting another queue row or resetting attempt limits.',
|
|
441
|
+
inputSchema: {
|
|
442
|
+
site_id: z.string().uuid(),
|
|
443
|
+
campaign_id: z.string().uuid().optional(),
|
|
444
|
+
queue_ids: z.array(z.string().uuid()).min(1).max(200).optional(),
|
|
445
|
+
},
|
|
446
|
+
}, async ({ site_id, campaign_id, queue_ids }) => {
|
|
447
|
+
try {
|
|
448
|
+
const { retriedCount } = await retrySendQueueRows({
|
|
449
|
+
projectId: getProjectId(),
|
|
450
|
+
siteId: site_id,
|
|
451
|
+
campaignId: campaign_id,
|
|
452
|
+
queueIds: queue_ids,
|
|
453
|
+
}, db());
|
|
454
|
+
await logAudit({
|
|
455
|
+
action: 'send_queue.retry',
|
|
456
|
+
resourceType: 'site',
|
|
457
|
+
resourceId: site_id,
|
|
458
|
+
details: { retried_count: retriedCount, campaign_id: campaign_id ?? null },
|
|
459
|
+
});
|
|
460
|
+
return j({ retried_count: retriedCount });
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
return j({ error: error instanceof Error ? error.message : 'Queue retry failed' });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
server.registerTool('cancel_send_queue_rows', {
|
|
467
|
+
title: 'Cancel Send Queue Rows',
|
|
468
|
+
annotations: {
|
|
469
|
+
readOnlyHint: false,
|
|
470
|
+
destructiveHint: true,
|
|
471
|
+
idempotentHint: true,
|
|
472
|
+
openWorldHint: false,
|
|
473
|
+
},
|
|
474
|
+
description: 'Cancel selected unclaimed pending or retry rows, mark their queued email logs failed, and release their enrollment claims. Rows held by a processor are not changed.',
|
|
475
|
+
inputSchema: {
|
|
476
|
+
site_id: z.string().uuid(),
|
|
477
|
+
campaign_id: z.string().uuid().optional(),
|
|
478
|
+
queue_ids: z.array(z.string().uuid()).min(1).max(200).optional(),
|
|
479
|
+
},
|
|
480
|
+
}, async ({ site_id, campaign_id, queue_ids }) => {
|
|
481
|
+
try {
|
|
482
|
+
const { cancelledCount } = await cancelSendQueueRows({
|
|
483
|
+
projectId: getProjectId(),
|
|
484
|
+
siteId: site_id,
|
|
485
|
+
campaignId: campaign_id,
|
|
486
|
+
queueIds: queue_ids,
|
|
487
|
+
}, db());
|
|
488
|
+
await logAudit({
|
|
489
|
+
action: 'send_queue.cancel',
|
|
490
|
+
resourceType: 'site',
|
|
491
|
+
resourceId: site_id,
|
|
492
|
+
details: { cancelled_count: cancelledCount, campaign_id: campaign_id ?? null },
|
|
493
|
+
});
|
|
494
|
+
return j({ cancelled_count: cancelledCount });
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
return j({ error: error instanceof Error ? error.message : 'Queue cancellation failed' });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
server.registerTool('replay_enrollment_dlq_rows', {
|
|
501
|
+
title: 'Replay Enrollment DLQ Rows',
|
|
502
|
+
annotations: {
|
|
503
|
+
readOnlyHint: false,
|
|
504
|
+
destructiveHint: false,
|
|
505
|
+
idempotentHint: false,
|
|
506
|
+
openWorldHint: false,
|
|
507
|
+
},
|
|
508
|
+
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.',
|
|
509
|
+
inputSchema: {
|
|
510
|
+
site_id: z.string().uuid(),
|
|
511
|
+
max_count: z.number().int().min(1).max(1000).default(100).optional(),
|
|
512
|
+
failed_after: z.string().datetime().optional(),
|
|
513
|
+
confirmed: z.boolean().optional().default(false)
|
|
514
|
+
.describe('Two-call confirmation. First call previews the matched rows; confirmed=true replays them.'),
|
|
515
|
+
},
|
|
516
|
+
}, async ({ site_id, max_count = 100, failed_after, confirmed = false }) => {
|
|
517
|
+
const projectId = getProjectId();
|
|
518
|
+
if (!confirmed) {
|
|
519
|
+
try {
|
|
520
|
+
const { rows } = await findReplayableEnrollmentDlqRows({
|
|
521
|
+
projectId,
|
|
522
|
+
siteId: site_id,
|
|
523
|
+
maxCount: max_count,
|
|
524
|
+
failedAfter: failed_after,
|
|
525
|
+
}, db());
|
|
526
|
+
return j({
|
|
527
|
+
confirmation_required: true,
|
|
528
|
+
tool: 'replay_enrollment_dlq_rows',
|
|
529
|
+
site_id,
|
|
530
|
+
matched_count: rows.length,
|
|
531
|
+
preview: rows,
|
|
532
|
+
message: rows.length === 0
|
|
533
|
+
? 'No unresolved enrollment DLQ rows match this filter — nothing to replay.'
|
|
534
|
+
: `Replaying will re-enter ${rows.length} enrollment(s) into active sending, which can trigger real sends. Call again with confirmed=true to proceed.`,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
return j({ error: error instanceof Error ? error.message : 'Replay preview failed' });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const { replayedCount, skippedCount } = await replayEnrollmentDlqRows({
|
|
543
|
+
projectId,
|
|
544
|
+
siteId: site_id,
|
|
545
|
+
maxCount: max_count,
|
|
546
|
+
failedAfter: failed_after,
|
|
547
|
+
}, db());
|
|
548
|
+
await logAudit({
|
|
549
|
+
action: 'enrollment_dlq.replay',
|
|
550
|
+
resourceType: 'site',
|
|
551
|
+
resourceId: site_id,
|
|
552
|
+
details: {
|
|
553
|
+
replayed_count: replayedCount,
|
|
554
|
+
skipped_count: skippedCount,
|
|
555
|
+
max_count,
|
|
556
|
+
failed_after: failed_after ?? null,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
return j({ ok: true, replayed_count: replayedCount, skipped_count: skippedCount });
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
return j({ error: error instanceof Error ? error.message : 'Replay failed' });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
231
565
|
server.registerTool('get_scoring_rules', {
|
|
232
566
|
title: 'Get Scoring Rules',
|
|
233
567
|
annotations: {
|
|
@@ -468,4 +802,188 @@ export function registerDeliveryOpsTools(server) {
|
|
|
468
802
|
return j({ error: linkErr.message });
|
|
469
803
|
return j({ ok: true, site_id: site.id, domain: created });
|
|
470
804
|
});
|
|
805
|
+
server.registerTool('get_dmarc_status', {
|
|
806
|
+
title: 'Get DMARC Status',
|
|
807
|
+
annotations: {
|
|
808
|
+
readOnlyHint: true,
|
|
809
|
+
destructiveHint: false,
|
|
810
|
+
idempotentHint: true,
|
|
811
|
+
openWorldHint: true,
|
|
812
|
+
},
|
|
813
|
+
description: "Return the inbound DMARC RUA address, Cloudflare publish setup state, and monitor state for this project (or a specific site's sending domain).",
|
|
814
|
+
inputSchema: {
|
|
815
|
+
site_id: z.string().uuid().optional()
|
|
816
|
+
.describe('Site to scope the sending-domain setup metadata to. Omit for the project default.'),
|
|
817
|
+
},
|
|
818
|
+
}, async ({ site_id }) => {
|
|
819
|
+
const projectId = getProjectId();
|
|
820
|
+
const central = adminDb();
|
|
821
|
+
const { data: sites, error: siteError } = await db().schema('email').from('sites')
|
|
822
|
+
.select('id, sending_domain').eq('active', true).eq('project_id', projectId).order('name');
|
|
823
|
+
if (siteError)
|
|
824
|
+
return j({ error: siteError.message });
|
|
825
|
+
const selectedSite = site_id ? (sites ?? []).find((site) => site.id === site_id) : null;
|
|
826
|
+
if (site_id && !selectedSite)
|
|
827
|
+
return j({ error: 'site_id is not a valid site for this project', status: 400 });
|
|
828
|
+
try {
|
|
829
|
+
const [{ data: project, error: projectError }, { data: monitors, error: monitorError }] = await Promise.all([
|
|
830
|
+
central.schema('email').from('projects').select('org_id').eq('id', projectId).maybeSingle(),
|
|
831
|
+
central.schema('email').from('dmarc_monitors').select('domain, status')
|
|
832
|
+
.eq('project_id', projectId).order('created_at', { ascending: false }),
|
|
833
|
+
]);
|
|
834
|
+
if (projectError)
|
|
835
|
+
throw new Error(projectError.message);
|
|
836
|
+
if (monitorError)
|
|
837
|
+
throw new Error(monitorError.message);
|
|
838
|
+
const token = await getOrProvisionDmarcInboundToken(projectId);
|
|
839
|
+
const sendingDomains = (sites ?? []).map((site) => String(site.sending_domain ?? '').trim().toLowerCase()).filter(Boolean);
|
|
840
|
+
const domain = selectedSite?.sending_domain ?? sendingDomains[0] ?? null;
|
|
841
|
+
const ns = domain ? await resolveNameservers(domain) : { nameservers: [], zone: null };
|
|
842
|
+
const dnsProvider = detectDnsProvider(ns.nameservers);
|
|
843
|
+
let cloudflareConnected = false;
|
|
844
|
+
if (project?.org_id && dnsProvider === 'cloudflare') {
|
|
845
|
+
const { data: connection, error: connectionError } = await central.schema('email')
|
|
846
|
+
.from('cloudflare_oauth_connections').select('id').eq('org_id', project.org_id).maybeSingle();
|
|
847
|
+
if (connectionError)
|
|
848
|
+
throw new Error(connectionError.message);
|
|
849
|
+
cloudflareConnected = Boolean(connection?.id);
|
|
850
|
+
}
|
|
851
|
+
const monitorRows = (monitors ?? []).map((monitor) => ({
|
|
852
|
+
domain: monitor.domain,
|
|
853
|
+
status: monitor.status,
|
|
854
|
+
rua_record: 'v=DMARC1; p=none; rua=mailto:dmarc@sendinel.ai',
|
|
855
|
+
report_destination: 'dmarc@sendinel.ai',
|
|
856
|
+
}));
|
|
857
|
+
return j({
|
|
858
|
+
inbound_address: `dmarc+${token}@sendinel.ai`,
|
|
859
|
+
setup: {
|
|
860
|
+
domain,
|
|
861
|
+
cloudflare_connected: cloudflareConnected,
|
|
862
|
+
can_publish_cloudflare: cloudflareConnected,
|
|
863
|
+
dns_provider: dnsProvider,
|
|
864
|
+
dns_zone: ns.zone,
|
|
865
|
+
},
|
|
866
|
+
monitors: selectedSite?.sending_domain
|
|
867
|
+
? monitorRows.filter((monitor) => monitor.domain === selectedSite.sending_domain)
|
|
868
|
+
: monitorRows,
|
|
869
|
+
auth_record_errors: [],
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
return j({ error: error instanceof Error ? error.message : 'DMARC status query failed' });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
server.registerTool('list_dmarc_reports', {
|
|
877
|
+
title: 'List DMARC Reports',
|
|
878
|
+
annotations: {
|
|
879
|
+
readOnlyHint: true,
|
|
880
|
+
destructiveHint: false,
|
|
881
|
+
idempotentHint: true,
|
|
882
|
+
openWorldHint: true,
|
|
883
|
+
},
|
|
884
|
+
description: 'List aggregate DMARC (RUA) report summaries ingested for this project. Raw XML is excluded.',
|
|
885
|
+
inputSchema: {
|
|
886
|
+
site_id: z.string().uuid().optional(),
|
|
887
|
+
domain: z.string().optional().describe('Filter to a policy_domain from the reports.'),
|
|
888
|
+
start_date: z.string().datetime().optional(),
|
|
889
|
+
end_date: z.string().datetime().optional(),
|
|
890
|
+
cursor: z.string().optional().describe('created_at cursor from a previous page.'),
|
|
891
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
892
|
+
},
|
|
893
|
+
}, async ({ site_id, domain, start_date, end_date, cursor, limit }) => {
|
|
894
|
+
const pageLimit = limit ?? 50;
|
|
895
|
+
let query = db().schema('email').from('dmarc_reports')
|
|
896
|
+
.select('id, site_id, org_name, report_id, period_begin, period_end, policy_domain, policy_p, policy_sp, policy_pct, total_messages, pass_count, fail_count, created_at')
|
|
897
|
+
.eq('project_id', getProjectId())
|
|
898
|
+
.order('created_at', { ascending: false })
|
|
899
|
+
.limit(pageLimit + 1);
|
|
900
|
+
if (site_id)
|
|
901
|
+
query = query.eq('site_id', site_id);
|
|
902
|
+
if (domain)
|
|
903
|
+
query = query.eq('policy_domain', domain);
|
|
904
|
+
if (start_date)
|
|
905
|
+
query = query.gte('period_begin', new Date(start_date).toISOString());
|
|
906
|
+
if (end_date)
|
|
907
|
+
query = query.lte('period_begin', new Date(end_date).toISOString());
|
|
908
|
+
if (cursor)
|
|
909
|
+
query = query.lt('created_at', cursor);
|
|
910
|
+
const { data, error } = await query;
|
|
911
|
+
if (error)
|
|
912
|
+
return j({ error: 'Query failed', status: 500 });
|
|
913
|
+
const rows = (data ?? []);
|
|
914
|
+
const hasMore = rows.length > pageLimit;
|
|
915
|
+
const reports = hasMore ? rows.slice(0, pageLimit) : rows;
|
|
916
|
+
return j({
|
|
917
|
+
reports,
|
|
918
|
+
pagination: {
|
|
919
|
+
cursor: hasMore && reports.length > 0 ? reports[reports.length - 1].created_at : null,
|
|
920
|
+
has_more: hasMore,
|
|
921
|
+
limit: pageLimit,
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
server.registerTool('publish_dmarc_record', {
|
|
926
|
+
title: 'Publish DMARC Record',
|
|
927
|
+
annotations: {
|
|
928
|
+
readOnlyHint: false,
|
|
929
|
+
destructiveHint: true,
|
|
930
|
+
idempotentHint: true,
|
|
931
|
+
openWorldHint: true,
|
|
932
|
+
},
|
|
933
|
+
description: "Merge Sendinel's inbound RUA address into a sending domain's _dmarc TXT record via the organization's connected Cloudflare zone. Previews the exact merged record before writing; writing requires approval.",
|
|
934
|
+
inputSchema: {
|
|
935
|
+
domain: z.string().min(1)
|
|
936
|
+
.describe('Active sending domain for this project, managed by a connected Cloudflare zone.'),
|
|
937
|
+
confirmed: z.boolean().optional().default(false)
|
|
938
|
+
.describe('Two-call confirmation. First call previews the merged record, second call with confirmed=true writes it.'),
|
|
939
|
+
approval_id: z.string().uuid().optional()
|
|
940
|
+
.describe('Approval token from a previous confirmed call without approval_id.'),
|
|
941
|
+
},
|
|
942
|
+
}, async ({ domain, confirmed, approval_id }) => {
|
|
943
|
+
const approvalParams = { domain };
|
|
944
|
+
const preview = await callSendinelV1('/api/v1/dmarc/publish-cloudflare', {
|
|
945
|
+
method: 'POST',
|
|
946
|
+
body: { domain, dry_run: true },
|
|
947
|
+
});
|
|
948
|
+
if (!preview.ok) {
|
|
949
|
+
return j({ error: v1ErrorMessage(preview.error), status: preview.status });
|
|
950
|
+
}
|
|
951
|
+
const previewBody = asRecord(preview.data);
|
|
952
|
+
if (!confirmed) {
|
|
953
|
+
return j({
|
|
954
|
+
confirmation_required: true,
|
|
955
|
+
tool: 'publish_dmarc_record',
|
|
956
|
+
domain,
|
|
957
|
+
dns_zone: previewBody.dns_zone,
|
|
958
|
+
record_name: previewBody.record_name,
|
|
959
|
+
record_content: previewBody.record_content,
|
|
960
|
+
message: `Publishing will write this exact TXT record for ${previewBody.record_name} in the organization's Cloudflare zone. Call again with confirmed=true to proceed.`,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
const channelContext = currentChannelContext();
|
|
964
|
+
const decision = assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
|
|
965
|
+
if (!decision.allowed)
|
|
966
|
+
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
967
|
+
if (decision.tier !== 'autonomous') {
|
|
968
|
+
if (!approval_id) {
|
|
969
|
+
const approval = await requestApproval('publish_dmarc_record', approvalParams, undefined, channelContext);
|
|
970
|
+
return j(approval);
|
|
971
|
+
}
|
|
972
|
+
const check = await checkApproval(approval_id, {
|
|
973
|
+
consume: true,
|
|
974
|
+
action: 'publish_dmarc_record',
|
|
975
|
+
params: approvalParams,
|
|
976
|
+
});
|
|
977
|
+
if (!check.approved) {
|
|
978
|
+
return j({ error: `Approval ${approval_id} is ${check.status}.`, status: check.status });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const result = await callSendinelV1('/api/v1/dmarc/publish-cloudflare', {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
body: { domain, dry_run: false },
|
|
984
|
+
});
|
|
985
|
+
if (!result.ok)
|
|
986
|
+
return j({ error: v1ErrorMessage(result.error), status: result.status });
|
|
987
|
+
return j(result.data);
|
|
988
|
+
});
|
|
471
989
|
}
|
package/build/tools/forms.js
CHANGED
|
@@ -11,10 +11,10 @@ export function registerFormTools(server) {
|
|
|
11
11
|
idempotentHint: true,
|
|
12
12
|
openWorldHint: false,
|
|
13
13
|
},
|
|
14
|
-
description: 'List
|
|
14
|
+
description: 'List forms with destination, field, and submission counts.',
|
|
15
15
|
inputSchema: { site_id: z.string().uuid().optional() },
|
|
16
16
|
}, async ({ site_id }) => {
|
|
17
|
-
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
17
|
+
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
18
|
if (site_id)
|
|
19
19
|
query = query.eq('site_id', site_id);
|
|
20
20
|
const { data, error } = await query;
|
|
@@ -58,19 +58,20 @@ export function registerFormTools(server) {
|
|
|
58
58
|
idempotentHint: false,
|
|
59
59
|
openWorldHint: false,
|
|
60
60
|
},
|
|
61
|
-
description:
|
|
61
|
+
description: "Create an embeddable form. destination 'signup' (default) creates/updates a subscriber on submission; destination 'management' looks up an existing contact by the submitted email and merges the other submitted fields into that contact's custom_properties -- it never creates a new contact.",
|
|
62
62
|
inputSchema: {
|
|
63
63
|
site_id: z.string().uuid(),
|
|
64
64
|
name: z.string().min(1),
|
|
65
65
|
fields: z.array(z.record(z.string(), z.unknown())),
|
|
66
66
|
redirect_url: z.string().url().optional(),
|
|
67
67
|
double_opt_in: z.boolean().optional(),
|
|
68
|
+
form_type: z.enum(['signup', 'management']).optional(),
|
|
68
69
|
},
|
|
69
|
-
}, async ({ site_id, name, fields, redirect_url, double_opt_in }) => {
|
|
70
|
+
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
70
71
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
71
72
|
const { data, error } = await db().from('signup_forms')
|
|
72
|
-
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, status: 'active' })
|
|
73
|
-
.select('id, name, slug, fields, status')
|
|
73
|
+
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, form_type: form_type ?? 'signup', status: 'active' })
|
|
74
|
+
.select('id, name, slug, fields, status, form_type')
|
|
74
75
|
.single();
|
|
75
76
|
if (error)
|
|
76
77
|
return j({ error: error.message });
|