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