@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.
Files changed (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,1335 @@
1
+ import { z } from 'zod';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { checkPlanLimit } from '../plan-limits.js';
6
+ import { buildVoicePrompt, buildEmailSystemPrompt, validateTemplate } from '../lib/voice.js';
7
+ import { fireWebhookEvent } from '../lib/webhook-events.js';
8
+ import { getApprovalTier, requestApproval, checkApproval } from '../lib/action-approvals.js';
9
+ import { getAnthropicModelId } from '../anthropic-model.js';
10
+ import { getScheduleWindowBounds, groupScheduleByCampaign } from '../lib/schedule.js';
11
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
12
+ const j = (data) => txt(JSON.stringify(data, null, 2));
13
+ export function registerCampaignTools(server) {
14
+ server.registerTool('get_campaign_calendar', {
15
+ description: 'Get campaigns grouped by day for a calendar month.',
16
+ inputSchema: { year: z.number().int().min(2000).max(2100), month: z.number().int().min(1).max(12), site_id: z.string().uuid().optional() },
17
+ }, async ({ year, month, site_id }) => {
18
+ const start = new Date(Date.UTC(year, month - 1, 1));
19
+ const end = new Date(Date.UTC(year, month, 1));
20
+ let query = db().from('campaigns').select('id, name, type, status, site_id, send_at, trigger_event_name, trigger_tag, total_enrolled').eq('project_id', getProjectId()).in('status', ['active', 'draft']);
21
+ if (site_id)
22
+ query = query.eq('site_id', site_id);
23
+ const { data, error } = await query;
24
+ if (error)
25
+ return j({ error: error.message });
26
+ const days = {};
27
+ for (const campaign of data ?? []) {
28
+ if (campaign.type === 'triggered') {
29
+ const key = 'triggered';
30
+ days[key] = [...(days[key] ?? []), campaign];
31
+ continue;
32
+ }
33
+ if (!campaign.send_at)
34
+ continue;
35
+ const sendAt = new Date(campaign.send_at);
36
+ if (sendAt < start || sendAt >= end)
37
+ continue;
38
+ const key = sendAt.toISOString().slice(0, 10);
39
+ days[key] = [...(days[key] ?? []), campaign];
40
+ }
41
+ return j({ days });
42
+ });
43
+ server.registerTool('preview_campaign_audience', {
44
+ description: 'Preview active campaign audience and suppression warnings.',
45
+ inputSchema: { campaign_id: z.string().uuid() },
46
+ }, async ({ campaign_id }) => {
47
+ const projectId = getProjectId();
48
+ const { data: campaign } = await db().from('campaigns').select('id, name').eq('project_id', projectId).eq('id', campaign_id).maybeSingle();
49
+ if (!campaign)
50
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
51
+ const { data: enrollments } = await db().from('campaign_enrollments').select('contact_id').eq('campaign_id', campaign_id).eq('status', 'active');
52
+ const ids = (enrollments ?? []).map((row) => row.contact_id);
53
+ const { data: subscribers } = ids.length ? await db().from('subscribers').select('id, email').eq('project_id', projectId).in('id', ids) : { data: [] };
54
+ const emails = (subscribers ?? []).map((row) => row.email);
55
+ const { data: suppressions } = emails.length ? await db().from('suppressions').select('email').eq('project_id', projectId).in('email', emails) : { data: [] };
56
+ const suppressed = new Set((suppressions ?? []).map((row) => row.email));
57
+ const suppressedCount = (subscribers ?? []).filter((row) => suppressed.has(row.email)).length;
58
+ const total = ids.length;
59
+ return j({ total, suppressed_count: suppressedCount, will_receive: Math.max(total - suppressedCount, 0), warnings: suppressedCount ? [`${suppressedCount} contacts are suppressed and will be skipped`] : [], sample: (subscribers ?? []).filter((row) => !suppressed.has(row.email)).slice(0, 20) });
60
+ });
61
+ server.registerTool('list_campaigns', {
62
+ description: 'List campaigns, optionally filtered by site and/or status',
63
+ inputSchema: {
64
+ site_id: z.string().uuid().optional(),
65
+ status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']).optional(),
66
+ limit: z.number().int().min(1).max(100).optional().default(25),
67
+ },
68
+ }, async ({ site_id, status, limit }) => {
69
+ let q = db()
70
+ .from('campaigns')
71
+ .select('id, site_id, name, type, status, total_enrolled, total_sent, total_opened, total_clicked, created_at')
72
+ .eq('project_id', getProjectId())
73
+ .order('created_at', { ascending: false })
74
+ .limit(limit ?? 25);
75
+ if (site_id)
76
+ q = q.eq('site_id', site_id);
77
+ if (status)
78
+ q = q.eq('status', status);
79
+ const { data, error } = await q;
80
+ if (error)
81
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
82
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
83
+ });
84
+ server.registerTool('get_schedule', {
85
+ description: 'Get scheduled campaign sends for a site within a time window, grouped by campaign.',
86
+ inputSchema: {
87
+ site_id: z.string().uuid(),
88
+ window: z.enum(['today', 'tomorrow', '7d', '30d']).optional().default('today'),
89
+ },
90
+ }, async ({ site_id, window }) => {
91
+ const { start, end } = getScheduleWindowBounds(window ?? 'today');
92
+ const { data, error } = await db()
93
+ .from('campaign_enrollments')
94
+ .select('id, next_send_at, status, campaigns!inner(id, name, type, site_id, project_id)')
95
+ .gte('next_send_at', start.toISOString())
96
+ .lt('next_send_at', end.toISOString())
97
+ .not('status', 'in', '(completed,unsubscribed,failed)')
98
+ .eq('campaigns.project_id', getProjectId())
99
+ .eq('campaigns.site_id', site_id)
100
+ .order('next_send_at', { ascending: true });
101
+ if (error)
102
+ return txt(`Error: ${error.message}`);
103
+ const campaigns = groupScheduleByCampaign((data ?? []));
104
+ const total_sends_scheduled = campaigns.reduce((sum, campaign) => sum + campaign.total_sends_scheduled, 0);
105
+ return j({
106
+ site_id,
107
+ window: window ?? 'today',
108
+ start: start.toISOString(),
109
+ end: end.toISOString(),
110
+ total_sends_scheduled,
111
+ campaigns,
112
+ empty_message: total_sends_scheduled === 0 ? 'No campaign sends scheduled in this window.' : null,
113
+ });
114
+ });
115
+ server.registerTool('create_campaign', {
116
+ description: 'Create a new email campaign',
117
+ inputSchema: {
118
+ site_id: z.string().uuid(),
119
+ name: z.string().min(1).max(200),
120
+ type: z.enum(['sequence', 'broadcast', 'triggered']),
121
+ description: z.string().optional(),
122
+ requires_approval: z.boolean().optional().default(true),
123
+ },
124
+ }, async ({ site_id, name, type, description, requires_approval }) => {
125
+ const limit = await checkPlanLimit('active_campaigns');
126
+ if (!limit.allowed) {
127
+ return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
128
+ }
129
+ const { data, error } = await db()
130
+ .from('campaigns')
131
+ .insert({ project_id: getProjectId(), site_id, name, type, description, requires_approval })
132
+ .select()
133
+ .single();
134
+ if (error)
135
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
136
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
137
+ });
138
+ server.registerTool('add_campaign_step', {
139
+ description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
140
+ inputSchema: {
141
+ campaign_id: z.string().uuid(),
142
+ step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
143
+ subject: z.string().min(1),
144
+ body_html: z.string().optional(),
145
+ body_text: z.string().optional(),
146
+ delay_days: z.number().int().min(0).optional().default(0),
147
+ delay_hours: z.number().int().min(0).max(23).optional().default(0),
148
+ variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
149
+ },
150
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label }) => {
151
+ const { data, error } = await db()
152
+ .from('campaign_steps')
153
+ .insert({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
154
+ .select()
155
+ .single();
156
+ if (error)
157
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
158
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
159
+ });
160
+ server.registerTool('update_campaign_status', {
161
+ description: 'Activate, pause, or archive a campaign. Activating requires human approval via the dashboard — call without approval_id to request one, then call again with the approved token.',
162
+ inputSchema: {
163
+ campaign_id: z.string().uuid(),
164
+ status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']),
165
+ approval_id: z.string().uuid().optional().describe('Required when activating. Get this by calling once without it, then have a human approve.'),
166
+ },
167
+ }, async ({ campaign_id, status, approval_id }) => {
168
+ // Human approval required for activation
169
+ if (status === 'active') {
170
+ if (!approval_id) {
171
+ const approval = await requestApproval('update_campaign_status', { campaign_id, status });
172
+ return j(approval);
173
+ }
174
+ const check = await checkApproval(approval_id, { consume: true });
175
+ if (!check.approved) {
176
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
177
+ }
178
+ }
179
+ const { data, error } = await db()
180
+ .from('campaigns')
181
+ .update({ status, updated_at: new Date().toISOString() })
182
+ .eq('id', campaign_id)
183
+ .select()
184
+ .single();
185
+ if (error)
186
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
187
+ // Fire webhook for status changes
188
+ const eventMap = { active: 'campaign.activated', completed: 'campaign.completed', paused: 'campaign.paused' };
189
+ if (eventMap[status]) {
190
+ fireWebhookEvent(eventMap[status], {
191
+ campaign_id: data.id,
192
+ campaign_name: data.name,
193
+ status,
194
+ }).catch(() => { });
195
+ }
196
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
197
+ });
198
+ server.registerTool('enroll_contact', {
199
+ description: 'Enroll a single contact in a campaign',
200
+ inputSchema: {
201
+ campaign_id: z.string().uuid(),
202
+ contact_id: z.string().uuid(),
203
+ next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
204
+ },
205
+ }, async ({ campaign_id, contact_id, next_send_at }) => {
206
+ const { data, error } = await db()
207
+ .from('campaign_enrollments')
208
+ .insert({ campaign_id, contact_id, next_send_at: next_send_at ?? new Date().toISOString() })
209
+ .select()
210
+ .single();
211
+ if (error)
212
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
213
+ // Update campaign enrolled counter
214
+ const { data: campaign } = await db().from('campaigns').select('total_enrolled').eq('id', campaign_id).maybeSingle();
215
+ if (campaign) {
216
+ await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + 1 }).eq('id', campaign_id);
217
+ }
218
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
219
+ });
220
+ server.registerTool('get_enrollment_status', {
221
+ description: "Get one contact's enrollment status and sequence position for a campaign",
222
+ inputSchema: {
223
+ campaign_id: z.string().uuid(),
224
+ contact_id: z.string().uuid(),
225
+ },
226
+ }, async ({ campaign_id, contact_id }) => {
227
+ const projectId = getProjectId();
228
+ const [campaignRes, contactRes] = await Promise.all([
229
+ db()
230
+ .from('campaigns')
231
+ .select('id, name, type, status')
232
+ .eq('id', campaign_id)
233
+ .eq('project_id', projectId)
234
+ .maybeSingle(),
235
+ db()
236
+ .from('subscribers')
237
+ .select('id, email, first_name, last_name')
238
+ .eq('id', contact_id)
239
+ .eq('project_id', projectId)
240
+ .maybeSingle(),
241
+ ]);
242
+ if (campaignRes.error)
243
+ return txt(`Error: ${campaignRes.error.message}`);
244
+ if (contactRes.error)
245
+ return txt(`Error: ${contactRes.error.message}`);
246
+ if (!campaignRes.data || !contactRes.data)
247
+ return j({ error: 'Campaign or contact not found', code: 'NOT_FOUND' });
248
+ const { data: enrollment, error } = await db()
249
+ .from('campaign_enrollments')
250
+ .select('id, status, current_step, current_step_id, enrolled_at, completed_at, next_send_at, exit_reason')
251
+ .eq('campaign_id', campaign_id)
252
+ .eq('contact_id', contact_id)
253
+ .maybeSingle();
254
+ if (error)
255
+ return txt(`Error: ${error.message}`);
256
+ if (!enrollment) {
257
+ return j({ enrolled: false, enrollment: null, campaign: campaignRes.data, contact: contactRes.data });
258
+ }
259
+ const { count: stepsSent } = await db()
260
+ .from('email_log')
261
+ .select('id', { count: 'exact', head: true })
262
+ .eq('project_id', projectId)
263
+ .eq('campaign_id', campaign_id)
264
+ .eq('contact_id', contact_id)
265
+ .not('status', 'eq', 'failed');
266
+ return j({
267
+ enrolled: true,
268
+ campaign: campaignRes.data,
269
+ contact: contactRes.data,
270
+ enrollment: {
271
+ ...enrollment,
272
+ current_step_order: enrollment.current_step ?? 0,
273
+ steps_sent: stepsSent ?? 0,
274
+ },
275
+ });
276
+ });
277
+ server.registerTool('enroll_segment', {
278
+ description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
279
+ inputSchema: {
280
+ campaign_id: z.string().uuid(),
281
+ site_id: z.string().uuid(),
282
+ next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
283
+ },
284
+ }, async ({ campaign_id, site_id, next_send_at }) => {
285
+ const sendAt = next_send_at ?? new Date().toISOString();
286
+ const { data: subs, error: subErr } = await db()
287
+ .from('site_subscriptions')
288
+ .select('contact_id')
289
+ .eq('site_id', site_id)
290
+ .eq('subscribed', true);
291
+ if (subErr)
292
+ return { content: [{ type: 'text', text: `Error: ${subErr.message}` }] };
293
+ if (!subs?.length)
294
+ return { content: [{ type: 'text', text: 'No active subscribers for this site.' }] };
295
+ const ids = subs.map((s) => s.contact_id);
296
+ const { data: contacts } = await db()
297
+ .from('subscribers')
298
+ .select('id, email')
299
+ .in('id', ids)
300
+ .eq('global_unsubscribed', false);
301
+ const { data: suppressed } = await db().from('suppressions').select('email').eq('project_id', getProjectId());
302
+ const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
303
+ const eligible = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
304
+ if (!eligible.length)
305
+ return { content: [{ type: 'text', text: 'All contacts are suppressed or unsubscribed.' }] };
306
+ const rows = eligible.map((c) => ({ campaign_id, contact_id: c.id, next_send_at: sendAt }));
307
+ const { error: enrollErr } = await db()
308
+ .from('campaign_enrollments')
309
+ .upsert(rows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
310
+ if (enrollErr)
311
+ return { content: [{ type: 'text', text: `Error: ${enrollErr.message}` }] };
312
+ // Update campaign enrolled counter
313
+ const { data: campaign } = await db().from('campaigns').select('total_enrolled').eq('id', campaign_id).maybeSingle();
314
+ if (campaign) {
315
+ await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + eligible.length }).eq('id', campaign_id);
316
+ }
317
+ return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
318
+ });
319
+ server.registerTool('clone_campaign', {
320
+ description: 'Clone an existing campaign (with all steps) into a new draft campaign',
321
+ inputSchema: {
322
+ campaign_id: z.string().uuid().describe('Source campaign to clone'),
323
+ new_name: z.string().min(1).describe('Name for the cloned campaign'),
324
+ site_id: z.string().uuid().optional().describe('Override site_id, defaults to original'),
325
+ },
326
+ }, async ({ campaign_id, new_name, site_id }) => {
327
+ const projectId = getProjectId();
328
+ const { data: original, error: fetchErr } = await db()
329
+ .from('campaigns')
330
+ .select('*, campaign_steps(*)')
331
+ .eq('id', campaign_id)
332
+ .eq('project_id', projectId)
333
+ .maybeSingle();
334
+ if (fetchErr || !original)
335
+ return txt(`Error: Campaign not found — ${fetchErr?.message ?? 'not found'}`);
336
+ const limit = await checkPlanLimit('active_campaigns');
337
+ if (!limit.allowed)
338
+ return txt(`Active campaign limit reached (${limit.current}/${limit.limit}).`);
339
+ const { data: newCampaign, error: createErr } = await db()
340
+ .from('campaigns')
341
+ .insert({
342
+ project_id: projectId,
343
+ site_id: site_id ?? original.site_id,
344
+ name: new_name,
345
+ type: original.type,
346
+ description: original.description,
347
+ requires_approval: original.requires_approval,
348
+ status: 'draft',
349
+ })
350
+ .select()
351
+ .single();
352
+ if (createErr)
353
+ return txt(`Error creating campaign: ${createErr.message}`);
354
+ const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
355
+ if (steps.length > 0) {
356
+ const stepRows = steps.map((s) => ({
357
+ campaign_id: newCampaign.id,
358
+ step_order: s.step_order,
359
+ subject: s.subject,
360
+ body_html: s.body_html,
361
+ body_text: s.body_text,
362
+ delay_days: s.delay_days,
363
+ delay_hours: s.delay_hours,
364
+ }));
365
+ const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
366
+ if (stepErr)
367
+ return txt(`Campaign created but steps failed: ${stepErr.message}`);
368
+ }
369
+ return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
370
+ });
371
+ server.registerTool('generate_email', {
372
+ description: 'Generate email content (subject, HTML, plain text) from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
373
+ inputSchema: {
374
+ site_id: z.string().uuid(),
375
+ brief: z.string().min(1).describe('Description of the email to generate'),
376
+ format: z.enum(['both', 'html', 'text']).optional().describe('Output format, default both'),
377
+ contact_id: z.string().uuid().optional().describe('Optional contact UUID — when provided, AI adapts tone based on their engagement level and history'),
378
+ },
379
+ }, async ({ site_id, brief, format = 'both', contact_id }) => {
380
+ const apiKey = process.env.ANTHROPIC_API_KEY;
381
+ if (!apiKey)
382
+ return txt('Error: ANTHROPIC_API_KEY not configured');
383
+ const projectId = getProjectId();
384
+ // Fetch site and optionally contact data in parallel
385
+ const contactPromise = contact_id
386
+ ? db()
387
+ .from('subscribers')
388
+ .select('engagement_score, lifecycle_stage, tags, total_opens, total_clicks, last_opened_at')
389
+ .eq('id', contact_id)
390
+ .eq('project_id', projectId)
391
+ .maybeSingle()
392
+ : Promise.resolve({ data: null, error: null });
393
+ const [{ data: site }, { data: contact }, { data: project }] = await Promise.all([
394
+ db()
395
+ .from('sites')
396
+ .select('name, sending_domain, from_email, from_name, brand_voice')
397
+ .eq('id', site_id)
398
+ .eq('project_id', projectId)
399
+ .maybeSingle(),
400
+ contactPromise,
401
+ db()
402
+ .from('projects')
403
+ .select('brand_settings')
404
+ .eq('id', projectId)
405
+ .maybeSingle(),
406
+ ]);
407
+ const brandContext = site
408
+ ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name} <${site.from_email}>`
409
+ : 'No brand info available';
410
+ const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
411
+ let systemPrompt = buildEmailSystemPrompt(brandContext, voicePrompt);
412
+ // Inject per-contact context when available
413
+ if (contact) {
414
+ const lastActivity = contact.last_opened_at
415
+ ? new Date(contact.last_opened_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
416
+ : 'never';
417
+ systemPrompt += `\n\nRecipient context:
418
+ - Lifecycle stage: ${contact.lifecycle_stage ?? 'unknown'}
419
+ - Engagement score: ${contact.engagement_score ?? 0}/100
420
+ - Tags: ${(contact.tags ?? []).join(', ') || 'none'}
421
+ - Total opens: ${contact.total_opens ?? 0}, Total clicks: ${contact.total_clicks ?? 0}
422
+ - Last activity: ${lastActivity}
423
+
424
+ Adapt your tone accordingly:
425
+ - For "new" contacts: welcoming, introductory, low-commitment CTA
426
+ - For "active" contacts: value-adding, feature highlights, deeper engagement
427
+ - For "cooling" contacts: re-engage with curiosity, remind of value, easy win CTA
428
+ - For "cold" contacts: acknowledge absence, offer compelling reason to return, urgency
429
+ - For "churned" contacts: last-chance, emotional appeal, exclusive offer`;
430
+ }
431
+ const anthropic = new Anthropic({ apiKey });
432
+ const genResponse = await anthropic.messages.create({
433
+ model: getAnthropicModelId(),
434
+ max_tokens: 2048,
435
+ system: systemPrompt,
436
+ messages: [{ role: 'user', content: brief }],
437
+ });
438
+ const textBlock = genResponse.content.find((b) => b.type === 'text');
439
+ const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
440
+ try {
441
+ const result = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
442
+ if (format === 'html')
443
+ delete result.body_text;
444
+ if (format === 'text')
445
+ delete result.body_html;
446
+ return j(result);
447
+ }
448
+ catch {
449
+ return txt(`Error: Failed to parse generated email. AI returned: ${raw.slice(0, 200)}`);
450
+ }
451
+ });
452
+ server.registerTool('launch_campaign', {
453
+ description: 'Full campaign lifecycle in one call: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Requires confirmation.',
454
+ inputSchema: {
455
+ name: z.string().min(1).describe('Campaign name'),
456
+ site_id: z.string().uuid(),
457
+ type: z.enum(['sequence', 'broadcast', 'triggered']).optional().default('sequence'),
458
+ segment_description: z.string().optional().describe('Natural language audience description'),
459
+ segment_id: z.string().uuid().optional().describe('Existing segment ID (alternative to segment_description)'),
460
+ emails: z.array(z.object({
461
+ brief: z.string().describe('What this email should say'),
462
+ delay_days: z.number().int().min(0).optional().default(0),
463
+ delay_hours: z.number().int().min(0).optional().default(0),
464
+ })).min(1),
465
+ confirmed: z.boolean().optional().describe('Set true after previewing to execute'),
466
+ },
467
+ }, async ({ name, site_id, type, segment_description, segment_id, emails, confirmed }) => {
468
+ if (!segment_description && !segment_id)
469
+ return txt('Provide segment_description or segment_id.');
470
+ const apiKey = process.env.ANTHROPIC_API_KEY;
471
+ if (!apiKey)
472
+ return txt('Error: ANTHROPIC_API_KEY not configured');
473
+ const anthropic = new Anthropic({ apiKey });
474
+ const projectId = getProjectId();
475
+ function applyPaymentRules(query, payment) {
476
+ if (!payment)
477
+ return query;
478
+ let q = query;
479
+ if (payment.plan)
480
+ q = q.contains('custom_properties', { payment: { plan: payment.plan } });
481
+ if (payment.mrr_cents !== undefined)
482
+ q = q.contains('custom_properties', { payment: { mrr_cents: payment.mrr_cents } });
483
+ if (payment.ltv_cents !== undefined)
484
+ q = q.contains('custom_properties', { payment: { ltv_cents: payment.ltv_cents } });
485
+ if (payment.subscription_status)
486
+ q = q.contains('custom_properties', { payment: { subscription_status: payment.subscription_status } });
487
+ q = applyPaymentDateRule(q, 'trial_ends_at', payment.trial_ends_at);
488
+ q = applyPaymentDateRule(q, 'churned_at', payment.churned_at);
489
+ q = applyPaymentDateRule(q, 'paid_at', payment.paid_at);
490
+ return q;
491
+ }
492
+ function applyPaymentDateRule(query, field, rule) {
493
+ if (!rule)
494
+ return query;
495
+ if (typeof rule === 'string') {
496
+ return query.contains('custom_properties', { payment: { [field]: rule } });
497
+ }
498
+ let q = query;
499
+ const column = `custom_properties->payment->>${field}`;
500
+ if (typeof rule.within_days === 'number') {
501
+ const start = new Date();
502
+ const end = new Date(start.getTime() + rule.within_days * 24 * 60 * 60 * 1000);
503
+ q = q.gte(column, start.toISOString()).lte(column, end.toISOString());
504
+ }
505
+ if (rule.after)
506
+ q = q.gte(column, rule.after);
507
+ if (rule.before)
508
+ q = q.lte(column, rule.before);
509
+ return q;
510
+ }
511
+ // Helper to get segment contact IDs (simplified inline)
512
+ const getContactIds = async (rules) => {
513
+ let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false);
514
+ if (rules.lifecycle_stage)
515
+ q = q.eq('lifecycle_stage', rules.lifecycle_stage);
516
+ q = applyPaymentRules(q, rules.payment);
517
+ if (rules.min_score !== undefined)
518
+ q = q.gte('engagement_score', rules.min_score);
519
+ if (rules.max_score !== undefined)
520
+ q = q.lte('engagement_score', rules.max_score);
521
+ if (rules.source)
522
+ q = q.eq('source', rules.source);
523
+ if (rules.subscribed_after)
524
+ q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
525
+ if (rules.subscribed_before)
526
+ q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
527
+ if (rules.has_opened === true)
528
+ q = q.not('last_opened_at', 'is', null);
529
+ if (rules.has_opened === false)
530
+ q = q.is('last_opened_at', null);
531
+ if (rules.has_clicked === true)
532
+ q = q.not('last_clicked_at', 'is', null);
533
+ if (rules.has_clicked === false)
534
+ q = q.is('last_clicked_at', null);
535
+ if (rules.tag)
536
+ q = q.contains('tags', [rules.tag]);
537
+ if (rules.site_id) {
538
+ const { data: subs } = await db().from('site_subscriptions').select('contact_id').eq('site_id', rules.site_id).eq('subscribed', true);
539
+ const siteIds = (subs ?? []).map((s) => s.contact_id);
540
+ if (!siteIds.length)
541
+ return [];
542
+ q = q.in('id', siteIds);
543
+ }
544
+ const { data, error } = await q;
545
+ if (error || !data)
546
+ return [];
547
+ let ids = data.map((c) => c.id);
548
+ const { data: suppressed } = await db().from('suppressions').select('email').eq('project_id', projectId);
549
+ if (suppressed?.length) {
550
+ const suppressedEmails = new Set(suppressed.map((s) => s.email));
551
+ ids = data.filter((c) => !suppressedEmails.has(c.email)).map((c) => c.id);
552
+ }
553
+ return ids;
554
+ };
555
+ let segId = segment_id;
556
+ let segmentName;
557
+ let contactCount = 0;
558
+ let parsedRules;
559
+ if (segment_description) {
560
+ const parseResp = await anthropic.messages.create({
561
+ model: getAnthropicModelId(),
562
+ max_tokens: 512,
563
+ system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property, exclude_suppressed. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
564
+ messages: [{ role: 'user', content: segment_description }],
565
+ });
566
+ const tb = parseResp.content.find((b) => b.type === 'text');
567
+ const rawRules = tb && 'text' in tb ? tb.text : '{}';
568
+ try {
569
+ parsedRules = JSON.parse(rawRules.replace(/```json?\n?|```/g, '').trim());
570
+ }
571
+ catch {
572
+ return txt(`Error: Failed to parse segment from "${segment_description}"`);
573
+ }
574
+ const ids = await getContactIds(parsedRules);
575
+ contactCount = ids.length;
576
+ segmentName = `${name} — segment`;
577
+ }
578
+ else {
579
+ const { data: seg, error: segErr } = await db()
580
+ .from('segments')
581
+ .select('id, name, rules')
582
+ .eq('id', segment_id)
583
+ .eq('project_id', projectId)
584
+ .maybeSingle();
585
+ if (segErr || !seg)
586
+ return txt('Error: segment not found');
587
+ segmentName = seg.name;
588
+ parsedRules = seg.rules;
589
+ const ids = await getContactIds(parsedRules);
590
+ contactCount = ids.length;
591
+ }
592
+ if (!confirmed) {
593
+ const subjectPreviews = [];
594
+ for (const email of emails) {
595
+ const subResp = await anthropic.messages.create({
596
+ model: getAnthropicModelId(),
597
+ max_tokens: 100,
598
+ system: 'Generate a single email subject line for: ' + email.brief + '. Return ONLY the subject line text.',
599
+ messages: [{ role: 'user', content: email.brief }],
600
+ });
601
+ const stb = subResp.content.find((b) => b.type === 'text');
602
+ subjectPreviews.push(stb && 'text' in stb ? stb.text.trim() : email.brief);
603
+ }
604
+ return j({
605
+ confirmation_required: true,
606
+ campaign_name: name,
607
+ segment: segment_description
608
+ ? { description: segment_description, parsed_rules: parsedRules, matching_contacts: contactCount }
609
+ : { id: segId, name: segmentName, matching_contacts: contactCount },
610
+ emails: emails.map((e, i) => ({
611
+ step: i, brief: e.brief, subject_preview: subjectPreviews[i], delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0,
612
+ })),
613
+ message: `Campaign "${name}" will target ${contactCount} contacts with ${emails.length} email(s). Call again with confirmed: true to create everything.`,
614
+ });
615
+ }
616
+ if (contactCount === 0)
617
+ return txt('No contacts match the segment. Adjust targeting criteria.');
618
+ // Create segment if from NL description
619
+ if (segment_description) {
620
+ const { data: seg, error: segErr } = await db()
621
+ .from('segments')
622
+ .insert({ project_id: projectId, name: segmentName, description: segment_description, rules: parsedRules })
623
+ .select('id')
624
+ .single();
625
+ if (segErr)
626
+ return txt(`Error creating segment: ${segErr.message}`);
627
+ segId = seg.id;
628
+ }
629
+ const campLimit = await checkPlanLimit('active_campaigns');
630
+ if (!campLimit.allowed)
631
+ return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
632
+ const { data: campaign, error: campErr } = await db()
633
+ .from('campaigns')
634
+ .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, requires_approval: false })
635
+ .select('id')
636
+ .single();
637
+ if (campErr)
638
+ return txt(`Error creating campaign: ${campErr.message}`);
639
+ const { data: site } = await db()
640
+ .from('sites')
641
+ .select('name, sending_domain, from_name, from_email, brand_voice')
642
+ .eq('id', site_id)
643
+ .eq('project_id', projectId)
644
+ .maybeSingle();
645
+ const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
646
+ const voicePrompt = buildVoicePrompt(site?.brand_voice);
647
+ const systemPrompt = buildEmailSystemPrompt(brandCtx, voicePrompt);
648
+ const steps = [];
649
+ for (let i = 0; i < emails.length; i++) {
650
+ const email = emails[i];
651
+ const genResp = await anthropic.messages.create({
652
+ model: getAnthropicModelId(),
653
+ max_tokens: 2048,
654
+ system: systemPrompt,
655
+ messages: [{ role: 'user', content: email.brief }],
656
+ });
657
+ const gtb = genResp.content.find((b) => b.type === 'text');
658
+ const rawEmail = gtb && 'text' in gtb ? gtb.text : '{}';
659
+ let emailContent;
660
+ try {
661
+ emailContent = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
662
+ }
663
+ catch {
664
+ emailContent = { subject: `Step ${i + 1}`, body_html: '<p>' + email.brief + '</p>', body_text: email.brief };
665
+ }
666
+ const { data: step, error: stepErr } = await db()
667
+ .from('campaign_steps')
668
+ .insert({
669
+ campaign_id: campaign.id, step_order: i,
670
+ subject: emailContent.subject, body_html: emailContent.body_html, body_text: emailContent.body_text,
671
+ delay_days: email.delay_days ?? 0, delay_hours: email.delay_hours ?? 0,
672
+ })
673
+ .select('id, step_order, subject')
674
+ .single();
675
+ if (stepErr)
676
+ return txt(`Error adding step ${i}: ${stepErr.message}`);
677
+ steps.push(step);
678
+ }
679
+ // Enroll contacts
680
+ const enrollIds = await getContactIds(parsedRules);
681
+ const enrollRows = enrollIds.map((id) => ({ campaign_id: campaign.id, contact_id: id, next_send_at: new Date().toISOString() }));
682
+ if (enrollRows.length) {
683
+ await db().from('campaign_enrollments').upsert(enrollRows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
684
+ }
685
+ // Activate + update enrolled count
686
+ await db().from('campaigns').update({ status: 'active', total_enrolled: enrollIds.length, updated_at: new Date().toISOString() }).eq('id', campaign.id);
687
+ return j({
688
+ campaign_id: campaign.id, campaign_name: name, status: 'active',
689
+ segment: { id: segId, name: segmentName, contacts: contactCount },
690
+ enrolled: enrollIds.length,
691
+ steps: steps.map(s => ({ step: s.step_order, subject: s.subject })),
692
+ message: `Campaign "${name}" is live! ${enrollIds.length} contacts enrolled across ${steps.length} email(s).`,
693
+ });
694
+ });
695
+ server.registerTool('preview_email', {
696
+ description: 'Preview email content with merge variables resolved using sample contact data. Provide content via step_id, draft_id, or raw body_html/subject.',
697
+ inputSchema: {
698
+ step_id: z.string().uuid().optional().describe('Campaign step UUID'),
699
+ draft_id: z.string().uuid().optional().describe('AI draft UUID'),
700
+ subject: z.string().optional().describe('Raw subject'),
701
+ body_html: z.string().optional().describe('Raw HTML'),
702
+ },
703
+ }, async ({ step_id, draft_id, subject, body_html }) => {
704
+ const projectId = getProjectId();
705
+ let subj = subject ?? '';
706
+ let html = body_html ?? '';
707
+ let text = '';
708
+ if (!subj && !html) {
709
+ if (step_id) {
710
+ const { data: step, error } = await db()
711
+ .from('campaign_steps')
712
+ .select('subject, body_html, body_text')
713
+ .eq('id', step_id)
714
+ .maybeSingle();
715
+ if (error || !step)
716
+ return txt('Error: Step not found');
717
+ subj = step.subject;
718
+ html = step.body_html ?? '';
719
+ text = step.body_text ?? '';
720
+ }
721
+ else if (draft_id) {
722
+ const { data: draft, error } = await db()
723
+ .from('ai_drafts')
724
+ .select('subject, body_html, body_text')
725
+ .eq('id', draft_id)
726
+ .eq('project_id', projectId)
727
+ .maybeSingle();
728
+ if (error || !draft)
729
+ return txt('Error: Draft not found');
730
+ subj = draft.subject;
731
+ html = draft.body_html ?? '';
732
+ text = draft.body_text ?? '';
733
+ }
734
+ else {
735
+ return txt('Provide step_id, draft_id, or raw subject/body_html.');
736
+ }
737
+ }
738
+ const { data: sample } = await db()
739
+ .from('subscribers')
740
+ .select('id, email, first_name, last_name')
741
+ .eq('project_id', projectId)
742
+ .eq('global_unsubscribed', false)
743
+ .order('created_at', { ascending: true })
744
+ .limit(1)
745
+ .maybeSingle();
746
+ const contact = sample ?? { email: 'preview@example.com', first_name: 'Jane' };
747
+ const resolve = (t) => t
748
+ .replace(/\{\{first_name\}\}/g, contact.first_name || 'there')
749
+ .replace(/\{\{email\}\}/g, contact.email)
750
+ .replace(/\{\{unsubscribe_url\}\}/g, '#unsubscribe-preview');
751
+ return j({ subject: resolve(subj), body_html: resolve(html), body_text: resolve(text), sample_contact: contact });
752
+ });
753
+ server.registerTool('send_test_email', {
754
+ description: 'Send a test email to a specified address via Resend. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
755
+ inputSchema: {
756
+ to_email: z.string().email().describe('Email address to send the test to'),
757
+ site_id: z.string().uuid().describe('Site UUID (determines from address)'),
758
+ step_id: z.string().uuid().optional(),
759
+ draft_id: z.string().uuid().optional(),
760
+ subject: z.string().optional(),
761
+ body_html: z.string().optional(),
762
+ body_text: z.string().optional(),
763
+ },
764
+ }, async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
765
+ const projectId = getProjectId();
766
+ let subj = subject ?? '';
767
+ let html = body_html ?? '';
768
+ let text = body_text ?? '';
769
+ if (!subj && !html) {
770
+ if (step_id) {
771
+ const { data: step } = await db().from('campaign_steps').select('subject, body_html, body_text').eq('id', step_id).maybeSingle();
772
+ if (!step)
773
+ return txt('Error: Step not found');
774
+ subj = step.subject;
775
+ html = step.body_html ?? '';
776
+ text = step.body_text ?? '';
777
+ }
778
+ else if (draft_id) {
779
+ const { data: draft } = await db().from('ai_drafts').select('subject, body_html, body_text').eq('id', draft_id).eq('project_id', projectId).maybeSingle();
780
+ if (!draft)
781
+ return txt('Error: Draft not found');
782
+ subj = draft.subject;
783
+ html = draft.body_html ?? '';
784
+ text = draft.body_text ?? '';
785
+ }
786
+ else {
787
+ return txt('Provide content via subject/body_html, step_id, or draft_id.');
788
+ }
789
+ }
790
+ const resolve = (t) => t
791
+ .replace(/\{\{first_name\}\}/g, 'Test')
792
+ .replace(/\{\{email\}\}/g, to_email)
793
+ .replace(/\{\{unsubscribe_url\}\}/g, '#test-unsubscribe');
794
+ // Suppression check — don't send test emails to bounced/complained addresses
795
+ const { data: suppressed } = await db()
796
+ .from('suppressions')
797
+ .select('reason')
798
+ .eq('project_id', projectId)
799
+ .eq('email', to_email.toLowerCase())
800
+ .maybeSingle();
801
+ if (suppressed)
802
+ return txt(`Error: Recipient suppressed (${suppressed.reason}). Test emails to suppressed addresses risk domain reputation.`);
803
+ const { data: site } = await db()
804
+ .from('sites')
805
+ .select('from_email, from_name')
806
+ .eq('id', site_id)
807
+ .eq('project_id', projectId)
808
+ .maybeSingle();
809
+ if (!site)
810
+ return txt('Error: Site not found');
811
+ // Use Resend via env var (MCP server context)
812
+ const { Resend } = await import('resend');
813
+ const resendKey = process.env.RESEND_API_KEY;
814
+ if (!resendKey)
815
+ return txt('Error: RESEND_API_KEY not configured');
816
+ const resend = new Resend(resendKey);
817
+ const payload = {
818
+ from: `${site.from_name} <${site.from_email}>`,
819
+ to: to_email,
820
+ subject: `[TEST] ${resolve(subj)}`,
821
+ };
822
+ const resolvedHtml = resolve(html);
823
+ const resolvedText = resolve(text);
824
+ if (resolvedHtml)
825
+ payload.html = resolvedHtml;
826
+ if (resolvedText)
827
+ payload.text = resolvedText;
828
+ const { data: resendData, error: sendError } = await resend.emails.send(payload);
829
+ if (sendError)
830
+ return txt(`Error: Send failed — ${sendError.message}`);
831
+ await db().from('email_log').insert({
832
+ project_id: projectId,
833
+ site_id,
834
+ contact_id: null,
835
+ to_email,
836
+ from_email: site.from_email,
837
+ subject: resolve(subj),
838
+ status: 'sent',
839
+ category: 'test',
840
+ sent_at: new Date().toISOString(),
841
+ provider_message_id: resendData?.id ?? null,
842
+ body_html: resolvedHtml ?? null,
843
+ });
844
+ return j({ sent: true, to: to_email, resend_id: resendData?.id });
845
+ });
846
+ server.registerTool('send_transactional', {
847
+ description: 'Send a transactional email (password reset, receipt, notification). Checks suppressions and plan limits. Logged with category transactional.',
848
+ inputSchema: {
849
+ to: z.string().email().describe('Recipient email address'),
850
+ site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
851
+ subject: z.string().describe('Email subject line'),
852
+ html: z.string().optional().describe('HTML body'),
853
+ text: z.string().optional().describe('Plain text body'),
854
+ reply_to: z.string().email().optional().describe('Reply-to address'),
855
+ idempotency_key: z.string().optional().describe('Unique key to prevent duplicate sends'),
856
+ },
857
+ }, async ({ to, site_id, subject, html, text, reply_to, idempotency_key }) => {
858
+ const projectId = getProjectId();
859
+ if (!html && !text)
860
+ return txt('At least one of html or text is required');
861
+ // Suppression check
862
+ const { data: suppression } = await db()
863
+ .from('suppressions')
864
+ .select('id, reason')
865
+ .eq('project_id', projectId)
866
+ .eq('email', to.toLowerCase())
867
+ .limit(1)
868
+ .maybeSingle();
869
+ if (suppression)
870
+ return txt(`Error: Recipient suppressed — ${suppression.reason}`);
871
+ // Idempotency check
872
+ if (idempotency_key) {
873
+ const { data: existing } = await db()
874
+ .from('email_log')
875
+ .select('id, provider_message_id, status')
876
+ .eq('project_id', projectId)
877
+ .eq('idempotency_key', idempotency_key)
878
+ .maybeSingle();
879
+ if (existing)
880
+ return j({ error: 'Duplicate send', id: existing.id, resend_id: existing.provider_message_id });
881
+ }
882
+ // Site verification
883
+ const { data: site } = await db()
884
+ .from('sites')
885
+ .select('from_email, from_name')
886
+ .eq('id', site_id)
887
+ .eq('project_id', projectId)
888
+ .maybeSingle();
889
+ if (!site)
890
+ return txt('Error: Site not found');
891
+ // Send via Resend
892
+ const { Resend } = await import('resend');
893
+ const resendKey = process.env.RESEND_API_KEY;
894
+ if (!resendKey)
895
+ return txt('Error: RESEND_API_KEY not configured');
896
+ const resend = new Resend(resendKey);
897
+ const payload = {
898
+ from: `${site.from_name} <${site.from_email}>`,
899
+ to,
900
+ subject,
901
+ };
902
+ if (html)
903
+ payload.html = html;
904
+ if (text)
905
+ payload.text = text;
906
+ if (reply_to)
907
+ payload.reply_to = reply_to;
908
+ const { data: resendData, error: sendError } = await resend.emails.send(payload);
909
+ if (sendError)
910
+ return txt(`Error: Send failed — ${sendError.message}`);
911
+ // Log to email_log
912
+ const { data: logRow } = await db().from('email_log').insert({
913
+ project_id: projectId,
914
+ site_id,
915
+ contact_id: null,
916
+ to_email: to,
917
+ from_email: site.from_email,
918
+ subject,
919
+ status: 'sent',
920
+ category: 'transactional',
921
+ sent_at: new Date().toISOString(),
922
+ provider_message_id: resendData?.id ?? null,
923
+ idempotency_key: idempotency_key ?? null,
924
+ body_html: html ?? null,
925
+ }).select('id').single();
926
+ return j({ sent: true, id: logRow?.id, to, resend_id: resendData?.id });
927
+ });
928
+ server.registerTool('validate_template', {
929
+ description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, stock phrases, excessive CTAs, missing sign-off, and more.',
930
+ inputSchema: {
931
+ html: z.string().describe('HTML email content to validate'),
932
+ site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
933
+ },
934
+ }, async ({ html, site_id }) => {
935
+ let voiceConfig = null;
936
+ if (site_id) {
937
+ const { data: site } = await db()
938
+ .from('sites')
939
+ .select('brand_voice')
940
+ .eq('id', site_id)
941
+ .eq('project_id', getProjectId())
942
+ .maybeSingle();
943
+ voiceConfig = site?.brand_voice ?? null;
944
+ }
945
+ const result = validateTemplate(html, voiceConfig);
946
+ return j(result);
947
+ });
948
+ // ── Orchestrator fast path ──────────────────────────────────────────────
949
+ // Creates a campaign with pre-built content in a single call.
950
+ // Designed for external orchestrators (any MCP client, HTTP API, or automation)
951
+ // that draft their own content and just need Sendinel to deliver it.
952
+ server.registerTool('create_campaign_with_content', {
953
+ description: 'Create a campaign with pre-built email content in one call. Creates the campaign, adds email step(s), optionally enrolls a segment, and activates. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
954
+ inputSchema: {
955
+ site_id: z.string().uuid().describe('Site to send from'),
956
+ name: z.string().min(1).max(200).describe('Campaign name'),
957
+ type: z.enum(['sequence', 'broadcast']).describe('Campaign type'),
958
+ steps: z.array(z.object({
959
+ subject: z.string().min(1).describe('Email subject line'),
960
+ body_html: z.string().min(1).describe('Full HTML email body'),
961
+ body_text: z.string().optional().describe('Plain text fallback'),
962
+ delay_days: z.number().int().min(0).optional().default(0),
963
+ delay_hours: z.number().int().min(0).max(23).optional().default(0),
964
+ })).min(1).describe('Email steps with pre-built content'),
965
+ segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. If omitted, campaign is created in draft without enrollments.'),
966
+ activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id.'),
967
+ confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
968
+ approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
969
+ },
970
+ }, async ({ site_id, name, type, steps, segment_id, activate, confirmed, approval_id }) => {
971
+ // Human-in-the-loop gate for activation
972
+ if (activate && confirmed) {
973
+ const tier = getApprovalTier('create_campaign_with_content', { activate });
974
+ if (tier !== 'autonomous') {
975
+ if (!approval_id) {
976
+ // No approval token — request one
977
+ const approval = await requestApproval('create_campaign_with_content', { site_id, name, type, steps_count: steps.length, segment_id, activate });
978
+ return j(approval);
979
+ }
980
+ // Have a token — verify it's approved
981
+ const status = await checkApproval(approval_id, { consume: true });
982
+ if (!status.approved) {
983
+ return txt(`Approval ${approval_id} is ${status.status}. ${status.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
984
+ }
985
+ }
986
+ }
987
+ // Plan limit check
988
+ const limit = await checkPlanLimit('active_campaigns');
989
+ if (!limit.allowed) {
990
+ return txt(`Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`);
991
+ }
992
+ // Verify site exists
993
+ const { data: site, error: siteErr } = await db()
994
+ .from('sites')
995
+ .select('id, name, sending_domain, from_email')
996
+ .eq('id', site_id)
997
+ .eq('project_id', getProjectId())
998
+ .maybeSingle();
999
+ if (siteErr || !site)
1000
+ return txt(`Site not found: ${site_id}`);
1001
+ // If segment provided, get contact count
1002
+ let contactCount = 0;
1003
+ if (segment_id) {
1004
+ const { data: subs } = await db()
1005
+ .from('site_subscriptions')
1006
+ .select('contact_id', { count: 'exact', head: true })
1007
+ .eq('site_id', site_id)
1008
+ .eq('subscribed', true);
1009
+ contactCount = subs ?? 0;
1010
+ }
1011
+ // Preview mode (first call)
1012
+ if (!confirmed) {
1013
+ return j({
1014
+ confirmation_required: true,
1015
+ campaign: { name, type, site: site.name },
1016
+ steps: steps.map((s, i) => ({
1017
+ step: i + 1,
1018
+ subject: s.subject,
1019
+ delay: s.delay_days ? `${s.delay_days}d ${s.delay_hours ?? 0}h` : `${s.delay_hours ?? 0}h`,
1020
+ has_html: !!s.body_html,
1021
+ has_text: !!s.body_text,
1022
+ })),
1023
+ segment_id: segment_id ?? null,
1024
+ estimated_contacts: contactCount,
1025
+ will_activate: activate && !!segment_id,
1026
+ message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : 'No segment — created as draft.'} ${activate && segment_id ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
1027
+ });
1028
+ }
1029
+ // Execute mode (second call)
1030
+ // 1. Create campaign
1031
+ const { data: campaign, error: campErr } = await db()
1032
+ .from('campaigns')
1033
+ .insert({
1034
+ project_id: getProjectId(),
1035
+ site_id,
1036
+ name,
1037
+ type,
1038
+ status: 'draft',
1039
+ segment_id: segment_id ?? null,
1040
+ })
1041
+ .select()
1042
+ .single();
1043
+ if (campErr)
1044
+ return txt(`Failed to create campaign: ${campErr.message}`);
1045
+ // 2. Add steps
1046
+ const stepInserts = steps.map((s, i) => ({
1047
+ campaign_id: campaign.id,
1048
+ step_order: i,
1049
+ subject: s.subject,
1050
+ body_html: s.body_html,
1051
+ body_text: s.body_text ?? null,
1052
+ delay_days: s.delay_days ?? 0,
1053
+ delay_hours: s.delay_hours ?? 0,
1054
+ }));
1055
+ const { error: stepErr } = await db()
1056
+ .from('campaign_steps')
1057
+ .insert(stepInserts);
1058
+ if (stepErr)
1059
+ return txt(`Campaign created (${campaign.id}) but failed to add steps: ${stepErr.message}`);
1060
+ // 3. Enroll segment if provided
1061
+ let enrolled = 0;
1062
+ if (segment_id) {
1063
+ const { data: subs } = await db()
1064
+ .from('site_subscriptions')
1065
+ .select('contact_id')
1066
+ .eq('site_id', site_id)
1067
+ .eq('subscribed', true);
1068
+ if (subs?.length) {
1069
+ const ids = subs.map((s) => s.contact_id);
1070
+ const { data: contacts } = await db()
1071
+ .from('subscribers')
1072
+ .select('id, email')
1073
+ .in('id', ids)
1074
+ .eq('global_unsubscribed', false);
1075
+ const { data: suppressed } = await db()
1076
+ .from('suppressions')
1077
+ .select('email')
1078
+ .eq('project_id', getProjectId());
1079
+ const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
1080
+ const eligible = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
1081
+ if (eligible.length) {
1082
+ const enrollments = eligible.map((c) => ({
1083
+ campaign_id: campaign.id,
1084
+ contact_id: c.id,
1085
+ next_send_at: new Date().toISOString(),
1086
+ }));
1087
+ const { error: enrollErr } = await db()
1088
+ .from('campaign_enrollments')
1089
+ .insert(enrollments);
1090
+ if (!enrollErr) {
1091
+ enrolled = eligible.length;
1092
+ await db().from('campaigns').update({ total_enrolled: eligible.length }).eq('id', campaign.id);
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+ // 4. Activate if requested
1098
+ if (activate && segment_id && enrolled > 0) {
1099
+ await db()
1100
+ .from('campaigns')
1101
+ .update({ status: 'active' })
1102
+ .eq('id', campaign.id);
1103
+ // Fire webhook event (non-blocking)
1104
+ fireWebhookEvent('campaign.activated', {
1105
+ campaign_id: campaign.id,
1106
+ campaign_name: name,
1107
+ type,
1108
+ contacts_enrolled: enrolled,
1109
+ steps: steps.length,
1110
+ }).catch(() => { }); // fire-and-forget
1111
+ }
1112
+ return j({
1113
+ campaign_id: campaign.id,
1114
+ name,
1115
+ type,
1116
+ steps_created: steps.length,
1117
+ contacts_enrolled: enrolled,
1118
+ status: activate && enrolled > 0 ? 'active' : 'draft',
1119
+ message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${activate && enrolled > 0 ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
1120
+ });
1121
+ });
1122
+ server.registerTool('list_campaign_steps', {
1123
+ description: 'List all steps for a campaign in send order.',
1124
+ inputSchema: { campaign_id: z.string().uuid() },
1125
+ }, async ({ campaign_id }) => {
1126
+ const projectId = getProjectId();
1127
+ const { data: campaign, error: campaignErr } = await db()
1128
+ .from('campaigns')
1129
+ .select('id')
1130
+ .eq('id', campaign_id)
1131
+ .eq('project_id', projectId)
1132
+ .maybeSingle();
1133
+ if (campaignErr || !campaign)
1134
+ return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
1135
+ const { data, error } = await db()
1136
+ .from('campaign_steps')
1137
+ .select('id, step_order, subject, body_html, body_text, delay_days, delay_hours, created_at, updated_at')
1138
+ .eq('campaign_id', campaign_id)
1139
+ .order('step_order', { ascending: true });
1140
+ if (error)
1141
+ return j({ error: error.message });
1142
+ return j({ steps: data ?? [], count: data?.length ?? 0 });
1143
+ });
1144
+ server.registerTool('update_campaign_step', {
1145
+ description: 'Update a campaign step — change subject, body, delay, or step_order.',
1146
+ inputSchema: {
1147
+ step_id: z.string().uuid(),
1148
+ subject: z.string().optional(),
1149
+ body_html: z.string().optional(),
1150
+ body_text: z.string().optional(),
1151
+ delay_days: z.number().int().min(0).optional(),
1152
+ delay_hours: z.number().int().min(0).optional(),
1153
+ step_order: z.number().int().min(0).optional(),
1154
+ },
1155
+ }, async ({ step_id, ...updates }) => {
1156
+ const projectId = getProjectId();
1157
+ const { data: step, error: stepErr } = await db()
1158
+ .from('campaign_steps')
1159
+ .select('id, campaign_id')
1160
+ .eq('id', step_id)
1161
+ .maybeSingle();
1162
+ if (stepErr || !step)
1163
+ return j({ error: stepErr?.message ?? 'Step not found', code: 'NOT_FOUND' });
1164
+ const { data: campaign } = await db()
1165
+ .from('campaigns')
1166
+ .select('id')
1167
+ .eq('id', step.campaign_id)
1168
+ .eq('project_id', projectId)
1169
+ .maybeSingle();
1170
+ if (!campaign)
1171
+ return j({ error: 'Step not found', code: 'NOT_FOUND' });
1172
+ const { data, error } = await db()
1173
+ .from('campaign_steps')
1174
+ .update({ ...updates, updated_at: new Date().toISOString() })
1175
+ .eq('id', step_id)
1176
+ .select('id, step_order, subject, delay_days, delay_hours, updated_at')
1177
+ .single();
1178
+ if (error)
1179
+ return j({ error: error.message });
1180
+ return j({ step: data, ok: true });
1181
+ });
1182
+ server.registerTool('delete_campaign_step', {
1183
+ description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
1184
+ inputSchema: { step_id: z.string().uuid() },
1185
+ }, async ({ step_id }) => {
1186
+ const projectId = getProjectId();
1187
+ const { data: step } = await db()
1188
+ .from('campaign_steps')
1189
+ .select('campaign_id')
1190
+ .eq('id', step_id)
1191
+ .maybeSingle();
1192
+ if (!step)
1193
+ return j({ error: 'Step not found', code: 'NOT_FOUND' });
1194
+ const { data: campaign } = await db()
1195
+ .from('campaigns')
1196
+ .select('status')
1197
+ .eq('id', step.campaign_id)
1198
+ .eq('project_id', projectId)
1199
+ .maybeSingle();
1200
+ if (!campaign)
1201
+ return j({ error: 'Step not found', code: 'NOT_FOUND' });
1202
+ if (campaign.status !== 'draft') {
1203
+ return j({ error: 'Can only delete steps from draft campaigns', code: 'INVALID_STATUS' });
1204
+ }
1205
+ const { error } = await db()
1206
+ .from('campaign_steps')
1207
+ .delete()
1208
+ .eq('id', step_id);
1209
+ if (error)
1210
+ return j({ error: error.message });
1211
+ return j({ ok: true, deleted: step_id });
1212
+ });
1213
+ server.registerTool('delete_campaign', {
1214
+ description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
1215
+ inputSchema: { campaign_id: z.string().uuid() },
1216
+ }, async ({ campaign_id }) => {
1217
+ const projectId = getProjectId();
1218
+ const { data: campaign, error: fetchErr } = await db()
1219
+ .from('campaigns')
1220
+ .select('id, name, status')
1221
+ .eq('id', campaign_id)
1222
+ .eq('project_id', projectId)
1223
+ .maybeSingle();
1224
+ if (fetchErr || !campaign)
1225
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
1226
+ if (campaign.status === 'active') {
1227
+ return j({ error: 'Pause the campaign before deleting', code: 'INVALID_STATUS' });
1228
+ }
1229
+ const { error } = await db()
1230
+ .from('campaigns')
1231
+ .delete()
1232
+ .eq('id', campaign_id)
1233
+ .eq('project_id', projectId);
1234
+ if (error)
1235
+ return j({ error: error.message });
1236
+ return j({ success: true, deleted: campaign.name });
1237
+ });
1238
+ server.registerTool('update_campaign', {
1239
+ description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. Only provided fields are updated.',
1240
+ inputSchema: {
1241
+ campaign_id: z.string().uuid(),
1242
+ name: z.string().min(1).max(200).optional(),
1243
+ site_id: z.string().uuid().optional(),
1244
+ segment_id: z.string().uuid().nullable().optional(),
1245
+ send_at: z.string().datetime().nullable().optional(),
1246
+ topic: z.string().nullable().optional(),
1247
+ trigger_event_name: z.string().nullable().optional(),
1248
+ trigger_tag: z.string().nullable().optional(),
1249
+ send_in_contact_timezone: z.boolean().optional(),
1250
+ },
1251
+ }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, send_in_contact_timezone }) => {
1252
+ const projectId = getProjectId();
1253
+ const updates = { updated_at: new Date().toISOString() };
1254
+ if (name !== undefined)
1255
+ updates.name = name;
1256
+ if (site_id !== undefined)
1257
+ updates.site_id = site_id;
1258
+ if (segment_id !== undefined)
1259
+ updates.segment_id = segment_id;
1260
+ if (send_at !== undefined)
1261
+ updates.send_at = send_at;
1262
+ if (topic !== undefined)
1263
+ updates.topic = topic;
1264
+ if (trigger_event_name !== undefined)
1265
+ updates.trigger_event_name = trigger_event_name;
1266
+ if (trigger_tag !== undefined)
1267
+ updates.trigger_tag = trigger_tag;
1268
+ if (send_in_contact_timezone !== undefined)
1269
+ updates.send_in_contact_timezone = send_in_contact_timezone;
1270
+ const { data, error } = await db()
1271
+ .from('campaigns')
1272
+ .update(updates)
1273
+ .eq('id', campaign_id)
1274
+ .eq('project_id', projectId)
1275
+ .select()
1276
+ .single();
1277
+ if (error)
1278
+ return j({ error: error.message });
1279
+ return j(data);
1280
+ });
1281
+ server.registerTool('get_campaign', {
1282
+ description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
1283
+ inputSchema: { campaign_id: z.string().uuid() },
1284
+ }, async ({ campaign_id }) => {
1285
+ const projectId = getProjectId();
1286
+ const { data: campaign, error: campErr } = await db()
1287
+ .from('campaigns')
1288
+ .select('*')
1289
+ .eq('id', campaign_id)
1290
+ .eq('project_id', projectId)
1291
+ .maybeSingle();
1292
+ if (campErr || !campaign)
1293
+ return j({ error: campErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
1294
+ const [{ data: steps }, { count: enrollCount }] = await Promise.all([
1295
+ db()
1296
+ .from('campaign_steps')
1297
+ .select('*')
1298
+ .eq('campaign_id', campaign_id)
1299
+ .order('step_order', { ascending: true }),
1300
+ db()
1301
+ .from('campaign_enrollments')
1302
+ .select('id', { count: 'exact', head: true })
1303
+ .eq('campaign_id', campaign_id)
1304
+ .eq('status', 'active'),
1305
+ ]);
1306
+ return j({ ...campaign, steps: steps ?? [], active_enrollments: enrollCount ?? 0 });
1307
+ });
1308
+ server.registerTool('unenroll_contact', {
1309
+ description: 'Remove a contact from an active campaign enrollment. Use when a contact should no longer receive campaign emails.',
1310
+ inputSchema: {
1311
+ campaign_id: z.string().uuid(),
1312
+ contact_id: z.string().uuid(),
1313
+ reason: z.string().optional().describe('Optional reason for unenrollment'),
1314
+ },
1315
+ }, async ({ campaign_id, contact_id, reason }) => {
1316
+ const projectId = getProjectId();
1317
+ // Verify the campaign belongs to this project
1318
+ const { data: campaign } = await db()
1319
+ .from('campaigns')
1320
+ .select('id')
1321
+ .eq('id', campaign_id)
1322
+ .eq('project_id', projectId)
1323
+ .maybeSingle();
1324
+ if (!campaign)
1325
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
1326
+ const { error } = await db()
1327
+ .from('campaign_enrollments')
1328
+ .update({ status: 'exited', exited_reason: reason ?? 'manual_unenroll', updated_at: new Date().toISOString() })
1329
+ .eq('campaign_id', campaign_id)
1330
+ .eq('contact_id', contact_id);
1331
+ if (error)
1332
+ return j({ error: error.message });
1333
+ return j({ success: true });
1334
+ });
1335
+ }