@sendinel/mcp-server 1.0.0
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 +113 -0
- package/build/anthropic-model.d.ts +5 -0
- package/build/anthropic-model.js +6 -0
- package/build/audit.d.ts +21 -0
- package/build/audit.js +38 -0
- package/build/auth.d.ts +10 -0
- package/build/auth.js +57 -0
- package/build/byod-client.d.ts +30 -0
- package/build/byod-client.js +95 -0
- package/build/crypto.d.ts +2 -0
- package/build/crypto.js +27 -0
- package/build/db.d.ts +38 -0
- package/build/db.js +70 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +97 -0
- package/build/lib/action-approvals.d.ts +30 -0
- package/build/lib/action-approvals.js +154 -0
- package/build/lib/schedule.d.ts +36 -0
- package/build/lib/schedule.js +63 -0
- package/build/lib/social-engine/archetypes.d.ts +27 -0
- package/build/lib/social-engine/archetypes.js +160 -0
- package/build/lib/social-engine/index.d.ts +5 -0
- package/build/lib/social-engine/index.js +6 -0
- package/build/lib/social-engine/parse.d.ts +7 -0
- package/build/lib/social-engine/parse.js +83 -0
- package/build/lib/social-engine/platform-constraints.d.ts +27 -0
- package/build/lib/social-engine/platform-constraints.js +70 -0
- package/build/lib/social-engine/prompt-builder.d.ts +13 -0
- package/build/lib/social-engine/prompt-builder.js +80 -0
- package/build/lib/social-engine/types.d.ts +60 -0
- package/build/lib/social-engine/types.js +19 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +51 -0
- package/build/lib/voice.d.ts +32 -0
- package/build/lib/voice.js +140 -0
- package/build/lib/webhook-events.d.ts +15 -0
- package/build/lib/webhook-events.js +120 -0
- package/build/plan-limits.d.ts +8 -0
- package/build/plan-limits.js +9 -0
- package/build/project.d.ts +1 -0
- package/build/project.js +9 -0
- package/build/server.d.ts +18 -0
- package/build/server.js +235 -0
- package/build/tools/ab-testing.d.ts +2 -0
- package/build/tools/ab-testing.js +204 -0
- package/build/tools/advisor.d.ts +23 -0
- package/build/tools/advisor.js +762 -0
- package/build/tools/analytics.d.ts +33 -0
- package/build/tools/analytics.js +1105 -0
- package/build/tools/approvals.d.ts +2 -0
- package/build/tools/approvals.js +32 -0
- package/build/tools/automations.d.ts +2 -0
- package/build/tools/automations.js +344 -0
- package/build/tools/campaigns.d.ts +2 -0
- package/build/tools/campaigns.js +1335 -0
- package/build/tools/compound.d.ts +2 -0
- package/build/tools/compound.js +312 -0
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +1483 -0
- package/build/tools/content.d.ts +2 -0
- package/build/tools/content.js +68 -0
- package/build/tools/data-proposals.d.ts +2 -0
- package/build/tools/data-proposals.js +155 -0
- package/build/tools/data.d.ts +2 -0
- package/build/tools/data.js +707 -0
- package/build/tools/delivery-ops.d.ts +2 -0
- package/build/tools/delivery-ops.js +387 -0
- package/build/tools/drafts.d.ts +2 -0
- package/build/tools/drafts.js +204 -0
- package/build/tools/forms.d.ts +2 -0
- package/build/tools/forms.js +46 -0
- package/build/tools/gdpr.d.ts +2 -0
- package/build/tools/gdpr.js +61 -0
- package/build/tools/org.d.ts +2 -0
- package/build/tools/org.js +71 -0
- package/build/tools/segments.d.ts +2 -0
- package/build/tools/segments.js +384 -0
- package/build/tools/sites.d.ts +2 -0
- package/build/tools/sites.js +182 -0
- package/build/tools/sms.d.ts +2 -0
- package/build/tools/sms.js +489 -0
- package/build/tools/social-posts.d.ts +2 -0
- package/build/tools/social-posts.js +380 -0
- package/build/tools/templates.d.ts +2 -0
- package/build/tools/templates.js +282 -0
- package/build/tools/warmup.d.ts +2 -0
- package/build/tools/warmup.js +57 -0
- package/build/tools/webhooks.d.ts +2 -0
- package/build/tools/webhooks.js +127 -0
- package/package.json +63 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
5
|
+
export function registerDeliveryOpsTools(server) {
|
|
6
|
+
server.registerTool('refresh_domain_dns', {
|
|
7
|
+
description: 'Refresh and return stored DNS verification status for a sending domain.',
|
|
8
|
+
inputSchema: { domain_id: z.string().uuid() },
|
|
9
|
+
}, async ({ domain_id }) => {
|
|
10
|
+
const { data, error } = await db().from('domains')
|
|
11
|
+
.select('id, status, dns_records, spf_valid, dkim_valid, dmarc_valid')
|
|
12
|
+
.eq('project_id', getProjectId())
|
|
13
|
+
.eq('id', domain_id)
|
|
14
|
+
.maybeSingle();
|
|
15
|
+
if (error)
|
|
16
|
+
return j({ error: error.message });
|
|
17
|
+
if (!data)
|
|
18
|
+
return j({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
19
|
+
const records = Array.isArray(data.dns_records) ? data.dns_records : [];
|
|
20
|
+
const spf = data.spf_valid ?? records.some((record) => String(record.value ?? '').includes('v=spf1'));
|
|
21
|
+
const dkim = data.dkim_valid ?? records.some((record) => /dkim/i.test(`${record.name ?? ''} ${record.type ?? ''}`));
|
|
22
|
+
const dmarc = data.dmarc_valid ?? records.some((record) => String(record.name ?? '').includes('_dmarc'));
|
|
23
|
+
return j({ spf, dkim, dmarc, all_verified: Boolean(spf && dkim && dmarc) });
|
|
24
|
+
});
|
|
25
|
+
server.registerTool('dnsbl_check', {
|
|
26
|
+
description: 'Return the latest DNSBL snapshot for all monitored zones for a site sending domain.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
siteId: z.string().uuid(),
|
|
29
|
+
},
|
|
30
|
+
}, async ({ siteId }) => {
|
|
31
|
+
const projectId = getProjectId();
|
|
32
|
+
const { data: site, error: siteErr } = await db().from('sites')
|
|
33
|
+
.select('id, sending_domain')
|
|
34
|
+
.eq('id', siteId)
|
|
35
|
+
.eq('project_id', projectId)
|
|
36
|
+
.maybeSingle();
|
|
37
|
+
if (siteErr || !site)
|
|
38
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
39
|
+
const { data, error } = await db().from('dnsbl_snapshots')
|
|
40
|
+
.select('domain, zone, listed, response_code, checked_at')
|
|
41
|
+
.eq('site_id', siteId)
|
|
42
|
+
.order('checked_at', { ascending: false })
|
|
43
|
+
.limit(60);
|
|
44
|
+
if (error)
|
|
45
|
+
return j({ error: error.message });
|
|
46
|
+
const latestByZone = new Map();
|
|
47
|
+
for (const row of data ?? []) {
|
|
48
|
+
if (!latestByZone.has(row.zone))
|
|
49
|
+
latestByZone.set(row.zone, row);
|
|
50
|
+
}
|
|
51
|
+
const snapshots = Array.from(latestByZone.values());
|
|
52
|
+
return j({
|
|
53
|
+
site_id: siteId,
|
|
54
|
+
domain: site.sending_domain,
|
|
55
|
+
snapshots,
|
|
56
|
+
active_listings: snapshots.filter((row) => row.listed),
|
|
57
|
+
checked_at: snapshots.map((row) => row.checked_at).sort().at(-1) ?? null,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
server.registerTool('dnsbl_delisting_draft', {
|
|
61
|
+
description: 'Draft a DNSBL delisting ticket using tenant send metrics as evidence. Does not auto-submit.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
siteId: z.string().uuid(),
|
|
64
|
+
zone: z.string().min(3),
|
|
65
|
+
},
|
|
66
|
+
}, async ({ siteId, zone }) => {
|
|
67
|
+
const projectId = getProjectId();
|
|
68
|
+
const [{ data: site, error: siteErr }, { data: snapshot }, { data: metrics }] = await Promise.all([
|
|
69
|
+
db().from('sites').select('id, name, sending_domain').eq('id', siteId).eq('project_id', projectId).maybeSingle(),
|
|
70
|
+
db().from('dnsbl_snapshots')
|
|
71
|
+
.select('domain, zone, response_code, checked_at')
|
|
72
|
+
.eq('site_id', siteId)
|
|
73
|
+
.eq('zone', zone)
|
|
74
|
+
.eq('listed', true)
|
|
75
|
+
.order('checked_at', { ascending: false })
|
|
76
|
+
.limit(1)
|
|
77
|
+
.maybeSingle(),
|
|
78
|
+
db().from('tenant_email_metrics')
|
|
79
|
+
.select('window_30d_sent, window_30d_complaints, complaint_rate, updated_at')
|
|
80
|
+
.eq('site_id', siteId)
|
|
81
|
+
.maybeSingle(),
|
|
82
|
+
]);
|
|
83
|
+
if (siteErr || !site)
|
|
84
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
85
|
+
const complaintRate = Number(metrics?.complaint_rate ?? 0);
|
|
86
|
+
const draft = [
|
|
87
|
+
`Subject: Delisting request for ${site.sending_domain} from ${zone}`,
|
|
88
|
+
'',
|
|
89
|
+
'Hello,',
|
|
90
|
+
'',
|
|
91
|
+
`Please review ${site.sending_domain} for delisting from ${zone}. We operate this sending domain for ${site.name ?? 'our tenant'} and have remediated the source of the listing.`,
|
|
92
|
+
'',
|
|
93
|
+
'Evidence for review:',
|
|
94
|
+
`- Domain: ${site.sending_domain}`,
|
|
95
|
+
`- Zone: ${zone}`,
|
|
96
|
+
`- Latest response code: ${snapshot?.response_code ?? 'unknown'}`,
|
|
97
|
+
`- Latest checked_at: ${snapshot?.checked_at ?? 'unknown'}`,
|
|
98
|
+
`- 30-day send volume: ${metrics?.window_30d_sent ?? 0}`,
|
|
99
|
+
`- 30-day complaints: ${metrics?.window_30d_complaints ?? 0}`,
|
|
100
|
+
`- 30-day complaint rate: ${(complaintRate * 100).toFixed(4)}%`,
|
|
101
|
+
'',
|
|
102
|
+
'We have confirmed our suppression and complaint handling pipeline is active, and will continue monitoring DNSBL status daily.',
|
|
103
|
+
'',
|
|
104
|
+
'Thank you.',
|
|
105
|
+
].join('\n');
|
|
106
|
+
return j({
|
|
107
|
+
site_id: siteId,
|
|
108
|
+
zone,
|
|
109
|
+
auto_submitted: false,
|
|
110
|
+
draft,
|
|
111
|
+
metrics,
|
|
112
|
+
listing: snapshot ?? null,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
server.registerTool('get_cron_runs', {
|
|
116
|
+
description: 'List recent cron job executions to diagnose why scheduled sends are not firing.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
job_name: z.string().optional(),
|
|
119
|
+
limit: z.number().int().min(1).max(50).default(20).optional(),
|
|
120
|
+
since_hours: z.number().int().min(1).max(168).default(24).optional(),
|
|
121
|
+
},
|
|
122
|
+
}, async ({ job_name, limit = 20, since_hours = 24 }) => {
|
|
123
|
+
const since = new Date(Date.now() - since_hours * 3600 * 1000).toISOString();
|
|
124
|
+
let query = db().from('cron_runs')
|
|
125
|
+
.select('id, job_name, project_id, status, started_at, finished_at, duration_ms, items_processed, items_failed, error_message')
|
|
126
|
+
.or(`project_id.is.null,project_id.eq.${getProjectId()}`)
|
|
127
|
+
.gte('started_at', since)
|
|
128
|
+
.order('started_at', { ascending: false })
|
|
129
|
+
.limit(limit);
|
|
130
|
+
if (job_name)
|
|
131
|
+
query = query.eq('job_name', job_name);
|
|
132
|
+
const { data, error } = await query;
|
|
133
|
+
if (error)
|
|
134
|
+
return j({ error: error.message });
|
|
135
|
+
const failed = (data ?? []).filter((row) => row.status === 'failed');
|
|
136
|
+
return j({ runs: data ?? [], recent_failures: failed.length, total: data?.length ?? 0 });
|
|
137
|
+
});
|
|
138
|
+
server.registerTool('get_queue_status', {
|
|
139
|
+
description: 'Get the current send queue status — pending, processing, retry, and failed counts.',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
site_id: z.string().uuid().optional(),
|
|
142
|
+
},
|
|
143
|
+
}, async ({ site_id }) => {
|
|
144
|
+
const projectId = getProjectId();
|
|
145
|
+
let queueQuery = db().from('send_queue').select('status').eq('project_id', projectId);
|
|
146
|
+
if (site_id)
|
|
147
|
+
queueQuery = queueQuery.eq('site_id', site_id);
|
|
148
|
+
const [{ data: queueRows, error: queueErr }, { data: dlqRows, error: dlqErr }] = await Promise.all([
|
|
149
|
+
queueQuery,
|
|
150
|
+
db().from('send_dlq').select('id', { count: 'exact' }).eq('project_id', projectId).eq('resolved', false),
|
|
151
|
+
]);
|
|
152
|
+
if (queueErr)
|
|
153
|
+
return j({ error: queueErr.message });
|
|
154
|
+
if (dlqErr)
|
|
155
|
+
return j({ error: dlqErr.message });
|
|
156
|
+
const counts = {};
|
|
157
|
+
for (const row of queueRows ?? []) {
|
|
158
|
+
counts[row.status] = (counts[row.status] ?? 0) + 1;
|
|
159
|
+
}
|
|
160
|
+
return j({
|
|
161
|
+
queue: counts,
|
|
162
|
+
total: queueRows?.length ?? 0,
|
|
163
|
+
pending: counts.pending ?? 0,
|
|
164
|
+
processing: counts.processing ?? 0,
|
|
165
|
+
retry: counts.retry ?? 0,
|
|
166
|
+
failed: dlqRows?.length ?? 0,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
server.registerTool('get_send_dlq', {
|
|
170
|
+
description: 'List messages in the send dead-letter queue — emails that failed all retries.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
limit: z.number().int().min(1).max(50).default(20).optional(),
|
|
173
|
+
unresolved_only: z.boolean().default(true).optional(),
|
|
174
|
+
},
|
|
175
|
+
}, async ({ limit = 20, unresolved_only = true }) => {
|
|
176
|
+
const projectId = getProjectId();
|
|
177
|
+
let query = db().from('send_dlq')
|
|
178
|
+
.select('id, final_error, attempts, created_at, resolved, resolved_at, email_log_id')
|
|
179
|
+
.eq('project_id', projectId)
|
|
180
|
+
.order('created_at', { ascending: false })
|
|
181
|
+
.limit(limit);
|
|
182
|
+
if (unresolved_only)
|
|
183
|
+
query = query.eq('resolved', false);
|
|
184
|
+
const { data, error } = await query;
|
|
185
|
+
if (error)
|
|
186
|
+
return j({ error: error.message });
|
|
187
|
+
return j({ dlq: data ?? [], count: data?.length ?? 0 });
|
|
188
|
+
});
|
|
189
|
+
server.registerTool('get_scoring_rules', {
|
|
190
|
+
description: 'Get contact scoring rules for the current project.',
|
|
191
|
+
inputSchema: {},
|
|
192
|
+
}, async () => {
|
|
193
|
+
const projectId = getProjectId();
|
|
194
|
+
const { data, error } = await db().from('scoring_rules')
|
|
195
|
+
.select('*')
|
|
196
|
+
.eq('project_id', projectId)
|
|
197
|
+
.maybeSingle();
|
|
198
|
+
if (error)
|
|
199
|
+
return j({ error: error.message });
|
|
200
|
+
return j({ rules: data });
|
|
201
|
+
});
|
|
202
|
+
server.registerTool('update_scoring_rules', {
|
|
203
|
+
description: 'Update contact scoring rules. Only the provided fields are changed.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
open_weight: z.number().min(0).max(100).optional(),
|
|
206
|
+
click_weight: z.number().min(0).max(100).optional(),
|
|
207
|
+
send_weight: z.number().min(0).max(100).optional(),
|
|
208
|
+
open_cap: z.number().int().min(0).optional(),
|
|
209
|
+
click_cap: z.number().int().min(0).optional(),
|
|
210
|
+
send_cap: z.number().int().min(0).optional(),
|
|
211
|
+
recency_boost_7d: z.number().int().optional(),
|
|
212
|
+
recency_boost_30d: z.number().int().optional(),
|
|
213
|
+
recency_boost_90d: z.number().int().optional(),
|
|
214
|
+
decay_penalty_30d: z.number().int().optional(),
|
|
215
|
+
decay_penalty_60d: z.number().int().optional(),
|
|
216
|
+
decay_penalty_90d: z.number().int().optional(),
|
|
217
|
+
active_threshold: z.number().int().optional(),
|
|
218
|
+
cooling_threshold: z.number().int().optional(),
|
|
219
|
+
cold_threshold: z.number().int().optional(),
|
|
220
|
+
},
|
|
221
|
+
}, async (updates) => {
|
|
222
|
+
const projectId = getProjectId();
|
|
223
|
+
const row = { project_id: projectId, ...updates, updated_at: new Date().toISOString() };
|
|
224
|
+
const { data, error } = await db().from('scoring_rules')
|
|
225
|
+
.upsert(row, { onConflict: 'project_id' })
|
|
226
|
+
.select('*')
|
|
227
|
+
.single();
|
|
228
|
+
if (error)
|
|
229
|
+
return j({ error: error.message });
|
|
230
|
+
return j({ rules: data, ok: true });
|
|
231
|
+
});
|
|
232
|
+
server.registerTool('explain_contact_score', {
|
|
233
|
+
description: 'Explain why a subscriber has their current engagement score.',
|
|
234
|
+
inputSchema: {
|
|
235
|
+
contact_id: z.string().uuid().optional(),
|
|
236
|
+
email: z.string().email().optional(),
|
|
237
|
+
},
|
|
238
|
+
}, async ({ contact_id, email }) => {
|
|
239
|
+
const projectId = getProjectId();
|
|
240
|
+
if (!contact_id && !email)
|
|
241
|
+
return j({ error: 'contact_id or email is required', code: 'BAD_REQUEST' });
|
|
242
|
+
let contactQuery = db().from('subscribers')
|
|
243
|
+
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, total_opens, total_clicks, total_emails_sent, last_engaged_at')
|
|
244
|
+
.eq('project_id', projectId);
|
|
245
|
+
if (contact_id)
|
|
246
|
+
contactQuery = contactQuery.eq('id', contact_id);
|
|
247
|
+
else
|
|
248
|
+
contactQuery = contactQuery.eq('email', email.toLowerCase());
|
|
249
|
+
const [{ data: contact, error: contactErr }, { data: rules }] = await Promise.all([
|
|
250
|
+
contactQuery.maybeSingle(),
|
|
251
|
+
db().from('scoring_rules').select('*').eq('project_id', projectId).maybeSingle(),
|
|
252
|
+
]);
|
|
253
|
+
if (contactErr || !contact)
|
|
254
|
+
return j({ error: contactErr?.message ?? 'Contact not found', code: 'NOT_FOUND' });
|
|
255
|
+
const effectiveRules = rules ?? {
|
|
256
|
+
click_weight: 5,
|
|
257
|
+
open_weight: 2,
|
|
258
|
+
send_weight: 0.5,
|
|
259
|
+
click_cap: 20,
|
|
260
|
+
open_cap: 30,
|
|
261
|
+
send_cap: 50,
|
|
262
|
+
active_threshold: 60,
|
|
263
|
+
cooling_threshold: 30,
|
|
264
|
+
cold_threshold: 10,
|
|
265
|
+
};
|
|
266
|
+
const openPoints = Math.min(contact.total_opens ?? 0, effectiveRules.open_cap) * effectiveRules.open_weight;
|
|
267
|
+
const clickPoints = Math.min(contact.total_clicks ?? 0, effectiveRules.click_cap) * effectiveRules.click_weight;
|
|
268
|
+
const sendPoints = Math.min(contact.total_emails_sent ?? 0, effectiveRules.send_cap) * effectiveRules.send_weight;
|
|
269
|
+
const breakdown = [
|
|
270
|
+
`${contact.total_opens ?? 0} opens × ${effectiveRules.open_weight} pts (cap ${effectiveRules.open_cap}) = ${openPoints} pts`,
|
|
271
|
+
`${contact.total_clicks ?? 0} clicks × ${effectiveRules.click_weight} pts (cap ${effectiveRules.click_cap}) = ${clickPoints} pts`,
|
|
272
|
+
`${contact.total_emails_sent ?? 0} sends × ${effectiveRules.send_weight} pts (cap ${effectiveRules.send_cap}) = ${sendPoints} pts`,
|
|
273
|
+
];
|
|
274
|
+
if (contact.last_engaged_at) {
|
|
275
|
+
const daysSince = Math.floor((Date.now() - new Date(contact.last_engaged_at).getTime()) / 86400000);
|
|
276
|
+
if (daysSince > 90)
|
|
277
|
+
breakdown.push(`No engagement for ${daysSince} days -> decay penalties likely apply`);
|
|
278
|
+
else if (daysSince <= 7)
|
|
279
|
+
breakdown.push('Engaged within 7 days -> strongest recency boost window');
|
|
280
|
+
else if (daysSince <= 30)
|
|
281
|
+
breakdown.push('Engaged within 30 days -> recency boost window');
|
|
282
|
+
}
|
|
283
|
+
return j({
|
|
284
|
+
contact: {
|
|
285
|
+
id: contact.id,
|
|
286
|
+
email: contact.email,
|
|
287
|
+
name: `${contact.first_name ?? ''} ${contact.last_name ?? ''}`.trim(),
|
|
288
|
+
score: contact.engagement_score ?? 0,
|
|
289
|
+
lifecycle_stage: contact.lifecycle_stage ?? 'new',
|
|
290
|
+
last_engaged_at: contact.last_engaged_at ?? null,
|
|
291
|
+
},
|
|
292
|
+
rules: effectiveRules,
|
|
293
|
+
score_breakdown: breakdown,
|
|
294
|
+
thresholds: {
|
|
295
|
+
active: `>= ${effectiveRules.active_threshold}`,
|
|
296
|
+
cooling: `>= ${effectiveRules.cooling_threshold}`,
|
|
297
|
+
cold: `>= ${effectiveRules.cold_threshold}`,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
server.registerTool('export_data', {
|
|
302
|
+
description: 'Export contacts, email log, campaign, or suppression data as JSON.',
|
|
303
|
+
inputSchema: {
|
|
304
|
+
resource: z.enum(['contacts', 'email_log', 'campaigns', 'suppressions']),
|
|
305
|
+
site_id: z.string().uuid().optional(),
|
|
306
|
+
since: z.string().datetime().optional(),
|
|
307
|
+
limit: z.number().int().min(1).max(5000).default(1000).optional(),
|
|
308
|
+
},
|
|
309
|
+
}, async ({ resource, site_id, since, limit = 1000 }) => {
|
|
310
|
+
const projectId = getProjectId();
|
|
311
|
+
const tableMap = {
|
|
312
|
+
contacts: 'subscribers',
|
|
313
|
+
email_log: 'email_log',
|
|
314
|
+
campaigns: 'campaigns',
|
|
315
|
+
suppressions: 'suppressions',
|
|
316
|
+
};
|
|
317
|
+
let query = db().from(tableMap[resource]).select('*').eq('project_id', projectId).limit(limit);
|
|
318
|
+
if (site_id && (resource === 'email_log' || resource === 'campaigns'))
|
|
319
|
+
query = query.eq('site_id', site_id);
|
|
320
|
+
if (since)
|
|
321
|
+
query = query.gte('created_at', since);
|
|
322
|
+
query = query.order('created_at', { ascending: false });
|
|
323
|
+
const { data, error } = await query;
|
|
324
|
+
if (error)
|
|
325
|
+
return j({ error: error.message });
|
|
326
|
+
return j({ resource, count: data?.length ?? 0, data: data ?? [], truncated: (data?.length ?? 0) === limit });
|
|
327
|
+
});
|
|
328
|
+
server.registerTool('list_domains', {
|
|
329
|
+
description: 'List sending domains configured for the project and their current verification status.',
|
|
330
|
+
inputSchema: { site_id: z.string().uuid().optional() },
|
|
331
|
+
}, async ({ site_id }) => {
|
|
332
|
+
const projectId = getProjectId();
|
|
333
|
+
let domainQuery = db().from('domains')
|
|
334
|
+
.select('id, name, provider, provider_id, region, status, dns_records, verified_at, created_at')
|
|
335
|
+
.eq('project_id', projectId)
|
|
336
|
+
.order('created_at', { ascending: false });
|
|
337
|
+
if (site_id) {
|
|
338
|
+
const { data: site } = await db().from('sites')
|
|
339
|
+
.select('domain_id')
|
|
340
|
+
.eq('id', site_id)
|
|
341
|
+
.eq('project_id', projectId)
|
|
342
|
+
.maybeSingle();
|
|
343
|
+
if (!site?.domain_id)
|
|
344
|
+
return j({ domains: [], count: 0 });
|
|
345
|
+
domainQuery = domainQuery.eq('id', site.domain_id);
|
|
346
|
+
}
|
|
347
|
+
const { data, error } = await domainQuery;
|
|
348
|
+
if (error)
|
|
349
|
+
return j({ error: error.message });
|
|
350
|
+
return j({ domains: data ?? [], count: data?.length ?? 0 });
|
|
351
|
+
});
|
|
352
|
+
server.registerTool('register_domain', {
|
|
353
|
+
description: 'Register a sending domain for a site and link it to the site record.',
|
|
354
|
+
inputSchema: {
|
|
355
|
+
site_id: z.string().uuid(),
|
|
356
|
+
domain: z.string().min(1),
|
|
357
|
+
provider: z.enum(['resend', 'sendgrid', 'ses', 'postmark']).optional().default('ses'),
|
|
358
|
+
region: z.string().optional().default('us-east-1'),
|
|
359
|
+
},
|
|
360
|
+
}, async ({ site_id, domain, provider = 'ses', region = 'us-east-1' }) => {
|
|
361
|
+
const projectId = getProjectId();
|
|
362
|
+
const { data: site, error: siteErr } = await db().from('sites')
|
|
363
|
+
.select('id, name')
|
|
364
|
+
.eq('id', site_id)
|
|
365
|
+
.eq('project_id', projectId)
|
|
366
|
+
.maybeSingle();
|
|
367
|
+
if (siteErr || !site)
|
|
368
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
369
|
+
const dnsRecords = [
|
|
370
|
+
{ type: 'TXT', name: domain, value: 'v=spf1 include:amazonses.com ~all' },
|
|
371
|
+
{ type: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=none; rua=mailto:dmarc@sendinel.ai' },
|
|
372
|
+
];
|
|
373
|
+
const { data: created, error } = await db().from('domains')
|
|
374
|
+
.upsert({ project_id: projectId, name: domain, provider, region, status: 'pending', dns_records: dnsRecords }, { onConflict: 'project_id,name' })
|
|
375
|
+
.select('id, name, provider, region, status, dns_records, created_at')
|
|
376
|
+
.single();
|
|
377
|
+
if (error)
|
|
378
|
+
return j({ error: error.message });
|
|
379
|
+
const { error: linkErr } = await db().from('sites')
|
|
380
|
+
.update({ domain_id: created.id, sending_domain: domain, updated_at: new Date().toISOString() })
|
|
381
|
+
.eq('id', site_id)
|
|
382
|
+
.eq('project_id', projectId);
|
|
383
|
+
if (linkErr)
|
|
384
|
+
return j({ error: linkErr.message });
|
|
385
|
+
return j({ ok: true, site_id: site.id, domain: created });
|
|
386
|
+
});
|
|
387
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
import { checkPlanLimit } from '../plan-limits.js';
|
|
5
|
+
export function registerDraftTools(server) {
|
|
6
|
+
server.registerTool('create_draft', {
|
|
7
|
+
description: 'Create an AI-generated email draft pending human approval',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
site_id: z.string().uuid(),
|
|
10
|
+
subject: z.string().min(1),
|
|
11
|
+
body_html: z.string().min(1),
|
|
12
|
+
body_text: z.string().optional(),
|
|
13
|
+
campaign_id: z.string().uuid().optional(),
|
|
14
|
+
prompt: z.string().optional().describe('The prompt or brief used to generate this draft'),
|
|
15
|
+
model: z.string().optional().describe('Model that generated the draft, defaults to ANTHROPIC_MODEL_ID'),
|
|
16
|
+
},
|
|
17
|
+
}, async ({ site_id, subject, body_html, body_text, campaign_id, prompt, model }) => {
|
|
18
|
+
const limit = await checkPlanLimit('ai_drafts');
|
|
19
|
+
if (!limit.allowed) {
|
|
20
|
+
return { content: [{ type: 'text', text: `AI draft limit reached (${limit.current}/${limit.limit}). Upgrade to BYOD for unlimited drafts.` }] };
|
|
21
|
+
}
|
|
22
|
+
const { data, error } = await db()
|
|
23
|
+
.from('ai_drafts')
|
|
24
|
+
.insert({ project_id: getProjectId(), site_id, subject, body_html, body_text, campaign_id, prompt, model })
|
|
25
|
+
.select()
|
|
26
|
+
.single();
|
|
27
|
+
if (error)
|
|
28
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
29
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
30
|
+
});
|
|
31
|
+
server.registerTool('list_drafts', {
|
|
32
|
+
description: 'List AI email drafts by status',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
site_id: z.string().uuid().optional(),
|
|
35
|
+
status: z.enum(['pending', 'approved', 'rejected', 'expired']).optional().default('pending'),
|
|
36
|
+
limit: z.number().int().min(1).max(100).optional().default(25),
|
|
37
|
+
},
|
|
38
|
+
}, async ({ site_id, status, limit }) => {
|
|
39
|
+
let q = db()
|
|
40
|
+
.from('ai_drafts')
|
|
41
|
+
.select('id, site_id, campaign_id, subject, status, created_at, expires_at, reviewed_at, reviewer_notes')
|
|
42
|
+
.eq('project_id', getProjectId())
|
|
43
|
+
.order('created_at', { ascending: false })
|
|
44
|
+
.limit(limit ?? 25);
|
|
45
|
+
if (status)
|
|
46
|
+
q = q.eq('status', status);
|
|
47
|
+
if (site_id)
|
|
48
|
+
q = q.eq('site_id', site_id);
|
|
49
|
+
const { data, error } = await q;
|
|
50
|
+
if (error)
|
|
51
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
52
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
53
|
+
});
|
|
54
|
+
server.registerTool('get_draft', {
|
|
55
|
+
description: 'Get one AI email draft by ID, including full body content.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
draft_id: z.string().uuid(),
|
|
58
|
+
},
|
|
59
|
+
}, async ({ draft_id }) => {
|
|
60
|
+
const { data, error } = await db()
|
|
61
|
+
.from('ai_drafts')
|
|
62
|
+
.select('id, project_id, site_id, campaign_id, subject, body_html, body_text, prompt, model, status, created_at, expires_at, reviewed_at, reviewer_notes')
|
|
63
|
+
.eq('id', draft_id)
|
|
64
|
+
.eq('project_id', getProjectId())
|
|
65
|
+
.maybeSingle();
|
|
66
|
+
if (error)
|
|
67
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
68
|
+
if (!data)
|
|
69
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Draft not found', code: 'NOT_FOUND' }, null, 2) }] };
|
|
70
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
71
|
+
});
|
|
72
|
+
server.registerTool('promote_draft_to_campaign', {
|
|
73
|
+
description: 'Promote an AI draft into an email campaign in one call. Creates the campaign and first campaign step, then links the draft to the campaign. The campaign is created as draft for review/launch.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
draft_id: z.string().uuid(),
|
|
76
|
+
name: z.string().min(1).max(200).optional().describe('Campaign name. Defaults to the draft subject.'),
|
|
77
|
+
type: z.enum(['broadcast', 'sequence', 'triggered']).optional().default('broadcast'),
|
|
78
|
+
description: z.string().optional(),
|
|
79
|
+
send_at: z.string().datetime().optional().describe('Optional broadcast send time. Campaign still remains draft.'),
|
|
80
|
+
trigger_event_name: z.string().optional().describe('Event name for triggered campaigns.'),
|
|
81
|
+
trigger_tag: z.string().optional().describe('Tag trigger for triggered campaigns.'),
|
|
82
|
+
reviewer_notes: z.string().optional(),
|
|
83
|
+
},
|
|
84
|
+
}, async ({ draft_id, name, type, description, send_at, trigger_event_name, trigger_tag, reviewer_notes }) => {
|
|
85
|
+
const projectId = getProjectId();
|
|
86
|
+
const limit = await checkPlanLimit('active_campaigns');
|
|
87
|
+
if (!limit.allowed) {
|
|
88
|
+
return { content: [{ type: 'text', text: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.` }] };
|
|
89
|
+
}
|
|
90
|
+
const { data: draft, error: draftError } = await db()
|
|
91
|
+
.from('ai_drafts')
|
|
92
|
+
.select('id, site_id, campaign_id, subject, body_html, body_text, status')
|
|
93
|
+
.eq('id', draft_id)
|
|
94
|
+
.eq('project_id', projectId)
|
|
95
|
+
.maybeSingle();
|
|
96
|
+
if (draftError)
|
|
97
|
+
return { content: [{ type: 'text', text: `Error: ${draftError.message}` }] };
|
|
98
|
+
if (!draft)
|
|
99
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Draft not found', code: 'NOT_FOUND' }, null, 2) }] };
|
|
100
|
+
const { data: site, error: siteError } = await db()
|
|
101
|
+
.from('sites')
|
|
102
|
+
.select('id')
|
|
103
|
+
.eq('id', draft.site_id)
|
|
104
|
+
.eq('project_id', projectId)
|
|
105
|
+
.maybeSingle();
|
|
106
|
+
if (siteError)
|
|
107
|
+
return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
|
|
108
|
+
if (!site)
|
|
109
|
+
return { content: [{ type: 'text', text: 'Draft site not found for this project.' }] };
|
|
110
|
+
const insert = {
|
|
111
|
+
project_id: projectId,
|
|
112
|
+
site_id: draft.site_id,
|
|
113
|
+
name: name ?? draft.subject,
|
|
114
|
+
description: description ?? `Promoted from AI draft ${draft.id}`,
|
|
115
|
+
type: type ?? 'broadcast',
|
|
116
|
+
status: 'draft',
|
|
117
|
+
ai_drafted: true,
|
|
118
|
+
requires_approval: true,
|
|
119
|
+
};
|
|
120
|
+
if (send_at)
|
|
121
|
+
insert.send_at = send_at;
|
|
122
|
+
if (trigger_event_name)
|
|
123
|
+
insert.trigger_event_name = trigger_event_name;
|
|
124
|
+
if (trigger_tag)
|
|
125
|
+
insert.trigger_tag = trigger_tag.trim().toLowerCase();
|
|
126
|
+
const { data: campaign, error: campaignError } = await db()
|
|
127
|
+
.from('campaigns')
|
|
128
|
+
.insert(insert)
|
|
129
|
+
.select()
|
|
130
|
+
.single();
|
|
131
|
+
if (campaignError)
|
|
132
|
+
return { content: [{ type: 'text', text: `Failed to create campaign: ${campaignError.message}` }] };
|
|
133
|
+
const { data: step, error: stepError } = await db()
|
|
134
|
+
.from('campaign_steps')
|
|
135
|
+
.insert({
|
|
136
|
+
campaign_id: campaign.id,
|
|
137
|
+
step_order: 0,
|
|
138
|
+
subject: draft.subject,
|
|
139
|
+
body_html: draft.body_html,
|
|
140
|
+
body_text: draft.body_text ?? null,
|
|
141
|
+
delay_days: 0,
|
|
142
|
+
delay_hours: 0,
|
|
143
|
+
})
|
|
144
|
+
.select()
|
|
145
|
+
.single();
|
|
146
|
+
if (stepError)
|
|
147
|
+
return { content: [{ type: 'text', text: `Campaign created (${campaign.id}) but failed to create step: ${stepError.message}` }] };
|
|
148
|
+
const { data: updatedDraft } = await db()
|
|
149
|
+
.from('ai_drafts')
|
|
150
|
+
.update({
|
|
151
|
+
campaign_id: campaign.id,
|
|
152
|
+
status: draft.status === 'pending' ? 'approved' : draft.status,
|
|
153
|
+
reviewed_at: new Date().toISOString(),
|
|
154
|
+
reviewer_notes: reviewer_notes ?? 'Promoted to campaign via MCP.',
|
|
155
|
+
})
|
|
156
|
+
.eq('id', draft.id)
|
|
157
|
+
.eq('project_id', projectId)
|
|
158
|
+
.select('id, campaign_id, status, reviewed_at, reviewer_notes')
|
|
159
|
+
.single();
|
|
160
|
+
return { content: [{ type: 'text', text: JSON.stringify({ campaign, first_step: step, draft: updatedDraft }, null, 2) }] };
|
|
161
|
+
});
|
|
162
|
+
server.registerTool('approve_draft', {
|
|
163
|
+
description: 'Approve a pending AI draft, marking it ready to send',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
draft_id: z.string().uuid(),
|
|
166
|
+
reviewer_notes: z.string().optional(),
|
|
167
|
+
},
|
|
168
|
+
}, async ({ draft_id, reviewer_notes }) => {
|
|
169
|
+
const { data, error } = await db()
|
|
170
|
+
.from('ai_drafts')
|
|
171
|
+
.update({ status: 'approved', reviewed_at: new Date().toISOString(), reviewer_notes })
|
|
172
|
+
.eq('id', draft_id)
|
|
173
|
+
.eq('project_id', getProjectId())
|
|
174
|
+
.eq('status', 'pending')
|
|
175
|
+
.select()
|
|
176
|
+
.single();
|
|
177
|
+
if (error)
|
|
178
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
179
|
+
if (!data)
|
|
180
|
+
return { content: [{ type: 'text', text: 'Draft not found or not in pending status.' }] };
|
|
181
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
182
|
+
});
|
|
183
|
+
server.registerTool('reject_draft', {
|
|
184
|
+
description: 'Reject a pending AI draft with optional feedback',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
draft_id: z.string().uuid(),
|
|
187
|
+
reviewer_notes: z.string().optional(),
|
|
188
|
+
},
|
|
189
|
+
}, async ({ draft_id, reviewer_notes }) => {
|
|
190
|
+
const { data, error } = await db()
|
|
191
|
+
.from('ai_drafts')
|
|
192
|
+
.update({ status: 'rejected', reviewed_at: new Date().toISOString(), reviewer_notes })
|
|
193
|
+
.eq('id', draft_id)
|
|
194
|
+
.eq('project_id', getProjectId())
|
|
195
|
+
.eq('status', 'pending')
|
|
196
|
+
.select()
|
|
197
|
+
.single();
|
|
198
|
+
if (error)
|
|
199
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
200
|
+
if (!data)
|
|
201
|
+
return { content: [{ type: 'text', text: 'Draft not found or not in pending status.' }] };
|
|
202
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
5
|
+
export function registerFormTools(server) {
|
|
6
|
+
server.registerTool('list_forms', {
|
|
7
|
+
description: 'List signup forms with field and submission counts.',
|
|
8
|
+
inputSchema: { site_id: z.string().uuid().optional() },
|
|
9
|
+
}, async ({ site_id }) => {
|
|
10
|
+
let query = db().from('forms').select('id, name, slug, fields, active, site_id, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
11
|
+
if (site_id)
|
|
12
|
+
query = query.eq('site_id', site_id);
|
|
13
|
+
const { data, error } = await query;
|
|
14
|
+
if (error)
|
|
15
|
+
return j({ error: error.message });
|
|
16
|
+
return j({ forms: (data ?? []).map((form) => ({ ...form, field_count: Array.isArray(form.fields) ? form.fields.length : 0, submission_count: 0 })) });
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('get_form_stats', {
|
|
19
|
+
description: 'Get submission stats for a signup form.',
|
|
20
|
+
inputSchema: { form_id: z.string().uuid() },
|
|
21
|
+
}, async ({ form_id }) => {
|
|
22
|
+
const { count } = await db().from('form_submissions').select('id', { count: 'exact', head: true }).eq('form_id', form_id).eq('project_id', getProjectId());
|
|
23
|
+
const since = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
24
|
+
const { count: sevenDay } = await db().from('form_submissions').select('id', { count: 'exact', head: true }).eq('form_id', form_id).eq('project_id', getProjectId()).gte('created_at', since);
|
|
25
|
+
return j({ submissions_total: count ?? 0, submissions_7d: sevenDay ?? 0, top_sources: [], conversion_rate: null });
|
|
26
|
+
});
|
|
27
|
+
server.registerTool('create_form', {
|
|
28
|
+
description: 'Create a signup form.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
site_id: z.string().uuid(),
|
|
31
|
+
name: z.string().min(1),
|
|
32
|
+
fields: z.array(z.record(z.unknown())),
|
|
33
|
+
redirect_url: z.string().url().optional(),
|
|
34
|
+
double_opt_in: z.boolean().optional(),
|
|
35
|
+
},
|
|
36
|
+
}, async ({ site_id, name, fields, redirect_url, double_opt_in }) => {
|
|
37
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
38
|
+
const { data, error } = await db().from('forms')
|
|
39
|
+
.insert({ project_id: getProjectId(), site_id, name, slug, fields, redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false, active: true })
|
|
40
|
+
.select('id, name, slug, fields, active')
|
|
41
|
+
.single();
|
|
42
|
+
if (error)
|
|
43
|
+
return j({ error: error.message });
|
|
44
|
+
return j({ form: data });
|
|
45
|
+
});
|
|
46
|
+
}
|