@sendinel/mcp-server 1.0.42 → 1.0.44
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 +104 -6
- package/build/tools/contacts.js +37 -79
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
-
import { NAME_FALLBACKS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
|
|
3
|
+
import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
|
|
4
4
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
5
5
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
6
6
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -229,6 +229,25 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
|
|
|
229
229
|
* send_broadcast so both run the same steps. Enrolls exactly the given contact ids.
|
|
230
230
|
*/
|
|
231
231
|
async function executeBroadcast(input) {
|
|
232
|
+
// #3749 One Send: the same email (site + subject + body) joins its existing active/completed
|
|
233
|
+
// campaign from the last 90 days instead of creating another. Same rule as the dashboard helper.
|
|
234
|
+
const contentKey = computeSendContentKey({ siteId: input.siteId, subject: input.subject, bodyHtml: input.bodyHtml });
|
|
235
|
+
const since = new Date(Date.now() - ONE_SEND_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
236
|
+
const { data: matches, error: matchErr } = await db()
|
|
237
|
+
.from('campaigns')
|
|
238
|
+
.select('id, status')
|
|
239
|
+
.eq('project_id', input.projectId)
|
|
240
|
+
.eq('metadata->>content_key', contentKey)
|
|
241
|
+
.in('status', [...ONE_SEND_MATCH_STATUSES])
|
|
242
|
+
.gte('created_at', since)
|
|
243
|
+
.order('created_at', { ascending: false })
|
|
244
|
+
.limit(1);
|
|
245
|
+
if (matchErr) {
|
|
246
|
+
return { ok: false, error: matchErr.message, code: 'ONE_SEND_LOOKUP_FAILED' };
|
|
247
|
+
}
|
|
248
|
+
const match = (matches ?? [])[0];
|
|
249
|
+
if (match)
|
|
250
|
+
return attachBroadcastToExisting(input, match);
|
|
232
251
|
const limit = await checkPlanLimit('active_campaigns');
|
|
233
252
|
if (!limit.allowed) {
|
|
234
253
|
return { ok: false, error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' };
|
|
@@ -246,7 +265,7 @@ async function executeBroadcast(input) {
|
|
|
246
265
|
send_class: input.sendClass,
|
|
247
266
|
sends_per_hour: input.sendsPerHour ?? null,
|
|
248
267
|
requires_approval: false,
|
|
249
|
-
metadata: { source: input.source, ...(input.metadata ?? {}) },
|
|
268
|
+
metadata: { source: input.source, ...(input.metadata ?? {}), content_key: contentKey },
|
|
250
269
|
})
|
|
251
270
|
.select('id')
|
|
252
271
|
.single();
|
|
@@ -305,6 +324,83 @@ async function executeBroadcast(input) {
|
|
|
305
324
|
}
|
|
306
325
|
return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
|
|
307
326
|
}
|
|
327
|
+
/** #3749: enroll into the matched campaign, skipping anyone enrolled or already sent to. */
|
|
328
|
+
async function attachBroadcastToExisting(input, match) {
|
|
329
|
+
const skip = new Set();
|
|
330
|
+
const sentStatuses = new Set(ONE_SEND_SENT_STATUSES);
|
|
331
|
+
const chunk = 200;
|
|
332
|
+
for (let i = 0; i < input.contactIds.length; i += chunk) {
|
|
333
|
+
const ids = input.contactIds.slice(i, i + chunk);
|
|
334
|
+
const { data: enrolledRows, error: enrolledErr } = await db()
|
|
335
|
+
.from('campaign_enrollments')
|
|
336
|
+
.select('contact_id')
|
|
337
|
+
.eq('campaign_id', match.id)
|
|
338
|
+
.in('contact_id', ids)
|
|
339
|
+
.limit(10000);
|
|
340
|
+
if (enrolledErr)
|
|
341
|
+
return { ok: false, error: enrolledErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
342
|
+
for (const row of enrolledRows ?? [])
|
|
343
|
+
skip.add(row.contact_id);
|
|
344
|
+
const { data: logRows, error: logErr } = await db()
|
|
345
|
+
.from('email_log')
|
|
346
|
+
.select('contact_id, status')
|
|
347
|
+
.eq('project_id', input.projectId)
|
|
348
|
+
.eq('campaign_id', match.id)
|
|
349
|
+
.in('contact_id', ids)
|
|
350
|
+
.limit(10000);
|
|
351
|
+
if (logErr)
|
|
352
|
+
return { ok: false, error: logErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
353
|
+
for (const row of logRows ?? [])
|
|
354
|
+
if (sentStatuses.has(row.status))
|
|
355
|
+
skip.add(row.contact_id);
|
|
356
|
+
}
|
|
357
|
+
const fresh = input.contactIds.filter((id) => !skip.has(id));
|
|
358
|
+
const alreadySent = input.contactIds.length - fresh.length;
|
|
359
|
+
const nextSendAt = input.sendAt ?? new Date().toISOString();
|
|
360
|
+
let enrolled = 0;
|
|
361
|
+
for (let i = 0; i < fresh.length; i += 500) {
|
|
362
|
+
const batch = fresh.slice(i, i + 500).map((contact_id) => ({
|
|
363
|
+
campaign_id: match.id,
|
|
364
|
+
contact_id,
|
|
365
|
+
next_send_at: nextSendAt,
|
|
366
|
+
}));
|
|
367
|
+
const { data: inserted, error: enrollErr } = await db()
|
|
368
|
+
.from('campaign_enrollments')
|
|
369
|
+
.upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true })
|
|
370
|
+
.select('contact_id');
|
|
371
|
+
if (enrollErr) {
|
|
372
|
+
return { ok: false, error: enrollErr.message, code: 'ENROLLMENT_FAILED', campaign_id: match.id, enrolled_before_failure: enrolled };
|
|
373
|
+
}
|
|
374
|
+
enrolled += (inserted ?? []).length;
|
|
375
|
+
}
|
|
376
|
+
if (enrolled > 0) {
|
|
377
|
+
const { count } = await db()
|
|
378
|
+
.from('campaign_enrollments')
|
|
379
|
+
.select('id', { count: 'exact', head: true })
|
|
380
|
+
.eq('campaign_id', match.id);
|
|
381
|
+
const update = { updated_at: new Date().toISOString() };
|
|
382
|
+
if (typeof count === 'number')
|
|
383
|
+
update.total_enrolled = count;
|
|
384
|
+
if (match.status === 'completed')
|
|
385
|
+
update.status = 'active';
|
|
386
|
+
const { error: updateErr } = await db()
|
|
387
|
+
.from('campaigns')
|
|
388
|
+
.update(update)
|
|
389
|
+
.eq('id', match.id)
|
|
390
|
+
.eq('project_id', input.projectId);
|
|
391
|
+
if (updateErr) {
|
|
392
|
+
return { ok: false, error: updateErr.message, code: 'ACTIVATION_FAILED', campaign_id: match.id, enrolled };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const { data: steps } = await db()
|
|
396
|
+
.from('campaign_steps')
|
|
397
|
+
.select('id')
|
|
398
|
+
.eq('campaign_id', match.id)
|
|
399
|
+
.order('step_order', { ascending: true })
|
|
400
|
+
.limit(1);
|
|
401
|
+
const stepId = (steps ?? [])[0]?.id ?? '';
|
|
402
|
+
return { ok: true, campaignId: match.id, stepId, enrolled, nextSendAt, grouped: true, alreadySent };
|
|
403
|
+
}
|
|
308
404
|
/** #3714: a send nobody could receive leaves one audit row (counts by reason, no addresses). */
|
|
309
405
|
function logRefusedSend(kind, siteId, sendClass, audience, extra = {}) {
|
|
310
406
|
const reasons = {};
|
|
@@ -993,7 +1089,7 @@ export function registerCampaignTools(server) {
|
|
|
993
1089
|
details: {
|
|
994
1090
|
site_id,
|
|
995
1091
|
segment_id,
|
|
996
|
-
contacts_enrolled:
|
|
1092
|
+
contacts_enrolled: executed.enrolled,
|
|
997
1093
|
source: 'mcp',
|
|
998
1094
|
},
|
|
999
1095
|
}).catch(() => { });
|
|
@@ -1001,7 +1097,7 @@ export function registerCampaignTools(server) {
|
|
|
1001
1097
|
campaign_id: campaign.id,
|
|
1002
1098
|
campaign_name: campaignName,
|
|
1003
1099
|
type: 'broadcast',
|
|
1004
|
-
contacts_enrolled:
|
|
1100
|
+
contacts_enrolled: executed.enrolled,
|
|
1005
1101
|
steps: 1,
|
|
1006
1102
|
}).catch(() => { });
|
|
1007
1103
|
return j({
|
|
@@ -1010,9 +1106,10 @@ export function registerCampaignTools(server) {
|
|
|
1010
1106
|
name: campaignName,
|
|
1011
1107
|
status: 'active',
|
|
1012
1108
|
segment: { id: segment.id, name: segment.name },
|
|
1013
|
-
contacts_enrolled:
|
|
1109
|
+
contacts_enrolled: executed.enrolled,
|
|
1014
1110
|
next_send_at: sendAt,
|
|
1015
|
-
message: `Broadcast "${campaignName}" is active with ${
|
|
1111
|
+
message: `Broadcast "${campaignName}" is active with ${executed.enrolled} contact(s) enrolled for immediate sending.`,
|
|
1112
|
+
...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
|
|
1016
1113
|
});
|
|
1017
1114
|
});
|
|
1018
1115
|
server.registerTool('send_broadcast', {
|
|
@@ -1108,6 +1205,7 @@ export function registerCampaignTools(server) {
|
|
|
1108
1205
|
status: 'active',
|
|
1109
1206
|
contacts_enrolled: executed.enrolled,
|
|
1110
1207
|
send_at: payload.send_at ?? 'now',
|
|
1208
|
+
...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
|
|
1111
1209
|
message: payload.send_at
|
|
1112
1210
|
? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
|
|
1113
1211
|
: `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
|
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, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { db, projectClientForWriteback } from '../db.js';
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
@@ -2104,7 +2104,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2104
2104
|
idempotentHint: true,
|
|
2105
2105
|
openWorldHint: false,
|
|
2106
2106
|
},
|
|
2107
|
-
description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores.',
|
|
2107
|
+
description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores. Each group also suggests which contact to keep (suggested_target_id, source_ids) for merge_subscribers, and flags probably_different_people when the first names differ (e.g. two people at one company): never merge those without asking the user.',
|
|
2108
2108
|
inputSchema: {
|
|
2109
2109
|
confidence_threshold: z.number().min(0.5).max(1).optional().describe('Minimum similarity (0.5-1.0, default 0.8)'),
|
|
2110
2110
|
limit: z.number().int().min(1).max(200).optional().describe('Max duplicate groups (default 50)'),
|
|
@@ -2130,15 +2130,35 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2130
2130
|
return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
|
|
2131
2131
|
siteIds = ids;
|
|
2132
2132
|
}
|
|
2133
|
+
// If the last sync time can't be read, hide nothing extra: a longer list beats a missing duplicate.
|
|
2134
|
+
let byodCutoff = null;
|
|
2135
|
+
try {
|
|
2136
|
+
const { data: lastSync } = await db()
|
|
2137
|
+
.from('cron_runs')
|
|
2138
|
+
.select('started_at')
|
|
2139
|
+
.eq('job_name', 'byod-sync')
|
|
2140
|
+
.eq('status', 'success')
|
|
2141
|
+
.order('started_at', { ascending: false })
|
|
2142
|
+
.limit(1)
|
|
2143
|
+
.maybeSingle();
|
|
2144
|
+
byodCutoff = lastSync?.started_at ?? null;
|
|
2145
|
+
}
|
|
2146
|
+
catch {
|
|
2147
|
+
byodCutoff = null;
|
|
2148
|
+
}
|
|
2133
2149
|
const pageQuery = (from) => {
|
|
2134
2150
|
let q = db()
|
|
2135
2151
|
.from('subscribers')
|
|
2136
2152
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
|
|
2137
|
-
.eq('project_id', projectId)
|
|
2138
|
-
.eq('global_unsubscribed', false);
|
|
2153
|
+
.eq('project_id', projectId);
|
|
2139
2154
|
// #3604: hide seed/fixture rows like every other default contact view.
|
|
2140
2155
|
if (!include_test)
|
|
2141
2156
|
q = q.not('is_test', 'is', true);
|
|
2157
|
+
// #3750: scan the same contacts as the dashboard's Find & Merge Duplicates screen. Unsubscribed
|
|
2158
|
+
// contacts are still duplicates (a merge keeps their consent), and rows the last BYOD sync
|
|
2159
|
+
// no longer saw are hidden there.
|
|
2160
|
+
if (byodCutoff)
|
|
2161
|
+
q = q.or(`source.neq.database_import,updated_at.gte.${byodCutoff}`);
|
|
2142
2162
|
if (siteIds)
|
|
2143
2163
|
q = q.in('id', siteIds);
|
|
2144
2164
|
return q.order('created_at', { ascending: true }).order('id', { ascending: true })
|
|
@@ -2168,82 +2188,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2168
2188
|
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : matching <= scanned };
|
|
2169
2189
|
if (!contacts?.length)
|
|
2170
2190
|
return j({ duplicate_groups: [], total_groups: 0, coverage });
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
}
|
|
2183
|
-
return dp[m];
|
|
2184
|
-
};
|
|
2185
|
-
const similarity = (a, b) => {
|
|
2186
|
-
if (a === b)
|
|
2187
|
-
return 1;
|
|
2188
|
-
const maxLen = Math.max(a.length, b.length);
|
|
2189
|
-
if (maxLen === 0)
|
|
2190
|
-
return 1;
|
|
2191
|
-
return 1 - lev(a, b) / maxLen;
|
|
2192
|
-
};
|
|
2193
|
-
const byDomain = {};
|
|
2194
|
-
for (const c of contacts) {
|
|
2195
|
-
const [, domain] = c.email.toLowerCase().split('@');
|
|
2196
|
-
if (!byDomain[domain])
|
|
2197
|
-
byDomain[domain] = [];
|
|
2198
|
-
byDomain[domain].push(c);
|
|
2199
|
-
}
|
|
2200
|
-
const groups = [];
|
|
2201
|
-
const merged = new Set();
|
|
2202
|
-
for (const domain of Object.keys(byDomain)) {
|
|
2203
|
-
const dc = byDomain[domain];
|
|
2204
|
-
for (let i = 0; i < dc.length; i++) {
|
|
2205
|
-
if (merged.has(dc[i].id))
|
|
2206
|
-
continue;
|
|
2207
|
-
const localA = dc[i].email.split('@')[0].toLowerCase();
|
|
2208
|
-
const group = [dc[i]];
|
|
2209
|
-
for (let jj = i + 1; jj < dc.length; jj++) {
|
|
2210
|
-
if (merged.has(dc[jj].id))
|
|
2211
|
-
continue;
|
|
2212
|
-
const localB = dc[jj].email.split('@')[0].toLowerCase();
|
|
2213
|
-
const sim = similarity(localA, localB);
|
|
2214
|
-
if (sim >= confidence_threshold && sim < 1) {
|
|
2215
|
-
group.push(dc[jj]);
|
|
2216
|
-
merged.add(dc[jj].id);
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
if (group.length > 1) {
|
|
2220
|
-
merged.add(dc[i].id);
|
|
2221
|
-
const avgSim = similarity(group[0].email.split('@')[0].toLowerCase(), group[1].email.split('@')[0].toLowerCase());
|
|
2222
|
-
groups.push({ group, confidence: Math.round(avgSim * 100) / 100, reason: 'similar_email' });
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
}
|
|
2226
|
-
const byName = {};
|
|
2227
|
-
for (const c of contacts) {
|
|
2228
|
-
if (merged.has(c.id))
|
|
2229
|
-
continue;
|
|
2230
|
-
const fn = (c.first_name ?? '').toLowerCase().trim();
|
|
2231
|
-
const ln = (c.last_name ?? '').toLowerCase().trim();
|
|
2232
|
-
if (!fn && !ln)
|
|
2233
|
-
continue;
|
|
2234
|
-
const key = `${fn}|${ln}`;
|
|
2235
|
-
if (!byName[key])
|
|
2236
|
-
byName[key] = [];
|
|
2237
|
-
byName[key].push(c);
|
|
2238
|
-
}
|
|
2239
|
-
for (const nameGroup of Object.values(byName)) {
|
|
2240
|
-
if (nameGroup.length > 1) {
|
|
2241
|
-
groups.push({ group: nameGroup, confidence: 0.7, reason: 'same_name' });
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
groups.sort((a, b) => b.confidence - a.confidence);
|
|
2191
|
+
// The matching rule is shared with the dashboard screen (@sendinel/domain, #3743/#3750).
|
|
2192
|
+
const groups = findContactDuplicateGroups(contacts, { threshold: confidence_threshold })
|
|
2193
|
+
.map((g) => ({
|
|
2194
|
+
group: g.contacts,
|
|
2195
|
+
confidence: g.confidence,
|
|
2196
|
+
reason: g.reason,
|
|
2197
|
+
suggested_target_id: g.suggested_target_id,
|
|
2198
|
+
source_ids: g.source_ids,
|
|
2199
|
+
probably_different_people: g.probably_different_people,
|
|
2200
|
+
different_first_names: g.different_first_names,
|
|
2201
|
+
}));
|
|
2245
2202
|
const result = groups.slice(0, maxGroups);
|
|
2246
|
-
|
|
2203
|
+
// total_groups counts every group found; groups_truncated says when limit cut the list.
|
|
2204
|
+
return j({ duplicate_groups: result, total_groups: groups.length, groups_truncated: groups.length > result.length, coverage });
|
|
2247
2205
|
});
|
|
2248
2206
|
server.registerTool('merge_subscribers', {
|
|
2249
2207
|
title: 'Merge Subscribers',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.44",
|
|
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",
|