@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,762 @@
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 { buildVoicePrompt } from '../lib/voice.js';
6
+ import { getAnthropicModelId } from '../anthropic-model.js';
7
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
8
+ const j = (data) => txt(JSON.stringify(data, null, 2));
9
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
10
+ export function computeMetrics(rows) {
11
+ const sent = rows.length;
12
+ const delivered = rows.filter((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
13
+ const opened = rows.filter((r) => ['opened', 'clicked'].includes(r.status)).length;
14
+ const clicked = rows.filter((r) => r.status === 'clicked').length;
15
+ const bounced = rows.filter((r) => r.status === 'bounced').length;
16
+ const complained = rows.filter((r) => r.status === 'complained').length;
17
+ return {
18
+ sent,
19
+ delivered,
20
+ opened,
21
+ clicked,
22
+ bounced,
23
+ complained,
24
+ open_rate: sent > 0 ? +(opened / sent * 100).toFixed(2) : 0,
25
+ click_rate: sent > 0 ? +(clicked / sent * 100).toFixed(2) : 0,
26
+ bounce_rate: sent > 0 ? +(bounced / sent * 100).toFixed(2) : 0,
27
+ complaint_rate: sent > 0 ? +(complained / sent * 100).toFixed(2) : 0,
28
+ };
29
+ }
30
+ export function buildTimeBuckets(rows, groupBy, metric) {
31
+ const buckets = new Map();
32
+ for (const row of rows) {
33
+ const date = new Date(row.created_at);
34
+ const key = groupBy === 'hour' ? date.getUTCHours() : date.getUTCDay();
35
+ const entry = buckets.get(key) ?? { sends: 0, engagements: 0 };
36
+ entry.sends++;
37
+ const engaged = metric === 'click_rate'
38
+ ? row.status === 'clicked'
39
+ : ['opened', 'clicked'].includes(row.status);
40
+ if (engaged)
41
+ entry.engagements++;
42
+ buckets.set(key, entry);
43
+ }
44
+ const results = [];
45
+ for (const [key, val] of buckets.entries()) {
46
+ if (val.sends < 10)
47
+ continue;
48
+ results.push({
49
+ label: groupBy === 'hour' ? `${key.toString().padStart(2, '0')}:00 UTC` : DAY_NAMES[key],
50
+ sends: val.sends,
51
+ engagements: val.engagements,
52
+ rate: +(val.engagements / val.sends * 100).toFixed(2),
53
+ });
54
+ }
55
+ results.sort((a, b) => b.rate - a.rate);
56
+ return results;
57
+ }
58
+ // ── Registration ──────────────────────────────────────────────────────
59
+ export function registerAdvisorTools(server) {
60
+ server.registerTool('run_portfolio_analysis', {
61
+ description: 'Run a grounded portfolio health analysis across active campaigns.',
62
+ inputSchema: { force_run: z.boolean().optional().default(false) },
63
+ }, async ({ force_run = false }) => {
64
+ const projectId = getProjectId();
65
+ const since = new Date(Date.now() - 30 * 86400000).toISOString();
66
+ const { data: campaigns, error } = await db().from('campaigns').select('id, name, type').eq('project_id', projectId).eq('status', 'active').limit(200);
67
+ if (error)
68
+ return j({ error: error.message });
69
+ const recommendations = [];
70
+ let skipped = 0;
71
+ for (const campaign of campaigns ?? []) {
72
+ const { data: logs } = await db().from('email_log').select('status').eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since).limit(10000);
73
+ const rows = logs ?? [];
74
+ const sends = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
75
+ if (sends < 100) {
76
+ skipped++;
77
+ continue;
78
+ }
79
+ const bounces = rows.filter((row) => row.status === 'bounced').length;
80
+ const bounceRate = sends ? Number(((bounces / sends) * 100).toFixed(1)) : 0;
81
+ recommendations.push({
82
+ campaign_id: campaign.id,
83
+ campaign_name: campaign.name,
84
+ metric_name: 'bounce_rate',
85
+ metric_value: bounceRate,
86
+ sample_size: sends,
87
+ confidence: sends >= 500 ? 'HIGH' : 'MEDIUM',
88
+ data_citation: `${bounceRate}% bounce rate from ${sends} sends over 30 days`,
89
+ recommendation_text: bounceRate >= 5
90
+ ? `Review list quality before sending more from ${campaign.name}. Bounce rate is ${bounceRate}% from ${sends} sends.`
91
+ : `${campaign.name} has acceptable bounce pressure at ${bounceRate}% from ${sends} sends.`,
92
+ });
93
+ }
94
+ const next = new Date();
95
+ next.setUTCDate(next.getUTCDate() + ((8 - next.getUTCDay()) % 7 || 7));
96
+ next.setUTCHours(6, 0, 0, 0);
97
+ return j({ campaigns_analyzed: campaigns?.length ?? 0, recommendations, skipped_insufficient_data: skipped, next_scheduled: next.toISOString(), force_run });
98
+ });
99
+ // ── 1. campaign_advisor ────────────────────────────────────────────
100
+ server.registerTool('campaign_advisor', {
101
+ description: 'Analyze campaign performance and get actionable recommendations. Returns period-over-period metrics, domain health issues, engagement distribution, and prioritized recommendations with severity levels.',
102
+ inputSchema: {
103
+ site_id: z.string().uuid().optional().describe('Filter to a specific site'),
104
+ days: z.number().int().min(1).max(365).optional().default(30).describe('Analysis period in days (default: 30)'),
105
+ },
106
+ }, async ({ site_id, days: daysParam }) => {
107
+ const projectId = getProjectId();
108
+ const days = daysParam ?? 30;
109
+ const now = new Date();
110
+ const periodStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
111
+ const priorStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000);
112
+ // 1. Current period email_log
113
+ let currentQuery = db()
114
+ .from('email_log')
115
+ .select('status, created_at')
116
+ .eq('project_id', projectId)
117
+ .gte('created_at', periodStart.toISOString())
118
+ .limit(10000);
119
+ if (site_id)
120
+ currentQuery = currentQuery.eq('site_id', site_id);
121
+ const { data: currentRows } = await currentQuery;
122
+ // 2. Prior period
123
+ let priorQuery = db()
124
+ .from('email_log')
125
+ .select('status, created_at')
126
+ .eq('project_id', projectId)
127
+ .gte('created_at', priorStart.toISOString())
128
+ .lt('created_at', periodStart.toISOString())
129
+ .limit(10000);
130
+ if (site_id)
131
+ priorQuery = priorQuery.eq('site_id', site_id);
132
+ const { data: priorRows } = await priorQuery;
133
+ const current = computeMetrics(currentRows ?? []);
134
+ const prior = computeMetrics(priorRows ?? []);
135
+ // 3. Domain health (latest per site)
136
+ let healthQuery = db()
137
+ .from('domain_health')
138
+ .select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day')
139
+ .eq('project_id', projectId)
140
+ .order('check_date', { ascending: false })
141
+ .limit(20);
142
+ if (site_id)
143
+ healthQuery = healthQuery.eq('site_id', site_id);
144
+ const { data: healthRows } = await healthQuery;
145
+ // Deduplicate: keep only latest per site_id
146
+ const healthBySite = new Map();
147
+ for (const row of healthRows ?? []) {
148
+ if (!healthBySite.has(row.site_id))
149
+ healthBySite.set(row.site_id, row);
150
+ }
151
+ // 4. Engagement summary
152
+ const { data: engagementSummary } = await db()
153
+ .rpc('get_engagement_summary', { p_project_id: projectId });
154
+ // 5. Unpromoted A/B test winners
155
+ const { data: unpromoted } = await db()
156
+ .from('ab_tests')
157
+ .select('id, name, winner, campaign_id')
158
+ .eq('project_id', projectId)
159
+ .eq('status', 'significant');
160
+ // 6. Build recommendations
161
+ const recommendations = [];
162
+ // No recent sends
163
+ if (current.sent === 0) {
164
+ recommendations.push({
165
+ type: 'no_recent_sends',
166
+ severity: 'info',
167
+ message: `No emails sent in the last ${days} days.`,
168
+ });
169
+ }
170
+ // High bounce rate
171
+ if (current.sent > 0 && current.bounce_rate > 5) {
172
+ recommendations.push({
173
+ type: 'high_bounce_rate',
174
+ severity: 'critical',
175
+ message: `Bounce rate is ${current.bounce_rate}% (threshold: 5%).`,
176
+ detail: 'Clean your list: remove hard bounces, verify email addresses, check for typos in imported data.',
177
+ });
178
+ }
179
+ // High complaint rate
180
+ if (current.sent > 0 && current.complaint_rate > 0.3) {
181
+ recommendations.push({
182
+ type: 'high_complaint_rate',
183
+ severity: 'critical',
184
+ message: `Complaint rate is ${current.complaint_rate}% (threshold: 0.3%).`,
185
+ detail: 'Review email content and frequency. Ensure clear unsubscribe links. Consider re-permission campaign.',
186
+ });
187
+ }
188
+ // Open rate declining
189
+ if (prior.sent >= 50 && current.sent >= 50) {
190
+ const delta = current.open_rate - prior.open_rate;
191
+ if (delta < -10) {
192
+ recommendations.push({
193
+ type: 'open_rate_declining',
194
+ severity: 'warning',
195
+ message: `Open rate dropped ${Math.abs(delta).toFixed(1)}pp (${prior.open_rate}% → ${current.open_rate}%).`,
196
+ detail: 'Test different subject lines with optimize_subject_lines. Check if you are hitting spam filters.',
197
+ });
198
+ }
199
+ }
200
+ // Low click-to-open
201
+ if (current.opened >= 50) {
202
+ const cto = +(current.clicked / current.opened * 100).toFixed(2);
203
+ if (cto < 5) {
204
+ recommendations.push({
205
+ type: 'low_click_to_open',
206
+ severity: 'warning',
207
+ message: `Click-to-open rate is ${cto}% (threshold: 5%).`,
208
+ detail: 'Improve CTA clarity and placement. Ensure one clear call-to-action per email.',
209
+ });
210
+ }
211
+ }
212
+ // Volume spike
213
+ if (prior.sent > 0 && current.sent > prior.sent * 3) {
214
+ recommendations.push({
215
+ type: 'volume_spike',
216
+ severity: 'warning',
217
+ message: `Send volume jumped ${(current.sent / prior.sent).toFixed(1)}x vs prior period (${prior.sent} → ${current.sent}).`,
218
+ detail: 'Sudden volume increases can trigger spam filters. Consider ramping up gradually.',
219
+ });
220
+ }
221
+ // Cold contacts growing
222
+ if (engagementSummary) {
223
+ const summary = typeof engagementSummary === 'string' ? JSON.parse(engagementSummary) : engagementSummary;
224
+ const lifecycle = summary?.lifecycle_distribution;
225
+ if (lifecycle) {
226
+ const total = Object.values(lifecycle).reduce((a, b) => a + Number(b), 0);
227
+ const coldChurned = (Number(lifecycle.cold ?? 0) + Number(lifecycle.churned ?? 0));
228
+ if (total > 0 && (coldChurned / total) > 0.3) {
229
+ recommendations.push({
230
+ type: 'cold_contacts_growing',
231
+ severity: 'warning',
232
+ message: `${((coldChurned / total) * 100).toFixed(0)}% of contacts are cold or churned.`,
233
+ detail: 'Run a re-engagement campaign or remove inactive contacts to improve deliverability.',
234
+ });
235
+ }
236
+ }
237
+ }
238
+ // Warmup stalled
239
+ for (const health of healthBySite.values()) {
240
+ if (health.warmup_day > 7 && (health.bounce_rate ?? 0) > 3) {
241
+ recommendations.push({
242
+ type: 'warmup_stalled',
243
+ severity: 'warning',
244
+ message: `Domain warmup day ${health.warmup_day} but bounce rate is ${health.bounce_rate}%.`,
245
+ detail: 'Warmup is at risk. Reduce volume or pause sending for 48 hours to recover.',
246
+ });
247
+ }
248
+ }
249
+ // Unpromoted A/B winners
250
+ for (const test of unpromoted ?? []) {
251
+ recommendations.push({
252
+ type: 'unpromoted_ab_winner',
253
+ severity: 'info',
254
+ message: `A/B test "${test.name}" has a significant winner (${test.winner}) but hasn't been promoted.`,
255
+ detail: `Use promote_ab_winner with test_id ${test.id} to keep the winning variant.`,
256
+ });
257
+ }
258
+ // Sort: critical > warning > info
259
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
260
+ recommendations.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
261
+ const hasCritical = recommendations.some(r => r.severity === 'critical');
262
+ const hasWarning = recommendations.some(r => r.severity === 'warning');
263
+ const overall = hasCritical ? 'CRITICAL' : hasWarning ? 'NEEDS_ATTENTION' : 'HEALTHY';
264
+ const trends = prior.sent > 0 ? {
265
+ open_rate_delta: +(current.open_rate - prior.open_rate).toFixed(2),
266
+ click_rate_delta: +(current.click_rate - prior.click_rate).toFixed(2),
267
+ bounce_rate_delta: +(current.bounce_rate - prior.bounce_rate).toFixed(2),
268
+ volume_change: +((current.sent - prior.sent) / prior.sent * 100).toFixed(1),
269
+ } : null;
270
+ return j({
271
+ period: { days, from: periodStart.toISOString().split('T')[0], to: now.toISOString().split('T')[0] },
272
+ current_metrics: current,
273
+ prior_metrics: prior.sent > 0 ? prior : null,
274
+ trends,
275
+ recommendations,
276
+ overall,
277
+ });
278
+ });
279
+ // ── 2. optimize_subject_lines ──────────────────────────────────────
280
+ server.registerTool('optimize_subject_lines', {
281
+ description: 'Generate optimized subject line variants using AI, informed by historical top performers and brand voice. Optionally creates campaign step variants for A/B testing.',
282
+ inputSchema: {
283
+ campaign_id: z.string().uuid(),
284
+ step_order: z.number().int().min(0).optional().default(0),
285
+ count: z.number().int().min(1).max(10).optional().default(5).describe('Number of suggestions'),
286
+ site_id: z.string().uuid().describe('Site for brand voice context'),
287
+ create_variants: z.boolean().optional().default(false).describe('If true, insert top 2 as campaign step variants B and C'),
288
+ },
289
+ }, async ({ campaign_id, step_order, count, site_id, create_variants }) => {
290
+ const projectId = getProjectId();
291
+ const apiKey = process.env.ANTHROPIC_API_KEY;
292
+ if (!apiKey)
293
+ return txt('Error: ANTHROPIC_API_KEY not configured.');
294
+ // 1. Get current subject (variant A)
295
+ const { data: currentStep } = await db()
296
+ .from('campaign_steps')
297
+ .select('id, subject, html_body, text_body, delay_days, delay_hours')
298
+ .eq('campaign_id', campaign_id)
299
+ .eq('step_order', step_order ?? 0)
300
+ .eq('variant_label', 'A')
301
+ .maybeSingle();
302
+ if (!currentStep)
303
+ return txt(`Error: No campaign step found for step_order ${step_order ?? 0}, variant A.`);
304
+ // 2. Top-performing historical subjects
305
+ let logQuery = db()
306
+ .from('email_log')
307
+ .select('subject, status')
308
+ .eq('project_id', projectId)
309
+ .in('status', ['delivered', 'opened', 'clicked'])
310
+ .limit(5000);
311
+ if (site_id)
312
+ logQuery = logQuery.eq('site_id', site_id);
313
+ const { data: logRows } = await logQuery;
314
+ // Aggregate by subject
315
+ const subjectStats = new Map();
316
+ for (const row of logRows ?? []) {
317
+ if (!row.subject)
318
+ continue;
319
+ const entry = subjectStats.get(row.subject) ?? { sent: 0, opened: 0 };
320
+ entry.sent++;
321
+ if (['opened', 'clicked'].includes(row.status))
322
+ entry.opened++;
323
+ subjectStats.set(row.subject, entry);
324
+ }
325
+ const topPerformers = Array.from(subjectStats.entries())
326
+ .filter(([, stats]) => stats.sent >= 50)
327
+ .map(([subject, stats]) => ({
328
+ subject,
329
+ open_rate: +(stats.opened / stats.sent * 100).toFixed(1),
330
+ sample_size: stats.sent,
331
+ }))
332
+ .sort((a, b) => b.open_rate - a.open_rate)
333
+ .slice(0, 5);
334
+ // 3. Brand voice
335
+ const { data: site } = await db()
336
+ .from('sites')
337
+ .select('name, brand_voice')
338
+ .eq('id', site_id)
339
+ .eq('project_id', projectId)
340
+ .maybeSingle();
341
+ const voicePrompt = buildVoicePrompt(site?.brand_voice);
342
+ // 4. Call Claude
343
+ const anthropic = new Anthropic({ apiKey });
344
+ const topPerformerContext = topPerformers.length > 0
345
+ ? `\n\nTop-performing historical subjects for reference:\n${topPerformers.map(tp => `- "${tp.subject}" (${tp.open_rate}% open rate, ${tp.sample_size} sends)`).join('\n')}`
346
+ : '';
347
+ const response = await anthropic.messages.create({
348
+ model: getAnthropicModelId(),
349
+ max_tokens: 1024,
350
+ system: `You are an email subject line optimization expert. Follow the brand voice rules strictly.\n\n${voicePrompt}`,
351
+ messages: [{
352
+ role: 'user',
353
+ content: `Generate ${count ?? 5} subject line alternatives for this email.
354
+
355
+ Current subject: "${currentStep.subject}"
356
+ ${topPerformerContext}
357
+
358
+ Return ONLY a JSON array: [{"subject": "...", "rationale": "..."}]
359
+ Each subject should be distinct in approach (urgency, curiosity, benefit, social proof, etc). Keep them under 60 characters. No emojis. No clickbait. No em dashes.`,
360
+ }],
361
+ });
362
+ const textBlock = response.content.find((b) => b.type === 'text');
363
+ const raw = textBlock && 'text' in textBlock ? textBlock.text : '[]';
364
+ let suggestions;
365
+ try {
366
+ suggestions = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
367
+ }
368
+ catch {
369
+ return txt(`Error: Failed to parse AI response. Raw: ${raw.slice(0, 200)}`);
370
+ }
371
+ // 5. Optionally create variants
372
+ let variantsCreated;
373
+ if (create_variants && suggestions.length >= 2) {
374
+ const labels = ['B', 'C'];
375
+ variantsCreated = [];
376
+ for (let i = 0; i < 2; i++) {
377
+ const { error: insertErr } = await db()
378
+ .from('campaign_steps')
379
+ .insert({
380
+ campaign_id,
381
+ step_order: step_order ?? 0,
382
+ variant_label: labels[i],
383
+ subject: suggestions[i].subject,
384
+ html_body: currentStep.html_body,
385
+ text_body: currentStep.text_body,
386
+ delay_days: currentStep.delay_days,
387
+ delay_hours: currentStep.delay_hours,
388
+ });
389
+ if (!insertErr)
390
+ variantsCreated.push(labels[i]);
391
+ }
392
+ }
393
+ return j({
394
+ current_subject: currentStep.subject,
395
+ suggestions,
396
+ top_performers_used: topPerformers,
397
+ variants_created: variantsCreated,
398
+ next_step: create_variants && variantsCreated?.length
399
+ ? 'Variants created. Use create_ab_test to set up an A/B test for this step.'
400
+ : 'Review suggestions and use add_campaign_step with variant_label to create variants manually.',
401
+ });
402
+ });
403
+ // ── 3. suggest_content_topics ────────────────────────────────────
404
+ server.registerTool('suggest_content_topics', {
405
+ description: 'Analyze past email performance and recommend content topics for future emails. Uses AI to identify patterns in high- and low-performing content, then suggests specific topics with expected engagement levels.',
406
+ inputSchema: {
407
+ site_id: z.string().uuid().describe('Site UUID to analyze'),
408
+ days: z.number().int().min(7).max(365).optional().default(90).describe('Days of history to analyze (default: 90)'),
409
+ },
410
+ }, async ({ site_id, days: daysParam }) => {
411
+ const projectId = getProjectId();
412
+ const apiKey = process.env.ANTHROPIC_API_KEY;
413
+ if (!apiKey)
414
+ return txt('Error: ANTHROPIC_API_KEY not configured.');
415
+ const days = daysParam ?? 90;
416
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
417
+ // 1. Query email_log with campaign step subjects
418
+ const { data: logRows } = await db()
419
+ .from('email_log')
420
+ .select('campaign_id, subject, status')
421
+ .eq('project_id', projectId)
422
+ .eq('site_id', site_id)
423
+ .gte('created_at', since)
424
+ .not('campaign_id', 'is', null)
425
+ .limit(10000);
426
+ if (!logRows || logRows.length < 20) {
427
+ return j({
428
+ error: 'insufficient_data',
429
+ message: `Only ${logRows?.length ?? 0} campaign emails found in the last ${days} days. Need at least 20 for meaningful analysis.`,
430
+ });
431
+ }
432
+ // 2. Group by campaign + subject, compute engagement
433
+ const campaignStats = new Map();
434
+ for (const row of logRows) {
435
+ const key = `${row.campaign_id}::${row.subject ?? '(no subject)'}`;
436
+ const entry = campaignStats.get(key) ?? { subject: row.subject ?? '(no subject)', sent: 0, opened: 0, clicked: 0 };
437
+ entry.sent++;
438
+ if (['opened', 'clicked'].includes(row.status))
439
+ entry.opened++;
440
+ if (row.status === 'clicked')
441
+ entry.clicked++;
442
+ campaignStats.set(key, entry);
443
+ }
444
+ // 3. Compute rates and sort by composite score
445
+ const ranked = Array.from(campaignStats.values())
446
+ .filter(s => s.sent >= 5)
447
+ .map(s => ({
448
+ subject: s.subject,
449
+ sent: s.sent,
450
+ open_rate: +(s.opened / s.sent * 100).toFixed(1),
451
+ click_rate: +(s.clicked / s.sent * 100).toFixed(1),
452
+ score: +(s.opened / s.sent * 0.6 + s.clicked / s.sent * 0.4).toFixed(4),
453
+ }))
454
+ .sort((a, b) => b.score - a.score);
455
+ const topPerformers = ranked.slice(0, 10);
456
+ const bottomPerformers = ranked.slice(-5);
457
+ if (topPerformers.length < 3) {
458
+ return j({
459
+ error: 'insufficient_data',
460
+ message: `Only ${topPerformers.length} campaigns with enough sends to analyze. Need at least 3 for meaningful suggestions.`,
461
+ });
462
+ }
463
+ // 4. Brand voice context
464
+ const { data: site } = await db()
465
+ .from('sites')
466
+ .select('name, brand_voice')
467
+ .eq('id', site_id)
468
+ .eq('project_id', projectId)
469
+ .maybeSingle();
470
+ const voicePrompt = buildVoicePrompt(site?.brand_voice);
471
+ // 5. Call Claude
472
+ const anthropic = new Anthropic({ apiKey });
473
+ const topContext = topPerformers.map(t => `- "${t.subject}" (open: ${t.open_rate}%, click: ${t.click_rate}%)`).join('\n');
474
+ const bottomContext = bottomPerformers.map(t => `- "${t.subject}" (open: ${t.open_rate}%, click: ${t.click_rate}%)`).join('\n');
475
+ const response = await anthropic.messages.create({
476
+ model: getAnthropicModelId(),
477
+ max_tokens: 1500,
478
+ system: `You are an email content strategist. Analyze email performance data and suggest future content topics.\n\n${voicePrompt}`,
479
+ messages: [{
480
+ role: 'user',
481
+ content: `Analyze these email performance results and suggest 5 content topics for future emails.
482
+
483
+ Top performing emails (high engagement):
484
+ ${topContext}
485
+
486
+ Low performing emails:
487
+ ${bottomContext}
488
+
489
+ Based on what resonated vs what didn't, suggest:
490
+ 1. 5 specific topic ideas with expected engagement level
491
+ 2. Content patterns that work (length, tone, CTA style)
492
+ 3. Topics to avoid based on poor performance
493
+
494
+ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rationale": "...", "confidence": "high|medium|low" }], "patterns": { "works": ["..."], "avoid": ["..."] } }`,
495
+ }],
496
+ });
497
+ const textBlock = response.content.find((b) => b.type === 'text');
498
+ const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
499
+ let suggestions;
500
+ try {
501
+ suggestions = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
502
+ }
503
+ catch {
504
+ return txt(`Error: Failed to parse AI response. Raw: ${raw.slice(0, 300)}`);
505
+ }
506
+ return j({
507
+ site: site?.name ?? site_id,
508
+ period_days: days,
509
+ emails_analyzed: logRows.length,
510
+ campaigns_analyzed: ranked.length,
511
+ top_performers: topPerformers,
512
+ bottom_performers: bottomPerformers,
513
+ suggestions,
514
+ });
515
+ });
516
+ // ── 4. suggest_send_time ───────────────────────────────────────────
517
+ server.registerTool('suggest_send_time', {
518
+ description: 'Analyze historical engagement patterns to recommend optimal send times. Groups by hour-of-day and day-of-week to identify when your audience is most responsive.',
519
+ inputSchema: {
520
+ site_id: z.string().uuid(),
521
+ segment_id: z.string().uuid().optional().describe('Narrow analysis to a specific segment'),
522
+ days: z.number().int().min(7).max(365).optional().default(90).describe('Lookback period in days'),
523
+ metric: z.enum(['open_rate', 'click_rate']).optional().default('open_rate'),
524
+ },
525
+ }, async ({ site_id, segment_id, days: daysParam, metric }) => {
526
+ const projectId = getProjectId();
527
+ const days = daysParam ?? 90;
528
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
529
+ // 1. Get contact IDs for segment filter
530
+ let contactFilter = null;
531
+ if (segment_id) {
532
+ const { data: segContacts } = await db()
533
+ .from('subscribers')
534
+ .select('id')
535
+ .eq('project_id', projectId)
536
+ .limit(5000);
537
+ // For now, we pass all contacts and filter at query time
538
+ // A proper segment query would use segment-query.ts, but MCP server
539
+ // doesn't have that module. We filter by site instead.
540
+ contactFilter = (segContacts ?? []).map((c) => c.id);
541
+ }
542
+ // 2. Query email_log
543
+ let query = db()
544
+ .from('email_log')
545
+ .select('status, created_at')
546
+ .eq('project_id', projectId)
547
+ .eq('site_id', site_id)
548
+ .gte('created_at', since)
549
+ .in('status', ['delivered', 'opened', 'clicked'])
550
+ .limit(10000);
551
+ if (contactFilter) {
552
+ query = query.in('contact_id', contactFilter.slice(0, 500));
553
+ }
554
+ const { data: rows } = await query;
555
+ if (!rows || rows.length < 30) {
556
+ return j({
557
+ error: 'insufficient_data',
558
+ message: `Only ${rows?.length ?? 0} qualifying emails found in the last ${days} days. Need at least 30 for meaningful analysis.`,
559
+ });
560
+ }
561
+ // 3. Build buckets
562
+ const hourBuckets = buildTimeBuckets(rows, 'hour', metric ?? 'open_rate');
563
+ const dayBuckets = buildTimeBuckets(rows, 'day', metric ?? 'open_rate');
564
+ const best3Hours = hourBuckets.slice(0, 3);
565
+ const worst3Hours = hourBuckets.slice(-3).reverse();
566
+ const best3Days = dayBuckets.slice(0, 3);
567
+ const worst3Days = dayBuckets.slice(-3).reverse();
568
+ const topHour = best3Hours[0];
569
+ const topDay = best3Days[0];
570
+ const recommendation = topHour && topDay
571
+ ? `Best performance: ${topDay.label}s at ${topHour.label} (${topHour.rate}% ${metric ?? 'open_rate'}).`
572
+ : 'Not enough data across time slots to make a recommendation.';
573
+ return j({
574
+ metric: metric ?? 'open_rate',
575
+ period_days: days,
576
+ total_emails_analyzed: rows.length,
577
+ best_hours: best3Hours,
578
+ worst_hours: worst3Hours,
579
+ best_days: best3Days,
580
+ worst_days: worst3Days,
581
+ recommendation,
582
+ });
583
+ });
584
+ // ── 5. recovery_plan ───────────────────────────────────────────────
585
+ server.registerTool('recovery_plan', {
586
+ description: 'Analyze deliverability issues for a site and generate a structured recovery plan. Examines domain health (SPF/DKIM/DMARC), bounce/complaint rates, recent send stats, and new suppressions to produce prioritized immediate, short-term, and monitoring steps. Deterministic — no AI call, pure rule-based analysis.',
587
+ inputSchema: {
588
+ site_id: z.string().uuid().describe('Site UUID with deliverability issues'),
589
+ },
590
+ }, async ({ site_id }) => {
591
+ const projectId = getProjectId();
592
+ // 1. Verify site ownership
593
+ const { data: site, error: siteErr } = await db()
594
+ .from('sites')
595
+ .select('id, slug, name, sending_domain, from_email')
596
+ .eq('id', site_id)
597
+ .eq('project_id', projectId)
598
+ .maybeSingle();
599
+ if (siteErr || !site)
600
+ return txt(`Error: Site not found — ${siteErr?.message ?? 'not found'}`);
601
+ // 2. Fetch latest domain_health
602
+ const { data: healthRows } = await db()
603
+ .from('domain_health')
604
+ .select('spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
605
+ .eq('site_id', site_id)
606
+ .order('check_date', { ascending: false })
607
+ .limit(1);
608
+ const health = (healthRows ?? [])[0];
609
+ const spfValid = health?.spf_valid ?? false;
610
+ const dkimValid = health?.dkim_valid ?? false;
611
+ const dmarcValid = health?.dmarc_valid ?? false;
612
+ const warmupDay = health?.warmup_day ?? null;
613
+ const warmupDailyLimit = health?.warmup_daily_limit ?? null;
614
+ // 3. Fetch recent 7-day email_log stats
615
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
616
+ const { data: recentLogs } = await db()
617
+ .from('email_log')
618
+ .select('status, opened_at, clicked_at')
619
+ .eq('project_id', projectId)
620
+ .eq('site_id', site_id)
621
+ .gte('sent_at', sevenDaysAgo);
622
+ const logs = recentLogs ?? [];
623
+ const totalSent = logs.length;
624
+ const bounced = logs.filter((l) => l.status === 'bounced').length;
625
+ const complained = logs.filter((l) => l.status === 'complained').length;
626
+ const delivered = logs.filter((l) => ['delivered', 'opened', 'clicked'].includes(l.status)).length;
627
+ const opened = logs.filter((l) => l.opened_at).length;
628
+ const bounceRate = totalSent > 0 ? +(bounced / totalSent * 100).toFixed(2) : 0;
629
+ const complaintRate = totalSent > 0 ? +(complained / totalSent * 100).toFixed(2) : 0;
630
+ const openRate = totalSent > 0 ? +(opened / totalSent * 100).toFixed(2) : 0;
631
+ // 4. Fetch new suppressions this week
632
+ const { count: newSuppressions } = await db()
633
+ .from('suppressions')
634
+ .select('id', { count: 'exact', head: true })
635
+ .eq('project_id', projectId)
636
+ .gte('created_at', sevenDaysAgo);
637
+ const issues = [];
638
+ if (bounceRate > 5)
639
+ issues.push({ type: 'high_bounce', severity: 'critical', detail: `Bounce rate ${bounceRate}% exceeds 5% threshold` });
640
+ else if (bounceRate > 2)
641
+ issues.push({ type: 'elevated_bounce', severity: 'high', detail: `Bounce rate ${bounceRate}% approaching danger zone` });
642
+ if (complaintRate > 0.3)
643
+ issues.push({ type: 'high_complaint', severity: 'critical', detail: `Complaint rate ${complaintRate}% exceeds 0.3% threshold` });
644
+ else if (complaintRate > 0.1)
645
+ issues.push({ type: 'elevated_complaint', severity: 'high', detail: `Complaint rate ${complaintRate}% approaching danger zone` });
646
+ if (!spfValid || !dkimValid)
647
+ issues.push({ type: 'dns_invalid', severity: 'high', detail: `Missing DNS: ${!spfValid ? 'SPF' : ''}${!spfValid && !dkimValid ? ' + ' : ''}${!dkimValid ? 'DKIM' : ''}` });
648
+ if (!dmarcValid)
649
+ issues.push({ type: 'no_dmarc', severity: 'medium', detail: 'DMARC record missing or invalid' });
650
+ if (warmupDay != null && warmupDay < 42 && bounceRate > 3) {
651
+ issues.push({ type: 'warmup_risk', severity: 'high', detail: `Domain on warmup day ${warmupDay} with ${bounceRate}% bounce rate — warmup at risk` });
652
+ }
653
+ if (totalSent === 0) {
654
+ issues.push({ type: 'no_recent_sends', severity: 'low', detail: 'No sends in the last 7 days — no active data to assess' });
655
+ }
656
+ // 6. Build recovery plan steps
657
+ const immediate = [];
658
+ const shortTerm = [];
659
+ const monitoring = [];
660
+ // Critical: pause sending
661
+ if (bounceRate > 8 || complaintRate > 0.5) {
662
+ immediate.push('Pause all active campaigns immediately');
663
+ immediate.push('Review and suppress all hard-bounced contacts');
664
+ monitoring.push('Wait 48 hours before resuming any sends');
665
+ }
666
+ else if (bounceRate > 5 || complaintRate > 0.3) {
667
+ immediate.push('Pause marketing campaigns (transactional OK)');
668
+ immediate.push('Review recent bounce reasons in suppression log');
669
+ }
670
+ // DNS fixes
671
+ if (!spfValid)
672
+ immediate.push('Add SPF record for sending domain');
673
+ if (!dkimValid)
674
+ immediate.push('Configure DKIM record (CNAME for resend._domainkey)');
675
+ if (!dmarcValid)
676
+ shortTerm.push('Add DMARC record (start with p=none for monitoring)');
677
+ // List hygiene
678
+ if (bounceRate > 2 || complained > 0) {
679
+ shortTerm.push('Run identify_inactive to find cold contacts and suppress them');
680
+ shortTerm.push(`Suppress ${newSuppressions ?? 0} newly identified problem addresses`);
681
+ }
682
+ // Volume reduction
683
+ if (bounceRate > 3 && warmupDailyLimit) {
684
+ const reducedLimit = Math.max(10, Math.floor(warmupDailyLimit * 0.5));
685
+ shortTerm.push(`Reduce daily volume to ${reducedLimit}/day (50% of current limit) for 48 hours`);
686
+ }
687
+ else if (bounceRate > 3) {
688
+ shortTerm.push('Reduce daily send volume by 50% for at least 48 hours');
689
+ }
690
+ // Re-verification
691
+ if (!spfValid || !dkimValid) {
692
+ shortTerm.push('Re-verify domain after DNS changes (use dns-verify API)');
693
+ }
694
+ // Monitoring steps
695
+ monitoring.push('Check bounce rate daily for 7 days');
696
+ monitoring.push('Monitor complaint rate — must stay below 0.3%');
697
+ if (bounceRate > 5 || complaintRate > 0.3) {
698
+ monitoring.push('Re-assess after 48h pause before resuming');
699
+ monitoring.push('Resume at 25% of normal volume, ramp over 5 days');
700
+ }
701
+ if (warmupDay != null && warmupDay < 42) {
702
+ monitoring.push(`Continue warmup protocol — currently day ${warmupDay} of 42`);
703
+ }
704
+ // 7. Compute health score (0-100)
705
+ let healthScore = 100;
706
+ if (!spfValid)
707
+ healthScore -= 15;
708
+ if (!dkimValid)
709
+ healthScore -= 15;
710
+ if (!dmarcValid)
711
+ healthScore -= 5;
712
+ if (bounceRate > 8)
713
+ healthScore -= 30;
714
+ else if (bounceRate > 5)
715
+ healthScore -= 20;
716
+ else if (bounceRate > 2)
717
+ healthScore -= 10;
718
+ if (complaintRate > 0.5)
719
+ healthScore -= 25;
720
+ else if (complaintRate > 0.3)
721
+ healthScore -= 15;
722
+ else if (complaintRate > 0.1)
723
+ healthScore -= 5;
724
+ if (warmupDay != null && warmupDay < 42 && bounceRate > 3)
725
+ healthScore -= 10;
726
+ healthScore = Math.max(0, healthScore);
727
+ // 8. Estimate recovery time
728
+ let estimatedRecovery = '1-2 days';
729
+ if (bounceRate > 8 || complaintRate > 0.5)
730
+ estimatedRecovery = '7-14 days';
731
+ else if (bounceRate > 5 || complaintRate > 0.3)
732
+ estimatedRecovery = '5-7 days';
733
+ else if (!spfValid || !dkimValid)
734
+ estimatedRecovery = '2-3 days';
735
+ else if (bounceRate > 2)
736
+ estimatedRecovery = '3-5 days';
737
+ return j({
738
+ site: { id: site.id, slug: site.slug, domain: site.sending_domain },
739
+ health_score: healthScore,
740
+ recent_7d: {
741
+ total_sent: totalSent,
742
+ delivered,
743
+ bounced,
744
+ complained,
745
+ opened,
746
+ bounce_rate: `${bounceRate}%`,
747
+ complaint_rate: `${complaintRate}%`,
748
+ open_rate: `${openRate}%`,
749
+ },
750
+ dns: { spf_valid: spfValid, dkim_valid: dkimValid, dmarc_valid: dmarcValid },
751
+ warmup: warmupDay != null ? { day: warmupDay, daily_limit: warmupDailyLimit } : null,
752
+ new_suppressions_7d: newSuppressions ?? 0,
753
+ issues,
754
+ plan: {
755
+ immediate: immediate.length > 0 ? immediate : ['No immediate action required'],
756
+ short_term: shortTerm.length > 0 ? shortTerm : ['Continue current sending practices'],
757
+ monitoring,
758
+ },
759
+ estimated_recovery: issues.length === 0 ? 'No recovery needed — site is healthy' : estimatedRecovery,
760
+ });
761
+ });
762
+ }