@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,1483 @@
1
+ import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
2
+ import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
3
+ import { z } from 'zod';
4
+ import { db } from '../db.js';
5
+ import { getProjectId } from '../project.js';
6
+ import { checkPlanLimit } from '../plan-limits.js';
7
+ import { logConsent, logAudit } from '../audit.js';
8
+ import { requestApproval, checkApproval } from '../lib/action-approvals.js';
9
+ import { decrypt } from '../crypto.js';
10
+ import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
11
+ import Anthropic from '@anthropic-ai/sdk';
12
+ import { getAnthropicModelId } from '../anthropic-model.js';
13
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
14
+ const j = (data) => txt(JSON.stringify(data, null, 2));
15
+ function daysAgo(days) {
16
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
17
+ }
18
+ function bucketSubscriber(c) {
19
+ if (!c.last_engaged_at)
20
+ return 'sunset_candidates';
21
+ const days = Math.floor((Date.now() - new Date(c.last_engaged_at).getTime()) / 86400000);
22
+ if (days <= 30)
23
+ return 'active';
24
+ if (days <= 90)
25
+ return 'cooling';
26
+ if (days <= 180)
27
+ return 'dormant';
28
+ return 'sunset_candidates';
29
+ }
30
+ function activePaying(c) {
31
+ const props = c.custom_properties ?? {};
32
+ const status = String(props.subscription_status ?? props.stripe_subscription_status ?? '').toLowerCase();
33
+ return Boolean(props.stripe_customer_id) && ['active', 'trialing', 'past_due'].includes(status);
34
+ }
35
+ function normalizeCompanyName(value) {
36
+ if (typeof value !== 'string')
37
+ return null;
38
+ const normalized = value.trim().replace(/\s+/g, ' ');
39
+ return normalized || null;
40
+ }
41
+ function normalizeCompanyKey(value) {
42
+ const normalized = normalizeCompanyName(value);
43
+ return normalized ? normalized.toLowerCase() : null;
44
+ }
45
+ function normalizeCompanyDomain(value) {
46
+ if (typeof value !== 'string')
47
+ return null;
48
+ const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
49
+ return normalized || null;
50
+ }
51
+ function normalizeEmail(value) {
52
+ return String(value ?? '').trim().toLowerCase();
53
+ }
54
+ const companiesEmbedCache = new Map();
55
+ function isMissingRelationshipError(error) {
56
+ if (!error)
57
+ return false;
58
+ if (error.code === 'PGRST200')
59
+ return true;
60
+ return /could not find a relationship/i.test(error.message ?? '');
61
+ }
62
+ async function hasCompaniesEmbed(projectId) {
63
+ const cached = companiesEmbedCache.get(projectId);
64
+ if (cached !== undefined)
65
+ return cached;
66
+ const { error } = await db()
67
+ .from('subscribers')
68
+ .select('id, company:companies(id)')
69
+ .eq('project_id', projectId)
70
+ .limit(1);
71
+ const available = !isMissingRelationshipError(error);
72
+ companiesEmbedCache.set(projectId, available);
73
+ return available;
74
+ }
75
+ function aggregateCompanyMetrics(rows) {
76
+ const now = Date.now();
77
+ let contactCount = 0;
78
+ let activeCount = 0;
79
+ let engaged30d = 0;
80
+ let totalOpens = 0;
81
+ let totalClicks = 0;
82
+ let scoreSum = 0;
83
+ let scoreCount = 0;
84
+ let lastEngagedAt = null;
85
+ for (const row of rows) {
86
+ contactCount += 1;
87
+ const stage = String(row.lifecycle_stage ?? '').toLowerCase();
88
+ if (['active', 'trial', 'customer'].includes(stage))
89
+ activeCount += 1;
90
+ const lastEngaged = typeof row.last_engaged_at === 'string' ? row.last_engaged_at : null;
91
+ if (lastEngaged) {
92
+ const engagedAtMs = new Date(lastEngaged).getTime();
93
+ if (Number.isFinite(engagedAtMs) && (now - engagedAtMs) <= 30 * 24 * 60 * 60 * 1000)
94
+ engaged30d += 1;
95
+ if (!lastEngagedAt || lastEngaged > lastEngagedAt)
96
+ lastEngagedAt = lastEngaged;
97
+ }
98
+ totalOpens += Number(row.total_opens ?? 0);
99
+ totalClicks += Number(row.total_clicks ?? 0);
100
+ if (typeof row.engagement_score === 'number' && Number.isFinite(row.engagement_score)) {
101
+ scoreSum += row.engagement_score;
102
+ scoreCount += 1;
103
+ }
104
+ }
105
+ return {
106
+ contact_count: contactCount,
107
+ active_contact_count: activeCount,
108
+ engaged_30d_count: engaged30d,
109
+ avg_engagement_score: scoreCount > 0 ? Number((scoreSum / scoreCount).toFixed(1)) : null,
110
+ total_opens: totalOpens,
111
+ total_clicks: totalClicks,
112
+ last_engaged_at: lastEngagedAt,
113
+ };
114
+ }
115
+ function primaryContactValue(value) {
116
+ if (typeof value === 'string')
117
+ return value.trim() || null;
118
+ if (Array.isArray(value)) {
119
+ const primary = value.find((item) => item && typeof item === 'object' && item.primary && item.value);
120
+ const fallback = value.find((item) => item && typeof item === 'object' && item.value);
121
+ const selected = primary ?? fallback;
122
+ return selected && typeof selected === 'object' ? String(selected.value ?? '').trim() || null : null;
123
+ }
124
+ return null;
125
+ }
126
+ const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
127
+ const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
128
+ const SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
129
+ const SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
130
+ const UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), custom_properties, tags';
131
+ const UPDATE_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, custom_properties, tags';
132
+ const GET_SUBSCRIBER_SELECT_WITH_COMPANY = '*, company:companies(id, name, domain), site_subscriptions(site_id, subscribed)';
133
+ const GET_SUBSCRIBER_SELECT_NO_COMPANY = '*, site_subscriptions(site_id, subscribed)';
134
+ const CONTACT_ENRICHMENT_ADAPTERS = createContactEnrichmentAdapters({
135
+ normalizeActiveCampaignAccountUrl,
136
+ });
137
+ async function enrichContactFromProviders(projectId, email, provider) {
138
+ const { data: connections, error } = await db()
139
+ .from('platform_connections')
140
+ .select('platform, credentials_enc')
141
+ .eq('project_id', projectId)
142
+ .eq('is_active', true)
143
+ .in('platform', provider ? [provider] : [...CONTACT_ENRICHMENT_PROVIDERS]);
144
+ if (error)
145
+ throw new Error(error.message);
146
+ return enrichContactFromConnections({
147
+ connections: (connections ?? []),
148
+ email,
149
+ provider,
150
+ adapters: CONTACT_ENRICHMENT_ADAPTERS,
151
+ decryptCredentials(credentialsEnc) {
152
+ return JSON.parse(decrypt(credentialsEnc));
153
+ },
154
+ });
155
+ }
156
+ async function resolveCompanyId(projectId, input) {
157
+ if (input.company_id)
158
+ return input.company_id;
159
+ const companyName = normalizeCompanyName(input.company_name);
160
+ const companyKey = normalizeCompanyKey(input.company_name);
161
+ const companyDomain = normalizeCompanyDomain(input.company_domain);
162
+ if (!companyName && !companyDomain)
163
+ return undefined;
164
+ let existing = null;
165
+ if (companyDomain) {
166
+ const { data } = await db()
167
+ .from('companies')
168
+ .select('id')
169
+ .eq('project_id', projectId)
170
+ .eq('domain', companyDomain)
171
+ .maybeSingle();
172
+ existing = data ?? null;
173
+ }
174
+ if (!existing && companyKey) {
175
+ const { data } = await db()
176
+ .from('companies')
177
+ .select('id')
178
+ .eq('project_id', projectId)
179
+ .eq('name_key', companyKey)
180
+ .maybeSingle();
181
+ existing = data ?? null;
182
+ }
183
+ if (existing)
184
+ return existing.id;
185
+ const { data, error } = await db()
186
+ .from('companies')
187
+ .insert({
188
+ project_id: projectId,
189
+ name: companyName ?? companyDomain ?? 'Unknown company',
190
+ name_key: companyKey ?? companyDomain,
191
+ domain: companyDomain,
192
+ })
193
+ .select('id')
194
+ .single();
195
+ if (error)
196
+ throw error;
197
+ return data.id;
198
+ }
199
+ async function exportSubscriberData(projectId, contactId) {
200
+ const { data: contact, error: contactError } = await db()
201
+ .from('subscribers')
202
+ .select('id, email, tags, custom_properties, source, source_site, lifecycle_stage, created_at, opted_in_at, global_unsubscribed')
203
+ .eq('id', contactId)
204
+ .eq('project_id', projectId)
205
+ .maybeSingle();
206
+ if (contactError)
207
+ return { error: contactError.message };
208
+ if (!contact)
209
+ return { error: 'Subscriber not found', code: 'NOT_FOUND' };
210
+ const { data: emailHistory, error: logError } = await db()
211
+ .from('email_log')
212
+ .select('id, subject, sent_at, status, opened_at, clicked_at')
213
+ .eq('project_id', projectId)
214
+ .eq('contact_id', contactId)
215
+ .not('to_email', 'like', 'sha256:%')
216
+ .order('sent_at', { ascending: false });
217
+ if (logError)
218
+ return { error: logError.message };
219
+ const { data: suppression, error: suppressionError } = await db()
220
+ .from('suppressions')
221
+ .select('reason, created_at')
222
+ .eq('project_id', projectId)
223
+ .eq('email', contact.email)
224
+ .not('email', 'like', 'sha256:%')
225
+ .maybeSingle();
226
+ if (suppressionError)
227
+ return { error: suppressionError.message };
228
+ return {
229
+ contact: {
230
+ id: contact.id,
231
+ email: contact.email,
232
+ tags: contact.tags ?? [],
233
+ custom_properties: contact.custom_properties ?? {},
234
+ source: contact.source ?? null,
235
+ source_site: contact.source_site ?? null,
236
+ lifecycle_stage: contact.lifecycle_stage ?? null,
237
+ created_at: contact.created_at ?? null,
238
+ opted_in_at: contact.opted_in_at ?? null,
239
+ global_unsubscribed: contact.global_unsubscribed ?? null,
240
+ },
241
+ emailHistory: emailHistory ?? [],
242
+ suppressionStatus: {
243
+ suppressed: !!suppression,
244
+ reason: suppression?.reason ?? null,
245
+ created_at: suppression?.created_at ?? null,
246
+ },
247
+ };
248
+ }
249
+ export function registerContactTools(server) {
250
+ server.registerTool('get_subscriber_timeline', {
251
+ description: 'Get a merged activity timeline for a contact.',
252
+ inputSchema: { subscriber_id: z.string().uuid(), limit: z.number().int().min(1).max(100).optional().default(50) },
253
+ }, async ({ subscriber_id, limit = 50 }) => {
254
+ const projectId = getProjectId();
255
+ const { data: contact } = await db().from('subscribers').select('id, email').eq('project_id', projectId).eq('id', subscriber_id).maybeSingle();
256
+ if (!contact)
257
+ return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
258
+ const [emails, enrollments, events, suppressions] = await Promise.all([
259
+ db().from('email_log').select('id, status, subject, created_at, campaign_id').eq('project_id', projectId).eq('contact_id', subscriber_id).limit(limit),
260
+ db().from('campaign_enrollments').select('id, status, enrolled_at, completed_at, campaigns(name)').eq('contact_id', subscriber_id).limit(limit),
261
+ db().from('event_log').select('id, event_name, source, properties, created_at').eq('project_id', projectId).eq('contact_id', subscriber_id).limit(limit),
262
+ db().from('suppressions').select('id, reason, created_at').eq('project_id', projectId).eq('email', contact.email).limit(limit),
263
+ ]);
264
+ const timeline = [
265
+ ...(emails.data ?? []).map((row) => ({ id: `email:${row.id}`, type: `email_${row.status}`, title: `Email ${row.status}: ${row.subject ?? '(no subject)'}`, metadata: row, created_at: row.created_at })),
266
+ ...(enrollments.data ?? []).map((row) => ({ id: `enrollment:${row.id}`, type: row.status === 'completed' ? 'completed' : 'enrolled', title: row.status === 'completed' ? 'Campaign completed' : 'Campaign enrollment', metadata: row, created_at: row.completed_at ?? row.enrolled_at })),
267
+ ...(events.data ?? []).map((row) => ({ id: `api_event:${row.id}`, type: 'api_event', title: `API event received: ${row.event_name}`, metadata: row, created_at: row.created_at })),
268
+ ...(suppressions.data ?? []).map((row) => ({ id: `suppression:${row.id}`, type: 'suppressed', title: `Suppressed: ${row.reason}`, metadata: row, created_at: row.created_at })),
269
+ ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()).slice(0, limit);
270
+ return j({ events: timeline });
271
+ });
272
+ server.registerTool('get_hygiene_config', {
273
+ description: 'Get list hygiene automation config.',
274
+ inputSchema: {},
275
+ }, async () => {
276
+ const { data, error } = await db().from('list_hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
277
+ if (error)
278
+ return j({ error: error.message });
279
+ return j(data ?? { enabled: false, inactive_days: 90, action: 'tag', tag_name: 'cold', suppress_after_days: 180, last_run_at: null });
280
+ });
281
+ server.registerTool('update_hygiene_config', {
282
+ description: 'Update list hygiene automation config.',
283
+ inputSchema: {
284
+ enabled: z.boolean().optional(),
285
+ inactive_days: z.number().int().min(1).optional(),
286
+ action: z.enum(['tag', 'suppress', 'tag_and_suppress']).optional(),
287
+ tag_name: z.string().optional(),
288
+ suppress_after_days: z.number().int().min(1).optional(),
289
+ },
290
+ }, async (config) => {
291
+ const { error } = await db().from('list_hygiene_config').upsert({ project_id: getProjectId(), ...config, updated_at: new Date().toISOString() }, { onConflict: 'project_id' });
292
+ if (error)
293
+ return j({ error: error.message });
294
+ return j({ updated: true });
295
+ });
296
+ server.registerTool('run_list_hygiene', {
297
+ description: 'Run list hygiene immediately for the project.',
298
+ inputSchema: {},
299
+ }, async () => {
300
+ const projectId = getProjectId();
301
+ const cutoff = daysAgo(90);
302
+ const { data: contacts } = await db().from('subscribers').select('id, email, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
303
+ const ids = (contacts ?? []).map((contact) => contact.id);
304
+ if (ids.length) {
305
+ await db().from('subscribers').update({ tags: ['cold'] }).eq('project_id', projectId).in('id', ids);
306
+ }
307
+ return j({ tagged: ids.length, suppressed: 0, skipped: 0 });
308
+ });
309
+ server.registerTool('list_hygiene_audit', {
310
+ description: 'Audit list engagement health and identify sunset candidates while excluding active paying customers.',
311
+ inputSchema: {
312
+ siteId: z.string().uuid(),
313
+ lookbackDays: z.number().int().min(30).max(730).optional().default(180),
314
+ },
315
+ }, async ({ siteId, lookbackDays = 180 }) => {
316
+ const projectId = getProjectId();
317
+ const { data: subs } = await db()
318
+ .from('site_subscriptions')
319
+ .select('contact_id')
320
+ .eq('site_id', siteId)
321
+ .eq('subscribed', true);
322
+ const ids = (subs ?? []).map((s) => s.contact_id);
323
+ if (!ids.length)
324
+ return j({
325
+ summary: { total: 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 },
326
+ sunset_candidates: [],
327
+ active_paying_low_engagement: [],
328
+ recommendation: 'No subscribed contacts found for this site.',
329
+ lookback_days: lookbackDays,
330
+ });
331
+ const { data: contacts, error } = await db()
332
+ .from('subscribers')
333
+ .select('id, email, last_engaged_at, total_opens, total_clicks, total_emails_sent, custom_properties')
334
+ .eq('project_id', projectId)
335
+ .eq('global_unsubscribed', false)
336
+ .in('id', ids)
337
+ .limit(5000);
338
+ if (error)
339
+ return txt(`Error: ${error.message}`);
340
+ const summary = { total: contacts?.length ?? 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 };
341
+ const sunset = [];
342
+ const payingLowEngagement = [];
343
+ const cutoff = daysAgo(lookbackDays);
344
+ for (const c of contacts ?? []) {
345
+ const bucket = bucketSubscriber(c);
346
+ summary[bucket]++;
347
+ const noEngagement = !c.last_engaged_at || c.last_engaged_at < cutoff;
348
+ const noOpensClicks = (c.total_opens ?? 0) === 0 && (c.total_clicks ?? 0) === 0;
349
+ const sentEnough = (c.total_emails_sent ?? 0) >= 5;
350
+ if (noEngagement && noOpensClicks && sentEnough) {
351
+ if (activePaying(c)) {
352
+ payingLowEngagement.push(c.id);
353
+ }
354
+ else {
355
+ sunset.push(c.id);
356
+ }
357
+ }
358
+ }
359
+ summary.sunset_candidates = sunset.length;
360
+ summary.active_paying_low_engagement = payingLowEngagement.length;
361
+ const percent = summary.total ? Math.round((sunset.length / summary.total) * 100) : 0;
362
+ const estimatedMonthlySavings = Math.round(sunset.length * 0.01);
363
+ return j({
364
+ summary,
365
+ sunset_candidates: sunset.slice(0, 500),
366
+ active_paying_low_engagement: payingLowEngagement.slice(0, 500),
367
+ recommendation: `${sunset.length} contacts have not engaged in ${lookbackDays}+ days after 5+ sends. Suppressing them would reduce list size by ${percent}% and could reduce Klaviyo active-profile billing by approximately $${estimatedMonthlySavings}/mo if migrated.`,
368
+ estimated_send_volume_reduction: sunset.length,
369
+ estimated_klaviyo_monthly_reduction_usd: estimatedMonthlySavings,
370
+ lookback_days: lookbackDays,
371
+ });
372
+ });
373
+ server.registerTool('list_win_back_draft', {
374
+ description: 'Draft a human-approved win-back email for dormant or sunset contacts. Does not enqueue or send.',
375
+ inputSchema: {
376
+ siteId: z.string().uuid(),
377
+ segmentId: z.string().uuid().optional(),
378
+ cohort: z.enum(['dormant', 'sunset_candidates']).optional().default('dormant'),
379
+ },
380
+ }, async ({ siteId, segmentId, cohort = 'dormant' }) => {
381
+ const projectId = getProjectId();
382
+ const [{ data: site }, { data: subs }] = await Promise.all([
383
+ db().from('sites').select('id, name, sending_domain, brand_voice, projects(org_id, organizations(brand_settings))').eq('id', siteId).eq('project_id', projectId).maybeSingle(),
384
+ db().from('site_subscriptions').select('contact_id').eq('site_id', siteId).eq('subscribed', true),
385
+ ]);
386
+ if (!site)
387
+ return j({ error: 'Site not found', code: 'NOT_FOUND' });
388
+ const ids = (subs ?? []).map((s) => s.contact_id);
389
+ if (!ids.length)
390
+ return j({ error: 'No subscribed contacts for this site', code: 'EMPTY_COHORT' });
391
+ let q = db()
392
+ .from('subscribers')
393
+ .select('id, email, first_name, created_at, last_engaged_at, tags, total_opens, total_clicks, total_emails_sent')
394
+ .eq('project_id', projectId)
395
+ .eq('global_unsubscribed', false)
396
+ .in('id', ids)
397
+ .limit(50);
398
+ if (cohort === 'dormant')
399
+ q = q.lt('last_engaged_at', daysAgo(90)).gte('last_engaged_at', daysAgo(180));
400
+ else
401
+ q = q.or(`last_engaged_at.is.null,last_engaged_at.lt.${daysAgo(180)}`).eq('total_opens', 0).eq('total_clicks', 0).gte('total_emails_sent', 5);
402
+ if (segmentId)
403
+ q = q.contains('tags', [`segment:${segmentId}`]);
404
+ const { data: sample, error } = await q;
405
+ if (error)
406
+ return txt(`Error: ${error.message}`);
407
+ const apiKey = process.env.ANTHROPIC_API_KEY;
408
+ if (!apiKey)
409
+ return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_UNAVAILABLE' });
410
+ const client = new Anthropic({ apiKey });
411
+ const prompt = `Draft a win-back email for ${site.name}.
412
+ Cohort: ${cohort}
413
+ Sample contacts: ${JSON.stringify((sample ?? []).slice(0, 10))}
414
+ Brand settings: ${JSON.stringify({ site_brand_voice: site.brand_voice, org_brand_settings: site.projects?.organizations?.brand_settings })}
415
+
416
+ Return JSON only with subject, preview_text, body_html, cta_text, reasoning.
417
+ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, visible one-click unsubscribe link in body using {{unsubscribe_url}}, no auto-send language.`;
418
+ const response = await client.messages.create({
419
+ model: getAnthropicModelId(),
420
+ max_tokens: 1200,
421
+ system: 'You are an expert lifecycle email strategist. Return valid JSON only.',
422
+ messages: [{ role: 'user', content: prompt }],
423
+ });
424
+ const text = response.content.map((part) => part.type === 'text' ? part.text : '').join('').trim();
425
+ let draft = text;
426
+ try {
427
+ draft = JSON.parse(text);
428
+ }
429
+ catch { }
430
+ return j({
431
+ site_id: siteId,
432
+ segment_id: segmentId ?? null,
433
+ cohort,
434
+ sample_size: sample?.length ?? 0,
435
+ draft,
436
+ auto_enqueued: false,
437
+ follow_up_note: 'Contacts that do not respond to this win-back should be auto-suppressed by a follow-up automation after the send window.',
438
+ });
439
+ });
440
+ server.registerTool('list_subscribers', {
441
+ description: 'List contacts, optionally filtered by site and/or search term',
442
+ inputSchema: {
443
+ site_id: z.string().uuid().optional(),
444
+ company_id: z.string().uuid().optional(),
445
+ company_domain: z.string().optional(),
446
+ search: z.string().optional().describe('Partial match on email, first_name, or last_name'),
447
+ limit: z.number().int().min(1).max(200).optional().default(50),
448
+ include_unsubscribed: z.boolean().optional().default(false),
449
+ },
450
+ }, async ({ site_id, company_id, company_domain, search, limit, include_unsubscribed }) => {
451
+ const projectId = getProjectId();
452
+ const embedOk = await hasCompaniesEmbed(projectId);
453
+ let q = db()
454
+ .from('subscribers')
455
+ .select(embedOk ? SUBSCRIBER_SELECT_WITH_COMPANY : SUBSCRIBER_SELECT_NO_COMPANY)
456
+ .eq('project_id', projectId)
457
+ .order('created_at', { ascending: false })
458
+ .limit(limit ?? 50);
459
+ if (!include_unsubscribed)
460
+ q = q.eq('global_unsubscribed', false);
461
+ if (search)
462
+ q = q.or(`email.ilike.%${search}%,first_name.ilike.%${search}%,last_name.ilike.%${search}%`);
463
+ if (company_id)
464
+ q = q.eq('company_id', company_id);
465
+ if (site_id) {
466
+ const { data: subs } = await db()
467
+ .from('site_subscriptions')
468
+ .select('contact_id')
469
+ .eq('site_id', site_id)
470
+ .eq('subscribed', true);
471
+ const ids = (subs ?? []).map((s) => s.contact_id);
472
+ if (!ids.length)
473
+ return { content: [{ type: 'text', text: '[]' }] };
474
+ q = q.in('id', ids);
475
+ }
476
+ if (company_domain) {
477
+ const { data: companies } = await db()
478
+ .from('companies')
479
+ .select('id')
480
+ .eq('project_id', projectId)
481
+ .eq('domain', company_domain.trim().toLowerCase());
482
+ const ids = (companies ?? []).map((company) => company.id);
483
+ if (!ids.length)
484
+ return { content: [{ type: 'text', text: '[]' }] };
485
+ q = q.in('company_id', ids);
486
+ }
487
+ const { data, error } = await q;
488
+ if (error)
489
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
490
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
491
+ });
492
+ server.registerTool('list_companies', {
493
+ description: 'List linked companies for the current project with rolled-up contact engagement metrics.',
494
+ inputSchema: {
495
+ search: z.string().optional().describe('Partial match on company name or domain'),
496
+ limit: z.number().int().min(1).max(200).optional().default(50),
497
+ },
498
+ }, async ({ search, limit }) => {
499
+ const projectId = getProjectId();
500
+ let query = db()
501
+ .from('companies')
502
+ .select('id, name, domain, created_at')
503
+ .eq('project_id', projectId)
504
+ .order('name', { ascending: true })
505
+ .limit(limit ?? 50);
506
+ if (search) {
507
+ query = query.or(`name.ilike.%${search}%,domain.ilike.%${search}%`);
508
+ }
509
+ const { data: companies, error } = await query;
510
+ if (error)
511
+ return txt(`Error: ${error.message}`);
512
+ const rows = companies ?? [];
513
+ if (!rows.length)
514
+ return j([]);
515
+ const companyIds = rows.map((company) => company.id);
516
+ const { data: subscribers, error: subscriberError } = await db()
517
+ .from('subscribers')
518
+ .select('company_id, engagement_score, total_opens, total_clicks, lifecycle_stage, last_engaged_at')
519
+ .eq('project_id', projectId)
520
+ .in('company_id', companyIds);
521
+ if (subscriberError)
522
+ return txt(`Error: ${subscriberError.message}`);
523
+ const grouped = new Map();
524
+ for (const subscriber of subscribers ?? []) {
525
+ const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
526
+ if (!companyId)
527
+ continue;
528
+ const bucket = grouped.get(companyId) ?? [];
529
+ bucket.push(subscriber);
530
+ grouped.set(companyId, bucket);
531
+ }
532
+ return j(rows.map((company) => ({
533
+ ...company,
534
+ metrics: aggregateCompanyMetrics(grouped.get(company.id) ?? []),
535
+ })));
536
+ });
537
+ server.registerTool('add_subscriber', {
538
+ description: 'Add a contact and subscribe them to a site',
539
+ inputSchema: {
540
+ email: z.string().email(),
541
+ site_id: z.string().uuid(),
542
+ first_name: z.string().optional(),
543
+ last_name: z.string().optional(),
544
+ company_id: z.string().uuid().optional(),
545
+ company_name: z.string().optional(),
546
+ company_domain: z.string().optional(),
547
+ source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
548
+ utm_source: z.string().optional(),
549
+ utm_medium: z.string().optional(),
550
+ utm_campaign: z.string().optional(),
551
+ utm_content: z.string().optional(),
552
+ utm_term: z.string().optional(),
553
+ },
554
+ }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
555
+ const projectId = getProjectId();
556
+ const embedOk = await hasCompaniesEmbed(projectId);
557
+ const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
558
+ // Block distribution list / system addresses
559
+ const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
560
+ const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
561
+ const local = email.trim().toLowerCase().split('@')[0] ?? '';
562
+ if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
563
+ return { content: [{ type: 'text', text: `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.` }] };
564
+ }
565
+ const limit = await checkPlanLimit('contacts');
566
+ if (!limit.allowed) {
567
+ return { content: [{ type: 'text', text: `Contact limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for more contacts.` }] };
568
+ }
569
+ // Check if site requires double opt-in
570
+ const { data: siteInfo } = await db().from('sites').select('double_opt_in').eq('id', site_id).maybeSingle();
571
+ const needsConfirm = siteInfo?.double_opt_in === true;
572
+ const { data: existingContact } = await db()
573
+ .from('subscribers')
574
+ .select('id')
575
+ .eq('project_id', projectId)
576
+ .eq('email', email)
577
+ .maybeSingle();
578
+ const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
579
+ if (company_name || company_domain) {
580
+ row.custom_properties = {
581
+ ...(typeof row.custom_properties === 'object' && row.custom_properties ? row.custom_properties : {}),
582
+ ...(company_name ? { company_name } : {}),
583
+ ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
584
+ };
585
+ }
586
+ if (!needsConfirm)
587
+ row.opted_in_at = new Date().toISOString();
588
+ if (!existingContact) {
589
+ if (utm_source)
590
+ row.utm_source = utm_source;
591
+ if (utm_medium)
592
+ row.utm_medium = utm_medium;
593
+ if (utm_campaign)
594
+ row.utm_campaign = utm_campaign;
595
+ if (utm_content)
596
+ row.utm_content = utm_content;
597
+ if (utm_term)
598
+ row.utm_term = utm_term;
599
+ }
600
+ const { data: contact, error: cErr } = await db()
601
+ .from('subscribers')
602
+ .upsert(row, { onConflict: 'project_id,email' })
603
+ .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
604
+ .single();
605
+ if (cErr)
606
+ return { content: [{ type: 'text', text: `Error creating contact: ${cErr.message}` }] };
607
+ const { error: subErr } = await db()
608
+ .from('site_subscriptions')
609
+ .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
610
+ if (subErr)
611
+ return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
612
+ if (needsConfirm) {
613
+ // Create opt-in token (confirmation email must be sent externally in MCP context)
614
+ await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
615
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
616
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.' }, null, 2) }] };
617
+ }
618
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
619
+ logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
620
+ return { content: [{ type: 'text', text: JSON.stringify(contact, null, 2) }] };
621
+ });
622
+ server.registerTool('import_subscribers', {
623
+ description: 'Bulk import contacts and subscribe them to a site. Max 200 per call.',
624
+ inputSchema: {
625
+ contacts: z.array(z.object({
626
+ email: z.string().email(),
627
+ first_name: z.string().optional(),
628
+ last_name: z.string().optional(),
629
+ })).min(1).max(200),
630
+ site_id: z.string().uuid(),
631
+ },
632
+ }, async ({ contacts, site_id }) => {
633
+ const projectId = getProjectId();
634
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
635
+ // Distribution list / system address block — same patterns as dashboard email-validation.ts
636
+ const DISTRO_LOCAL_PARTS = new Set([
637
+ 'all', 'everyone', 'distro', 'broadcast', 'announce',
638
+ 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces',
639
+ 'noreply', 'no-reply', 'nobody', 'null', 'void',
640
+ ]);
641
+ const DISTRO_PATTERNS = [
642
+ /^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/,
643
+ /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/,
644
+ /[-+.]broadcast$/, /^broadcast[-+.]/,
645
+ ];
646
+ const isDistro = (email) => {
647
+ const local = email.split('@')[0] ?? '';
648
+ return DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local));
649
+ };
650
+ const seen = new Set();
651
+ const valid = [];
652
+ const skippedDistro = [];
653
+ for (const c of contacts) {
654
+ const email = c.email.trim().toLowerCase();
655
+ if (!emailRegex.test(email) || seen.has(email))
656
+ continue;
657
+ if (isDistro(email)) {
658
+ skippedDistro.push(email);
659
+ continue;
660
+ }
661
+ seen.add(email);
662
+ valid.push({ ...c, email });
663
+ }
664
+ if (!valid.length) {
665
+ const msg = skippedDistro.length
666
+ ? `No valid contacts to import. ${skippedDistro.length} address(es) blocked as distribution lists: ${skippedDistro.slice(0, 5).join(', ')}`
667
+ : 'No valid contacts to import.';
668
+ return { content: [{ type: 'text', text: msg }] };
669
+ }
670
+ // Check which already exist
671
+ const { data: existing } = await db()
672
+ .from('subscribers')
673
+ .select('email')
674
+ .eq('project_id', projectId)
675
+ .in('email', valid.map(c => c.email));
676
+ const existingEmails = new Set((existing ?? []).map((e) => e.email));
677
+ const newCount = valid.filter(c => !existingEmails.has(c.email)).length;
678
+ if (newCount > 0) {
679
+ const limit = await checkPlanLimit('contacts');
680
+ const remaining = limit.limit - limit.current;
681
+ if (newCount > remaining) {
682
+ return { content: [{ type: 'text', text: `Import would add ${newCount} new contacts but only ${remaining} slots remaining (${limit.current}/${limit.limit}). Upgrade to import more.` }] };
683
+ }
684
+ }
685
+ const rows = valid.map(c => ({
686
+ project_id: projectId,
687
+ email: c.email,
688
+ first_name: c.first_name || null,
689
+ last_name: c.last_name || null,
690
+ source: 'import',
691
+ opted_in_at: new Date().toISOString(),
692
+ }));
693
+ const { data: upserted, error: uErr } = await db()
694
+ .from('subscribers')
695
+ .upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
696
+ .select('id, email');
697
+ if (uErr)
698
+ return { content: [{ type: 'text', text: `Error: ${uErr.message}` }] };
699
+ const contactRows = upserted ?? [];
700
+ const subRows = contactRows.map((c) => ({ contact_id: c.id, site_id, subscribed: true }));
701
+ if (subRows.length) {
702
+ await db().from('site_subscriptions').upsert(subRows, { onConflict: 'contact_id,site_id', ignoreDuplicates: true });
703
+ }
704
+ const created = contactRows.filter((c) => !existingEmails.has(c.email)).length;
705
+ const updated = contactRows.length - created;
706
+ return { content: [{ type: 'text', text: JSON.stringify({ created, updated, total: contactRows.length }, null, 2) }] };
707
+ });
708
+ server.registerTool('unsubscribe_subscriber', {
709
+ description: 'Globally unsubscribe a contact from all sites',
710
+ inputSchema: {
711
+ email: z.string().email(),
712
+ },
713
+ }, async ({ email }) => {
714
+ const now = new Date().toISOString();
715
+ const { data, error } = await db()
716
+ .from('subscribers')
717
+ .update({ global_unsubscribed: true, global_unsubscribed_at: now })
718
+ .eq('project_id', getProjectId())
719
+ .eq('email', email)
720
+ .select('id, email')
721
+ .single();
722
+ if (error)
723
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
724
+ if (data) {
725
+ // Cascade to site subscriptions
726
+ await db()
727
+ .from('site_subscriptions')
728
+ .update({ subscribed: false, unsubscribed_at: now, unsubscribe_reason: 'global_unsubscribe' })
729
+ .eq('contact_id', data.id)
730
+ .eq('subscribed', true);
731
+ logConsent({ contactId: data.id, action: 'revoke', consentType: 'email_marketing', source: 'mcp_unsubscribe' });
732
+ logAudit({ action: 'contact.unsubscribed', resourceType: 'contact', resourceId: data.id, details: { email } });
733
+ }
734
+ return { content: [{ type: 'text', text: JSON.stringify({ unsubscribed: true, ...data }, null, 2) }] };
735
+ });
736
+ server.registerTool('list_email_log', {
737
+ description: 'Recent email sends with status, filterable by site and/or recipient',
738
+ inputSchema: {
739
+ site_id: z.string().uuid().optional(),
740
+ contact_email: z.string().optional(),
741
+ status: z.enum(['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed']).optional(),
742
+ limit: z.number().int().min(1).max(200).optional().default(50),
743
+ },
744
+ }, async ({ site_id, contact_email, status, limit }) => {
745
+ let q = db()
746
+ .from('email_log')
747
+ .select('id, to_email, subject, status, sent_at, opened_at, clicked_at, bounced_at, site_id, campaign_id')
748
+ .eq('project_id', getProjectId())
749
+ .order('sent_at', { ascending: false })
750
+ .limit(limit ?? 50);
751
+ if (site_id)
752
+ q = q.eq('site_id', site_id);
753
+ if (status)
754
+ q = q.eq('status', status);
755
+ if (contact_email)
756
+ q = q.eq('to_email', contact_email);
757
+ const { data, error } = await q;
758
+ if (error)
759
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
760
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
761
+ });
762
+ server.registerTool('list_suppressions', {
763
+ description: 'List suppressed email addresses (hard bounces, complaints, manual blocks)',
764
+ inputSchema: {
765
+ limit: z.number().int().min(1).max(500).optional().default(100),
766
+ reason: z.enum(['hard_bounce', 'complaint', 'manual', 'repeated_soft_bounce']).optional(),
767
+ },
768
+ }, async ({ limit, reason }) => {
769
+ let q = db()
770
+ .from('suppressions')
771
+ .select('email, reason, source, created_at')
772
+ .eq('project_id', getProjectId())
773
+ .order('created_at', { ascending: false })
774
+ .limit(limit ?? 100);
775
+ if (reason)
776
+ q = q.eq('reason', reason);
777
+ const { data, error } = await q;
778
+ if (error)
779
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
780
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
781
+ });
782
+ server.registerTool('add_suppression', {
783
+ description: 'Manually add an email to the suppression list',
784
+ inputSchema: {
785
+ email: z.string().email(),
786
+ reason: z.enum(['hard_bounce', 'complaint', 'manual', 'repeated_soft_bounce']).default('manual'),
787
+ details: z.string().optional(),
788
+ },
789
+ }, async ({ email, reason, details }) => {
790
+ const { data, error } = await db()
791
+ .from('suppressions')
792
+ .upsert({ project_id: getProjectId(), email, reason, source: 'manual', details: details ? { note: details } : null }, { onConflict: 'project_id,email' })
793
+ .select()
794
+ .single();
795
+ if (error)
796
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
797
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
798
+ });
799
+ server.registerTool('list_subscribers_by_segment', {
800
+ description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
801
+ inputSchema: {
802
+ lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
803
+ min_score: z.number().int().min(0).max(100).optional(),
804
+ max_score: z.number().int().min(0).max(100).optional(),
805
+ site_id: z.string().uuid().optional(),
806
+ company_id: z.string().uuid().optional(),
807
+ company_domain: z.string().optional(),
808
+ limit: z.number().int().min(1).max(200).optional().default(50),
809
+ },
810
+ }, async ({ lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
811
+ const projectId = getProjectId();
812
+ const embedOk = await hasCompaniesEmbed(projectId);
813
+ let q = db()
814
+ .from('subscribers')
815
+ .select(embedOk ? SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY : SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY)
816
+ .eq('project_id', projectId)
817
+ .eq('global_unsubscribed', false)
818
+ .order('engagement_score', { ascending: false })
819
+ .limit(limit ?? 50);
820
+ if (lifecycle_stage)
821
+ q = q.eq('lifecycle_stage', lifecycle_stage);
822
+ if (min_score !== undefined)
823
+ q = q.gte('engagement_score', min_score);
824
+ if (max_score !== undefined)
825
+ q = q.lte('engagement_score', max_score);
826
+ if (company_id)
827
+ q = q.eq('company_id', company_id);
828
+ if (site_id) {
829
+ const { data: subs } = await db()
830
+ .from('site_subscriptions')
831
+ .select('contact_id')
832
+ .eq('site_id', site_id)
833
+ .eq('subscribed', true);
834
+ const ids = (subs ?? []).map((s) => s.contact_id);
835
+ if (!ids.length)
836
+ return { content: [{ type: 'text', text: '[]' }] };
837
+ q = q.in('id', ids);
838
+ }
839
+ if (company_domain) {
840
+ const { data: companies } = await db()
841
+ .from('companies')
842
+ .select('id')
843
+ .eq('project_id', projectId)
844
+ .eq('domain', company_domain.trim().toLowerCase());
845
+ const ids = (companies ?? []).map((company) => company.id);
846
+ if (!ids.length)
847
+ return { content: [{ type: 'text', text: '[]' }] };
848
+ q = q.in('company_id', ids);
849
+ }
850
+ const { data, error } = await q;
851
+ if (error)
852
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
853
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
854
+ });
855
+ server.registerTool('update_subscriber', {
856
+ description: 'Update a contact\'s email, name, tags, or custom properties',
857
+ inputSchema: {
858
+ contact_id: z.string().uuid(),
859
+ email: z.string().email().optional(),
860
+ first_name: z.string().optional(),
861
+ last_name: z.string().optional(),
862
+ company_id: z.string().uuid().nullable().optional(),
863
+ company_name: z.string().nullable().optional(),
864
+ company_domain: z.string().nullable().optional(),
865
+ tags: z.array(z.string()).optional(),
866
+ custom_properties: z.record(z.unknown()).optional().describe('Merged into existing custom_properties'),
867
+ },
868
+ }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties }) => {
869
+ const projectId = getProjectId();
870
+ const embedOk = await hasCompaniesEmbed(projectId);
871
+ const updates = {};
872
+ if (email !== undefined)
873
+ updates.email = email;
874
+ if (first_name !== undefined)
875
+ updates.first_name = first_name;
876
+ if (last_name !== undefined)
877
+ updates.last_name = last_name;
878
+ if (company_id !== undefined) {
879
+ updates.company_id = company_id;
880
+ }
881
+ else if (company_name || company_domain) {
882
+ updates.company_id = await resolveCompanyId(projectId, {
883
+ company_name: company_name ?? undefined,
884
+ company_domain: company_domain ?? undefined,
885
+ });
886
+ }
887
+ if (tags !== undefined)
888
+ updates.tags = tags;
889
+ if (custom_properties !== undefined) {
890
+ const { data: existing } = await db()
891
+ .from('subscribers')
892
+ .select('custom_properties')
893
+ .eq('id', contact_id)
894
+ .eq('project_id', projectId)
895
+ .maybeSingle();
896
+ updates.custom_properties = {
897
+ ...(existing?.custom_properties ?? {}),
898
+ ...custom_properties,
899
+ ...(company_name ? { company_name } : {}),
900
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
901
+ };
902
+ }
903
+ else if (company_name || company_domain) {
904
+ const { data: existing } = await db()
905
+ .from('subscribers')
906
+ .select('custom_properties')
907
+ .eq('id', contact_id)
908
+ .eq('project_id', projectId)
909
+ .maybeSingle();
910
+ updates.custom_properties = {
911
+ ...(existing?.custom_properties ?? {}),
912
+ ...(company_name ? { company_name } : {}),
913
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
914
+ };
915
+ }
916
+ if (!Object.keys(updates).length)
917
+ return txt('No fields to update.');
918
+ updates.updated_at = new Date().toISOString();
919
+ const { data, error } = await db()
920
+ .from('subscribers')
921
+ .update(updates)
922
+ .eq('id', contact_id)
923
+ .eq('project_id', projectId)
924
+ .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
925
+ .single();
926
+ if (error)
927
+ return txt(`Error: ${error.message}`);
928
+ return j(data);
929
+ });
930
+ server.registerTool('get_subscriber', {
931
+ description: 'Get full contact details including site subscriptions, by ID or email',
932
+ inputSchema: {
933
+ contact_id: z.string().uuid().optional(),
934
+ email: z.string().email().optional(),
935
+ },
936
+ }, async ({ contact_id, email }) => {
937
+ const projectId = getProjectId();
938
+ const embedOk = await hasCompaniesEmbed(projectId);
939
+ let q = db()
940
+ .from('subscribers')
941
+ .select(embedOk ? GET_SUBSCRIBER_SELECT_WITH_COMPANY : GET_SUBSCRIBER_SELECT_NO_COMPANY)
942
+ .eq('project_id', projectId);
943
+ if (contact_id)
944
+ q = q.eq('id', contact_id);
945
+ else if (email)
946
+ q = q.eq('email', email);
947
+ else
948
+ return txt('Provide either contact_id or email.');
949
+ const { data, error } = await q.maybeSingle();
950
+ if (error)
951
+ return txt(`Error: ${error.message}`);
952
+ return j(data);
953
+ });
954
+ server.registerTool('enrich_subscriber', {
955
+ description: 'Enrich a subscriber from a connected CRM/ESP provider and merge provider metadata plus company info when available.',
956
+ inputSchema: {
957
+ contact_id: z.string().uuid(),
958
+ provider: z.enum(CONTACT_ENRICHMENT_PROVIDERS).optional(),
959
+ },
960
+ }, async ({ contact_id, provider }) => {
961
+ const projectId = getProjectId();
962
+ const embedOk = await hasCompaniesEmbed(projectId);
963
+ const { data: contact, error } = await db()
964
+ .from('subscribers')
965
+ .select('id, email, first_name, last_name, company_id, external_id, custom_properties')
966
+ .eq('project_id', projectId)
967
+ .eq('id', contact_id)
968
+ .maybeSingle();
969
+ if (error)
970
+ return txt(`Error: ${error.message}`);
971
+ if (!contact?.email)
972
+ return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
973
+ const enriched = await enrichContactFromProviders(projectId, contact.email, provider);
974
+ if (!enriched)
975
+ return j({ contact, enriched: false, applied_fields: [] });
976
+ const nextProperties = {
977
+ ...(contact.custom_properties ?? {}),
978
+ [enriched.provider]: {
979
+ ...((contact.custom_properties ?? {})[enriched.provider] ?? {}),
980
+ ...enriched.properties,
981
+ },
982
+ };
983
+ const companyId = await resolveCompanyId(projectId, {
984
+ company_name: enriched.companyName ?? undefined,
985
+ company_domain: enriched.companyDomain ?? undefined,
986
+ });
987
+ const { data: updated, error: updateError } = await db()
988
+ .from('subscribers')
989
+ .update({
990
+ first_name: contact.first_name ?? enriched.firstName ?? null,
991
+ last_name: contact.last_name ?? enriched.lastName ?? null,
992
+ company_id: companyId ?? contact.company_id ?? null,
993
+ external_id: contact.external_id ?? enriched.externalId ?? null,
994
+ custom_properties: nextProperties,
995
+ updated_at: new Date().toISOString(),
996
+ })
997
+ .eq('project_id', projectId)
998
+ .eq('id', contact_id)
999
+ .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
1000
+ .single();
1001
+ if (updateError)
1002
+ return txt(`Error: ${updateError.message}`);
1003
+ const appliedFields = [];
1004
+ if (!contact.first_name && enriched.firstName)
1005
+ appliedFields.push('first_name');
1006
+ if (!contact.last_name && enriched.lastName)
1007
+ appliedFields.push('last_name');
1008
+ if (enriched.companyName)
1009
+ appliedFields.push('company');
1010
+ if (!contact.external_id && enriched.externalId)
1011
+ appliedFields.push('external_id');
1012
+ appliedFields.push(`custom_properties.${enriched.provider}`);
1013
+ return j({ contact: updated, enriched: true, provider: enriched.provider, applied_fields: appliedFields });
1014
+ });
1015
+ server.registerTool('subscriber_export', {
1016
+ description: 'Export a contact data portability bundle with contact metadata, email history, and suppression status.',
1017
+ inputSchema: {
1018
+ contactId: z.string().uuid(),
1019
+ },
1020
+ }, async ({ contactId }) => {
1021
+ const result = await exportSubscriberData(getProjectId(), contactId);
1022
+ return j(result);
1023
+ });
1024
+ server.registerTool('set_subscriber_tags', {
1025
+ description: 'Add or remove tags from one or more contacts',
1026
+ inputSchema: {
1027
+ contact_ids: z.array(z.string().uuid()).min(1),
1028
+ add_tags: z.array(z.string()).optional(),
1029
+ remove_tags: z.array(z.string()).optional(),
1030
+ },
1031
+ }, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
1032
+ if (!add_tags.length && !remove_tags.length)
1033
+ return txt('No tags to add or remove.');
1034
+ const projectId = getProjectId();
1035
+ let updated = 0;
1036
+ for (const cid of contact_ids) {
1037
+ const { data: contact } = await db()
1038
+ .from('subscribers')
1039
+ .select('tags')
1040
+ .eq('id', cid)
1041
+ .eq('project_id', projectId)
1042
+ .maybeSingle();
1043
+ if (!contact)
1044
+ continue;
1045
+ const currentTags = contact.tags ?? [];
1046
+ const removeSet = new Set(remove_tags);
1047
+ const merged = currentTags.filter((t) => !removeSet.has(t));
1048
+ for (const t of add_tags) {
1049
+ if (!merged.includes(t))
1050
+ merged.push(t);
1051
+ }
1052
+ const { error } = await db()
1053
+ .from('subscribers')
1054
+ .update({ tags: merged })
1055
+ .eq('id', cid)
1056
+ .eq('project_id', projectId);
1057
+ if (!error)
1058
+ updated++;
1059
+ }
1060
+ return j({ updated, total: contact_ids.length, add_tags, remove_tags });
1061
+ });
1062
+ server.registerTool('estimate_segment_size', {
1063
+ description: 'Estimate how many contacts match filter criteria, with a sample. Useful before creating a segment.',
1064
+ inputSchema: {
1065
+ lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
1066
+ min_score: z.number().int().min(0).max(100).optional(),
1067
+ max_score: z.number().int().min(0).max(100).optional(),
1068
+ source: z.string().optional(),
1069
+ subscribed_after: z.string().optional().describe('YYYY-MM-DD'),
1070
+ subscribed_before: z.string().optional().describe('YYYY-MM-DD'),
1071
+ has_opened: z.boolean().optional(),
1072
+ has_clicked: z.boolean().optional(),
1073
+ tag: z.string().optional(),
1074
+ company_id: z.string().uuid().optional(),
1075
+ company_domain: z.string().optional(),
1076
+ custom_property: z.string().optional().describe('key=value format'),
1077
+ site_id: z.string().uuid().optional(),
1078
+ sample_size: z.number().int().min(1).max(20).optional().default(5),
1079
+ },
1080
+ }, async (params) => {
1081
+ const projectId = getProjectId();
1082
+ const sampleSize = params.sample_size ?? 5;
1083
+ let q = db()
1084
+ .from('subscribers')
1085
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
1086
+ .eq('project_id', projectId)
1087
+ .eq('global_unsubscribed', false);
1088
+ if (params.lifecycle_stage)
1089
+ q = q.eq('lifecycle_stage', params.lifecycle_stage);
1090
+ if (params.min_score !== undefined)
1091
+ q = q.gte('engagement_score', params.min_score);
1092
+ if (params.max_score !== undefined)
1093
+ q = q.lte('engagement_score', params.max_score);
1094
+ if (params.source)
1095
+ q = q.eq('source', params.source);
1096
+ if (params.subscribed_after)
1097
+ q = q.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
1098
+ if (params.subscribed_before)
1099
+ q = q.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
1100
+ if (params.has_opened === true)
1101
+ q = q.not('last_opened_at', 'is', null);
1102
+ if (params.has_opened === false)
1103
+ q = q.is('last_opened_at', null);
1104
+ if (params.has_clicked === true)
1105
+ q = q.not('last_clicked_at', 'is', null);
1106
+ if (params.has_clicked === false)
1107
+ q = q.is('last_clicked_at', null);
1108
+ if (params.tag)
1109
+ q = q.contains('tags', [params.tag]);
1110
+ if (params.company_id)
1111
+ q = q.eq('company_id', params.company_id);
1112
+ if (params.custom_property) {
1113
+ const eqIdx = params.custom_property.indexOf('=');
1114
+ if (eqIdx > 0) {
1115
+ const key = params.custom_property.slice(0, eqIdx);
1116
+ const val = params.custom_property.slice(eqIdx + 1);
1117
+ q = q.contains('custom_properties', { [key]: val });
1118
+ }
1119
+ }
1120
+ if (params.site_id) {
1121
+ const { data: subs } = await db()
1122
+ .from('site_subscriptions')
1123
+ .select('contact_id')
1124
+ .eq('site_id', params.site_id)
1125
+ .eq('subscribed', true);
1126
+ const ids = (subs ?? []).map((s) => s.contact_id);
1127
+ if (!ids.length)
1128
+ return j({ total: 0, sample: [] });
1129
+ q = q.in('id', ids);
1130
+ }
1131
+ if (params.company_domain) {
1132
+ const { data: companies } = await db()
1133
+ .from('companies')
1134
+ .select('id')
1135
+ .eq('project_id', projectId)
1136
+ .eq('domain', params.company_domain.trim().toLowerCase());
1137
+ const ids = (companies ?? []).map((company) => company.id);
1138
+ if (!ids.length)
1139
+ return j({ total: 0, sample: [] });
1140
+ q = q.in('company_id', ids);
1141
+ }
1142
+ const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
1143
+ if (error)
1144
+ return txt(`Error: ${error.message}`);
1145
+ // Get full count separately
1146
+ let countQ = db()
1147
+ .from('subscribers')
1148
+ .select('id', { count: 'exact', head: true })
1149
+ .eq('project_id', projectId)
1150
+ .eq('global_unsubscribed', false);
1151
+ if (params.lifecycle_stage)
1152
+ countQ = countQ.eq('lifecycle_stage', params.lifecycle_stage);
1153
+ if (params.min_score !== undefined)
1154
+ countQ = countQ.gte('engagement_score', params.min_score);
1155
+ if (params.max_score !== undefined)
1156
+ countQ = countQ.lte('engagement_score', params.max_score);
1157
+ if (params.source)
1158
+ countQ = countQ.eq('source', params.source);
1159
+ if (params.subscribed_after)
1160
+ countQ = countQ.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
1161
+ if (params.subscribed_before)
1162
+ countQ = countQ.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
1163
+ if (params.has_opened === true)
1164
+ countQ = countQ.not('last_opened_at', 'is', null);
1165
+ if (params.has_opened === false)
1166
+ countQ = countQ.is('last_opened_at', null);
1167
+ if (params.has_clicked === true)
1168
+ countQ = countQ.not('last_clicked_at', 'is', null);
1169
+ if (params.has_clicked === false)
1170
+ countQ = countQ.is('last_clicked_at', null);
1171
+ if (params.tag)
1172
+ countQ = countQ.contains('tags', [params.tag]);
1173
+ const { count } = await countQ;
1174
+ return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
1175
+ });
1176
+ server.registerTool('identify_inactive', {
1177
+ description: 'Find contacts with no engagement in N days. Dry run by default; set dry_run=false to suppress them.',
1178
+ inputSchema: {
1179
+ days: z.number().int().min(1).optional().default(90).describe('Inactivity threshold in days'),
1180
+ site_id: z.string().uuid().optional(),
1181
+ dry_run: z.boolean().optional().default(true).describe('Preview only (true) or suppress (false)'),
1182
+ limit: z.number().int().min(1).max(200).optional().default(20),
1183
+ },
1184
+ }, async ({ days = 90, site_id, dry_run = true, limit = 20 }) => {
1185
+ const projectId = getProjectId();
1186
+ const sampleLimit = Math.min(limit, 200);
1187
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
1188
+ let q = db()
1189
+ .from('subscribers')
1190
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_emails_sent')
1191
+ .eq('project_id', projectId)
1192
+ .eq('global_unsubscribed', false)
1193
+ .or(`last_opened_at.is.null,last_opened_at.lt.${cutoff}`)
1194
+ .or(`last_clicked_at.is.null,last_clicked_at.lt.${cutoff}`);
1195
+ if (site_id) {
1196
+ const { data: subs } = await db()
1197
+ .from('site_subscriptions')
1198
+ .select('contact_id')
1199
+ .eq('site_id', site_id)
1200
+ .eq('subscribed', true);
1201
+ const ids = (subs ?? []).map((s) => s.contact_id);
1202
+ if (!ids.length)
1203
+ return j({ inactive_count: 0, sample: [], message: 'No subscribed contacts for this site.' });
1204
+ q = q.in('id', ids);
1205
+ }
1206
+ const { data: suppressed } = await db()
1207
+ .from('suppressions')
1208
+ .select('email')
1209
+ .eq('project_id', projectId);
1210
+ const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
1211
+ const { data: contacts, error } = await q.order('engagement_score', { ascending: true }).limit(sampleLimit);
1212
+ if (error)
1213
+ return txt(`Error: ${error.message}`);
1214
+ const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
1215
+ if (!dry_run && inactive.length > 0) {
1216
+ const suppressionRows = inactive.map((c) => ({
1217
+ project_id: projectId,
1218
+ email: c.email,
1219
+ reason: 'manual',
1220
+ source: 'inactive_cleanup',
1221
+ details: { days_inactive: days, engagement_score: c.engagement_score },
1222
+ }));
1223
+ const { error: supErr } = await db()
1224
+ .from('suppressions')
1225
+ .upsert(suppressionRows, { onConflict: 'project_id,email', ignoreDuplicates: true });
1226
+ if (supErr)
1227
+ return txt(`Error suppressing: ${supErr.message}`);
1228
+ return j({ suppressed: inactive.length, days_threshold: days, sample: inactive });
1229
+ }
1230
+ return j({
1231
+ inactive_count: inactive.length,
1232
+ days_threshold: days,
1233
+ dry_run: true,
1234
+ already_suppressed: suppressedSet.size,
1235
+ sample: inactive,
1236
+ message: inactive.length > 0
1237
+ ? `Found ${inactive.length} contacts with no engagement in ${days} days. Run with dry_run=false to suppress them.`
1238
+ : `No inactive contacts found (threshold: ${days} days).`,
1239
+ });
1240
+ });
1241
+ server.registerTool('remove_suppression', {
1242
+ description: 'Remove an email address from the suppression list',
1243
+ inputSchema: {
1244
+ email: z.string().email(),
1245
+ },
1246
+ }, async ({ email }) => {
1247
+ const { data, error } = await db()
1248
+ .from('suppressions')
1249
+ .delete()
1250
+ .eq('project_id', getProjectId())
1251
+ .eq('email', email)
1252
+ .select()
1253
+ .single();
1254
+ if (error)
1255
+ return txt(`Error: ${error.message}`);
1256
+ if (!data)
1257
+ return txt(`No suppression found for ${email}.`);
1258
+ return j({ removed: true, email, previous_reason: data.reason });
1259
+ });
1260
+ server.registerTool('find_duplicates', {
1261
+ description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores.',
1262
+ inputSchema: {
1263
+ confidence_threshold: z.number().min(0.5).max(1).optional().describe('Minimum similarity (0.5-1.0, default 0.8)'),
1264
+ limit: z.number().int().min(1).max(200).optional().describe('Max duplicate groups (default 50)'),
1265
+ site_id: z.string().uuid().optional().describe('Filter to contacts subscribed to this site'),
1266
+ },
1267
+ }, async ({ confidence_threshold = 0.8, limit = 50, site_id }) => {
1268
+ const projectId = getProjectId();
1269
+ const maxGroups = Math.min(limit, 200);
1270
+ let q = db()
1271
+ .from('subscribers')
1272
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source')
1273
+ .eq('project_id', projectId)
1274
+ .eq('global_unsubscribed', false)
1275
+ .order('created_at', { ascending: true })
1276
+ .limit(2000);
1277
+ if (site_id) {
1278
+ const { data: subs } = await db()
1279
+ .from('site_subscriptions')
1280
+ .select('contact_id')
1281
+ .eq('site_id', site_id)
1282
+ .eq('subscribed', true);
1283
+ const ids = (subs ?? []).map((s) => s.contact_id);
1284
+ if (!ids.length)
1285
+ return j({ duplicate_groups: [], total_groups: 0 });
1286
+ q = q.in('id', ids);
1287
+ }
1288
+ const { data: contacts, error } = await q;
1289
+ if (error)
1290
+ return txt(`Error: ${error.message}`);
1291
+ if (!contacts?.length)
1292
+ return j({ duplicate_groups: [], total_groups: 0 });
1293
+ const lev = (a, b) => {
1294
+ const m = a.length, n = b.length;
1295
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
1296
+ for (let jj = 1; jj <= n; jj++) {
1297
+ let prev = dp[0];
1298
+ dp[0] = jj;
1299
+ for (let i = 1; i <= m; i++) {
1300
+ const tmp = dp[i];
1301
+ dp[i] = a[i - 1] === b[jj - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
1302
+ prev = tmp;
1303
+ }
1304
+ }
1305
+ return dp[m];
1306
+ };
1307
+ const similarity = (a, b) => {
1308
+ if (a === b)
1309
+ return 1;
1310
+ const maxLen = Math.max(a.length, b.length);
1311
+ if (maxLen === 0)
1312
+ return 1;
1313
+ return 1 - lev(a, b) / maxLen;
1314
+ };
1315
+ const byDomain = {};
1316
+ for (const c of contacts) {
1317
+ const [, domain] = c.email.toLowerCase().split('@');
1318
+ if (!byDomain[domain])
1319
+ byDomain[domain] = [];
1320
+ byDomain[domain].push(c);
1321
+ }
1322
+ const groups = [];
1323
+ const merged = new Set();
1324
+ for (const domain of Object.keys(byDomain)) {
1325
+ const dc = byDomain[domain];
1326
+ for (let i = 0; i < dc.length; i++) {
1327
+ if (merged.has(dc[i].id))
1328
+ continue;
1329
+ const localA = dc[i].email.split('@')[0].toLowerCase();
1330
+ const group = [dc[i]];
1331
+ for (let jj = i + 1; jj < dc.length; jj++) {
1332
+ if (merged.has(dc[jj].id))
1333
+ continue;
1334
+ const localB = dc[jj].email.split('@')[0].toLowerCase();
1335
+ const sim = similarity(localA, localB);
1336
+ if (sim >= confidence_threshold && sim < 1) {
1337
+ group.push(dc[jj]);
1338
+ merged.add(dc[jj].id);
1339
+ }
1340
+ }
1341
+ if (group.length > 1) {
1342
+ merged.add(dc[i].id);
1343
+ const avgSim = similarity(group[0].email.split('@')[0].toLowerCase(), group[1].email.split('@')[0].toLowerCase());
1344
+ groups.push({ group, confidence: Math.round(avgSim * 100) / 100, reason: 'similar_email' });
1345
+ }
1346
+ }
1347
+ }
1348
+ const byName = {};
1349
+ for (const c of contacts) {
1350
+ if (merged.has(c.id))
1351
+ continue;
1352
+ const fn = (c.first_name ?? '').toLowerCase().trim();
1353
+ const ln = (c.last_name ?? '').toLowerCase().trim();
1354
+ if (!fn && !ln)
1355
+ continue;
1356
+ const key = `${fn}|${ln}`;
1357
+ if (!byName[key])
1358
+ byName[key] = [];
1359
+ byName[key].push(c);
1360
+ }
1361
+ for (const nameGroup of Object.values(byName)) {
1362
+ if (nameGroup.length > 1) {
1363
+ groups.push({ group: nameGroup, confidence: 0.7, reason: 'same_name' });
1364
+ }
1365
+ }
1366
+ groups.sort((a, b) => b.confidence - a.confidence);
1367
+ const result = groups.slice(0, maxGroups);
1368
+ return j({ duplicate_groups: result, total_groups: result.length });
1369
+ });
1370
+ server.registerTool('merge_subscribers', {
1371
+ description: 'Merge duplicate contacts into a target. Consolidates subscriptions, enrollments, email history, and consent records. Source contacts are deleted. Requires human approval via dashboard.',
1372
+ inputSchema: {
1373
+ target_id: z.string().uuid().describe('Contact to keep'),
1374
+ source_ids: z.array(z.string().uuid()).min(1).describe('Contacts to merge into target'),
1375
+ approval_id: z.string().uuid().optional().describe('Required — get this by calling once without it, then have a human approve via dashboard.'),
1376
+ },
1377
+ }, async ({ target_id, source_ids, approval_id }) => {
1378
+ // Preview: always show what will happen
1379
+ const { data: target } = await db()
1380
+ .from('subscribers')
1381
+ .select('id, email, first_name, last_name')
1382
+ .eq('id', target_id)
1383
+ .eq('project_id', getProjectId())
1384
+ .maybeSingle();
1385
+ const { data: sources } = await db()
1386
+ .from('subscribers')
1387
+ .select('id, email, first_name, last_name')
1388
+ .in('id', source_ids)
1389
+ .eq('project_id', getProjectId());
1390
+ if (!approval_id) {
1391
+ const approval = await requestApproval('merge_subscribers', {
1392
+ target_id, target_email: target?.email,
1393
+ source_ids, source_count: (sources ?? []).length,
1394
+ });
1395
+ return j({
1396
+ ...approval,
1397
+ preview: { target, sources: sources ?? [] },
1398
+ message: `Will merge ${(sources ?? []).length} contact(s) into ${target?.email ?? target_id}. Source contacts will be deleted. Requires human approval.`,
1399
+ });
1400
+ }
1401
+ const check = await checkApproval(approval_id, { consume: true });
1402
+ if (!check.approved) {
1403
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1404
+ }
1405
+ const { data, error } = await db().rpc('merge_subscribers', {
1406
+ p_project_id: getProjectId(),
1407
+ p_target_id: target_id,
1408
+ p_source_ids: source_ids,
1409
+ });
1410
+ if (error)
1411
+ return txt(`Error: ${error.message}`);
1412
+ logAudit({ action: 'contacts.merged', resourceType: 'contact', resourceId: target_id, details: { source_ids, result: data } });
1413
+ return j(data);
1414
+ });
1415
+ server.registerTool('get_contact_schema', {
1416
+ description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
1417
+ inputSchema: {
1418
+ site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
1419
+ },
1420
+ }, async ({ site_id }) => {
1421
+ const projectId = getProjectId();
1422
+ // Sample recent contacts to discover keys (LIMIT 500 is enough for schema discovery)
1423
+ const { data, error } = await db().rpc('get_contact_schema_keys', {
1424
+ p_project_id: projectId,
1425
+ p_site_id: site_id,
1426
+ });
1427
+ if (error) {
1428
+ // Fallback: inline query if RPC not yet available
1429
+ const { data: rows, error: qErr } = await db()
1430
+ .from('subscribers')
1431
+ .select('custom_properties')
1432
+ .eq('project_id', projectId)
1433
+ .eq('source_site', site_id)
1434
+ .not('custom_properties', 'is', null)
1435
+ .order('created_at', { ascending: false })
1436
+ .limit(500);
1437
+ if (qErr)
1438
+ return txt(`Error: ${qErr.message}`);
1439
+ const keyMap = new Map();
1440
+ for (const row of rows ?? []) {
1441
+ const props = row.custom_properties;
1442
+ if (!props)
1443
+ continue;
1444
+ for (const [k, v] of Object.entries(props)) {
1445
+ if (!keyMap.has(k))
1446
+ keyMap.set(k, { values: new Set(), count: 0 });
1447
+ const entry = keyMap.get(k);
1448
+ entry.count++;
1449
+ if (v !== null && v !== undefined && entry.values.size < 3) {
1450
+ entry.values.add(String(v));
1451
+ }
1452
+ }
1453
+ }
1454
+ const schema = Array.from(keyMap.entries())
1455
+ .sort((a, b) => b[1].count - a[1].count)
1456
+ .map(([key, { values, count }]) => ({
1457
+ key,
1458
+ merge_tag: `{{custom.${key}}}`,
1459
+ merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
1460
+ example_values: Array.from(values),
1461
+ contacts_with_this_key: count,
1462
+ }));
1463
+ return j({
1464
+ site_id,
1465
+ total_keys: schema.length,
1466
+ schema,
1467
+ note: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
1468
+ });
1469
+ }
1470
+ return j({
1471
+ site_id,
1472
+ total_keys: data.length,
1473
+ schema: data.map(r => ({
1474
+ key: r.key,
1475
+ merge_tag: `{{custom.${r.key}}}`,
1476
+ merge_tag_with_fallback: `{{custom.${r.key}|fallback}}`,
1477
+ example_values: r.examples ?? [],
1478
+ contacts_with_this_key: r.count,
1479
+ })),
1480
+ note: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
1481
+ });
1482
+ });
1483
+ }