@sendinel/mcp-server 1.0.99 → 1.0.101

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,3 +1,4 @@
1
+ import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
1
2
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
2
3
  import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
3
4
  import { z } from 'zod';
@@ -851,9 +852,10 @@ export function registerAnalyticsTools(server) {
851
852
  days: z.number().int().min(1).max(365).optional().describe('Lookback period in days, default 30'),
852
853
  site_id: z.string().uuid().optional(),
853
854
  campaign_id: z.string().uuid().optional(),
855
+ period: z.string().min(1).optional().describe('Return one enrollment period in by_period; existing totals keep their date window.'),
854
856
  group_by: z.enum(['campaign', 'site', 'company', 'day', 'week']).optional(),
855
857
  },
856
- }, async ({ days = 30, site_id, campaign_id, group_by }) => {
858
+ }, async ({ days = 30, site_id, campaign_id, group_by, period }) => {
857
859
  const projectId = getProjectId();
858
860
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
859
861
  let q = db()
@@ -868,20 +870,28 @@ export function registerAnalyticsTools(server) {
868
870
  const { data: logs, error } = await q;
869
871
  if (error)
870
872
  return txt(`Error: ${error.message}`);
873
+ let periodCampaigns;
874
+ try {
875
+ periodCampaigns = campaign_id || group_by === 'campaign' ? await readPeriodCampaigns(db(), projectId, { siteId: site_id, campaignId: campaign_id, start: since }) : new Map();
876
+ }
877
+ catch {
878
+ return txt("Error: Could not load results by period.");
879
+ }
880
+ const selectedFields = (id) => campaignPeriodFields(periodCampaigns.get(id)?.result, period);
871
881
  const rows = logs ?? [];
872
- if (!rows.length)
882
+ if (!rows.length && !periodCampaigns.size)
873
883
  return j({ message: `No email activity in the last ${days} days.`, total: 0 });
874
884
  const { data: attributionRows, error: attributionError } = await readRevenue({ sb: db(), projectId, siteId: site_id, campaignId: campaign_id, start: since });
875
885
  if (attributionError)
876
886
  return txt(`Error: ${attributionError.message}`);
877
887
  const scopedAttributionRows = attributionRows ?? [];
878
- const computeMetrics = (set) => {
879
- const total = set.length;
880
- const delivered = set.filter(isDeliveredRow).length;
881
- const opened = countPeople(set, isOpenedRow);
882
- const clicked = countPeople(set, isClickedRow);
883
- const bounced = set.filter(isBouncedRow).length;
884
- const complained = set.filter(isComplainedRow).length;
888
+ const computeMetrics = (set, counts) => {
889
+ const total = counts?.sent ?? set.length;
890
+ const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
891
+ const opened = counts?.opened ?? countPeople(set, isOpenedRow);
892
+ const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
893
+ const bounced = counts?.bounced ?? set.filter(isBouncedRow).length;
894
+ const complained = counts?.complained ?? set.filter(isComplainedRow).length;
885
895
  return {
886
896
  total_sent: total, delivered, opened, clicked, bounced, complained,
887
897
  delivery_rate: total > 0 ? `${percentRate(delivered, total, 1)?.toFixed(1)}%` : 'N/A',
@@ -892,7 +902,7 @@ export function registerAnalyticsTools(server) {
892
902
  click_to_open: opened > 0 ? `${percentRate(clicked, opened, 1)?.toFixed(1)}%` : 'N/A',
893
903
  };
894
904
  };
895
- const overall = computeMetrics(rows);
905
+ const overall = computeMetrics(rows, campaign_id ? periodCampaigns.get(campaign_id)?.result.totals : undefined);
896
906
  const trueConversionAnalytics = buildTrueConversionAnalytics({
897
907
  logs: rows,
898
908
  attributionRows: scopedAttributionRows,
@@ -913,9 +923,13 @@ export function registerAnalyticsTools(server) {
913
923
  byKey[key] = [];
914
924
  byKey[key].push(r);
915
925
  });
926
+ if (group_by === 'campaign')
927
+ for (const id of periodCampaigns.keys())
928
+ byKey[id] ??= [];
916
929
  grouped = Object.entries(byKey).map(([id, set]) => ({
917
930
  [`${group_by}_id`]: id,
918
- ...computeMetrics(set),
931
+ ...computeMetrics(set, group_by === 'campaign' ? periodCampaigns.get(id)?.result.totals : undefined),
932
+ ...(group_by === 'campaign' ? selectedFields(id) : {}),
919
933
  })).sort((a, b) => b.total_sent - a.total_sent);
920
934
  }
921
935
  else if (group_by === 'company') {
@@ -987,6 +1001,7 @@ export function registerAnalyticsTools(server) {
987
1001
  return j({
988
1002
  period: `Last ${days} days`,
989
1003
  overall,
1004
+ ...(campaign_id ? selectedFields(campaign_id) : {}),
990
1005
  true_conversion_analytics: trueConversionAnalytics,
991
1006
  best_send_times_utc: bestHours,
992
1007
  trend_vs_prior_period: trend,
@@ -1,3 +1,4 @@
1
+ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
1
2
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -643,13 +644,14 @@ export function registerCampaignTools(server) {
643
644
  description: 'List campaigns, optionally filtered by site and/or status',
644
645
  inputSchema: {
645
646
  site_id: z.string().uuid().optional(),
647
+ period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
646
648
  status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']).optional(),
647
649
  limit: z.number().int().min(1).max(100).optional().default(25),
648
650
  },
649
- }, async ({ site_id, status, limit }) => {
651
+ }, async ({ site_id, status, limit, period }) => {
650
652
  let q = db()
651
653
  .from('campaigns')
652
- .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at')
654
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key')
653
655
  .eq('project_id', getProjectId())
654
656
  .order('created_at', { ascending: false })
655
657
  .limit(limit ?? 25);
@@ -664,10 +666,16 @@ export function registerCampaignTools(server) {
664
666
  // counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
665
667
  const rows = data ?? [];
666
668
  const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
667
- const withPeople = rows.map((row) => ({
668
- ...row,
669
- unique_opened: engagement.ok ? engagement.opened.get(row.id) ?? 0 : null,
670
- unique_clicked: engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null,
669
+ const withPeople = await Promise.all(rows.map(async (row) => {
670
+ const periods = await readCampaignPeriods(db(), getProjectId(), row);
671
+ const { period_key: _periodKey, ...legacy } = row;
672
+ return {
673
+ ...legacy,
674
+ ...campaignPeriodFields(periods, period),
675
+ ...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
676
+ unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
677
+ unique_clicked: periods?.totals?.clicked ?? (engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null),
678
+ };
671
679
  }));
672
680
  return { content: [{ type: 'text', text: JSON.stringify(withPeople, null, 2) }] };
673
681
  });
@@ -3409,8 +3417,8 @@ Return ONLY valid JSON with this exact shape:
3409
3417
  openWorldHint: false,
3410
3418
  },
3411
3419
  description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
3412
- inputSchema: { campaign_id: z.string().uuid() },
3413
- }, async ({ campaign_id }) => {
3420
+ inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
3421
+ }, async ({ campaign_id, period }) => {
3414
3422
  const projectId = getProjectId();
3415
3423
  const { data: campaign, error: campErr } = await db()
3416
3424
  .from('campaigns')
@@ -3465,6 +3473,7 @@ Return ONLY valid JSON with this exact shape:
3465
3473
  const visibilityError = stepsError ?? enrollError ?? sentError ?? queuedEmailError ?? failedSendError ?? queueError ?? dlqError;
3466
3474
  if (visibilityError)
3467
3475
  return j({ error: visibilityError.message, code: 'CAMPAIGN_VISIBILITY_QUERY_FAILED' });
3476
+ const periods = await readCampaignPeriods(db(), projectId, campaign);
3468
3477
  const outstandingRows = queueRows ?? [];
3469
3478
  const pendingOrRetryRows = outstandingRows.filter((row) => row.status !== 'processing');
3470
3479
  const retryingCount = outstandingRows.filter((row) => row.status === 'retry').length;
@@ -3478,7 +3487,9 @@ Return ONLY valid JSON with this exact shape:
3478
3487
  const unresolvedCount = dlqUnresolvedCount ?? 0;
3479
3488
  return j({
3480
3489
  ...campaign,
3481
- total_sent: sentCount ?? 0,
3490
+ total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3491
+ ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3492
+ ...campaignPeriodFields(periods, period),
3482
3493
  steps: steps ?? [],
3483
3494
  active_enrollments: enrollCount ?? 0,
3484
3495
  send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
@@ -1166,21 +1166,27 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1166
1166
  if (!rows.length)
1167
1167
  return j([]);
1168
1168
  const companyIds = rows.map((company) => company.id);
1169
- const { data: subscribers, error: subscriberError } = await db()
1170
- .from('subscribers')
1171
- .select('company_id, engagement_score, total_opens, total_clicks, lifecycle_stage, last_engaged_at')
1172
- .eq('project_id', projectId)
1173
- .in('company_id', companyIds);
1174
- if (subscriberError)
1175
- return txt(`Error: ${subscriberError.message}`);
1176
1169
  const grouped = new Map();
1177
- for (const subscriber of subscribers ?? []) {
1178
- const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
1179
- if (!companyId)
1180
- continue;
1181
- const bucket = grouped.get(companyId) ?? [];
1182
- bucket.push(subscriber);
1183
- grouped.set(companyId, bucket);
1170
+ for (let offset = 0;; offset += 1000) {
1171
+ const { data: subscribers, error: subscriberError } = await db()
1172
+ .from('subscribers')
1173
+ .select('company_id, engagement_score, total_opens, total_clicks, lifecycle_stage, last_engaged_at')
1174
+ .eq('project_id', projectId)
1175
+ .in('company_id', companyIds)
1176
+ .order('id')
1177
+ .range(offset, offset + 999);
1178
+ if (subscriberError)
1179
+ return txt(`Error: ${subscriberError.message}`);
1180
+ for (const subscriber of subscribers ?? []) {
1181
+ const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
1182
+ if (!companyId)
1183
+ continue;
1184
+ const bucket = grouped.get(companyId) ?? [];
1185
+ bucket.push(subscriber);
1186
+ grouped.set(companyId, bucket);
1187
+ }
1188
+ if ((subscribers ?? []).length < 1000)
1189
+ break;
1184
1190
  }
1185
1191
  return j(rows.map((company) => ({
1186
1192
  ...company,
@@ -2196,11 +2202,21 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2196
2202
  return j({ inactive_count: 0, sample: [], message: 'No subscribed contacts for this site.' });
2197
2203
  q = q.in('id', ids);
2198
2204
  }
2199
- const { data: suppressed } = await db()
2200
- .from('suppressions')
2201
- .select('email')
2202
- .eq('project_id', projectId);
2203
- const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
2205
+ const suppressedSet = new Set();
2206
+ for (let offset = 0;; offset += 1000) {
2207
+ const { data: suppressed, error: suppressionError } = await db()
2208
+ .from('suppressions')
2209
+ .select('email')
2210
+ .eq('project_id', projectId)
2211
+ .order('email')
2212
+ .range(offset, offset + 999);
2213
+ if (suppressionError)
2214
+ return txt(`Error: ${suppressionError.message}`);
2215
+ for (const suppression of suppressed ?? [])
2216
+ suppressedSet.add(suppression.email);
2217
+ if ((suppressed ?? []).length < 1000)
2218
+ break;
2219
+ }
2204
2220
  const { data: contacts, error } = await q.order('engagement_score', { ascending: true }).limit(sampleLimit);
2205
2221
  if (error)
2206
2222
  return txt(`Error: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.99",
3
+ "version": "1.0.101",
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",