@sendinel/mcp-server 1.0.11 → 1.0.13

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.
@@ -1,6 +1,5 @@
1
1
  type ProjectDbClient = {
2
2
  from: (table: string) => any;
3
3
  };
4
- export declare function renderTemplateString(input: string, vars?: Record<string, string>): string;
5
4
  export declare function resolveSurveyShortLink(sb: ProjectDbClient, projectId: string, surveyId: string): Promise<string | null>;
6
5
  export {};
@@ -1,6 +1,3 @@
1
- export function renderTemplateString(input, vars = {}) {
2
- return String(input ?? '').replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
3
- }
4
1
  function appBaseUrl() {
5
2
  return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
6
3
  }
@@ -316,7 +316,7 @@ export function registerAdvisorTools(server) {
316
316
  // 1. Get current subject (variant A)
317
317
  const { data: currentStep } = await db()
318
318
  .from('campaign_steps')
319
- .select('id, subject, html_body, text_body, delay_days, delay_hours')
319
+ .select('id, subject, html_body:body_html, text_body:body_text, delay_days, delay_hours')
320
320
  .eq('campaign_id', campaign_id)
321
321
  .eq('step_order', step_order ?? 0)
322
322
  .eq('variant_label', 'A')
@@ -403,8 +403,8 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
403
403
  step_order: step_order ?? 0,
404
404
  variant_label: labels[i],
405
405
  subject: suggestions[i].subject,
406
- html_body: currentStep.html_body,
407
- text_body: currentStep.text_body,
406
+ body_html: currentStep.html_body,
407
+ body_text: currentStep.text_body,
408
408
  delay_days: currentStep.delay_days,
409
409
  delay_hours: currentStep.delay_hours,
410
410
  });
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  type EmailLogRow = {
3
3
  id?: string;
4
+ provider_message_id?: string | null;
4
5
  status: string;
5
6
  sent_at?: string | null;
6
7
  campaign_id?: string | null;
@@ -198,10 +198,10 @@ function buildRevenueAnalytics(params) {
198
198
  failed_payments_note: 'Best-effort count of unpaid invoices in the period; stripe_invoices does not store a dedicated payment_failed flag.',
199
199
  };
200
200
  }
201
- function distinctEventCount(events, eventType, logIds) {
201
+ function distinctEventCount(events, eventType, messageIds) {
202
202
  return new Set(events
203
- .filter(event => event.event_type === eventType && event.email_log_id && logIds.has(event.email_log_id))
204
- .map(event => event.email_log_id)).size;
203
+ .filter(event => event.event_type === eventType && event.provider_message_id && messageIds.has(event.provider_message_id))
204
+ .map(event => event.provider_message_id)).size;
205
205
  }
