@sendinel/mcp-server 1.0.56 → 1.0.57

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',
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.57",
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",