@sendinel/mcp-server 1.0.22 → 1.0.24
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/analytics.js +30 -8
- package/build/tools/contacts.js +27 -43
- package/package.json +1 -1
package/build/tools/analytics.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchAllRows } from '@sendinel/domain';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db, adminDb } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -431,42 +432,54 @@ export function registerAnalyticsTools(server) {
|
|
|
431
432
|
if (siteError)
|
|
432
433
|
return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
|
|
433
434
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
434
|
-
const { data, error } = await db()
|
|
435
|
+
const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
435
436
|
.from('email_log')
|
|
436
437
|
.select('id, provider_message_id, status, sent_at')
|
|
437
438
|
.eq('project_id', projectId)
|
|
438
439
|
.eq('site_id', site_id)
|
|
439
440
|
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
440
|
-
.lte('sent_at', `${end}T23:59:59Z`)
|
|
441
|
+
.lte('sent_at', `${end}T23:59:59Z`)
|
|
442
|
+
.order('id', { ascending: true })
|
|
443
|
+
.range(offset, offset + limit - 1));
|
|
441
444
|
if (error)
|
|
442
445
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
443
446
|
const rows = (data ?? []);
|
|
444
447
|
let eventCounts = { opened: 0, clicked: 0 };
|
|
448
|
+
let eventRows = [];
|
|
449
|
+
let eventPageCount = 0;
|
|
445
450
|
if (trackingEnabled) {
|
|
446
|
-
const { data: events, error: eventsError } = await db()
|
|
451
|
+
const { data: events, error: eventsError, pageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
447
452
|
.from('email_events')
|
|
448
453
|
.select('provider_message_id, event_type')
|
|
449
454
|
.eq('project_id', projectId)
|
|
450
455
|
.eq('site_id', site_id)
|
|
451
456
|
.in('event_type', ['open', 'click'])
|
|
452
457
|
.gte('occurred_at', `${start_date}T00:00:00Z`)
|
|
453
|
-
.lte('occurred_at', `${end}T23:59:59Z`)
|
|
458
|
+
.lte('occurred_at', `${end}T23:59:59Z`)
|
|
459
|
+
.order('id', { ascending: true })
|
|
460
|
+
.range(offset, offset + limit - 1));
|
|
454
461
|
if (eventsError)
|
|
455
462
|
return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
|
|
463
|
+
eventRows = events ?? [];
|
|
464
|
+
eventPageCount = pageCount;
|
|
456
465
|
const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
|
|
457
466
|
eventCounts = {
|
|
458
|
-
opened: distinctEventCount(
|
|
459
|
-
clicked: distinctEventCount(
|
|
467
|
+
opened: distinctEventCount(eventRows, 'open', messageIds),
|
|
468
|
+
clicked: distinctEventCount(eventRows, 'click', messageIds),
|
|
460
469
|
};
|
|
461
470
|
}
|
|
462
471
|
const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
|
|
463
472
|
const periodDays = daysBetween(start_date, end);
|
|
464
473
|
const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
|
|
465
474
|
const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
|
|
466
|
-
const { data: subscribers } = await db()
|
|
475
|
+
const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
467
476
|
.from('subscribers')
|
|
468
477
|
.select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
|
|
469
|
-
.eq('project_id', projectId)
|
|
478
|
+
.eq('project_id', projectId)
|
|
479
|
+
.order('id', { ascending: true })
|
|
480
|
+
.range(offset, offset + limit - 1));
|
|
481
|
+
if (subscribersError)
|
|
482
|
+
return { content: [{ type: 'text', text: `Error: ${subscribersError.message}` }] };
|
|
470
483
|
const subscriberRows = (subscribers ?? []);
|
|
471
484
|
const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
|
|
472
485
|
const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
|
|
@@ -488,6 +501,15 @@ export function registerAnalyticsTools(server) {
|
|
|
488
501
|
list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
|
|
489
502
|
inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
|
|
490
503
|
sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
|
|
504
|
+
coverage: {
|
|
505
|
+
log_rows_scanned: rows.length,
|
|
506
|
+
log_page_count: logPageCount,
|
|
507
|
+
event_rows_scanned: trackingEnabled ? eventRows.length : null,
|
|
508
|
+
event_page_count: trackingEnabled ? eventPageCount : null,
|
|
509
|
+
subscriber_rows_scanned: subscriberRows.length,
|
|
510
|
+
subscriber_page_count: subscriberPageCount,
|
|
511
|
+
complete: true,
|
|
512
|
+
},
|
|
491
513
|
};
|
|
492
514
|
if (trackingEnabled) {
|
|
493
515
|
response.open_rate = metrics.sent > 0 ? percent(metrics.open_rate) : 'N/A';
|
package/build/tools/contacts.js
CHANGED
|
@@ -2283,54 +2283,38 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2283
2283
|
},
|
|
2284
2284
|
}, async ({ site_id }) => {
|
|
2285
2285
|
const projectId = getProjectId();
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
.
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
return
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
let
|
|
2286
|
+
async function loadSchemaKeys(siteScope) {
|
|
2287
|
+
const { data, error } = await db().rpc('get_contact_schema_keys', {
|
|
2288
|
+
p_project_id: projectId,
|
|
2289
|
+
...(siteScope ? { p_site_id: siteScope } : {}),
|
|
2290
|
+
});
|
|
2291
|
+
if (error)
|
|
2292
|
+
return { error: `Error: ${error.message}` };
|
|
2293
|
+
const result = (Array.isArray(data) ? data[0] : data);
|
|
2294
|
+
if (typeof result?.has_membership !== 'boolean' || !Array.isArray(result.keys)) {
|
|
2295
|
+
return { error: 'Error: get_contact_schema_keys returned an unreadable result' };
|
|
2296
|
+
}
|
|
2297
|
+
return result;
|
|
2298
|
+
}
|
|
2299
|
+
// Site membership is hybrid (#3489): subscribers.source_site OR a site_subscriptions
|
|
2300
|
+
// row, counted once. A real site whose members have no custom_properties yet is still
|
|
2301
|
+
// site-scoped; project scope is only for a site with no members at all.
|
|
2302
|
+
let loaded = await loadSchemaKeys(site_id);
|
|
2303
|
+
if ('error' in loaded)
|
|
2304
|
+
return txt(loaded.error);
|
|
2303
2305
|
let scope = 'site';
|
|
2304
|
-
if (
|
|
2305
|
-
|
|
2306
|
-
if (
|
|
2307
|
-
return txt(
|
|
2308
|
-
rows = projectSample.data ?? [];
|
|
2306
|
+
if (!loaded.has_membership) {
|
|
2307
|
+
loaded = await loadSchemaKeys(null);
|
|
2308
|
+
if ('error' in loaded)
|
|
2309
|
+
return txt(loaded.error);
|
|
2309
2310
|
scope = 'project';
|
|
2310
2311
|
}
|
|
2311
|
-
const
|
|
2312
|
-
for (const row of rows ?? []) {
|
|
2313
|
-
const props = row.custom_properties;
|
|
2314
|
-
if (!props)
|
|
2315
|
-
continue;
|
|
2316
|
-
for (const [k, v] of Object.entries(props)) {
|
|
2317
|
-
if (!keyMap.has(k))
|
|
2318
|
-
keyMap.set(k, { values: new Set(), count: 0 });
|
|
2319
|
-
const entry = keyMap.get(k);
|
|
2320
|
-
entry.count++;
|
|
2321
|
-
if (v !== null && v !== undefined && entry.values.size < 3) {
|
|
2322
|
-
entry.values.add(String(v));
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
const schema = Array.from(keyMap.entries())
|
|
2327
|
-
.sort((a, b) => b[1].count - a[1].count)
|
|
2328
|
-
.map(([key, { values, count }]) => ({
|
|
2312
|
+
const schema = loaded.keys.map(({ key, contacts_with_this_key, example_values }) => ({
|
|
2329
2313
|
key,
|
|
2330
2314
|
merge_tag: `{{custom.${key}}}`,
|
|
2331
2315
|
merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
|
|
2332
|
-
example_values
|
|
2333
|
-
contacts_with_this_key
|
|
2316
|
+
example_values,
|
|
2317
|
+
contacts_with_this_key,
|
|
2334
2318
|
}));
|
|
2335
2319
|
return j({
|
|
2336
2320
|
site_id,
|
|
@@ -2338,7 +2322,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2338
2322
|
total_keys: schema.length,
|
|
2339
2323
|
schema,
|
|
2340
2324
|
note: scope === 'project'
|
|
2341
|
-
? `No contacts are
|
|
2325
|
+
? `No contacts are linked to site ${site_id} via source_site or site_subscriptions — read custom_properties across the whole project instead. Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).`
|
|
2342
2326
|
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2343
2327
|
});
|
|
2344
2328
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.24",
|
|
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",
|