206
206
  function isTrueConversionEvent(event) {
207
207
  const normalized = (event ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
@@ -433,7 +433,7 @@ export function registerAnalyticsTools(server) {
433
433
  const trackingEnabled = site?.tracking_enabled ?? false;
434
434
  const { data, error } = await db()
435
435
  .from('email_log')
436
- .select('id, status, sent_at')
436
+ .select('id, provider_message_id, status, sent_at')
437
437
  .eq('project_id', projectId)
438
438
  .eq('site_id', site_id)
439
439
  .gte('sent_at', `${start_date}T00:00:00Z`)
@@ -445,7 +445,7 @@ export function registerAnalyticsTools(server) {
445
445
  if (trackingEnabled) {
446
446
  const { data: events, error: eventsError } = await db()
447
447
  .from('email_events')
448
- .select('email_log_id, event_type')
448
+ .select('provider_message_id, event_type')
449
449
  .eq('project_id', projectId)
450
450
  .eq('site_id', site_id)
451
451
  .in('event_type', ['open', 'click'])
@@ -453,10 +453,10 @@ export function registerAnalyticsTools(server) {
453
453
  .lte('occurred_at', `${end}T23:59:59Z`);
454
454
  if (eventsError)
455
455
  return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
456
- const logIds = new Set(rows.map(row => row.id).filter((id) => Boolean(id)));
456
+ const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
457
457
  eventCounts = {
458
- opened: distinctEventCount(events ?? [], 'open', logIds),
459
- clicked: distinctEventCount(events ?? [], 'click', logIds),
458
+ opened: distinctEventCount(events ?? [], 'open', messageIds),
459
+ clicked: distinctEventCount(events ?? [], 'click', messageIds),
460
460
  };
461
461
  }
462
462
  const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
@@ -10,7 +10,7 @@ import { assertChannelCanExecute, currentChannelContext, requestApproval, checkA
10
10
  import { getAnthropicModelId } from '../anthropic-model.js';
11
11
  import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
12
12
  import { sendEmailForSite } from '../lib/email-provider.js';
13
- import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
13
+ import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
14
14
  import { logAudit } from '../audit.js';
15
15
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
16
16
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
@@ -472,11 +472,11 @@ export function registerCampaignTools(server) {
472
472
  return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
473
473
  renderVars.survey_link = surveyLink;
474
474
  }
475
- const renderedSubject = survey_id ? renderTemplateString(subject, renderVars) : subject;
475
+ const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
476
476
  const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
477
477
  const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
478
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html ?? htmlFromBlocks;
479
- const renderedText = body_text !== undefined && survey_id ? renderTemplateString(body_text, renderVars) : body_text;
478
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
479
+ const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
480
480
  const { data, error } = await db()
481
481
  .from('campaign_steps')
482
482
  .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
@@ -630,7 +630,7 @@ export function registerCampaignTools(server) {
630
630
  return j({ error: 'Campaign or contact not found', code: 'NOT_FOUND' });
631
631
  const { data: enrollment, error } = await db()
632
632
  .from('campaign_enrollments')
633
- .select('id, status, current_step, current_step_id, enrolled_at, completed_at, next_send_at, exit_reason')
633
+ .select('id, status, current_step, enrolled_at, completed_at, next_send_at, exit_reason:exited_reason')
634
634
  .eq('campaign_id', campaign_id)
635
635
  .eq('contact_id', contact_id)
636
636
  .maybeSingle();
@@ -639,19 +639,34 @@ export function registerCampaignTools(server) {
639
639
  if (!enrollment) {
640
640
  return j({ enrolled: false, enrollment: null, campaign: campaignRes.data, contact: contactRes.data });
641
641
  }
642
- const { count: stepsSent } = await db()
643
- .from('email_log')
644
- .select('id', { count: 'exact', head: true })
645
- .eq('project_id', projectId)
646
- .eq('campaign_id', campaign_id)
647
- .eq('contact_id', contact_id)
648
- .not('status', 'eq', 'failed');
642
+ const [{ count: stepsSent, error: stepsSentError }, currentStepResult] = await Promise.all([
643
+ db()
644
+ .from('email_log')
645
+ .select('id', { count: 'exact', head: true })
646
+ .eq('project_id', projectId)
647
+ .eq('campaign_id', campaign_id)
648
+ .eq('contact_id', contact_id)
649
+ .not('status', 'eq', 'failed'),
650
+ enrollment.current_step == null
651
+ ? Promise.resolve({ data: null, error: null })
652
+ : db()
653
+ .from('campaign_steps')
654
+ .select('id')
655
+ .eq('campaign_id', campaign_id)
656
+ .eq('step_order', enrollment.current_step)
657
+ .maybeSingle(),
658
+ ]);
659
+ if (stepsSentError)
660
+ return txt(`Error: ${stepsSentError.message}`);
661
+ if (currentStepResult.error)
662
+ return txt(`Error: ${currentStepResult.error.message}`);
649
663
  return j({
650
664
  enrolled: true,
651
665
  campaign: campaignRes.data,
652
666
  contact: contactRes.data,
653
667
  enrollment: {
654
668
  ...enrollment,
669
+ current_step_id: currentStepResult.data?.id ?? null,
655
670
  current_step_order: enrollment.current_step ?? 0,
656
671
  steps_sent: stepsSent ?? 0,
657
672
  },
@@ -2236,9 +2251,9 @@ Return ONLY valid JSON with this exact shape:
2236
2251
  ...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
2237
2252
  ...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
2238
2253
  ...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
2239
- ...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateString(stepUpdates.subject, renderVars) } : {}),
2240
- ...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateString(stepUpdates.body_html, renderVars) } : {}),
2241
- ...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateString(stepUpdates.body_text, renderVars) } : {}),
2254
+ ...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateVars(stepUpdates.subject, renderVars) } : {}),
2255
+ ...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateVars(stepUpdates.body_html, renderVars) } : {}),
2256
+ ...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateVars(stepUpdates.body_text, renderVars) } : {}),
2242
2257
  };
2243
2258
  const { data, error } = await db()
2244
2259
  .from('campaign_steps')
@@ -9,7 +9,7 @@ async function engagementSnapshot(projectId) {
9
9
  const [{ data: logs }, { count: audienceSize }, { data: segments }] = await Promise.all([
10
10
  db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since).limit(10000),
11
11
  db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
12
- db().from('segments').select('id, name, subscriber_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
12
+ db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
13
13
  ]);
14
14
  const rows = logs ?? [];
15
15
  const sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
@@ -1150,13 +1150,16 @@ export function registerDataTools(server) {
1150
1150
  description: 'List in-app notifications.',
1151
1151
  inputSchema: { unread_only: z.boolean().optional().default(false) },
1152
1152
  }, async ({ unread_only = false }) => {
1153
- let query = db().from('notifications').select('id, type, title, body, severity, read_at, created_at, action_url').eq('project_id', getProjectId()).order('created_at', { ascending: false }).limit(50);
1153
+ // notifications has a boolean `read`, not a read_at timestamp (#3323). Kevin,
1154
+ // 2026-09-15: "Option 1: use read". read_at stays in the response as null
1155
+ // (read time is unknown), and `read` is added; tool name and input unchanged.
1156
+ let query = db().from('notifications').select('id, type, title, body, severity, read, created_at, action_url').eq('project_id', getProjectId()).order('created_at', { ascending: false }).limit(50);
1154
1157
  if (unread_only)
1155
- query = query.is('read_at', null);
1158
+ query = query.eq('read', false);
1156
1159
  const { data, error } = await query;
1157
1160
  if (error)
1158
1161
  return j({ error: error.message });
1159
- return j({ notifications: data ?? [] });
1162
+ return j({ notifications: (data ?? []).map((row) => ({ ...row, read: row.read === true, read_at: null })) });
1160
1163
  });
