@sendinel/mcp-server 1.0.4 → 1.0.5

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 (60) hide show
  1. package/build/audit.d.ts +1 -0
  2. package/build/audit.js +9 -3
  3. package/build/auth.d.ts +10 -0
  4. package/build/auth.js +82 -9
  5. package/build/db.d.ts +12 -0
  6. package/build/db.js +37 -2
  7. package/build/index.js +38 -12
  8. package/build/lib/action-approvals.d.ts +28 -1
  9. package/build/lib/action-approvals.js +89 -2
  10. package/build/lib/brand-asset-persist.d.ts +35 -0
  11. package/build/lib/brand-asset-persist.js +161 -0
  12. package/build/lib/brand-asset-usage.d.ts +13 -0
  13. package/build/lib/brand-asset-usage.js +46 -0
  14. package/build/lib/email-provider.d.ts +19 -0
  15. package/build/lib/email-provider.js +281 -0
  16. package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
  17. package/build/lib/ssrf-pinned-fetch.js +101 -0
  18. package/build/lib/survey-link-resolver.d.ts +6 -0
  19. package/build/lib/survey-link-resolver.js +23 -0
  20. package/build/lib/svg-sanitize.d.ts +2 -0
  21. package/build/lib/svg-sanitize.js +110 -0
  22. package/build/lib/webhook-events.d.ts +1 -1
  23. package/build/scoped-token.d.ts +11 -0
  24. package/build/scoped-token.js +59 -0
  25. package/build/server.js +30 -9
  26. package/build/tools/ab-testing.js +28 -0
  27. package/build/tools/advisor.js +52 -2
  28. package/build/tools/ai-work.d.ts +2 -0
  29. package/build/tools/ai-work.js +460 -0
  30. package/build/tools/analytics.d.ts +1 -0
  31. package/build/tools/analytics.js +203 -0
  32. package/build/tools/approvals.js +15 -1
  33. package/build/tools/automations.js +288 -65
  34. package/build/tools/brand-kit.d.ts +2 -0
  35. package/build/tools/brand-kit.js +277 -0
  36. package/build/tools/campaigns.js +900 -69
  37. package/build/tools/compound.js +28 -0
  38. package/build/tools/contacts.js +338 -17
  39. package/build/tools/content.js +266 -1
  40. package/build/tools/data-proposals.js +21 -0
  41. package/build/tools/data.js +121 -5
  42. package/build/tools/delivery-ops.js +84 -0
  43. package/build/tools/drafts.js +64 -1
  44. package/build/tools/forms.js +42 -9
  45. package/build/tools/gdpr.js +22 -3
  46. package/build/tools/newsletters.d.ts +2 -0
  47. package/build/tools/newsletters.js +583 -0
  48. package/build/tools/org.js +297 -3
  49. package/build/tools/segments.js +45 -3
  50. package/build/tools/short-links.d.ts +2 -0
  51. package/build/tools/short-links.js +157 -0
  52. package/build/tools/sites.js +35 -0
  53. package/build/tools/sms.js +49 -0
  54. package/build/tools/social-posts.js +81 -5
  55. package/build/tools/surveys.d.ts +2 -0
  56. package/build/tools/surveys.js +222 -0
  57. package/build/tools/templates.js +78 -2
  58. package/build/tools/warmup.js +28 -0
  59. package/build/tools/webhooks.js +29 -0
  60. package/package.json +9 -3
