@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
|
+
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|