@sendinel/mcp-server 1.0.100 → 1.0.102
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/lib/send-audience.d.ts +5 -1
- package/build/lib/send-audience.js +7 -1
- package/build/tools/analytics.js +26 -11
- package/build/tools/campaigns.js +65 -19
- package/build/tools/contacts.js +14 -5
- package/build/tools/lists.js +14 -2
- package/build/tools/segments.js +59 -8
- package/package.json +1 -1
|
@@ -9,7 +9,11 @@ import { type SegmentRules } from '@sendinel/domain';
|
|
|
9
9
|
* Every read throws on failure: an unknown audience must never read as "nobody" or "everyone".
|
|
10
10
|
*/
|
|
11
11
|
export type SendClass = 'broadcast' | 'product';
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
|
|
14
|
+
* Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
|
|
15
|
+
* and `{ match_all: true }` is the only way to ask for every contact.
|
|
16
|
+
*/
|
|
13
17
|
export declare function resolveSegmentContactIds(projectId: string, rules: SegmentRules): Promise<string[]>;
|
|
14
18
|
export type SendAudience = {
|
|
15
19
|
/** People the send will enroll. */
|
|
@@ -15,9 +15,15 @@ async function loadFieldTypes(projectId) {
|
|
|
15
15
|
}
|
|
16
16
|
return fields;
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
|
|
20
|
+
* Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
|
|
21
|
+
* and `{ match_all: true }` is the only way to ask for every contact.
|
|
22
|
+
*/
|
|
19
23
|
export async function resolveSegmentContactIds(projectId, rules) {
|
|
20
24
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
25
|
+
if (tree.match_none)
|
|
26
|
+
return [];
|
|
21
27
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
22
28
|
const q = buildSegmentBaseQuery(db(), projectId);
|
|
23
29
|
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
|
|
1
2
|
import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
|
|
2
3
|
import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -851,9 +852,10 @@ export function registerAnalyticsTools(server) {
|
|
|
851
852
|
days: z.number().int().min(1).max(365).optional().describe('Lookback period in days, default 30'),
|
|
852
853
|
site_id: z.string().uuid().optional(),
|
|
853
854
|
campaign_id: z.string().uuid().optional(),
|
|
855
|
+
period: z.string().min(1).optional().describe('Return one enrollment period in by_period; existing totals keep their date window.'),
|
|
854
856
|
group_by: z.enum(['campaign', 'site', 'company', 'day', 'week']).optional(),
|
|
855
857
|
},
|
|
856
|
-
}, async ({ days = 30, site_id, campaign_id, group_by }) => {
|
|
858
|
+
}, async ({ days = 30, site_id, campaign_id, group_by, period }) => {
|
|
857
859
|
const projectId = getProjectId();
|
|
858
860
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
859
861
|
let q = db()
|
|
@@ -868,20 +870,28 @@ export function registerAnalyticsTools(server) {
|
|
|
868
870
|
const { data: logs, error } = await q;
|
|
869
871
|
if (error)
|
|
870
872
|
return txt(`Error: ${error.message}`);
|
|
873
|
+
let periodCampaigns;
|
|
874
|
+
try {
|
|
875
|
+
periodCampaigns = campaign_id || group_by === 'campaign' ? await readPeriodCampaigns(db(), projectId, { siteId: site_id, campaignId: campaign_id, start: since }) : new Map();
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return txt("Error: Could not load results by period.");
|
|
879
|
+
}
|
|
880
|
+
const selectedFields = (id) => campaignPeriodFields(periodCampaigns.get(id)?.result, period);
|
|
871
881
|
const rows = logs ?? [];
|
|
872
|
-
if (!rows.length)
|
|
882
|
+
if (!rows.length && !periodCampaigns.size)
|
|
873
883
|
return j({ message: `No email activity in the last ${days} days.`, total: 0 });
|
|
874
884
|
const { data: attributionRows, error: attributionError } = await readRevenue({ sb: db(), projectId, siteId: site_id, campaignId: campaign_id, start: since });
|
|
875
885
|
if (attributionError)
|
|
876
886
|
return txt(`Error: ${attributionError.message}`);
|
|
877
887
|
const scopedAttributionRows = attributionRows ?? [];
|
|
878
|
-
const computeMetrics = (set) => {
|
|
879
|
-
const total = set.length;
|
|
880
|
-
const delivered = set.filter(isDeliveredRow).length;
|
|
881
|
-
const opened = countPeople(set, isOpenedRow);
|
|
882
|
-
const clicked = countPeople(set, isClickedRow);
|
|
883
|
-
const bounced = set.filter(isBouncedRow).length;
|
|
884
|
-
const complained = set.filter(isComplainedRow).length;
|
|
888
|
+
const computeMetrics = (set, counts) => {
|
|
889
|
+
const total = counts?.sent ?? set.length;
|
|
890
|
+
const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
|
|
891
|
+
const opened = counts?.opened ?? countPeople(set, isOpenedRow);
|
|
892
|
+
const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
|
|
893
|
+
const bounced = counts?.bounced ?? set.filter(isBouncedRow).length;
|
|
894
|
+
const complained = counts?.complained ?? set.filter(isComplainedRow).length;
|
|
885
895
|
return {
|
|
886
896
|
total_sent: total, delivered, opened, clicked, bounced, complained,
|
|
887
897
|
delivery_rate: total > 0 ? `${percentRate(delivered, total, 1)?.toFixed(1)}%` : 'N/A',
|
|
@@ -892,7 +902,7 @@ export function registerAnalyticsTools(server) {
|
|
|
892
902
|
click_to_open: opened > 0 ? `${percentRate(clicked, opened, 1)?.toFixed(1)}%` : 'N/A',
|
|
893
903
|
};
|
|
894
904
|
};
|
|
895
|
-
const overall = computeMetrics(rows);
|
|
905
|
+
const overall = computeMetrics(rows, campaign_id ? periodCampaigns.get(campaign_id)?.result.totals : undefined);
|
|
896
906
|
const trueConversionAnalytics = buildTrueConversionAnalytics({
|
|
897
907
|
logs: rows,
|
|
898
908
|
attributionRows: scopedAttributionRows,
|
|
@@ -913,9 +923,13 @@ export function registerAnalyticsTools(server) {
|
|
|
913
923
|
byKey[key] = [];
|
|
914
924
|
byKey[key].push(r);
|
|
915
925
|
});
|
|
926
|
+
if (group_by === 'campaign')
|
|
927
|
+
for (const id of periodCampaigns.keys())
|
|
928
|
+
byKey[id] ??= [];
|
|
916
929
|
grouped = Object.entries(byKey).map(([id, set]) => ({
|
|
917
930
|
[`${group_by}_id`]: id,
|
|
918
|
-
...computeMetrics(set),
|
|
931
|
+
...computeMetrics(set, group_by === 'campaign' ? periodCampaigns.get(id)?.result.totals : undefined),
|
|
932
|
+
...(group_by === 'campaign' ? selectedFields(id) : {}),
|
|
919
933
|
})).sort((a, b) => b.total_sent - a.total_sent);
|
|
920
934
|
}
|
|
921
935
|
else if (group_by === 'company') {
|
|
@@ -987,6 +1001,7 @@ export function registerAnalyticsTools(server) {
|
|
|
987
1001
|
return j({
|
|
988
1002
|
period: `Last ${days} days`,
|
|
989
1003
|
overall,
|
|
1004
|
+
...(campaign_id ? selectedFields(campaign_id) : {}),
|
|
990
1005
|
true_conversion_analytics: trueConversionAnalytics,
|
|
991
1006
|
best_send_times_utc: bestHours,
|
|
992
1007
|
trend_vs_prior_period: trend,
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
1
2
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
4
|
-
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
|
|
5
|
+
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, } from '@sendinel/domain';
|
|
5
6
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
6
7
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
7
8
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -14,7 +15,7 @@ import { checkPlanLimit } from '../plan-limits.js';
|
|
|
14
15
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
15
16
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
16
17
|
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
17
|
-
import { resolveSendAudience } from '../lib/send-audience.js';
|
|
18
|
+
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
18
19
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
19
20
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
20
21
|
import { assertUsableStopReason } from '@sendinel/domain';
|
|
@@ -643,13 +644,14 @@ export function registerCampaignTools(server) {
|
|
|
643
644
|
description: 'List campaigns, optionally filtered by site and/or status',
|
|
644
645
|
inputSchema: {
|
|
645
646
|
site_id: z.string().uuid().optional(),
|
|
647
|
+
period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
|
|
646
648
|
status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']).optional(),
|
|
647
649
|
limit: z.number().int().min(1).max(100).optional().default(25),
|
|
648
650
|
},
|
|
649
|
-
}, async ({ site_id, status, limit }) => {
|
|
651
|
+
}, async ({ site_id, status, limit, period }) => {
|
|
650
652
|
let q = db()
|
|
651
653
|
.from('campaigns')
|
|
652
|
-
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at')
|
|
654
|
+
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key')
|
|
653
655
|
.eq('project_id', getProjectId())
|
|
654
656
|
.order('created_at', { ascending: false })
|
|
655
657
|
.limit(limit ?? 25);
|
|
@@ -664,10 +666,16 @@ export function registerCampaignTools(server) {
|
|
|
664
666
|
// counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
|
|
665
667
|
const rows = data ?? [];
|
|
666
668
|
const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
|
|
667
|
-
const withPeople = rows.map((row) =>
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
669
|
+
const withPeople = await Promise.all(rows.map(async (row) => {
|
|
670
|
+
const periods = await readCampaignPeriods(db(), getProjectId(), row);
|
|
671
|
+
const { period_key: _periodKey, ...legacy } = row;
|
|
672
|
+
return {
|
|
673
|
+
...legacy,
|
|
674
|
+
...campaignPeriodFields(periods, period),
|
|
675
|
+
...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
|
|
676
|
+
unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
|
|
677
|
+
unique_clicked: periods?.totals?.clicked ?? (engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null),
|
|
678
|
+
};
|
|
671
679
|
}));
|
|
672
680
|
return { content: [{ type: 'text', text: JSON.stringify(withPeople, null, 2) }] };
|
|
673
681
|
});
|
|
@@ -1156,10 +1164,7 @@ export function registerCampaignTools(server) {
|
|
|
1156
1164
|
.maybeSingle();
|
|
1157
1165
|
if (segmentErr || !segment)
|
|
1158
1166
|
return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
1159
|
-
const rules =
|
|
1160
|
-
...(segment.rules ?? {}),
|
|
1161
|
-
...(segment.site_id ? { site_id: segment.site_id } : {}),
|
|
1162
|
-
};
|
|
1167
|
+
const rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
|
|
1163
1168
|
// #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
|
|
1164
1169
|
// sender, consent gate applied. The count shown is the count enrolled.
|
|
1165
1170
|
let audience;
|
|
@@ -1167,6 +1172,9 @@ export function registerCampaignTools(server) {
|
|
|
1167
1172
|
audience = await resolveSendAudience(projectId, site_id, rules);
|
|
1168
1173
|
}
|
|
1169
1174
|
catch (error) {
|
|
1175
|
+
// #4270: a malformed segment enrolls nobody and says why.
|
|
1176
|
+
if (isSegmentRulesError(error))
|
|
1177
|
+
return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors });
|
|
1170
1178
|
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1171
1179
|
}
|
|
1172
1180
|
const contactIds = audience.contactIds;
|
|
@@ -1415,7 +1423,7 @@ export function registerCampaignTools(server) {
|
|
|
1415
1423
|
return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
|
|
1416
1424
|
if (!segment)
|
|
1417
1425
|
return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
1418
|
-
rules =
|
|
1426
|
+
rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
|
|
1419
1427
|
segmentName = segment.name;
|
|
1420
1428
|
}
|
|
1421
1429
|
else {
|
|
@@ -1433,6 +1441,9 @@ export function registerCampaignTools(server) {
|
|
|
1433
1441
|
resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
|
|
1434
1442
|
}
|
|
1435
1443
|
catch (error) {
|
|
1444
|
+
// #4270: malformed segment or parsed audience rules send to nobody and say why.
|
|
1445
|
+
if (isSegmentRulesError(error))
|
|
1446
|
+
return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors, rules });
|
|
1436
1447
|
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1437
1448
|
}
|
|
1438
1449
|
if (resolved.contactIds.length === 0) {
|
|
@@ -1876,6 +1887,10 @@ Adapt your tone accordingly:
|
|
|
1876
1887
|
async function launchFromPlan(plan) {
|
|
1877
1888
|
let segId = segment_id;
|
|
1878
1889
|
if (segment_description) {
|
|
1890
|
+
// #4270: never save a segment from rules the segment engine can't use.
|
|
1891
|
+
const rulesCheck = validateSegmentRules(plan.rules);
|
|
1892
|
+
if (!rulesCheck.ok)
|
|
1893
|
+
return txt(`Error: Could not use the audience rules. ${rulesCheck.errors[0]} Nothing was created.`);
|
|
1879
1894
|
const { data: seg, error: segErr } = await db()
|
|
1880
1895
|
.from('segments')
|
|
1881
1896
|
.insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
|
|
@@ -1998,8 +2013,22 @@ Adapt your tone accordingly:
|
|
|
1998
2013
|
q = q.lte(column, rule.before);
|
|
1999
2014
|
return q;
|
|
2000
2015
|
}
|
|
2001
|
-
// Helper to get segment contact IDs (simplified inline)
|
|
2016
|
+
// Helper to get segment contact IDs (simplified inline). #4270: rules are validated first
|
|
2017
|
+
// (SegmentRulesError), and anything this flat helper cannot evaluate (rule trees, markers,
|
|
2018
|
+
// custom_property, company filters, multi-tag) goes through the shared evaluator instead of
|
|
2019
|
+
// being ignored, which used to widen those segments to everyone.
|
|
2020
|
+
const INLINE_RULE_KEYS = new Set([
|
|
2021
|
+
'lifecycle_stage', 'payment', 'min_score', 'max_score', 'source', 'subscribed_after', 'subscribed_before',
|
|
2022
|
+
'has_opened', 'has_clicked', 'tag', 'site_id', 'exclude_suppressed', 'metadata',
|
|
2023
|
+
]);
|
|
2002
2024
|
const getContactIds = async (rules) => {
|
|
2025
|
+
const check = validateSegmentRules(rules);
|
|
2026
|
+
if (!check.ok)
|
|
2027
|
+
throw new SegmentRulesError(check.errors);
|
|
2028
|
+
const inlineCanEvaluate = Object.keys(rules).every((key) => rules[key] === undefined || INLINE_RULE_KEYS.has(key))
|
|
2029
|
+
&& (rules.tag === undefined || typeof rules.tag === 'string');
|
|
2030
|
+
if (!inlineCanEvaluate)
|
|
2031
|
+
return resolveSegmentContactIds(projectId, rules);
|
|
2003
2032
|
let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
|
|
2004
2033
|
if (rules.lifecycle_stage)
|
|
2005
2034
|
q = q.eq('lifecycle_stage', rules.lifecycle_stage);
|
|
@@ -2065,7 +2094,14 @@ Adapt your tone accordingly:
|
|
|
2065
2094
|
catch {
|
|
2066
2095
|
return txt(`Error: Failed to parse segment from "${segment_description}"`);
|
|
2067
2096
|
}
|
|
2068
|
-
|
|
2097
|
+
try {
|
|
2098
|
+
contactIds = await getContactIds(parsedRules);
|
|
2099
|
+
}
|
|
2100
|
+
catch (error) {
|
|
2101
|
+
if (isSegmentRulesError(error))
|
|
2102
|
+
return txt(`Error: Could not use the audience parsed from "${segment_description}". ${error.message}`);
|
|
2103
|
+
throw error;
|
|
2104
|
+
}
|
|
2069
2105
|
contactCount = contactIds.length;
|
|
2070
2106
|
segmentName = `${name} — segment`;
|
|
2071
2107
|
}
|
|
@@ -2080,7 +2116,14 @@ Adapt your tone accordingly:
|
|
|
2080
2116
|
return txt('Error: segment not found');
|
|
2081
2117
|
segmentName = seg.name;
|
|
2082
2118
|
parsedRules = seg.rules;
|
|
2083
|
-
|
|
2119
|
+
try {
|
|
2120
|
+
contactIds = await getContactIds(parsedRules);
|
|
2121
|
+
}
|
|
2122
|
+
catch (error) {
|
|
2123
|
+
if (isSegmentRulesError(error))
|
|
2124
|
+
return txt(`Error: Segment "${seg.name}" has rules that can't be used. ${error.message}`);
|
|
2125
|
+
throw error;
|
|
2126
|
+
}
|
|
2084
2127
|
contactCount = contactIds.length;
|
|
2085
2128
|
}
|
|
2086
2129
|
if (!confirmed) {
|
|
@@ -3409,8 +3452,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3409
3452
|
openWorldHint: false,
|
|
3410
3453
|
},
|
|
3411
3454
|
description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
|
|
3412
|
-
inputSchema: { campaign_id: z.string().uuid() },
|
|
3413
|
-
}, async ({ campaign_id }) => {
|
|
3455
|
+
inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
|
|
3456
|
+
}, async ({ campaign_id, period }) => {
|
|
3414
3457
|
const projectId = getProjectId();
|
|
3415
3458
|
const { data: campaign, error: campErr } = await db()
|
|
3416
3459
|
.from('campaigns')
|
|
@@ -3465,6 +3508,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3465
3508
|
const visibilityError = stepsError ?? enrollError ?? sentError ?? queuedEmailError ?? failedSendError ?? queueError ?? dlqError;
|
|
3466
3509
|
if (visibilityError)
|
|
3467
3510
|
return j({ error: visibilityError.message, code: 'CAMPAIGN_VISIBILITY_QUERY_FAILED' });
|
|
3511
|
+
const periods = await readCampaignPeriods(db(), projectId, campaign);
|
|
3468
3512
|
const outstandingRows = queueRows ?? [];
|
|
3469
3513
|
const pendingOrRetryRows = outstandingRows.filter((row) => row.status !== 'processing');
|
|
3470
3514
|
const retryingCount = outstandingRows.filter((row) => row.status === 'retry').length;
|
|
@@ -3478,7 +3522,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3478
3522
|
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
3479
3523
|
return j({
|
|
3480
3524
|
...campaign,
|
|
3481
|
-
total_sent: sentCount ?? 0,
|
|
3525
|
+
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3526
|
+
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3527
|
+
...campaignPeriodFields(periods, period),
|
|
3482
3528
|
steps: steps ?? [],
|
|
3483
3529
|
active_enrollments: enrollCount ?? 0,
|
|
3484
3530
|
send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
3
3
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
4
|
-
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
7
7
|
import { db, projectClientForWriteback } from '../db.js';
|
|
@@ -2103,11 +2103,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2103
2103
|
q = q.eq('company_id', params.company_id);
|
|
2104
2104
|
const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
|
|
2105
2105
|
if (params.custom_property) {
|
|
2106
|
-
|
|
2106
|
+
try {
|
|
2107
|
+
// #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
|
|
2108
|
+
({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2109
|
+
}
|
|
2110
|
+
catch (error) {
|
|
2111
|
+
if (isSegmentRulesError(error))
|
|
2112
|
+
return txt(`Error: ${error.message}`);
|
|
2113
|
+
throw error;
|
|
2114
|
+
}
|
|
2107
2115
|
}
|
|
2108
2116
|
if (params.site_id) {
|
|
2109
2117
|
// Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
|
|
2110
|
-
|
|
2118
|
+
// Deliberately everyone on the site, so allowEmpty.
|
|
2119
|
+
const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
|
|
2111
2120
|
if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
|
|
2112
2121
|
return j({ total: 0, sample: [] });
|
|
2113
2122
|
q = siteFiltered.query;
|
|
@@ -2155,11 +2164,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2155
2164
|
if (params.tag)
|
|
2156
2165
|
countQ = countQ.contains('tags', [params.tag]);
|
|
2157
2166
|
if (params.custom_property) {
|
|
2158
|
-
({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
|
|
2167
|
+
({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2159
2168
|
}
|
|
2160
2169
|
// The total must count the same site the sample came from (#3674).
|
|
2161
2170
|
if (params.site_id)
|
|
2162
|
-
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId));
|
|
2171
|
+
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
|
|
2163
2172
|
const { count } = await countQ;
|
|
2164
2173
|
return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
|
|
2165
2174
|
});
|
package/build/tools/lists.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
2
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
@@ -28,7 +28,10 @@ async function loadFieldTypes(projectId) {
|
|
|
28
28
|
}
|
|
29
29
|
async function getSegmentContactIds(rules) {
|
|
30
30
|
const projectId = getProjectId();
|
|
31
|
+
// Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
|
|
31
32
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
33
|
+
if (tree.match_none)
|
|
34
|
+
return [];
|
|
32
35
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
33
36
|
const q = buildSegmentBaseQuery(db(), projectId);
|
|
34
37
|
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
|
@@ -357,7 +360,16 @@ export function registerListTools(server) {
|
|
|
357
360
|
},
|
|
358
361
|
}, async ({ list_id, rules, confirmed, approval_id }) => {
|
|
359
362
|
const projectId = getProjectId();
|
|
360
|
-
|
|
363
|
+
let normalizedRules;
|
|
364
|
+
try {
|
|
365
|
+
normalizedRules = normalizeSegmentRulesToTree(rules);
|
|
366
|
+
}
|
|
367
|
+
catch (rulesError) {
|
|
368
|
+
// #4270: invalid (or empty) filter rules add nobody and say why. Everyone is { match_all: true }.
|
|
369
|
+
if (isSegmentRulesError(rulesError))
|
|
370
|
+
return j({ error: rulesError.message, code: 'INVALID_SEGMENT_RULES', errors: rulesError.errors });
|
|
371
|
+
throw rulesError;
|
|
372
|
+
}
|
|
361
373
|
const approvalParams = { list_id, rules: normalizedRules };
|
|
362
374
|
const { data: list, error: listError } = await db()
|
|
363
375
|
.from('contact_lists')
|
package/build/tools/segments.js
CHANGED
|
@@ -5,7 +5,7 @@ import { db } from '../db.js';
|
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
7
|
import { assertUsableStopReason } from '@sendinel/domain';
|
|
8
|
-
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
8
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
|
|
9
9
|
async function loadFieldTypes(projectId) {
|
|
10
10
|
const client = db();
|
|
11
11
|
const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
|
|
@@ -27,7 +27,10 @@ async function loadFieldTypes(projectId) {
|
|
|
27
27
|
}
|
|
28
28
|
async function getSegmentContactIds(rules) {
|
|
29
29
|
const projectId = getProjectId();
|
|
30
|
+
// Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
|
|
30
31
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
32
|
+
if (tree.match_none)
|
|
33
|
+
return [];
|
|
31
34
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
32
35
|
// Shared base query (#3084). This used to be hand-written here and omitted
|
|
33
36
|
// is_test / source_deleted_at / the cooled exclusion the dashboard applies,
|
|
@@ -53,6 +56,13 @@ async function getSegmentContactIds(rules) {
|
|
|
53
56
|
}
|
|
54
57
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
55
58
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
59
|
+
/**
|
|
60
|
+
* #4270: invalid segment rules are an error response naming the first problem,
|
|
61
|
+
* never an audience of everyone. Returns null for any other error.
|
|
62
|
+
*/
|
|
63
|
+
function invalidRules(error) {
|
|
64
|
+
return isSegmentRulesError(error) ? txt(`Error: ${error.message}`) : null;
|
|
65
|
+
}
|
|
56
66
|
export function registerSegmentTools(server) {
|
|
57
67
|
server.registerTool('create_segment', {
|
|
58
68
|
title: 'Create Segment',
|
|
@@ -69,9 +79,19 @@ export function registerSegmentTools(server) {
|
|
|
69
79
|
rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>. tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
70
80
|
},
|
|
71
81
|
}, async ({ name, description, rules }) => {
|
|
82
|
+
let storedRules;
|
|
83
|
+
try {
|
|
84
|
+
storedRules = prepareSegmentRulesForStorage(rules);
|
|
85
|
+
}
|
|
86
|
+
catch (rulesError) {
|
|
87
|
+
const invalid = invalidRules(rulesError);
|
|
88
|
+
if (invalid)
|
|
89
|
+
return invalid;
|
|
90
|
+
throw rulesError;
|
|
91
|
+
}
|
|
72
92
|
const { data, error } = await db()
|
|
73
93
|
.from('segments')
|
|
74
|
-
.insert({ project_id: getProjectId(), name, description, rules:
|
|
94
|
+
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
75
95
|
.select('id, name, description, rules, created_at')
|
|
76
96
|
.single();
|
|
77
97
|
if (error)
|
|
@@ -105,7 +125,17 @@ export function registerSegmentTools(server) {
|
|
|
105
125
|
return txt(`Error: ${error.message}`);
|
|
106
126
|
const segments = [];
|
|
107
127
|
for (const seg of data ?? []) {
|
|
108
|
-
|
|
128
|
+
let ids;
|
|
129
|
+
try {
|
|
130
|
+
ids = await getSegmentContactIds(seg.rules);
|
|
131
|
+
}
|
|
132
|
+
catch (rulesError) {
|
|
133
|
+
if (!isSegmentRulesError(rulesError))
|
|
134
|
+
throw rulesError;
|
|
135
|
+
// #4270: one stored segment with invalid rules matches nobody; its count is unknown, not 0.
|
|
136
|
+
segments.push({ ...seg, contact_count: null, rules_error: rulesError.message });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
109
139
|
segments.push({ ...seg, contact_count: ids.length });
|
|
110
140
|
}
|
|
111
141
|
return j(segments);
|
|
@@ -131,8 +161,17 @@ export function registerSegmentTools(server) {
|
|
|
131
161
|
updates.name = name;
|
|
132
162
|
if (description !== undefined)
|
|
133
163
|
updates.description = description;
|
|
134
|
-
if (rules)
|
|
135
|
-
|
|
164
|
+
if (rules) {
|
|
165
|
+
try {
|
|
166
|
+
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
167
|
+
}
|
|
168
|
+
catch (rulesError) {
|
|
169
|
+
const invalid = invalidRules(rulesError);
|
|
170
|
+
if (invalid)
|
|
171
|
+
return invalid;
|
|
172
|
+
throw rulesError;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
136
175
|
const { data, error } = await db()
|
|
137
176
|
.from('segments')
|
|
138
177
|
.update(updates)
|
|
@@ -199,7 +238,16 @@ export function registerSegmentTools(server) {
|
|
|
199
238
|
else {
|
|
200
239
|
return txt('Provide either segment_id or rules.');
|
|
201
240
|
}
|
|
202
|
-
|
|
241
|
+
let ids;
|
|
242
|
+
try {
|
|
243
|
+
ids = await getSegmentContactIds(segRules);
|
|
244
|
+
}
|
|
245
|
+
catch (rulesError) {
|
|
246
|
+
const invalid = invalidRules(rulesError);
|
|
247
|
+
if (invalid)
|
|
248
|
+
return invalid;
|
|
249
|
+
throw rulesError;
|
|
250
|
+
}
|
|
203
251
|
const sample = ids.slice(0, 10);
|
|
204
252
|
let sampleContacts = [];
|
|
205
253
|
if (sample.length) {
|
|
@@ -243,9 +291,12 @@ export function registerSegmentTools(server) {
|
|
|
243
291
|
return txt(`Error: Failed to parse rules from "${description}"`);
|
|
244
292
|
let parsedRules;
|
|
245
293
|
try {
|
|
246
|
-
parsedRules =
|
|
294
|
+
parsedRules = prepareSegmentRulesForStorage(JSON.parse(raw.replace(/```json?\n?|```/g, '').trim()));
|
|
247
295
|
}
|
|
248
|
-
catch {
|
|
296
|
+
catch (parseError) {
|
|
297
|
+
// #4270: parsed rules that are missing or malformed are an error, never "everyone".
|
|
298
|
+
if (isSegmentRulesError(parseError))
|
|
299
|
+
return txt(`Error: Could not use the rules parsed from "${description}". ${parseError.message}`);
|
|
249
300
|
return txt(`Error: Failed to parse rules from "${description}"`);
|
|
250
301
|
}
|
|
251
302
|
const ids = await getSegmentContactIds(parsedRules);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.102",
|
|
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",
|