@sendinel/mcp-server 1.0.72 → 1.0.74
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/campaign-engagement.d.ts +16 -13
- package/build/lib/campaign-engagement.js +4 -55
- package/build/tools/advisor.js +39 -84
- package/build/tools/analytics.d.ts +2 -1
- package/build/tools/analytics.js +43 -89
- package/build/tools/data-proposals.js +7 -7
- package/build/tools/org.js +9 -6
- package/package.json +1 -1
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
import { countUniqueEngagementByCampaign as readPeople } from "@sendinel/domain";
|
|
2
|
+
export type CampaignPeopleCounts = Awaited<ReturnType<typeof readPeople>>;
|
|
3
|
+
export declare function countUniqueEngagementByCampaign(sb: {
|
|
4
|
+
from: (table: string) => any;
|
|
5
|
+
}, projectId: string, ids: string[]): Promise<{
|
|
2
6
|
ok: true;
|
|
3
7
|
opened: Map<string, number>;
|
|
4
8
|
clicked: Map<string, number>;
|
|
9
|
+
sent: Map<string, number>;
|
|
10
|
+
delivered: Map<string, number>;
|
|
11
|
+
error?: undefined;
|
|
5
12
|
} | {
|
|
6
13
|
ok: false;
|
|
7
|
-
error:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
export declare function countUniqueEngagementByCampaign(sb: {
|
|
17
|
-
from: (table: string) => any;
|
|
18
|
-
}, projectId: string, campaignIds: string[]): Promise<CampaignPeopleCounts>;
|
|
14
|
+
error: {
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
opened?: undefined;
|
|
18
|
+
clicked?: undefined;
|
|
19
|
+
sent?: undefined;
|
|
20
|
+
delivered?: undefined;
|
|
21
|
+
}>;
|
|
@@ -1,56 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Distinct recipients who opened / clicked, per campaign. `campaigns.total_opened` / `total_clicked`
|
|
6
|
-
* are event counters and can exceed total_sent (#3788). Mirrors the dashboard's
|
|
7
|
-
* `countUniqueEngagementByCampaign` (packages/dashboard/lib/campaign-step-engagement.ts): the MCP
|
|
8
|
-
* package cannot import dashboard code, so keep the two in step. A click counts as an open.
|
|
9
|
-
* One paged email_log query for the whole id set; a failed read is `{ ok: false }`, never zeros.
|
|
10
|
-
*/
|
|
11
|
-
export async function countUniqueEngagementByCampaign(sb, projectId, campaignIds) {
|
|
12
|
-
const opened = new Map();
|
|
13
|
-
const clicked = new Map();
|
|
14
|
-
if (campaignIds.length === 0)
|
|
15
|
-
return { ok: true, opened, clicked };
|
|
16
|
-
const read = await readAllPages((from, to) => sb
|
|
17
|
-
.from('email_log')
|
|
18
|
-
.select('id, campaign_id, contact_id, to_email, opened_at, clicked_at')
|
|
19
|
-
.eq('project_id', projectId)
|
|
20
|
-
.in('campaign_id', campaignIds)
|
|
21
|
-
.or('opened_at.not.is.null,clicked_at.not.is.null')
|
|
22
|
-
.order('id', { ascending: true })
|
|
23
|
-
.range(from, to), { cap: Number.MAX_SAFE_INTEGER, pageSize: PAGE_SIZE });
|
|
24
|
-
if (!read.ok)
|
|
25
|
-
return { ok: false, error: read.error };
|
|
26
|
-
const openedRecipients = new Map();
|
|
27
|
-
const clickedRecipients = new Map();
|
|
28
|
-
const add = (bucket, campaignId, recipient) => {
|
|
29
|
-
let set = bucket.get(campaignId);
|
|
30
|
-
if (!set) {
|
|
31
|
-
set = new Set();
|
|
32
|
-
bucket.set(campaignId, set);
|
|
33
|
-
}
|
|
34
|
-
set.add(recipient);
|
|
35
|
-
};
|
|
36
|
-
for (const row of read.rows) {
|
|
37
|
-
if (!row.campaign_id)
|
|
38
|
-
continue;
|
|
39
|
-
const recipient = row.contact_id
|
|
40
|
-
? row.contact_id
|
|
41
|
-
: row.to_email
|
|
42
|
-
? row.to_email.trim().toLowerCase()
|
|
43
|
-
: null;
|
|
44
|
-
if (!recipient)
|
|
45
|
-
continue;
|
|
46
|
-
if (row.opened_at || row.clicked_at)
|
|
47
|
-
add(openedRecipients, row.campaign_id, recipient);
|
|
48
|
-
if (row.clicked_at)
|
|
49
|
-
add(clickedRecipients, row.campaign_id, recipient);
|
|
50
|
-
}
|
|
51
|
-
for (const [id, set] of openedRecipients)
|
|
52
|
-
opened.set(id, set.size);
|
|
53
|
-
for (const [id, set] of clickedRecipients)
|
|
54
|
-
clicked.set(id, set.size);
|
|
55
|
-
return { ok: true, opened, clicked };
|
|
1
|
+
import { countUniqueEngagementByCampaign as readPeople } from "@sendinel/domain";
|
|
2
|
+
export function countUniqueEngagementByCampaign(sb, projectId, ids) {
|
|
3
|
+
// MCP's db() is already an email-scoped query client.
|
|
4
|
+
return readPeople({ schema: () => sb }, projectId, ids);
|
|
56
5
|
}
|
package/build/tools/advisor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isBouncedRow } from "@sendinel/domain";
|
|
1
|
+
import { countEmailRows, groupMetricRows, percentRate, isDeliveredRow, isOpenedRow, countPeople, isComplainedRow, isBouncedRow, assertUsableStopReason, readAllRows } from "@sendinel/domain";
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import Anthropic from '@anthropic-ai/sdk';
|
|
4
4
|
import { db } from '../db.js';
|
|
@@ -6,54 +6,28 @@ import { getProjectId } from '../project.js';
|
|
|
6
6
|
import { buildVoicePrompt } from '../lib/voice.js';
|
|
7
7
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
8
8
|
import { dkimRemediationText } from '../lib/email-provider.js';
|
|
9
|
-
import { assertUsableStopReason, readAllRows } from '@sendinel/domain';
|
|
10
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
11
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
12
11
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
13
12
|
export function computeMetrics(rows) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
sent,
|
|
22
|
-
delivered,
|
|
23
|
-
opened,
|
|
24
|
-
clicked,
|
|
25
|
-
bounced,
|
|
26
|
-
complained,
|
|
27
|
-
open_rate: sent > 0 ? +(opened / sent * 100).toFixed(2) : 0,
|
|
28
|
-
click_rate: sent > 0 ? +(clicked / sent * 100).toFixed(2) : 0,
|
|
29
|
-
bounce_rate: sent > 0 ? +(bounced / sent * 100).toFixed(2) : 0,
|
|
30
|
-
complaint_rate: sent > 0 ? +(complained / sent * 100).toFixed(2) : 0,
|
|
13
|
+
const counts = countEmailRows(rows);
|
|
14
|
+
return { ...counts,
|
|
15
|
+
open_rate: percentRate(counts.opened, counts.delivered, 2) ?? 0,
|
|
16
|
+
click_rate: percentRate(counts.clicked, counts.delivered, 2) ?? 0,
|
|
17
|
+
bounce_rate: percentRate(counts.bounced, counts.sent, 2) ?? 0,
|
|
18
|
+
complaint_rate: percentRate(counts.complained, counts.sent, 2) ?? 0,
|
|
31
19
|
};
|
|
32
20
|
}
|
|
33
21
|
export function buildTimeBuckets(rows, groupBy, metric) {
|
|
34
|
-
const buckets = new
|
|
35
|
-
for (const row of rows) {
|
|
36
|
-
const date = new Date(row.created_at);
|
|
37
|
-
const key = groupBy === 'hour' ? date.getUTCHours() : date.getUTCDay();
|
|
38
|
-
const entry = buckets.get(key) ?? { sends: 0, engagements: 0 };
|
|
39
|
-
entry.sends++;
|
|
40
|
-
const engaged = metric === 'click_rate'
|
|
41
|
-
? row.status === 'clicked'
|
|
42
|
-
: ['opened', 'clicked'].includes(row.status);
|
|
43
|
-
if (engaged)
|
|
44
|
-
entry.engagements++;
|
|
45
|
-
buckets.set(key, entry);
|
|
46
|
-
}
|
|
22
|
+
const buckets = groupMetricRows(rows, row => String(groupBy === "hour" ? new Date(row.created_at).getUTCHours() : new Date(row.created_at).getUTCDay()));
|
|
47
23
|
const results = [];
|
|
48
|
-
for (const [key,
|
|
49
|
-
|
|
24
|
+
for (const [key, rows] of buckets) {
|
|
25
|
+
const counts = countEmailRows(rows);
|
|
26
|
+
if (counts.sent < 10)
|
|
50
27
|
continue;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
sends:
|
|
54
|
-
engagements: val.engagements,
|
|
55
|
-
rate: +(val.engagements / val.sends * 100).toFixed(2),
|
|
56
|
-
});
|
|
28
|
+
const engagements = metric === "click_rate" ? counts.clicked : counts.opened;
|
|
29
|
+
results.push({ label: groupBy === "hour" ? `${key.padStart(2, "0")}:00 UTC` : DAY_NAMES[Number(key)],
|
|
30
|
+
sends: counts.sent, engagements, rate: percentRate(engagements, counts.delivered, 2) ?? 0 });
|
|
57
31
|
}
|
|
58
32
|
results.sort((a, b) => b.rate - a.rate);
|
|
59
33
|
return results;
|
|
@@ -79,9 +53,9 @@ export function registerAdvisorTools(server) {
|
|
|
79
53
|
const recommendations = [];
|
|
80
54
|
let skipped = 0;
|
|
81
55
|
for (const campaign of campaigns ?? []) {
|
|
82
|
-
const rows = await readAllRows(db().from('email_log').select("status, bounced_at")
|
|
56
|
+
const rows = await readAllRows(db().from('email_log').select("status, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
83
57
|
.eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since));
|
|
84
|
-
const sends = rows.
|
|
58
|
+
const sends = countEmailRows(rows).sent;
|
|
85
59
|
if (sends < 100) {
|
|
86
60
|
skipped++;
|
|
87
61
|
continue;
|
|
@@ -129,7 +103,7 @@ export function registerAdvisorTools(server) {
|
|
|
129
103
|
// 1. Current period email_log
|
|
130
104
|
let currentQuery = db()
|
|
131
105
|
.from('email_log')
|
|
132
|
-
.select("status, created_at, bounced_at")
|
|
106
|
+
.select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
133
107
|
.eq('project_id', projectId)
|
|
134
108
|
.gte('created_at', periodStart.toISOString());
|
|
135
109
|
if (site_id)
|
|
@@ -138,7 +112,7 @@ export function registerAdvisorTools(server) {
|
|
|
138
112
|
// 2. Prior period
|
|
139
113
|
let priorQuery = db()
|
|
140
114
|
.from('email_log')
|
|
141
|
-
.select("status, created_at, bounced_at")
|
|
115
|
+
.select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
142
116
|
.eq('project_id', projectId)
|
|
143
117
|
.gte('created_at', priorStart.toISOString())
|
|
144
118
|
.lt('created_at', periodStart.toISOString());
|
|
@@ -214,7 +188,7 @@ export function registerAdvisorTools(server) {
|
|
|
214
188
|
}
|
|
215
189
|
// Low click-to-open
|
|
216
190
|
if (current.opened >= 50) {
|
|
217
|
-
const cto =
|
|
191
|
+
const cto = percentRate(current.clicked, current.opened, 2) ?? 0;
|
|
218
192
|
if (cto < 5) {
|
|
219
193
|
recommendations.push({
|
|
220
194
|
type: 'low_click_to_open',
|
|
@@ -326,28 +300,19 @@ export function registerAdvisorTools(server) {
|
|
|
326
300
|
// 2. Top-performing historical subjects
|
|
327
301
|
let logQuery = db()
|
|
328
302
|
.from('email_log')
|
|
329
|
-
.select("subject, status, bounced_at")
|
|
330
|
-
.eq('project_id', projectId)
|
|
331
|
-
.in('status', ['delivered', 'opened', 'clicked']);
|
|
303
|
+
.select("subject, status, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
304
|
+
.eq('project_id', projectId);
|
|
332
305
|
if (site_id)
|
|
333
306
|
logQuery = logQuery.eq('site_id', site_id);
|
|
334
307
|
const logRows = await readAllRows(logQuery);
|
|
335
308
|
// Aggregate by subject
|
|
336
|
-
const subjectStats = new Map()
|
|
337
|
-
|
|
338
|
-
if (!row.subject)
|
|
339
|
-
continue;
|
|
340
|
-
const entry = subjectStats.get(row.subject) ?? { sent: 0, opened: 0 };
|
|
341
|
-
entry.sent++;
|
|
342
|
-
if (['opened', 'clicked'].includes(row.status))
|
|
343
|
-
entry.opened++;
|
|
344
|
-
subjectStats.set(row.subject, entry);
|
|
345
|
-
}
|
|
309
|
+
const subjectStats = new Map([...groupMetricRows(logRows ?? [], row => row.subject ?? "")]
|
|
310
|
+
.filter(([subject]) => subject).map(([subject, rows]) => [subject, countEmailRows(rows)]));
|
|
346
311
|
const topPerformers = Array.from(subjectStats.entries())
|
|
347
312
|
.filter(([, stats]) => stats.sent >= 50)
|
|
348
313
|
.map(([subject, stats]) => ({
|
|
349
314
|
subject,
|
|
350
|
-
open_rate:
|
|
315
|
+
open_rate: percentRate(stats.opened, stats.delivered, 1) ?? 0,
|
|
351
316
|
sample_size: stats.sent,
|
|
352
317
|
}))
|
|
353
318
|
.sort((a, b) => b.open_rate - a.open_rate)
|
|
@@ -448,7 +413,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
448
413
|
// 1. Query email_log with campaign step subjects
|
|
449
414
|
const logRows = await readAllRows(db()
|
|
450
415
|
.from('email_log')
|
|
451
|
-
.select("campaign_id, subject, status, bounced_at")
|
|
416
|
+
.select("campaign_id, subject, status, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
452
417
|
.eq('project_id', projectId)
|
|
453
418
|
.eq('site_id', site_id)
|
|
454
419
|
.gte('created_at', since)
|
|
@@ -460,26 +425,17 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
460
425
|
});
|
|
461
426
|
}
|
|
462
427
|
// 2. Group by campaign + subject, compute engagement
|
|
463
|
-
const campaignStats = new Map()
|
|
464
|
-
|
|
465
|
-
const key = `${row.campaign_id}::${row.subject ?? '(no subject)'}`;
|
|
466
|
-
const entry = campaignStats.get(key) ?? { subject: row.subject ?? '(no subject)', sent: 0, opened: 0, clicked: 0 };
|
|
467
|
-
entry.sent++;
|
|
468
|
-
if (['opened', 'clicked'].includes(row.status))
|
|
469
|
-
entry.opened++;
|
|
470
|
-
if (row.status === 'clicked')
|
|
471
|
-
entry.clicked++;
|
|
472
|
-
campaignStats.set(key, entry);
|
|
473
|
-
}
|
|
428
|
+
const campaignStats = new Map([...groupMetricRows(logRows, row => `${row.campaign_id}::${row.subject ?? '(no subject)'}`)]
|
|
429
|
+
.map(([key, rows]) => [key, { subject: rows[0].subject ?? '(no subject)', ...countEmailRows(rows) }]));
|
|
474
430
|
// 3. Compute rates and sort by composite score
|
|
475
431
|
const ranked = Array.from(campaignStats.values())
|
|
476
432
|
.filter(s => s.sent >= 5)
|
|
477
433
|
.map(s => ({
|
|
478
434
|
subject: s.subject,
|
|
479
435
|
sent: s.sent,
|
|
480
|
-
open_rate:
|
|
481
|
-
click_rate:
|
|
482
|
-
score: +(s.opened
|
|
436
|
+
open_rate: (percentRate(s.opened, s.delivered, 1) ?? 0),
|
|
437
|
+
click_rate: (percentRate(s.clicked, s.delivered, 1) ?? 0),
|
|
438
|
+
score: +((percentRate(s.opened, s.delivered, 6) ?? 0) / 100 * 0.6 + (percentRate(s.clicked, s.delivered, 6) ?? 0) / 100 * 0.4).toFixed(4),
|
|
483
439
|
}))
|
|
484
440
|
.sort((a, b) => b.score - a.score);
|
|
485
441
|
const topPerformers = ranked.slice(0, 10);
|
|
@@ -584,11 +540,10 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
584
540
|
// 2. Query email_log
|
|
585
541
|
let query = db()
|
|
586
542
|
.from('email_log')
|
|
587
|
-
.select("status, created_at, bounced_at")
|
|
543
|
+
.select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
588
544
|
.eq('project_id', projectId)
|
|
589
545
|
.eq('site_id', site_id)
|
|
590
|
-
.gte('created_at', since)
|
|
591
|
-
.in('status', ['delivered', 'opened', 'clicked']);
|
|
546
|
+
.gte('created_at', since);
|
|
592
547
|
if (contactFilter) {
|
|
593
548
|
query = query.in('contact_id', contactFilter.slice(0, 500));
|
|
594
549
|
}
|
|
@@ -668,19 +623,19 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
668
623
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
669
624
|
const { data: recentLogs } = await db()
|
|
670
625
|
.from('email_log')
|
|
671
|
-
.select("status, opened_at, clicked_at, bounced_at")
|
|
626
|
+
.select("status, opened_at, clicked_at, bounced_at, contact_id, to_email")
|
|
672
627
|
.eq('project_id', projectId)
|
|
673
628
|
.eq('site_id', site_id)
|
|
674
629
|
.gte('sent_at', sevenDaysAgo);
|
|
675
630
|
const logs = recentLogs ?? [];
|
|
676
631
|
const totalSent = logs.length;
|
|
677
632
|
const bounced = logs.filter(isBouncedRow).length;
|
|
678
|
-
const complained = logs.filter(
|
|
679
|
-
const delivered = logs.filter(
|
|
680
|
-
const opened = logs
|
|
681
|
-
const bounceRate =
|
|
682
|
-
const complaintRate =
|
|
683
|
-
const openRate =
|
|
633
|
+
const complained = logs.filter(isComplainedRow).length;
|
|
634
|
+
const delivered = logs.filter(isDeliveredRow).length;
|
|
635
|
+
const opened = countPeople(logs, isOpenedRow);
|
|
636
|
+
const bounceRate = percentRate(bounced, totalSent, 2) ?? 0;
|
|
637
|
+
const complaintRate = percentRate(complained, totalSent, 2) ?? 0;
|
|
638
|
+
const openRate = percentRate(opened, delivered, 2) ?? 0;
|
|
684
639
|
// 4. Fetch new suppressions this week
|
|
685
640
|
const { count: newSuppressions } = await db()
|
|
686
641
|
.from('suppressions')
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
|
|
2
|
+
import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
|
|
2
3
|
import { withApproval } from '../lib/action-approvals.js';
|
|
3
|
-
import { fetchAllRows, readAllRows } from '@sendinel/domain';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { db, adminDb, requestContext } from '../db.js';
|
|
6
6
|
import { getProjectId } from '../project.js';
|
|
@@ -75,28 +75,10 @@ async function publishViaByoPublisher(publisher, credentials, params) {
|
|
|
75
75
|
throw new Error('Buffer publisher is not yet implemented. Connect UploadPost instead.');
|
|
76
76
|
}
|
|
77
77
|
export function computeAnalyticsMetrics(rows, eventCounts) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const bounced = rows.filter(isBouncedRow).length;
|
|
83
|
-
const complained = rows.filter(r => r.status === 'complained').length;
|
|
84
|
-
const unsubscribed = rows.filter(r => r.status === 'unsubscribed').length;
|
|
85
|
-
return {
|
|
86
|
-
sent,
|
|
87
|
-
delivered,
|
|
88
|
-
opened,
|
|
89
|
-
clicked,
|
|
90
|
-
bounced,
|
|
91
|
-
complained,
|
|
92
|
-
unsubscribed,
|
|
93
|
-
delivery_rate: sent > 0 ? delivered / sent : 0,
|
|
94
|
-
open_rate: delivered > 0 ? opened / delivered : 0,
|
|
95
|
-
click_rate: delivered > 0 ? clicked / delivered : 0,
|
|
96
|
-
bounce_rate: sent > 0 ? bounced / sent : 0,
|
|
97
|
-
complaint_rate: sent > 0 ? complained / sent : 0,
|
|
98
|
-
unsubscribe_rate: sent > 0 ? unsubscribed / sent : 0,
|
|
99
|
-
};
|
|
78
|
+
const metrics = summarizeEmailRows(rows);
|
|
79
|
+
return { ...metrics, delivery_rate: metrics.delivery_rate ?? 0, open_rate: metrics.open_rate ?? 0,
|
|
80
|
+
click_rate: metrics.click_rate ?? 0, bounce_rate: metrics.bounce_rate ?? 0,
|
|
81
|
+
complaint_rate: metrics.complaint_rate ?? 0, unsubscribe_rate: metrics.unsubscribe_rate ?? 0 };
|
|
100
82
|
}
|
|
101
83
|
export function computeSenderHealthScore(metrics, trackingEnabled) {
|
|
102
84
|
let score = 100;
|
|
@@ -207,15 +189,9 @@ function distinctEventCount(events, eventType, messageIds) {
|
|
|
207
189
|
.filter(event => event.event_type === eventType && event.provider_message_id && messageIds.has(event.provider_message_id))
|
|
208
190
|
.map(event => event.provider_message_id)).size;
|
|
209
191
|
}
|
|
210
|
-
function isTrueConversionEvent(event) {
|
|
211
|
-
const normalized = (event ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
212
|
-
if (!normalized)
|
|
213
|
-
return false;
|
|
214
|
-
return !['open', 'opened', 'email_open', 'click', 'clicked', 'email_click', 'page_view', 'view', 'impression'].includes(normalized);
|
|
215
|
-
}
|
|
216
192
|
function buildTrueConversionAnalytics(params) {
|
|
217
193
|
const sent = params.logs.length;
|
|
218
|
-
const delivered = params.logs.filter(
|
|
194
|
+
const delivered = params.logs.filter(isDeliveredRow).length;
|
|
219
195
|
const trueRows = params.attributionRows.filter(row => isTrueConversionEvent(row.conversion_event));
|
|
220
196
|
const campaignDenominators = new Map();
|
|
221
197
|
for (const row of params.logs) {
|
|
@@ -241,7 +217,7 @@ function buildTrueConversionAnalytics(params) {
|
|
|
241
217
|
variantStats.revenue += revenue;
|
|
242
218
|
byVariant.set(variantKey, variantStats);
|
|
243
219
|
}
|
|
244
|
-
const percentOf = (count, denominator) => denominator > 0 ? `${(
|
|
220
|
+
const percentOf = (count, denominator) => denominator > 0 ? `${percentRate(count, denominator, 1)?.toFixed(1)}%` : 'N/A';
|
|
245
221
|
const stage = (name, count, prior) => ({
|
|
246
222
|
stage: name,
|
|
247
223
|
count,
|
|
@@ -250,7 +226,7 @@ function buildTrueConversionAnalytics(params) {
|
|
|
250
226
|
drop_off_from_previous: prior == null ? 0 : Math.max(0, prior - count),
|
|
251
227
|
});
|
|
252
228
|
return {
|
|
253
|
-
definition:
|
|
229
|
+
definition: TRUE_CONVERSION_DEFINITION,
|
|
254
230
|
true_conversions: trueRows.length,
|
|
255
231
|
true_conversion_rate: percentOf(trueRows.length, sent),
|
|
256
232
|
revenue: Number(totalRevenue.toFixed(2)),
|
|
@@ -327,7 +303,7 @@ export function registerAnalyticsTools(server) {
|
|
|
327
303
|
const since = new Date(Date.now() - period_days * 86400000).toISOString();
|
|
328
304
|
let rows;
|
|
329
305
|
try {
|
|
330
|
-
rows = await readAllRows(db().from('email_log').select("site_id, status, sent_at, created_at, bounced_at")
|
|
306
|
+
rows = await readAllRows(db().from('email_log').select("site_id, status, sent_at, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
331
307
|
.eq('project_id', getProjectId()).gte('created_at', since));
|
|
332
308
|
}
|
|
333
309
|
catch (readError) {
|
|
@@ -548,7 +524,7 @@ export function registerAnalyticsTools(server) {
|
|
|
548
524
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
549
525
|
const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
550
526
|
.from('email_log')
|
|
551
|
-
.select("id, provider_message_id, status, sent_at, bounced_at")
|
|
527
|
+
.select("id, provider_message_id, status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
552
528
|
.eq('project_id', projectId)
|
|
553
529
|
.eq('site_id', site_id)
|
|
554
530
|
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
@@ -701,7 +677,7 @@ export function registerAnalyticsTools(server) {
|
|
|
701
677
|
const since7d = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
|
702
678
|
let q = db()
|
|
703
679
|
.from('email_log')
|
|
704
|
-
.select("status, opened_at, clicked_at, sent_at, bounced_at")
|
|
680
|
+
.select("status, opened_at, clicked_at, sent_at, bounced_at, contact_id, to_email")
|
|
705
681
|
.eq('project_id', projectId)
|
|
706
682
|
.gte('sent_at', since30d);
|
|
707
683
|
if (site_id)
|
|
@@ -874,16 +850,16 @@ export function registerAnalyticsTools(server) {
|
|
|
874
850
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
875
851
|
const { data: recentLogs } = await db()
|
|
876
852
|
.from('email_log')
|
|
877
|
-
.select("status, bounced_at")
|
|
853
|
+
.select("status, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
878
854
|
.eq('project_id', projectId)
|
|
879
855
|
.eq('site_id', site_id)
|
|
880
856
|
.gte('sent_at', sevenDaysAgo);
|
|
881
857
|
const logs = recentLogs ?? [];
|
|
882
858
|
const totalSent = logs.length;
|
|
883
859
|
const bounced = logs.filter((l) => isBouncedRow(l)).length;
|
|
884
|
-
const complained = logs.filter(
|
|
885
|
-
const bounceRate =
|
|
886
|
-
const complaintRate =
|
|
860
|
+
const complained = logs.filter(isComplainedRow).length;
|
|
861
|
+
const bounceRate = percentRate(bounced, totalSent, 8) ?? 0;
|
|
862
|
+
const complaintRate = percentRate(complained, totalSent, 8) ?? 0;
|
|
887
863
|
const risks = [];
|
|
888
864
|
if (verificationSource === 'dns_lookup') {
|
|
889
865
|
if (!dnsResults.spf?.found)
|
|
@@ -938,7 +914,7 @@ export function registerAnalyticsTools(server) {
|
|
|
938
914
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
939
915
|
let q = db()
|
|
940
916
|
.from('email_log')
|
|
941
|
-
.select(
|
|
917
|
+
.select("id, site_id, campaign_id, contact_id, status, category, sent_at, opened_at, clicked_at, bounced_at, complained_at, to_email")
|
|
942
918
|
.eq('project_id', projectId)
|
|
943
919
|
.gte('sent_at', since);
|
|
944
920
|
if (site_id)
|
|
@@ -951,35 +927,25 @@ export function registerAnalyticsTools(server) {
|
|
|
951
927
|
const rows = logs ?? [];
|
|
952
928
|
if (!rows.length)
|
|
953
929
|
return j({ message: `No email activity in the last ${days} days.`, total: 0 });
|
|
954
|
-
|
|
955
|
-
.from('revenue_attribution')
|
|
956
|
-
.select('campaign_id, conversion_event, utm_content, revenue_amount')
|
|
957
|
-
.eq('project_id', projectId)
|
|
958
|
-
.gte('converted_at', since);
|
|
959
|
-
if (campaign_id)
|
|
960
|
-
attributionQuery = attributionQuery.eq('campaign_id', campaign_id);
|
|
961
|
-
const { data: attributionRows, error: attributionError } = await attributionQuery;
|
|
930
|
+
const { data: attributionRows, error: attributionError } = await readRevenue({ sb: db(), projectId, siteId: site_id, campaignId: campaign_id, start: since });
|
|
962
931
|
if (attributionError)
|
|
963
932
|
return txt(`Error: ${attributionError.message}`);
|
|
964
|
-
const
|
|
965
|
-
const scopedAttributionRows = site_id
|
|
966
|
-
? (attributionRows ?? []).filter((row) => row.campaign_id && campaignIdsInScope.has(row.campaign_id))
|
|
967
|
-
: (attributionRows ?? []);
|
|
933
|
+
const scopedAttributionRows = attributionRows ?? [];
|
|
968
934
|
const computeMetrics = (set) => {
|
|
969
935
|
const total = set.length;
|
|
970
|
-
const delivered = set.filter(
|
|
971
|
-
const opened = set
|
|
972
|
-
const clicked = set
|
|
936
|
+
const delivered = set.filter(isDeliveredRow).length;
|
|
937
|
+
const opened = countPeople(set, isOpenedRow);
|
|
938
|
+
const clicked = countPeople(set, isClickedRow);
|
|
973
939
|
const bounced = set.filter(isBouncedRow).length;
|
|
974
|
-
const complained = set.filter(
|
|
940
|
+
const complained = set.filter(isComplainedRow).length;
|
|
975
941
|
return {
|
|
976
942
|
total_sent: total, delivered, opened, clicked, bounced, complained,
|
|
977
|
-
delivery_rate: total > 0 ? `${(
|
|
978
|
-
open_rate:
|
|
979
|
-
click_rate:
|
|
980
|
-
bounce_rate: total > 0 ? `${(
|
|
981
|
-
complaint_rate: total > 0 ? `${(
|
|
982
|
-
click_to_open: opened > 0 ? `${(
|
|
943
|
+
delivery_rate: total > 0 ? `${percentRate(delivered, total, 1)?.toFixed(1)}%` : 'N/A',
|
|
944
|
+
open_rate: delivered > 0 ? `${percentRate(opened, delivered, 1)?.toFixed(1)}%` : 'N/A',
|
|
945
|
+
click_rate: delivered > 0 ? `${percentRate(clicked, delivered, 1)?.toFixed(1)}%` : 'N/A',
|
|
946
|
+
bounce_rate: total > 0 ? `${percentRate(bounced, total, 1)?.toFixed(1)}%` : 'N/A',
|
|
947
|
+
complaint_rate: total > 0 ? `${percentRate(complained, total, 3)?.toFixed(3)}%` : 'N/A',
|
|
948
|
+
click_to_open: opened > 0 ? `${percentRate(clicked, opened, 1)?.toFixed(1)}%` : 'N/A',
|
|
983
949
|
};
|
|
984
950
|
};
|
|
985
951
|
const overall = computeMetrics(rows);
|
|
@@ -989,23 +955,11 @@ export function registerAnalyticsTools(server) {
|
|
|
989
955
|
opened: parseInt(String(overall.opened), 10) || 0,
|
|
990
956
|
clicked: parseInt(String(overall.clicked), 10) || 0,
|
|
991
957
|
});
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
if (!hourBuckets[hour])
|
|
998
|
-
hourBuckets[hour] = { sent: 0, opened: 0 };
|
|
999
|
-
hourBuckets[hour].sent++;
|
|
1000
|
-
if (r.opened_at)
|
|
1001
|
-
hourBuckets[hour].opened++;
|
|
1002
|
-
});
|
|
1003
|
-
const bestHours = Object.entries(hourBuckets)
|
|
1004
|
-
.map(([hour, b]) => ({ hour: Number(hour), sent: b.sent, open_rate: b.sent > 0 ? (b.opened / b.sent * 100) : 0 }))
|
|
1005
|
-
.filter(h => h.sent >= 5)
|
|
1006
|
-
.sort((a, b) => b.open_rate - a.open_rate)
|
|
1007
|
-
.slice(0, 3)
|
|
1008
|
-
.map(h => ({ hour_utc: h.hour, sent: h.sent, open_rate: `${h.open_rate.toFixed(1)}%` }));
|
|
958
|
+
const bestHours = [...groupMetricRows(rows, (row) => row.sent_at ? String(new Date(row.sent_at).getUTCHours()) : "")]
|
|
959
|
+
.filter(([hour]) => hour !== "")
|
|
960
|
+
.map(([hour, rows]) => { const counts = countEmailRows(rows); return { hour: Number(hour), sent: counts.sent, open_rate: percentRate(counts.opened, counts.delivered, 6) ?? 0 }; })
|
|
961
|
+
.filter(row => row.sent >= 5).sort((a, b) => b.open_rate - a.open_rate).slice(0, 3)
|
|
962
|
+
.map(row => ({ hour_utc: row.hour, sent: row.sent, open_rate: `${row.open_rate.toFixed(1)}%` }));
|
|
1009
963
|
let grouped = null;
|
|
1010
964
|
if (group_by === 'campaign' || group_by === 'site') {
|
|
1011
965
|
const byKey = {};
|
|
@@ -1144,16 +1098,16 @@ export function registerAnalyticsTools(server) {
|
|
|
1144
1098
|
const projDb = client ?? adminDb();
|
|
1145
1099
|
const { data: logs } = await projDb
|
|
1146
1100
|
.from('email_log')
|
|
1147
|
-
.select("status, opened_at, clicked_at, bounced_at")
|
|
1101
|
+
.select("status, opened_at, clicked_at, bounced_at, contact_id, to_email")
|
|
1148
1102
|
.eq('project_id', proj.id)
|
|
1149
1103
|
.gte('sent_at', since);
|
|
1150
1104
|
const rows = logs ?? [];
|
|
1151
1105
|
const sent = rows.length;
|
|
1152
|
-
const delivered = rows.filter(
|
|
1153
|
-
const opened = rows
|
|
1154
|
-
const clicked = rows
|
|
1106
|
+
const delivered = rows.filter(isDeliveredRow).length;
|
|
1107
|
+
const opened = countPeople(rows, isOpenedRow);
|
|
1108
|
+
const clicked = countPeople(rows, isClickedRow);
|
|
1155
1109
|
const bounced = rows.filter(isBouncedRow).length;
|
|
1156
|
-
const complained = rows.filter(
|
|
1110
|
+
const complained = rows.filter(isComplainedRow).length;
|
|
1157
1111
|
results.push({
|
|
1158
1112
|
project_id: proj.id,
|
|
1159
1113
|
name: proj.name,
|
|
@@ -1163,8 +1117,8 @@ export function registerAnalyticsTools(server) {
|
|
|
1163
1117
|
clicked,
|
|
1164
1118
|
bounced,
|
|
1165
1119
|
complained,
|
|
1166
|
-
open_rate:
|
|
1167
|
-
click_rate:
|
|
1120
|
+
open_rate: percentRate(opened, delivered, 1) ?? 0,
|
|
1121
|
+
click_rate: percentRate(clicked, delivered, 1) ?? 0,
|
|
1168
1122
|
});
|
|
1169
1123
|
}
|
|
1170
1124
|
catch {
|
|
@@ -1190,8 +1144,8 @@ export function registerAnalyticsTools(server) {
|
|
|
1190
1144
|
projects: results,
|
|
1191
1145
|
totals: {
|
|
1192
1146
|
...totals,
|
|
1193
|
-
open_rate:
|
|
1194
|
-
click_rate:
|
|
1147
|
+
open_rate: percentRate(totals.opened, totals.delivered, 1) ?? 0,
|
|
1148
|
+
click_rate: percentRate(totals.clicked, totals.delivered, 1) ?? 0,
|
|
1195
1149
|
},
|
|
1196
1150
|
});
|
|
1197
1151
|
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
+
import { countEmailRows, fractionRate, isOpenedRow, countPeople, isClickedRow, readAllRows } from "@sendinel/domain";
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
4
5
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
5
|
-
import { readAllRows } from '@sendinel/domain';
|
|
6
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
8
8
|
async function engagementSnapshot(projectId) {
|
|
9
9
|
const since = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
10
10
|
const [logs, { count: audienceSize }, { data: segments }] = await Promise.all([
|
|
11
|
-
readAllRows(db().from('email_log').select(
|
|
11
|
+
readAllRows(db().from('email_log').select("status, created_at, opened_at, clicked_at, contact_id, to_email, bounced_at").eq('project_id', projectId).gte('created_at', since)),
|
|
12
12
|
db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
|
|
13
13
|
db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
|
|
14
14
|
]);
|
|
15
15
|
const rows = logs;
|
|
16
|
-
const
|
|
17
|
-
const opened = rows
|
|
18
|
-
const clicked = rows
|
|
16
|
+
const { sent, delivered } = countEmailRows(rows);
|
|
17
|
+
const opened = countPeople(rows, isOpenedRow);
|
|
18
|
+
const clicked = countPeople(rows, isClickedRow);
|
|
19
19
|
return {
|
|
20
20
|
period_days: 7,
|
|
21
21
|
sent,
|
|
22
|
-
open_rate:
|
|
23
|
-
click_rate:
|
|
22
|
+
open_rate: fractionRate(opened, delivered, 4) ?? 0,
|
|
23
|
+
click_rate: fractionRate(clicked, delivered, 4) ?? 0,
|
|
24
24
|
audience_size: audienceSize ?? 0,
|
|
25
25
|
recent_segments: segments ?? [],
|
|
26
26
|
};
|
package/build/tools/org.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPlanUsage, readAllRows } from "@sendinel/domain";
|
|
1
2
|
import { randomBytes } from 'crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { logAudit } from '../audit.js';
|
|
@@ -5,7 +6,6 @@ import { adminDb, db } from '../db.js';
|
|
|
5
6
|
import { getProjectId } from '../project.js';
|
|
6
7
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
8
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
|
-
import { readAllRows } from '@sendinel/domain';
|
|
9
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
10
|
const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
|
|
11
11
|
async function callProviderSwitchApi(path, init) {
|
|
@@ -544,15 +544,18 @@ export function registerOrgTools(server) {
|
|
|
544
544
|
const { data: project } = await db().from('projects').select('plan').eq('id', projectId).maybeSingle();
|
|
545
545
|
const plan = project?.plan ?? 'free';
|
|
546
546
|
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free ?? {};
|
|
547
|
-
const [
|
|
548
|
-
db()
|
|
549
|
-
db()
|
|
547
|
+
const [contacts, sends, { count: sites }] = await Promise.all([
|
|
548
|
+
readPlanUsage(db(), projectId, "contacts"),
|
|
549
|
+
readPlanUsage(db(), projectId, "monthly_sends"),
|
|
550
550
|
db().from('sites').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
|
|
551
551
|
]);
|
|
552
|
+
if (contacts.error || sends.error || contacts.data?.error || sends.data?.error || !contacts.data || !sends.data) {
|
|
553
|
+
return j({ error: 'Unable to read plan usage' });
|
|
554
|
+
}
|
|
552
555
|
return j({
|
|
553
556
|
plan,
|
|
554
|
-
contacts: { used: contacts
|
|
555
|
-
monthly_sends: { used: sends
|
|
557
|
+
contacts: { used: contacts.data.current, limit: contacts.data.limit },
|
|
558
|
+
monthly_sends: { used: sends.data.current, limit: sends.data.limit },
|
|
556
559
|
sites: { used: sites ?? 0, limit: limits.sites ?? null },
|
|
557
560
|
ai_drafts: { used: 0, limit: limits.ai_drafts ?? null },
|
|
558
561
|
log_retention_days: limits.log_retention_days ?? null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.74",
|
|
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",
|