@sendinel/mcp-server 1.0.56 → 1.0.58

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.
@@ -0,0 +1,18 @@
1
+ export type CampaignPeopleCounts = {
2
+ ok: true;
3
+ opened: Map<string, number>;
4
+ clicked: Map<string, number>;
5
+ } | {
6
+ ok: false;
7
+ error: unknown;
8
+ };
9
+ /**
10
+ * Distinct recipients who opened / clicked, per campaign. `campaigns.total_opened` / `total_clicked`
11
+ * are event counters and can exceed total_sent (#3788). Mirrors the dashboard's
12
+ * `countUniqueEngagementByCampaign` (packages/dashboard/lib/campaign-step-engagement.ts): the MCP
13
+ * package cannot import dashboard code, so keep the two in step. A click counts as an open.
14
+ * One paged email_log query for the whole id set; a failed read is `{ ok: false }`, never zeros.
15
+ */
16
+ export declare function countUniqueEngagementByCampaign(sb: {
17
+ from: (table: string) => any;
18
+ }, projectId: string, campaignIds: string[]): Promise<CampaignPeopleCounts>;
@@ -0,0 +1,56 @@
1
+ import { readAllPages } from '@sendinel/domain';
2
+ /** PostgREST caps a response at 1,000 rows, so page explicitly. */
3
+ const PAGE_SIZE = 1000;
4
+ /**
5
+ * Distinct recipients who opened / clicked, per campaign. `campaigns.total_opened` / `total_clicked`
6
+ * are event counters and can exceed total_sent (#3788). Mirrors the dashboard's
7
+ * `countUniqueEngagementByCampaign` (packages/dashboard/lib/campaign-step-engagement.ts): the MCP
8
+ * package cannot import dashboard code, so keep the two in step. A click counts as an open.
9
+ * One paged email_log query for the whole id set; a failed read is `{ ok: false }`, never zeros.
10
+ */
11
+ export async function countUniqueEngagementByCampaign(sb, projectId, campaignIds) {
12
+ const opened = new Map();
13
+ const clicked = new Map();
14
+ if (campaignIds.length === 0)
15
+ return { ok: true, opened, clicked };
16
+ const read = await readAllPages((from, to) => sb
17
+ .from('email_log')
18
+ .select('id, campaign_id, contact_id, to_email, opened_at, clicked_at')
19
+ .eq('project_id', projectId)
20
+ .in('campaign_id', campaignIds)
21
+ .or('opened_at.not.is.null,clicked_at.not.is.null')
22
+ .order('id', { ascending: true })
23
+ .range(from, to), { cap: Number.MAX_SAFE_INTEGER, pageSize: PAGE_SIZE });
24
+ if (!read.ok)
25
+ return { ok: false, error: read.error };
26
+ const openedRecipients = new Map();
27
+ const clickedRecipients = new Map();
28
+ const add = (bucket, campaignId, recipient) => {
29
+ let set = bucket.get(campaignId);
30
+ if (!set) {
31
+ set = new Set();
32
+ bucket.set(campaignId, set);
33
+ }
34
+ set.add(recipient);
35
+ };
36
+ for (const row of read.rows) {
37
+ if (!row.campaign_id)
38
+ continue;
39
+ const recipient = row.contact_id
40
+ ? row.contact_id
41
+ : row.to_email
42
+ ? row.to_email.trim().toLowerCase()
43
+ : null;
44
+ if (!recipient)
45
+ continue;
46
+ if (row.opened_at || row.clicked_at)
47
+ add(openedRecipients, row.campaign_id, recipient);
48
+ if (row.clicked_at)
49
+ add(clickedRecipients, row.campaign_id, recipient);
50
+ }
51
+ for (const [id, set] of openedRecipients)
52
+ opened.set(id, set.size);
53
+ for (const [id, set] of clickedRecipients)
54
+ clicked.set(id, set.size);
55
+ return { ok: true, opened, clicked };
56
+ }
@@ -24,6 +24,7 @@ import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, conten
24
24
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
25
25
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
26
26
  import { callSendinelV1 } from '../lib/v1-client.js';
27
+ import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
27
28
  const txt = (text) => ({ content: [{ type: 'text', text }] });
28
29
  const j = (data) => txt(JSON.stringify(data, null, 2));
29
30
  const SENT_EMAIL_STATUSES = ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed'];
@@ -599,7 +600,16 @@ export function registerCampaignTools(server) {
599
600
  const { data, error } = await q;
600
601
  if (error)
601
602
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
602
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
603
+ // total_opened / total_clicked stay as the raw event counters (unchanged); unique_* are people
604
+ // counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
605
+ const rows = data ?? [];
606
+ const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
607
+ const withPeople = rows.map((row) => ({
608
+ ...row,
609
+ unique_opened: engagement.ok ? engagement.opened.get(row.id) ?? 0 : null,
610
+ unique_clicked: engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null,
611
+ }));
612
+ return { content: [{ type: 'text', text: JSON.stringify(withPeople, null, 2) }] };
603
613
  });
604
614
  server.registerTool('get_schedule', {
605
615
  title: 'Get Schedule',
@@ -1,3 +1,4 @@
1
+ import { anthropicResponseText } from '@sendinel/domain';
1
2
  import { z } from 'zod';
2
3
  import Anthropic from '@anthropic-ai/sdk';
3
4
  import { adminDb, db } from '../db.js';
@@ -99,7 +100,7 @@ Do not include body_html. Sendinel renders body_html from blocks.
99
100
  Respond with a JSON array.`,
100
101
  }],
101
102
  });
102
- const raw = msg.content[0].text;
103
+ const raw = anthropicResponseText(msg);
103
104
  steps = extractJsonArray(raw).map((step, index) => {
104
105
  const blocks = normalizeComposerBlocks(step.blocks, brandVisual.resolvedButtonColor);
105
106
  return {
@@ -208,7 +209,7 @@ Do not include body_html. Sendinel renders body_html from blocks.
208
209
  Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
209
210
  }],
210
211
  });
211
- const raw = msg.content[0].text;
212
+ const raw = anthropicResponseText(msg);
212
213
  const parsed = extractJsonObject(raw);
213
214
  const blocks = normalizeComposerBlocks(parsed.blocks, brandVisual.resolvedButtonColor);
214
215
  if (!blocks.length)
@@ -300,7 +301,7 @@ ${JSON.stringify(diagnosticData, null, 2)}
300
301
  Identify the most likely root cause and give 3-5 concise actionable next steps.`,
301
302
  }],
302
303
  });
303
- const analysis = analysisMsg.content[0].text;
304
+ const analysis = anthropicResponseText(analysisMsg);
304
305
  return j({
305
306
  diagnostic_data: diagnosticData,
306
307
  analysis,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
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",