@sendinel/mcp-server 1.0.37 → 1.0.39
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.
- package/build/tools/campaigns.js +29 -0
- package/build/tools/delivery-ops.js +37 -12
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -305,6 +305,33 @@ async function executeBroadcast(input) {
|
|
|
305
305
|
}
|
|
306
306
|
return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
|
|
307
307
|
}
|
|
308
|
+
/** #3714: a send nobody could receive leaves one audit row (counts by reason, no addresses). */
|
|
309
|
+
function logRefusedSend(kind, siteId, sendClass, audience, extra = {}) {
|
|
310
|
+
const reasons = {};
|
|
311
|
+
if (audience.notOnSender > 0)
|
|
312
|
+
reasons.not_subscribed_to_sender = audience.notOnSender;
|
|
313
|
+
for (const [reason, count] of Object.entries(audience.heldBack)) {
|
|
314
|
+
if (count > 0)
|
|
315
|
+
reasons[`consent_${reason}`] = count;
|
|
316
|
+
}
|
|
317
|
+
return logAudit({
|
|
318
|
+
action: 'send.refused',
|
|
319
|
+
resourceType: 'site',
|
|
320
|
+
resourceId: siteId,
|
|
321
|
+
details: {
|
|
322
|
+
kind,
|
|
323
|
+
outcome: 'nothing_sent',
|
|
324
|
+
send_class: sendClass,
|
|
325
|
+
site_id: siteId,
|
|
326
|
+
campaign_id: null,
|
|
327
|
+
requested: audience.matched,
|
|
328
|
+
enrolled: 0,
|
|
329
|
+
refused: Object.values(reasons).reduce((sum, count) => sum + count, 0),
|
|
330
|
+
reasons,
|
|
331
|
+
...extra,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
308
335
|
function heldBackLines(audience) {
|
|
309
336
|
return {
|
|
310
337
|
matched: audience.matched,
|
|
@@ -909,6 +936,7 @@ export function registerCampaignTools(server) {
|
|
|
909
936
|
});
|
|
910
937
|
}
|
|
911
938
|
if (contactIds.length === 0) {
|
|
939
|
+
await logRefusedSend('broadcast_to_segment', site_id, 'broadcast', audience, { audience: { segment_id } });
|
|
912
940
|
return j({
|
|
913
941
|
error: 'Nobody in this segment can get email from this sender right now.',
|
|
914
942
|
code: 'EMPTY_SEGMENT',
|
|
@@ -1153,6 +1181,7 @@ export function registerCampaignTools(server) {
|
|
|
1153
1181
|
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1154
1182
|
}
|
|
1155
1183
|
if (resolved.contactIds.length === 0) {
|
|
1184
|
+
await logRefusedSend('send_broadcast', site.id, sendClass, resolved, { audience: segment_id ? { segment_id } : { inline_rules: true } });
|
|
1156
1185
|
return j({ error: 'Nobody in this audience can get this email from this sender.', code: 'EMPTY_AUDIENCE', audience: heldBackLines(resolved), rules });
|
|
1157
1186
|
}
|
|
1158
1187
|
let content;
|
|
@@ -703,14 +703,15 @@ export function registerDeliveryOpsTools(server) {
|
|
|
703
703
|
idempotentHint: true,
|
|
704
704
|
openWorldHint: false,
|
|
705
705
|
},
|
|
706
|
-
description: 'Export contacts, email log, campaign, or suppression data as JSON.',
|
|
706
|
+
description: 'Export contacts, email log, campaign, or suppression data as JSON, newest first. Returns up to `limit` rows per call plus `total` (all matching rows). When `truncated` is true, call again with `offset` set to `next_offset` until it is false to get every row.',
|
|
707
707
|
inputSchema: {
|
|
708
708
|
resource: z.enum(['contacts', 'email_log', 'campaigns', 'suppressions']),
|
|
709
709
|
site_id: z.string().uuid().optional(),
|
|
710
710
|
since: z.string().datetime().optional(),
|
|
711
711
|
limit: z.number().int().min(1).max(5000).default(1000).optional(),
|
|
712
|
+
offset: z.number().int().min(0).default(0).optional().describe('Rows to skip. Use the next_offset from the previous call to continue.'),
|
|
712
713
|
},
|
|
713
|
-
}, async ({ resource, site_id, since, limit = 1000 }) => {
|
|
714
|
+
}, async ({ resource, site_id, since, limit = 1000, offset = 0 }) => {
|
|
714
715
|
const projectId = getProjectId();
|
|
715
716
|
const tableMap = {
|
|
716
717
|
contacts: 'subscribers',
|
|
@@ -718,16 +719,40 @@ export function registerDeliveryOpsTools(server) {
|
|
|
718
719
|
campaigns: 'campaigns',
|
|
719
720
|
suppressions: 'suppressions',
|
|
720
721
|
};
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
722
|
+
// #3715: one request returns at most 1,000 rows however large `limit` is, with no error.
|
|
723
|
+
// Read `limit` rows in pages, and report the real total so the caller can tell when it has
|
|
724
|
+
// everything. id breaks ties so pages do not overlap.
|
|
725
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
726
|
+
const filtered = (query) => {
|
|
727
|
+
let q = query.eq('project_id', projectId);
|
|
728
|
+
if (site_id && (resource === 'email_log' || resource === 'campaigns'))
|
|
729
|
+
q = q.eq('site_id', site_id);
|
|
730
|
+
if (since)
|
|
731
|
+
q = q.gte('created_at', since);
|
|
732
|
+
return q;
|
|
733
|
+
};
|
|
734
|
+
const PAGE = 1000;
|
|
735
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
736
|
+
const rows = [];
|
|
737
|
+
while (rows.length < limit) {
|
|
738
|
+
const size = Math.min(PAGE, limit - rows.length);
|
|
739
|
+
const from = offset + rows.length;
|
|
740
|
+
const { data, error } = await filtered(db().from(tableMap[resource]).select('*'))
|
|
741
|
+
.order('created_at', { ascending: false })
|
|
742
|
+
.order('id', { ascending: true })
|
|
743
|
+
.range(from, from + size - 1);
|
|
744
|
+
if (error)
|
|
745
|
+
return j({ error: error.message });
|
|
746
|
+
rows.push(...(data ?? []));
|
|
747
|
+
if ((data?.length ?? 0) < size)
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
const { count, error: countError } = await filtered(db().from(tableMap[resource]).select('id', { count: 'exact', head: true }));
|
|
751
|
+
// An unknown total is reported as null, never as a number.
|
|
752
|
+
const total = countError || typeof count !== 'number' ? null : count;
|
|
753
|
+
const nextOffset = offset + rows.length;
|
|
754
|
+
const truncated = total === null ? rows.length === limit : nextOffset < total;
|
|
755
|
+
return j({ resource, count: rows.length, total, offset, next_offset: truncated ? nextOffset : null, truncated, data: rows });
|
|
731
756
|
});
|
|
732
757
|
server.registerTool('list_domains', {
|
|
733
758
|
title: 'List Domains',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.39",
|
|
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",
|