@sendinel/mcp-server 1.0.45 → 1.0.47
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/advisor.js +21 -21
- package/build/tools/analytics.js +9 -5
- package/build/tools/campaigns.js +15 -12
- package/build/tools/contacts.js +82 -50
- package/build/tools/data-proposals.js +4 -3
- package/build/tools/org.js +25 -25
- package/package.json +1 -1
package/build/tools/advisor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
|
|
|
5
5
|
import { buildVoicePrompt } from '../lib/voice.js';
|
|
6
6
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
7
|
import { dkimRemediationText } from '../lib/email-provider.js';
|
|
8
|
+
import { readAllRows } from '@sendinel/domain';
|
|
8
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
10
11
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
@@ -77,8 +78,8 @@ export function registerAdvisorTools(server) {
|
|
|
77
78
|
const recommendations = [];
|
|
78
79
|
let skipped = 0;
|
|
79
80
|
for (const campaign of campaigns ?? []) {
|
|
80
|
-
const
|
|
81
|
-
|
|
81
|
+
const rows = await readAllRows(db().from('email_log').select('status')
|
|
82
|
+
.eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since));
|
|
82
83
|
const sends = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
|
83
84
|
if (sends < 100) {
|
|
84
85
|
skipped++;
|
|
@@ -129,22 +130,20 @@ export function registerAdvisorTools(server) {
|
|
|
129
130
|
.from('email_log')
|
|
130
131
|
.select('status, created_at')
|
|
131
132
|
.eq('project_id', projectId)
|
|
132
|
-
.gte('created_at', periodStart.toISOString())
|
|
133
|
-
.limit(10000);
|
|
133
|
+
.gte('created_at', periodStart.toISOString());
|
|
134
134
|
if (site_id)
|
|
135
135
|
currentQuery = currentQuery.eq('site_id', site_id);
|
|
136
|
-
const
|
|
136
|
+
const currentRows = await readAllRows(currentQuery);
|
|
137
137
|
// 2. Prior period
|
|
138
138
|
let priorQuery = db()
|
|
139
139
|
.from('email_log')
|
|
140
140
|
.select('status, created_at')
|
|
141
141
|
.eq('project_id', projectId)
|
|
142
142
|
.gte('created_at', priorStart.toISOString())
|
|
143
|
-
.lt('created_at', periodStart.toISOString())
|
|
144
|
-
.limit(10000);
|
|
143
|
+
.lt('created_at', periodStart.toISOString());
|
|
145
144
|
if (site_id)
|
|
146
145
|
priorQuery = priorQuery.eq('site_id', site_id);
|
|
147
|
-
const
|
|
146
|
+
const priorRows = await readAllRows(priorQuery);
|
|
148
147
|
const current = computeMetrics(currentRows ?? []);
|
|
149
148
|
const prior = computeMetrics(priorRows ?? []);
|
|
150
149
|
// 3. Domain health (latest per site)
|
|
@@ -328,11 +327,10 @@ export function registerAdvisorTools(server) {
|
|
|
328
327
|
.from('email_log')
|
|
329
328
|
.select('subject, status')
|
|
330
329
|
.eq('project_id', projectId)
|
|
331
|
-
.in('status', ['delivered', 'opened', 'clicked'])
|
|
332
|
-
.limit(5000);
|
|
330
|
+
.in('status', ['delivered', 'opened', 'clicked']);
|
|
333
331
|
if (site_id)
|
|
334
332
|
logQuery = logQuery.eq('site_id', site_id);
|
|
335
|
-
const
|
|
333
|
+
const logRows = await readAllRows(logQuery);
|
|
336
334
|
// Aggregate by subject
|
|
337
335
|
const subjectStats = new Map();
|
|
338
336
|
for (const row of logRows ?? []) {
|
|
@@ -444,14 +442,13 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
444
442
|
const days = daysParam ?? 90;
|
|
445
443
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
446
444
|
// 1. Query email_log with campaign step subjects
|
|
447
|
-
const
|
|
445
|
+
const logRows = await readAllRows(db()
|
|
448
446
|
.from('email_log')
|
|
449
447
|
.select('campaign_id, subject, status')
|
|
450
448
|
.eq('project_id', projectId)
|
|
451
449
|
.eq('site_id', site_id)
|
|
452
450
|
.gte('created_at', since)
|
|
453
|
-
.not('campaign_id', 'is', null)
|
|
454
|
-
.limit(10000);
|
|
451
|
+
.not('campaign_id', 'is', null));
|
|
455
452
|
if (!logRows || logRows.length < 20) {
|
|
456
453
|
return j({
|
|
457
454
|
error: 'insufficient_data',
|
|
@@ -565,16 +562,18 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
565
562
|
// 1. Get contact IDs for segment filter
|
|
566
563
|
let contactFilter = null;
|
|
567
564
|
if (segment_id) {
|
|
568
|
-
const
|
|
565
|
+
const segContacts = await readAllRows(db()
|
|
569
566
|
.from('subscribers')
|
|
570
567
|
.select('id')
|
|
571
|
-
.eq('project_id', projectId)
|
|
572
|
-
.limit(5000);
|
|
568
|
+
.eq('project_id', projectId));
|
|
573
569
|
// For now, we pass all contacts and filter at query time
|
|
574
570
|
// A proper segment query would use segment-query.ts, but MCP server
|
|
575
571
|
// doesn't have that module. We filter by site instead.
|
|
576
|
-
contactFilter =
|
|
572
|
+
contactFilter = segContacts.map((c) => c.id);
|
|
577
573
|
}
|
|
574
|
+
const coverage = contactFilter
|
|
575
|
+
? { contacts_matching: contactFilter.length, contacts_used: Math.min(contactFilter.length, 500), complete: contactFilter.length <= 500 }
|
|
576
|
+
: { contacts_matching: null, contacts_used: null, complete: true };
|
|
578
577
|
// 2. Query email_log
|
|
579
578
|
let query = db()
|
|
580
579
|
.from('email_log')
|
|
@@ -582,16 +581,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
582
581
|
.eq('project_id', projectId)
|
|
583
582
|
.eq('site_id', site_id)
|
|
584
583
|
.gte('created_at', since)
|
|
585
|
-
.in('status', ['delivered', 'opened', 'clicked'])
|
|
586
|
-
.limit(10000);
|
|
584
|
+
.in('status', ['delivered', 'opened', 'clicked']);
|
|
587
585
|
if (contactFilter) {
|
|
588
586
|
query = query.in('contact_id', contactFilter.slice(0, 500));
|
|
589
587
|
}
|
|
590
|
-
const
|
|
588
|
+
const rows = await readAllRows(query);
|
|
591
589
|
if (!rows || rows.length < 30) {
|
|
592
590
|
return j({
|
|
593
591
|
error: 'insufficient_data',
|
|
594
592
|
message: `Only ${rows?.length ?? 0} qualifying emails found in the last ${days} days. Need at least 30 for meaningful analysis.`,
|
|
593
|
+
coverage,
|
|
595
594
|
});
|
|
596
595
|
}
|
|
597
596
|
// 3. Build buckets
|
|
@@ -610,6 +609,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
610
609
|
metric: metric ?? 'open_rate',
|
|
611
610
|
period_days: days,
|
|
612
611
|
total_emails_analyzed: rows.length,
|
|
612
|
+
coverage,
|
|
613
613
|
best_hours: best3Hours,
|
|
614
614
|
worst_hours: worst3Hours,
|
|
615
615
|
best_days: best3Days,
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchAllRows } from '@sendinel/domain';
|
|
1
|
+
import { fetchAllRows, readAllRows } from '@sendinel/domain';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { db, adminDb, requestContext } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
@@ -323,10 +323,14 @@ export function registerAnalyticsTools(server) {
|
|
|
323
323
|
inputSchema: { period_days: z.number().int().min(1).max(365).optional().default(30) },
|
|
324
324
|
}, async ({ period_days = 30 }) => {
|
|
325
325
|
const since = new Date(Date.now() - period_days * 86400000).toISOString();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
326
|
+
let rows;
|
|
327
|
+
try {
|
|
328
|
+
rows = await readAllRows(db().from('email_log').select('site_id, status, sent_at, created_at')
|
|
329
|
+
.eq('project_id', getProjectId()).gte('created_at', since));
|
|
330
|
+
}
|
|
331
|
+
catch (readError) {
|
|
332
|
+
return j({ error: String(readError) });
|
|
333
|
+
}
|
|
330
334
|
const totals = computeAnalyticsMetrics(rows);
|
|
331
335
|
const bySite = new Map();
|
|
332
336
|
for (const row of rows) {
|
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 { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, 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, readAllRows, } 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
|
|
@@ -336,21 +336,24 @@ async function attachBroadcastToExisting(input, match) {
|
|
|
336
336
|
.select('contact_id')
|
|
337
337
|
.eq('campaign_id', match.id)
|
|
338
338
|
.in('contact_id', ids)
|
|
339
|
-
.
|
|
339
|
+
// One enrollment per campaign/contact, and ids has at most 200 entries.
|
|
340
|
+
.limit(200);
|
|
340
341
|
if (enrolledErr)
|
|
341
342
|
return { ok: false, error: enrolledErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
342
343
|
for (const row of enrolledRows ?? [])
|
|
343
344
|
skip.add(row.contact_id);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
345
|
+
let logRows;
|
|
346
|
+
try {
|
|
347
|
+
logRows = await readAllRows(db().from('email_log')
|
|
348
|
+
.select('contact_id, status')
|
|
349
|
+
.eq('project_id', input.projectId)
|
|
350
|
+
.eq('campaign_id', match.id)
|
|
351
|
+
.in('contact_id', ids));
|
|
352
|
+
}
|
|
353
|
+
catch (readError) {
|
|
354
|
+
return { ok: false, error: String(readError), code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
355
|
+
}
|
|
356
|
+
for (const row of logRows)
|
|
354
357
|
if (sentStatuses.has(row.status))
|
|
355
358
|
skip.add(row.contact_id);
|
|
356
359
|
}
|
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, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { db, projectClientForWriteback } from '../db.js';
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
@@ -625,12 +625,33 @@ export function registerContactTools(server) {
|
|
|
625
625
|
}, async () => {
|
|
626
626
|
const projectId = getProjectId();
|
|
627
627
|
const cutoff = daysAgo(90);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
628
|
+
// Overwrite of existing tags is fixed (#3834). Writes are per-row, so paging this
|
|
629
|
+
// read past 1000 is now safe as a follow-up.
|
|
630
|
+
const { data: contacts, error: readError } = await db().from('subscribers').select('id, email, first_name, last_name, custom_properties, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
|
|
631
|
+
if (readError)
|
|
632
|
+
return j({ error: readError.message });
|
|
633
|
+
let tagged = 0;
|
|
634
|
+
let skipped = 0;
|
|
635
|
+
let failed = 0;
|
|
636
|
+
let firstError;
|
|
637
|
+
for (const contact of contacts ?? []) {
|
|
638
|
+
const current = contact.tags ?? [];
|
|
639
|
+
if (current.includes('cold')) {
|
|
640
|
+
skipped++;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const merged = [...current, 'cold'];
|
|
644
|
+
const { error } = await db().from('subscribers').update({ tags: merged }).eq('id', contact.id).eq('project_id', projectId);
|
|
645
|
+
if (error) {
|
|
646
|
+
failed++;
|
|
647
|
+
if (!firstError)
|
|
648
|
+
firstError = error.message;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
tagged++;
|
|
652
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
632
653
|
}
|
|
633
|
-
return j({ tagged
|
|
654
|
+
return j({ tagged, suppressed: 0, skipped, ...(failed ? { failed, error: firstError } : {}) });
|
|
634
655
|
});
|
|
635
656
|
server.registerTool('list_hygiene_audit', {
|
|
636
657
|
title: 'List Hygiene Audit',
|
|
@@ -647,12 +668,15 @@ export function registerContactTools(server) {
|
|
|
647
668
|
},
|
|
648
669
|
}, async ({ siteId, lookbackDays = 180 }) => {
|
|
649
670
|
const projectId = getProjectId();
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
671
|
+
let subs;
|
|
672
|
+
try {
|
|
673
|
+
subs = await readAllRows(db().from('site_subscriptions')
|
|
674
|
+
.select('contact_id').eq('site_id', siteId).eq('subscribed', true));
|
|
675
|
+
}
|
|
676
|
+
catch (readError) {
|
|
677
|
+
return txt(`Error: ${String(readError)}`);
|
|
678
|
+
}
|
|
679
|
+
const ids = subs.map((s) => s.contact_id);
|
|
656
680
|
if (!ids.length)
|
|
657
681
|
return j({
|
|
658
682
|
summary: { total: 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 },
|
|
@@ -661,15 +685,17 @@ export function registerContactTools(server) {
|
|
|
661
685
|
recommendation: 'No subscribed contacts found for this site.',
|
|
662
686
|
lookback_days: lookbackDays,
|
|
663
687
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
688
|
+
let contacts = [];
|
|
689
|
+
try {
|
|
690
|
+
for (const batch of chunkIds(ids)) {
|
|
691
|
+
contacts.push(...await readAllRows(db().from('subscribers')
|
|
692
|
+
.select('id, email, last_engaged_at, total_opens, total_clicks, total_emails_sent, custom_properties')
|
|
693
|
+
.eq('project_id', projectId).eq('global_unsubscribed', false).in('id', batch)));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (readError) {
|
|
697
|
+
return txt(`Error: ${String(readError)}`);
|
|
698
|
+
}
|
|
673
699
|
const summary = { total: contacts?.length ?? 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 };
|
|
674
700
|
const sunset = [];
|
|
675
701
|
const payingLowEngagement = [];
|
|
@@ -2117,15 +2143,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2117
2143
|
const CONTACT_SCAN_LIMIT = 2000;
|
|
2118
2144
|
// #3604: PostgREST returns at most 1,000 rows per request, so a single
|
|
2119
2145
|
// .limit(2000) query only ever read 1,000. Read in pages up to the limit.
|
|
2120
|
-
const SCAN_PAGE = 1000;
|
|
2121
2146
|
let siteIds = null;
|
|
2122
2147
|
if (site_id) {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
.
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2148
|
+
let subs;
|
|
2149
|
+
try {
|
|
2150
|
+
subs = await readAllRows(db().from('site_subscriptions')
|
|
2151
|
+
.select('contact_id').eq('site_id', site_id).eq('subscribed', true));
|
|
2152
|
+
}
|
|
2153
|
+
catch (readError) {
|
|
2154
|
+
return txt(`Error: ${String(readError)}`);
|
|
2155
|
+
}
|
|
2156
|
+
const ids = subs.map((s) => s.contact_id);
|
|
2129
2157
|
if (!ids.length)
|
|
2130
2158
|
return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
|
|
2131
2159
|
siteIds = ids;
|
|
@@ -2146,7 +2174,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2146
2174
|
catch {
|
|
2147
2175
|
byodCutoff = null;
|
|
2148
2176
|
}
|
|
2149
|
-
const pageQuery = (from) => {
|
|
2177
|
+
const pageQuery = (from, to, ids) => {
|
|
2150
2178
|
let q = db()
|
|
2151
2179
|
.from('subscribers')
|
|
2152
2180
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
|
|
@@ -2159,33 +2187,37 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2159
2187
|
// no longer saw are hidden there.
|
|
2160
2188
|
if (byodCutoff)
|
|
2161
2189
|
q = q.or(`source.neq.database_import,updated_at.gte.${byodCutoff}`);
|
|
2162
|
-
if (
|
|
2163
|
-
q = q.in('id',
|
|
2190
|
+
if (ids)
|
|
2191
|
+
q = q.in('id', ids);
|
|
2164
2192
|
return q.order('created_at', { ascending: true }).order('id', { ascending: true })
|
|
2165
|
-
.range(from,
|
|
2193
|
+
.range(from, to);
|
|
2166
2194
|
};
|
|
2167
|
-
const
|
|
2168
|
-
let
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2195
|
+
const scanChunk = (ids) => readAllPages((from, to) => pageQuery(from, to, ids), { cap: CONTACT_SCAN_LIMIT });
|
|
2196
|
+
let scan;
|
|
2197
|
+
if (siteIds && siteIds.length > 200) {
|
|
2198
|
+
const all = [];
|
|
2199
|
+
let total = 0;
|
|
2200
|
+
for (const ids of chunkIds(siteIds)) {
|
|
2201
|
+
const part = await scanChunk(ids);
|
|
2202
|
+
if (!part.ok)
|
|
2203
|
+
return txt(`Error: ${part.error.message}`);
|
|
2204
|
+
all.push(...part.rows);
|
|
2205
|
+
total = total === null || part.total === null ? null : total + part.total;
|
|
2175
2206
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
contacts.push(...rows);
|
|
2180
|
-
if (rows.length < SCAN_PAGE)
|
|
2181
|
-
break;
|
|
2207
|
+
all.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')) || String(a.id).localeCompare(String(b.id)));
|
|
2208
|
+
const rows = all.slice(0, CONTACT_SCAN_LIMIT);
|
|
2209
|
+
scan = { ok: true, rows, cap: CONTACT_SCAN_LIMIT, total, complete: total !== null && total <= rows.length };
|
|
2182
2210
|
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2211
|
+
else {
|
|
2212
|
+
scan = await scanChunk(siteIds);
|
|
2213
|
+
}
|
|
2214
|
+
if (!scan.ok)
|
|
2215
|
+
return txt(`Error: ${scan.error.message}`);
|
|
2216
|
+
const contacts = scan.rows;
|
|
2185
2217
|
// #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
|
|
2186
2218
|
const scanned = contacts?.length ?? 0;
|
|
2187
|
-
const matching =
|
|
2188
|
-
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null :
|
|
2219
|
+
const matching = scan.total;
|
|
2220
|
+
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : scan.complete };
|
|
2189
2221
|
if (!contacts?.length)
|
|
2190
2222
|
return j({ duplicate_groups: [], total_groups: 0, coverage });
|
|
2191
2223
|
// The matching rule is shared with the dashboard screen (@sendinel/domain, #3743/#3750).
|
|
@@ -2,16 +2,17 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
5
|
+
import { readAllRows } from '@sendinel/domain';
|
|
5
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
8
|
async function engagementSnapshot(projectId) {
|
|
8
9
|
const since = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
9
|
-
const [
|
|
10
|
-
db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since)
|
|
10
|
+
const [logs, { count: audienceSize }, { data: segments }] = await Promise.all([
|
|
11
|
+
readAllRows(db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since)),
|
|
11
12
|
db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
|
|
12
13
|
db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
|
|
13
14
|
]);
|
|
14
|
-
const rows = logs
|
|
15
|
+
const rows = logs;
|
|
15
16
|
const sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
|
16
17
|
const opened = rows.filter((row) => row.status === 'opened' || row.status === 'clicked').length;
|
|
17
18
|
const clicked = rows.filter((row) => row.status === 'clicked').length;
|
package/build/tools/org.js
CHANGED
|
@@ -5,6 +5,7 @@ import { adminDb, db } from '../db.js';
|
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
|
+
import { readAllRows } from '@sendinel/domain';
|
|
8
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
9
10
|
const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
|
|
10
11
|
async function callProviderSwitchApi(path, init) {
|
|
@@ -636,41 +637,40 @@ export function registerOrgTools(server) {
|
|
|
636
637
|
const fetchWindow = async (fromDate, toDate) => {
|
|
637
638
|
let query = db()
|
|
638
639
|
.from('audit_log')
|
|
639
|
-
.select('action, actor_id, actor_type, created_at'
|
|
640
|
+
.select('action, actor_id, actor_type, created_at')
|
|
640
641
|
.eq('project_id', projectId)
|
|
641
642
|
.gte('created_at', `${fromDate}T00:00:00.000Z`)
|
|
642
643
|
.lte('created_at', `${toDate}T23:59:59.999Z`)
|
|
643
|
-
.order('created_at', { ascending: false })
|
|
644
|
-
.range(0, 9999);
|
|
644
|
+
.order('created_at', { ascending: false });
|
|
645
645
|
if (action)
|
|
646
646
|
query = query.eq('action', action);
|
|
647
647
|
if (resource_type)
|
|
648
648
|
query = query.eq('resource_type', resource_type);
|
|
649
649
|
if (actor_id)
|
|
650
650
|
query = query.eq('actor_id', actor_id);
|
|
651
|
-
return query;
|
|
651
|
+
return readAllRows(query);
|
|
652
652
|
};
|
|
653
653
|
const spanDays = dateSpanDays(dateFrom, dateTo);
|
|
654
654
|
const previousDateTo = addDaysUtc(dateFrom, -1);
|
|
655
655
|
const previousDateFrom = addDaysUtc(dateFrom, -spanDays);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
656
|
+
let rows, previousRows;
|
|
657
|
+
try {
|
|
658
|
+
[rows, previousRows] = await Promise.all([
|
|
659
|
+
fetchWindow(dateFrom, dateTo),
|
|
660
|
+
fetchWindow(previousDateFrom, previousDateTo),
|
|
661
|
+
]);
|
|
662
|
+
}
|
|
663
|
+
catch (readError) {
|
|
664
|
+
return j({ error: String(readError) });
|
|
665
|
+
}
|
|
666
666
|
const uniqueActors = new Set(rows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
667
667
|
const previousUniqueActors = new Set(previousRows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
668
668
|
const dangerousEvents = rows.filter((entry) => isDangerousAuditAction(entry.action)).length;
|
|
669
669
|
return j({
|
|
670
|
-
events:
|
|
670
|
+
events: rows.length,
|
|
671
671
|
uniqueActors,
|
|
672
672
|
dangerousEvents,
|
|
673
|
-
previousEvents:
|
|
673
|
+
previousEvents: previousRows.length,
|
|
674
674
|
previousUniqueActors,
|
|
675
675
|
dateFrom,
|
|
676
676
|
dateTo,
|
|
@@ -690,15 +690,15 @@ export function registerOrgTools(server) {
|
|
|
690
690
|
inputSchema: {},
|
|
691
691
|
}, async () => {
|
|
692
692
|
const projectId = getProjectId();
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
.select('action')
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const actions = Array.from(new Set(
|
|
693
|
+
let data;
|
|
694
|
+
try {
|
|
695
|
+
data = await readAllRows(db().from('audit_log').select('action')
|
|
696
|
+
.eq('project_id', projectId).order('action', { ascending: true }));
|
|
697
|
+
}
|
|
698
|
+
catch (readError) {
|
|
699
|
+
return j({ error: String(readError) });
|
|
700
|
+
}
|
|
701
|
+
const actions = Array.from(new Set(data.map((row) => row.action).filter(Boolean)));
|
|
702
702
|
return j({ actions });
|
|
703
703
|
});
|
|
704
704
|
server.registerTool('get_model_registry', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.47",
|
|
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",
|