@@ -0,0 +1,583 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
5
+ const j = (data) => txt(JSON.stringify(data, null, 2));
6
+ const NEWSLETTER_COLUMNS = 'id, org_id, project_id, site_id, name, slug, description, sender_name, sender_email, reply_to, cadence, branding, public_signup_enabled, double_opt_in, status, subscriber_count, source, source_payload, created_at, updated_at';
7
+ const ISSUE_COLUMNS = 'id, newsletter_id, org_id, project_id, campaign_id, blog_post_id, issue_number, subject, content, preheader, status, scheduled_at, sent_at, archive_url, source, source_payload, created_at, updated_at';
8
+ const SUBSCRIPTION_COLUMNS = 'id, newsletter_id, org_id, project_id, subscriber_id, email, status, opt_in_state, source, subscribed_at, unsubscribed_at, unsubscribe_reason, created_at, updated_at';
9
+ const EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
10
+ function slugifyNewsletter(input) {
11
+ return input
12
+ .toLowerCase()
13
+ .trim()
14
+ .replace(/[^a-z0-9\s-]/g, '')
15
+ .replace(/\s+/g, '-')
16
+ .replace(/-+/g, '-')
17
+ .slice(0, 120)
18
+ .replace(/^-|-$/g, '') || 'newsletter';
19
+ }
20
+ async function getProjectOrgId(projectId) {
21
+ const { data } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
22
+ return data?.org_id ?? null;
23
+ }
24
+ async function loadSendableIssue(newsletterId, issueId, projectId) {
25
+ const [{ data: newsletter, error: newsletterError }, { data: issue, error: issueError }] = await Promise.all([
26
+ db().from('newsletters')
27
+ .select('id, org_id, project_id, site_id, name, status')
28
+ .eq('id', newsletterId)
29
+ .eq('project_id', projectId)
30
+ .maybeSingle(),
31
+ db().from('newsletter_issues')
32
+ .select(ISSUE_COLUMNS)
33
+ .eq('id', issueId)
34
+ .eq('newsletter_id', newsletterId)
35
+ .eq('project_id', projectId)
36
+ .maybeSingle(),
37
+ ]);
38
+ if (newsletterError)
39
+ return { error: newsletterError.message };
40
+ if (issueError)
41
+ return { error: issueError.message };
42
+ if (!newsletter)
43
+ return { error: 'Newsletter not found', code: 'NOT_FOUND' };
44
+ if (!issue)
45
+ return { error: 'Newsletter issue not found', code: 'NOT_FOUND' };
46
+ if (newsletter.status !== 'active')
47
+ return { error: 'Newsletter must be active before sending', code: 'CONFLICT' };
48
+ if (!newsletter.site_id)
49
+ return { error: 'Newsletter requires site_id before issue sending can use the campaign pipeline', code: 'MISSING_FIELD' };
50
+ if (!String(issue.content ?? '').trim())
51
+ return { error: 'Issue content is required before sending', code: 'MISSING_FIELD' };
52
+ if (['sent', 'archived'].includes(String(issue.status)))
53
+ return { error: 'Issue has already been sent', code: 'CONFLICT' };
54
+ return { newsletter, issue };
55
+ }
56
+ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledAt) {
57
+ const metadata = { source: 'newsletter_issue', newsletter_id: newsletter.id, issue_id: issue.id };
58
+ const campaignName = `${newsletter.name} #${issue.issue_number}: ${issue.subject}`.slice(0, 200);
59
+ let campaignId = issue.campaign_id;
60
+ if (campaignId) {
61
+ const { data: campaign, error } = await db().from('campaigns')
62
+ .update({ name: campaignName, send_at: scheduledAt, metadata })
63
+ .eq('id', campaignId)
64
+ .eq('project_id', projectId)
65
+ .select('id')
66
+ .maybeSingle();
67
+ if (error)
68
+ return { error: error.message };
69
+ if (!campaign)
70
+ campaignId = null;
71
+ }
72
+ if (!campaignId) {
73
+ const { data: campaign, error } = await db().from('campaigns').insert({
74
+ project_id: projectId,
75
+ site_id: newsletter.site_id,
76
+ name: campaignName,
77
+ type: 'broadcast',
78
+ status: 'draft',
79
+ requires_approval: false,
80
+ send_at: scheduledAt,
81
+ metadata,
82
+ }).select('id').single();
83
+ if (error || !campaign)
84
+ return { error: error?.message ?? 'Campaign create failed' };
85
+ campaignId = campaign.id;
86
+ }
87
+ const { data: firstStep, error: stepFetchError } = await db().from('campaign_steps')
88
+ .select('id')
89
+ .eq('campaign_id', campaignId)
90
+ .eq('step_order', 0)
91
+ .maybeSingle();
92
+ if (stepFetchError)
93
+ return { error: stepFetchError.message };
94
+ if (firstStep?.id) {
95
+ const { error } = await db().from('campaign_steps')
96
+ .update({ subject: issue.subject, body_html: issue.content, body_text: issue.preheader })
97
+ .eq('id', firstStep.id);
98
+ if (error)
99
+ return { error: error.message };
100
+ }
101
+ else {
102
+ const { error } = await db().from('campaign_steps').insert({
103
+ campaign_id: campaignId,
104
+ step_order: 0,
105
+ subject: issue.subject,
106
+ body_html: issue.content,
107
+ body_text: issue.preheader,
108
+ delay_days: 0,
109
+ delay_hours: 0,
110
+ });
111
+ if (error)
112
+ return { error: error.message };
113
+ }
114
+ const { data: subscriptions, error: subscriptionError } = await db().from('newsletter_subscriptions')
115
+ .select('subscriber_id')
116
+ .eq('newsletter_id', newsletter.id)
117
+ .eq('project_id', projectId)
118
+ .eq('status', 'active')
119
+ .not('subscriber_id', 'is', null);
120
+ if (subscriptionError)
121
+ return { error: subscriptionError.message };
122
+ const now = new Date().toISOString();
123
+ const nextSendAt = scheduledAt ?? now;
124
+ const rows = (subscriptions ?? []).map((subscription) => ({
125
+ campaign_id: campaignId,
126
+ contact_id: subscription.subscriber_id,
127
+ status: 'active',
128
+ current_step: 0,
129
+ enrolled_at: now,
130
+ next_send_at: nextSendAt,
131
+ enrollment_context: { source: 'newsletter_issue', newsletter_id: newsletter.id, issue_id: issue.id },
132
+ }));
133
+ if (rows.length) {
134
+ const { error } = await db().from('campaign_enrollments')
135
+ .upsert(rows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
136
+ if (error)
137
+ return { error: error.message };
138
+ }
139
+ const { error: statusError } = await db().from('campaigns')
140
+ .update({ status: 'active', updated_at: now })
141
+ .eq('id', campaignId)
142
+ .eq('project_id', projectId);
143
+ if (statusError)
144
+ return { error: statusError.message };
145
+ return { campaignId, enrolled: rows.length };
146
+ }
147
+ function parseFutureDate(value) {
148
+ const date = new Date(value);
149
+ if (Number.isNaN(date.getTime()))
150
+ return { error: 'scheduled_at must be a valid ISO timestamp' };
151
+ if (date.getTime() <= Date.now())
152
+ return { error: 'scheduled_at must be in the future' };
153
+ return { scheduledAt: date.toISOString() };
154
+ }
155
+ export function registerNewsletterTools(server) {
156
+ server.registerTool('list_newsletters', {
157
+ title: 'List Newsletters',
158
+ annotations: {
159
+ readOnlyHint: true,
160
+ destructiveHint: false,
161
+ idempotentHint: true,
162
+ openWorldHint: false,
163
+ },
164
+ description: 'List newsletter publications for the current project.',
165
+ inputSchema: {
166
+ status: z.enum(['active', 'paused', 'archived']).optional(),
167
+ limit: z.number().int().min(1).max(200).default(50).optional(),
168
+ },
169
+ }, async ({ status, limit = 50 }) => {
170
+ const projectId = getProjectId();
171
+ let query = db().from('newsletters').select(NEWSLETTER_COLUMNS).eq('project_id', projectId).order('created_at', { ascending: false }).limit(limit);
172
+ query = status ? query.eq('status', status) : query.neq('status', 'archived');
173
+ const { data, error } = await query;
174
+ if (error)
175
+ return j({ error: error.message });
176
+ return j({ newsletters: data ?? [], count: data?.length ?? 0 });
177
+ });
178
+ server.registerTool('get_newsletter', {
179
+ title: 'Get Newsletter',
180
+ annotations: {
181
+ readOnlyHint: true,
182
+ destructiveHint: false,
183
+ idempotentHint: true,
184
+ openWorldHint: false,
185
+ },
186
+ description: 'Get one newsletter publication by ID.',
187
+ inputSchema: { newsletter_id: z.string().uuid() },
188
+ }, async ({ newsletter_id }) => {
189
+ const { data, error } = await db().from('newsletters').select(NEWSLETTER_COLUMNS).eq('id', newsletter_id).eq('project_id', getProjectId()).maybeSingle();
190
+ if (error)
191
+ return j({ error: error.message });
192
+ if (!data)
193
+ return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
194
+ return j(data);
195
+ });
196
+ server.registerTool('create_newsletter', {
197
+ title: 'Create Newsletter',
198
+ annotations: {
199
+ readOnlyHint: false,
200
+ destructiveHint: false,
201
+ idempotentHint: false,
202
+ openWorldHint: false,
203
+ },
204
+ description: 'Create a newsletter publication for the current project.',
205
+ inputSchema: {
206
+ name: z.string().min(1).max(200),
207
+ slug: z.string().optional(),
208
+ description: z.string().optional(),
209
+ site_id: z.string().uuid().nullable().optional(),
210
+ sender_name: z.string().optional(),
211
+ sender_email: z.string().optional(),
212
+ reply_to: z.string().optional(),
213
+ cadence: z.string().optional(),
214
+ branding: z.record(z.string(), z.unknown()).optional(),
215
+ public_signup_enabled: z.boolean().default(true).optional(),
216
+ double_opt_in: z.boolean().default(false).optional(),
217
+ status: z.enum(['active', 'paused', 'archived']).default('active').optional(),
218
+ },
219
+ }, async (args) => {
220
+ const projectId = getProjectId();
221
+ const orgId = await getProjectOrgId(projectId);
222
+ if (!orgId)
223
+ return j({ error: 'Organization not found for project', code: 'NOT_FOUND' });
224
+ const { data, error } = await db().from('newsletters').insert({
225
+ org_id: orgId,
226
+ project_id: projectId,
227
+ site_id: args.site_id ?? null,
228
+ name: args.name,
229
+ slug: args.slug ? slugifyNewsletter(args.slug) : slugifyNewsletter(args.name),
230
+ description: args.description ?? null,
231
+ sender_name: args.sender_name ?? null,
232
+ sender_email: args.sender_email ?? null,
233
+ reply_to: args.reply_to ?? null,
234
+ cadence: args.cadence ?? null,
235
+ branding: args.branding ?? {},
236
+ public_signup_enabled: args.public_signup_enabled ?? true,
237
+ double_opt_in: args.double_opt_in ?? false,
238
+ status: args.status ?? 'active',
239
+ source: 'mcp',
240
+ source_payload: args,
241
+ }).select(NEWSLETTER_COLUMNS).single();
242
+ if (error)
243
+ return j({ error: error.message, code: error.code === '23505' ? 'CONFLICT' : undefined });
244
+ return j(data);
245
+ });
246
+ server.registerTool('update_newsletter', {
247
+ title: 'Update Newsletter',
248
+ annotations: {
249
+ readOnlyHint: false,
250
+ destructiveHint: false,
251
+ idempotentHint: true,
252
+ openWorldHint: false,
253
+ },
254
+ description: 'Update newsletter publication settings.',
255
+ inputSchema: {
256
+ newsletter_id: z.string().uuid(),
257
+ name: z.string().max(200).optional(),
258
+ slug: z.string().nullable().optional(),
259
+ description: z.string().nullable().optional(),
260
+ site_id: z.string().uuid().nullable().optional(),
261
+ sender_name: z.string().nullable().optional(),
262
+ sender_email: z.string().nullable().optional(),
263
+ reply_to: z.string().nullable().optional(),
264
+ cadence: z.string().nullable().optional(),
265
+ branding: z.record(z.string(), z.unknown()).optional(),
266
+ public_signup_enabled: z.boolean().optional(),
267
+ double_opt_in: z.boolean().optional(),
268
+ status: z.enum(['active', 'paused', 'archived']).optional(),
269
+ },
270
+ }, async ({ newsletter_id, ...fields }) => {
271
+ const updates = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
272
+ if (updates.slug && typeof updates.slug === 'string')
273
+ updates.slug = slugifyNewsletter(updates.slug);
274
+ if (!Object.keys(updates).length)
275
+ return j({ error: 'No fields to update', code: 'MISSING_FIELD' });
276
+ const { data, error } = await db().from('newsletters')
277
+ .update(updates)
278
+ .eq('id', newsletter_id)
279
+ .eq('project_id', getProjectId())
280
+ .select(NEWSLETTER_COLUMNS)
281
+ .maybeSingle();
282
+ if (error)
283
+ return j({ error: error.message, code: error.code === '23505' ? 'CONFLICT' : undefined });
284
+ if (!data)
285
+ return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
286
+ return j(data);
287
+ });
288
+ server.registerTool('create_newsletter_issue', {
289
+ title: 'Create Newsletter Issue',
290
+ annotations: {
291
+ readOnlyHint: false,
292
+ destructiveHint: false,
293
+ idempotentHint: false,
294
+ openWorldHint: false,
295
+ },
296
+ description: 'Create a draft issue for a newsletter.',
297
+ inputSchema: {
298
+ newsletter_id: z.string().uuid(),
299
+ subject: z.string().min(1).max(300),
300
+ content: z.string().optional(),
301
+ preheader: z.string().optional(),
302
+ },
303
+ }, async ({ newsletter_id, subject, content, preheader }) => {
304
+ const projectId = getProjectId();
305
+ const { data: newsletter, error: newsletterError } = await db().from('newsletters').select('id, org_id').eq('id', newsletter_id).eq('project_id', projectId).maybeSingle();
306
+ if (newsletterError)
307
+ return j({ error: newsletterError.message });
308
+ if (!newsletter)
309
+ return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
310
+ const { data: latest } = await db().from('newsletter_issues').select('issue_number').eq('newsletter_id', newsletter_id).order('issue_number', { ascending: false }).limit(1).maybeSingle();
311
+ const { data, error } = await db().from('newsletter_issues').insert({
312
+ newsletter_id,
313
+ org_id: newsletter.org_id,
314
+ project_id: projectId,
315
+ issue_number: Number(latest?.issue_number ?? 0) + 1,
316
+ subject,
317
+ content: content ?? null,
318
+ preheader: preheader ?? null,
319
+ source: 'mcp',
320
+ source_payload: { newsletter_id, subject, content, preheader },
321
+ }).select(ISSUE_COLUMNS).single();
322
+ if (error)
323
+ return j({ error: error.message });
324
+ return j(data);
325
+ });
326
+ server.registerTool('list_newsletter_issues', {
327
+ title: 'List Newsletter Issues',
328
+ annotations: {
329
+ readOnlyHint: true,
330
+ destructiveHint: false,
331
+ idempotentHint: true,
332
+ openWorldHint: false,
333
+ },
334
+ description: 'List issues for one newsletter.',
335
+ inputSchema: {
336
+ newsletter_id: z.string().uuid(),
337
+ status: z.enum(['draft', 'scheduled', 'sent', 'archived']).optional(),
338
+ limit: z.number().int().min(1).max(200).default(50).optional(),
339
+ },
340
+ }, async ({ newsletter_id, status, limit = 50 }) => {
341
+ let query = db().from('newsletter_issues')
342
+ .select(ISSUE_COLUMNS)
343
+ .eq('newsletter_id', newsletter_id)
344
+ .eq('project_id', getProjectId())
345
+ .order('issue_number', { ascending: false })
346
+ .limit(limit);
347
+ if (status)
348
+ query = query.eq('status', status);
349
+ const { data, error } = await query;
350
+ if (error)
351
+ return j({ error: error.message });
352
+ return j({ issues: data ?? [], count: data?.length ?? 0 });
353
+ });
354
+ server.registerTool('schedule_newsletter_issue', {
355
+ title: 'Schedule Newsletter Issue',
356
+ annotations: {
357
+ readOnlyHint: false,
358
+ destructiveHint: true,
359
+ idempotentHint: false,
360
+ openWorldHint: true,
361
+ },
362
+ description: 'Schedule a newsletter issue through the campaign send pipeline.',
363
+ inputSchema: {
364
+ newsletter_id: z.string().uuid(),
365
+ issue_id: z.string().uuid(),
366
+ scheduled_at: z.string(),
367
+ },
368
+ }, async ({ newsletter_id, issue_id, scheduled_at }) => {
369
+ const parsed = parseFutureDate(scheduled_at);
370
+ if ('error' in parsed)
371
+ return j({ error: parsed.error, code: 'INVALID_FIELD' });
372
+ const projectId = getProjectId();
373
+ const loaded = await loadSendableIssue(newsletter_id, issue_id, projectId);
374
+ if ('error' in loaded)
375
+ return j(loaded);
376
+ if (!['draft', 'scheduled'].includes(String(loaded.issue.status)))
377
+ return j({ error: 'Only draft or scheduled issues can be scheduled', code: 'CONFLICT' });
378
+ const campaign = await ensureNewsletterCampaign(loaded.newsletter, loaded.issue, projectId, parsed.scheduledAt);
379
+ if ('error' in campaign)
380
+ return j(campaign);
381
+ const { data, error } = await db().from('newsletter_issues')
382
+ .update({ campaign_id: campaign.campaignId, status: 'scheduled', scheduled_at: parsed.scheduledAt })
383
+ .eq('id', issue_id)
384
+ .eq('newsletter_id', newsletter_id)
385
+ .eq('project_id', projectId)
386
+ .select(ISSUE_COLUMNS)
387
+ .maybeSingle();
388
+ if (error)
389
+ return j({ error: error.message });
390
+ return j({ issue: data, campaign_id: campaign.campaignId, enrolled: campaign.enrolled });
391
+ });
392
+ server.registerTool('send_newsletter_issue', {
393
+ title: 'Send Newsletter Issue',
394
+ annotations: {
395
+ readOnlyHint: false,
396
+ destructiveHint: true,
397
+ idempotentHint: false,
398
+ openWorldHint: true,
399
+ },
400
+ description: 'Send a newsletter issue now through the campaign send pipeline and create archive metadata.',
401
+ inputSchema: {
402
+ newsletter_id: z.string().uuid(),
403
+ issue_id: z.string().uuid(),
404
+ },
405
+ }, async ({ newsletter_id, issue_id }) => {
406
+ const projectId = getProjectId();
407
+ const loaded = await loadSendableIssue(newsletter_id, issue_id, projectId);
408
+ if ('error' in loaded)
409
+ return j(loaded);
410
+ const campaign = await ensureNewsletterCampaign(loaded.newsletter, loaded.issue, projectId, null);
411
+ if ('error' in campaign)
412
+ return j(campaign);
413
+ const archiveSlug = slugifyNewsletter(`${loaded.newsletter.name}-${loaded.issue.issue_number}-${loaded.issue.subject}`);
414
+ const { data: post } = await db().from('blog_posts').insert({
415
+ org_id: loaded.newsletter.org_id,
416
+ project_id: projectId,
417
+ site_id: loaded.newsletter.site_id,
418
+ title: loaded.issue.subject,
419
+ slug: archiveSlug,
420
+ body: loaded.issue.content ?? '',
421
+ excerpt: loaded.issue.preheader,
422
+ tags: ['newsletter'],
423
+ status: 'published',
424
+ source: 'newsletter_issue',
425
+ source_payload: { newsletter_id, issue_id, issue_number: loaded.issue.issue_number },
426
+ published_at: new Date().toISOString(),
427
+ }).select('id, external_url').single();
428
+ const sentAt = new Date().toISOString();
429
+ const { data, error } = await db().from('newsletter_issues')
430
+ .update({
431
+ campaign_id: campaign.campaignId,
432
+ blog_post_id: post?.id ?? null,
433
+ archive_url: post?.external_url ?? null,
434
+ status: 'sent',
435
+ sent_at: sentAt,
436
+ scheduled_at: null,
437
+ })
438
+ .eq('id', issue_id)
439
+ .eq('newsletter_id', newsletter_id)
440
+ .eq('project_id', projectId)
441
+ .select(ISSUE_COLUMNS)
442
+ .maybeSingle();
443
+ if (error)
444
+ return j({ error: error.message });
445
+ return j({ issue: data, campaign_id: campaign.campaignId, enrolled: campaign.enrolled, blog_post_id: post?.id ?? null, archive_url: post?.external_url ?? null });
446
+ });
447
+ server.registerTool('subscribe_to_newsletter', {
448
+ title: 'Subscribe To Newsletter',
449
+ annotations: {
450
+ readOnlyHint: false,
451
+ destructiveHint: false,
452
+ idempotentHint: false,
453
+ openWorldHint: false,
454
+ },
455
+ description: 'Add or reactivate a subscriber for one newsletter.',
456
+ inputSchema: {
457
+ newsletter_id: z.string().uuid(),
458
+ email: z.string().email(),
459
+ first_name: z.string().nullable().optional(),
460
+ last_name: z.string().nullable().optional(),
461
+ source: z.string().default('mcp').optional(),
462
+ },
463
+ }, async ({ newsletter_id, email, first_name, last_name, source = 'mcp' }) => {
464
+ const normalizedEmail = email.trim().toLowerCase();
465
+ if (!EMAIL_RE.test(normalizedEmail))
466
+ return j({ error: 'A valid email is required', code: 'INVALID_FIELD' });
467
+ const projectId = getProjectId();
468
+ const { data: newsletter, error: newsletterError } = await db().from('newsletters')
469
+ .select('id, org_id, project_id, double_opt_in, status')
470
+ .eq('id', newsletter_id)
471
+ .eq('project_id', projectId)
472
+ .eq('status', 'active')
473
+ .maybeSingle();
474
+ if (newsletterError)
475
+ return j({ error: newsletterError.message });
476
+ if (!newsletter)
477
+ return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
478
+ const { data: subscriber, error: subscriberError } = await db().from('subscribers').upsert({
479
+ project_id: projectId,
480
+ email: normalizedEmail,
481
+ first_name: first_name ?? null,
482
+ last_name: last_name ?? null,
483
+ source,
484
+ }, { onConflict: 'project_id,email' }).select('id').single();
485
+ if (subscriberError || !subscriber)
486
+ return j({ error: subscriberError?.message ?? 'Subscriber upsert failed' });
487
+ const activeImmediately = !newsletter.double_opt_in;
488
+ const { data, error } = await db().from('newsletter_subscriptions').upsert({
489
+ newsletter_id,
490
+ org_id: newsletter.org_id,
491
+ project_id: projectId,
492
+ subscriber_id: subscriber.id,
493
+ email: normalizedEmail,
494
+ status: activeImmediately ? 'active' : 'pending',
495
+ opt_in_state: activeImmediately ? 'single' : 'double_pending',
496
+ source,
497
+ subscribed_at: activeImmediately ? new Date().toISOString() : null,
498
+ unsubscribed_at: null,
499
+ unsubscribe_reason: null,
500
+ }, { onConflict: 'newsletter_id,email' }).select(SUBSCRIPTION_COLUMNS).single();
501
+ if (error)
502
+ return j({ error: error.message });
503
+ return j(data);
504
+ });
505
+ server.registerTool('unsubscribe_from_newsletter', {
506
+ title: 'Unsubscribe From Newsletter',
507
+ annotations: {
508
+ readOnlyHint: false,
509
+ destructiveHint: true,
510
+ idempotentHint: false,
511
+ openWorldHint: false,
512
+ },
513
+ description: 'Unsubscribe an address or subscriber ID from one newsletter without creating a global suppression.',
514
+ inputSchema: {
515
+ newsletter_id: z.string().uuid(),
516
+ subscriber_id: z.string().uuid().optional(),
517
+ email: z.string().email().optional(),
518
+ reason: z.string().default('mcp_request').optional(),
519
+ },
520
+ }, async ({ newsletter_id, subscriber_id, email, reason = 'mcp_request' }) => {
521
+ const projectId = getProjectId();
522
+ let resolvedSubscriberId = subscriber_id ?? null;
523
+ if (!resolvedSubscriberId && email) {
524
+ const { data: subscriber } = await db().from('subscribers').select('id').eq('project_id', projectId).eq('email', email.trim().toLowerCase()).maybeSingle();
525
+ resolvedSubscriberId = subscriber?.id ?? null;
526
+ }
527
+ if (!resolvedSubscriberId)
528
+ return j({ error: 'subscriber_id or email is required', code: 'MISSING_FIELD' });
529
+ const { data, error } = await db().rpc('process_newsletter_unsubscribe', {
530
+ p_newsletter_id: newsletter_id,
531
+ p_contact_id: resolvedSubscriberId,
532
+ p_reason: reason,
533
+ });
534
+ if (error)
535
+ return j({ error: error.message });
536
+ if (!data?.success)
537
+ return j({ error: data?.reason ?? 'Unsubscribe failed', code: 'NOT_FOUND' });
538
+ return j(data);
539
+ });
540
+ server.registerTool('list_newsletter_subscribers', {
541
+ title: 'List Newsletter Subscribers',
542
+ annotations: {
543
+ readOnlyHint: true,
544
+ destructiveHint: false,
545
+ idempotentHint: true,
546
+ openWorldHint: false,
547
+ },
548
+ description: 'List newsletter subscriptions for one newsletter.',
549
+ inputSchema: {
550
+ newsletter_id: z.string().uuid(),
551
+ status: z.enum(['pending', 'active', 'unsubscribed']).optional(),
552
+ limit: z.number().int().min(1).max(200).default(50).optional(),
553
+ },
554
+ }, async ({ newsletter_id, status, limit = 50 }) => {
555
+ let query = db().from('newsletter_subscriptions').select(SUBSCRIPTION_COLUMNS).eq('newsletter_id', newsletter_id).eq('project_id', getProjectId()).order('created_at', { ascending: false }).limit(limit);
556
+ if (status)
557
+ query = query.eq('status', status);
558
+ const { data, error } = await query;
559
+ if (error)
560
+ return j({ error: error.message });
561
+ return j({ subscribers: data ?? [], count: data?.length ?? 0 });
562
+ });
563
+ server.registerTool('get_newsletter_stats', {
564
+ title: 'Get Newsletter Stats',
565
+ annotations: {
566
+ readOnlyHint: true,
567
+ destructiveHint: false,
568
+ idempotentHint: true,
569
+ openWorldHint: false,
570
+ },
571
+ description: 'Get newsletter stats. Issue-level analytics are pending Phase 4.',
572
+ inputSchema: { newsletter_id: z.string().uuid() },
573
+ }, async ({ newsletter_id }) => {
574
+ const { count, error } = await db().from('newsletter_subscriptions')
575
+ .select('id', { count: 'exact', head: true })
576
+ .eq('newsletter_id', newsletter_id)
577
+ .eq('project_id', getProjectId())
578
+ .eq('status', 'active');
579
+ if (error)
580
+ return j({ error: error.message });
581
+ return j({ newsletter_id, active_subscribers: count ?? 0, issue_analytics_status: 'pending_phase_4' });
582
+ });
583
+ }