1161
1164
  server.registerTool('mark_notifications_read', {
1162
1165
  title: 'Mark Notifications Read',
@@ -1169,7 +1172,7 @@ export function registerDataTools(server) {
1169
1172
  description: 'Mark selected notifications read, or all unread notifications if ids is omitted.',
1170
1173
  inputSchema: { ids: z.array(z.string().uuid()).optional() },
1171
1174
  }, async ({ ids }) => {
1172
- let query = db().from('notifications').update({ read_at: new Date().toISOString() }).eq('project_id', getProjectId()).is('read_at', null);
1175
+ let query = db().from('notifications').update({ read: true }).eq('project_id', getProjectId()).eq('read', false);
1173
1176
  if (ids?.length)
1174
1177
  query = query.in('id', ids);
1175
1178
  const { data, error } = await query.select('id');
@@ -145,7 +145,7 @@ export function registerDeliveryOpsTools(server) {
145
145
  inputSchema: { domain_id: z.string().uuid() },
146
146
  }, async ({ domain_id }) => {
147
147
  const { data, error } = await db().from('domains')
148
- .select('id, status, dns_records, spf_valid, dkim_valid, dmarc_valid')
148
+ .select('id')
149
149
  .eq('project_id', getProjectId())
150
150
  .eq('id', domain_id)
151
151
  .maybeSingle();
@@ -153,11 +153,22 @@ export function registerDeliveryOpsTools(server) {
153
153
  return j({ error: error.message });
154
154
  if (!data)
155
155
  return j({ error: 'Domain not found', code: 'NOT_FOUND' });
156
- const records = Array.isArray(data.dns_records) ? data.dns_records : [];
157
- const spf = data.spf_valid ?? records.some((record) => String(record.value ?? '').includes('v=spf1'));
158
- const dkim = data.dkim_valid ?? records.some((record) => /dkim/i.test(`${record.name ?? ''} ${record.type ?? ''}`));
159
- const dmarc = data.dmarc_valid ?? records.some((record) => String(record.name ?? '').includes('_dmarc'));
160
- return j({ spf, dkim, dmarc, all_verified: Boolean(spf && dkim && dmarc) });
156
+ const { data: health, error: healthError } = await db().from('domain_health')
157
+ .select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
158
+ .eq('project_id', getProjectId())
159
+ .eq('sites.domain_id', domain_id)
160
+ .order('check_date', { ascending: false })
161
+ .limit(1)
162
+ .maybeSingle();
163
+ if (healthError)
164
+ return j({ error: healthError.message });
165
+ const spf = health?.spf_valid ?? null;
166
+ const dkim = health?.dkim_valid ?? null;
167
+ const dmarc = health?.dmarc_valid ?? null;
168
+ const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
169
+ ? Boolean(spf && dkim && dmarc)
170
+ : null;
171
+ return j({ spf, dkim, dmarc, all_verified: allVerified });
161
172
  });
162
173
  server.registerTool('dnsbl_check', {
163
174
  title: 'DNSBL Check',
@@ -146,10 +146,24 @@ export function registerOrgTools(server) {
146
146
  }
147
147
  if (!orgId)
148
148
  return j({ members: [] });
149
- const { data, error } = await db().from('org_members').select('user_id, email, role, created_at').eq('org_id', orgId);
149
+ const { data, error } = await db().from('org_members').select('user_id, role, created_at').eq('org_id', orgId);
150
150
  if (error)
151
151
  return j({ error: error.message });
152
- return j({ members: (data ?? []).map((member) => ({ user_id: member.user_id, email: member.email ?? null, role: member.role, joined_at: member.created_at })) });
152
+ const membersWithUsers = await Promise.all((data ?? []).map(async (member) => {
153
+ const { data: userData, error: userError } = await adminDb().auth.admin.getUserById(member.user_id);
154
+ return { member, user: userData?.user ?? null, error: userError };
155
+ }));
156
+ const userError = membersWithUsers.find((result) => result.error)?.error;
157
+ if (userError)
158
+ return j({ error: userError.message });
159
+ return j({
160
+ members: membersWithUsers.map(({ member, user }) => ({
161
+ user_id: member.user_id,
162
+ email: user?.email ?? null,
163
+ role: member.role,
164
+ joined_at: member.created_at,
165
+ })),
166
+ });
153
167
  });
154
168
  server.registerTool('invite_team_member', {
155
169
  title: 'Invite Team Member',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",