@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.
@@ -1,18 +1,21 @@
1
- export type CampaignPeopleCounts = {
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: unknown;
8
- };
9
- /**
10
- * Distinct recipients who opened / clicked, per campaign. `campaigns.total_opened` / `total_clicked`
11
- * are event counters and can exceed total_sent (#3788). Mirrors the dashboard's
12
- * `countUniqueEngagementByCampaign` (packages/dashboard/lib/campaign-step-engagement.ts): the MCP
13
- * package cannot import dashboard code, so keep the two in step. A click counts as an open.
14
- * One paged email_log query for the whole id set; a failed read is `{ ok: false }`, never zeros.
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 { readAllPages } from '@sendinel/domain';
2
- /** PostgREST caps a response at 1,000 rows, so page explicitly. */
3
- const PAGE_SIZE = 1000;
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
  }
@@ -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 sent = rows.length;
15
- const delivered = rows.filter((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
16
- const opened = rows.filter((r) => ['opened', 'clicked'].includes(r.status)).length;
17
- const clicked = rows.filter((r) => r.status === 'clicked').length;
18
- const bounced = rows.filter(isBouncedRow).length;
19
- const complained = rows.filter((r) => r.status === 'complained').length;
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 Map();
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, val] of buckets.entries()) {
49
- if (val.sends < 10)
24
+ for (const [key, rows] of buckets) {
25
+ const counts = countEmailRows(rows);
26
+ if (counts.sent < 10)
50
27
  continue;
51
- results.push({
52
- label: groupBy === 'hour' ? `${key.toString().padStart(2, '0')}:00 UTC` : DAY_NAMES[key],
53
- sends: val.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.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
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 = +(current.clicked / current.opened * 100).toFixed(2);
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
- for (const row of logRows ?? []) {
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: +(stats.opened / stats.sent * 100).toFixed(1),
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
- for (const row of logRows) {
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: +(s.opened / s.sent * 100).toFixed(1),
481
- click_rate: +(s.clicked / s.sent * 100).toFixed(1),
482
- score: +(s.opened / s.sent * 0.6 + s.clicked / s.sent * 0.4).toFixed(4),
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((l) => l.status === 'complained').length;
679
- const delivered = logs.filter((l) => ['delivered', 'opened', 'clicked'].includes(l.status)).length;
680
- const opened = logs.filter((l) => l.opened_at).length;
681
- const bounceRate = totalSent > 0 ? +(bounced / totalSent * 100).toFixed(2) : 0;
682
- const complaintRate = totalSent > 0 ? +(complained / totalSent * 100).toFixed(2) : 0;
683
- const openRate = totalSent > 0 ? +(opened / totalSent * 100).toFixed(2) : 0;
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')
@@ -1,5 +1,6 @@
1
+ import { type MetricRow } from "@sendinel/domain";
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- type EmailLogRow = {
3
+ type EmailLogRow = MetricRow & {
3
4
  bounced_at: string | null;
4
5
  id?: string;
5
6
  provider_message_id?: string | null;
@@ -1,6 +1,6 @@
1
- import { isBouncedRow } from "@sendinel/domain";
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 sent = rows.length;
79
- const delivered = rows.filter(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
80
- const opened = eventCounts?.opened ?? rows.filter(r => r.status === 'opened' || r.status === 'clicked').length;
81
- const clicked = eventCounts?.clicked ?? rows.filter(r => r.status === 'clicked').length;
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(row => ['delivered', 'opened', 'clicked'].includes(row.status)).length;
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 ? `${((count / denominator) * 100).toFixed(1)}%` : 'N/A';
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: 'revenue_attribution rows excluding open/click/page-view vanity events',
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((l) => l.status === 'complained').length;
885
- const bounceRate = totalSent > 0 ? (bounced / totalSent * 100) : 0;
886
- const complaintRate = totalSent > 0 ? (complained / totalSent * 100) : 0;
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('id, site_id, campaign_id, contact_id, status, category, sent_at, opened_at, clicked_at, bounced_at, complained_at')
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
- let attributionQuery = db()
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 campaignIdsInScope = new Set(rows.map((row) => typeof row.campaign_id === 'string' ? row.campaign_id : null).filter(Boolean));
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(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
971
- const opened = set.filter(r => r.opened_at).length;
972
- const clicked = set.filter(r => r.clicked_at).length;
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(r => r.status === 'complained').length;
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 ? `${((delivered / total) * 100).toFixed(1)}%` : 'N/A',
978
- open_rate: total > 0 ? `${((opened / total) * 100).toFixed(1)}%` : 'N/A',
979
- click_rate: total > 0 ? `${((clicked / total) * 100).toFixed(1)}%` : 'N/A',
980
- bounce_rate: total > 0 ? `${((bounced / total) * 100).toFixed(1)}%` : 'N/A',
981
- complaint_rate: total > 0 ? `${((complained / total) * 100).toFixed(3)}%` : 'N/A',
982
- click_to_open: opened > 0 ? `${((clicked / opened) * 100).toFixed(1)}%` : 'N/A',
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 hourBuckets = {};
993
- rows.forEach((r) => {
994
- if (!r.sent_at)
995
- return;
996
- const hour = new Date(r.sent_at).getUTCHours();
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((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
1153
- const opened = rows.filter((r) => r.opened_at).length;
1154
- const clicked = rows.filter((r) => r.clicked_at).length;
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((r) => r.status === 'complained').length;
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: sent > 0 ? Math.round((opened / sent) * 1000) / 10 : 0,
1167
- click_rate: sent > 0 ? Math.round((clicked / sent) * 1000) / 10 : 0,
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: totals.sent > 0 ? Math.round((totals.opened / totals.sent) * 1000) / 10 : 0,
1194
- click_rate: totals.sent > 0 ? Math.round((totals.clicked / totals.sent) * 1000) / 10 : 0,
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('status, created_at').eq('project_id', projectId).gte('created_at', since)),
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 sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
17
- const opened = rows.filter((row) => row.status === 'opened' || row.status === 'clicked').length;
18
- const clicked = rows.filter((row) => row.status === 'clicked').length;
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: sent > 0 ? Number((opened / sent).toFixed(4)) : 0,
23
- click_rate: sent > 0 ? Number((clicked / sent).toFixed(4)) : 0,
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
  };
@@ -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 [{ count: contacts }, { count: sends }, { count: sites }] = await Promise.all([
548
- db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
549
- db().from('email_log').select('id', { count: 'exact', head: true }).eq('project_id', projectId).gte('created_at', new Date(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1).toISOString()),
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 ?? 0, limit: limits.contacts ?? null },
555
- monthly_sends: { used: sends ?? 0, limit: limits.monthly_sends ?? limits.emails_per_month ?? null },
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.72",
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",