@sendinel/mcp-server 1.0.139 → 1.0.141
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/db-error.d.ts +16 -0
- package/build/lib/db-error.js +17 -0
- package/build/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/tools/advisor.js +23 -19
- package/build/tools/analytics.d.ts +2 -1
- package/build/tools/analytics.js +20 -8
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.js +92 -48
- package/build/tools/contacts.js +422 -310
- package/build/tools/content.js +12 -2
- package/build/tools/delivery-ops.js +5 -4
- package/build/tools/landing-pages.js +86 -9
- package/build/tools/segments.js +60 -46
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Public tool errors must not expose SQL, constraint names or database internals. */
|
|
2
|
+
export declare function dbToolError(error: {
|
|
3
|
+
code?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
}, message?: string): {
|
|
6
|
+
error: string;
|
|
7
|
+
code: string;
|
|
8
|
+
};
|
|
9
|
+
/** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
|
|
10
|
+
export declare function toolError(body: unknown): {
|
|
11
|
+
isError: true;
|
|
12
|
+
content: {
|
|
13
|
+
type: 'text';
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Public tool errors must not expose SQL, constraint names or database internals. */
|
|
2
|
+
export function dbToolError(error, message = 'Unable to complete database operation') {
|
|
3
|
+
if (error.code === '23505')
|
|
4
|
+
return { error: 'A record with these values already exists', code: 'conflict' };
|
|
5
|
+
if (error.code === '23503')
|
|
6
|
+
return { error: 'Referenced record not found', code: 'not_found' };
|
|
7
|
+
if (error.code === '42501')
|
|
8
|
+
return { error: 'Operation forbidden', code: 'forbidden' };
|
|
9
|
+
if (error.code?.startsWith('22') || error.code === '23514' || error.code === '23502') {
|
|
10
|
+
return { error: 'Invalid record values', code: 'validation_failed' };
|
|
11
|
+
}
|
|
12
|
+
return { error: message, code: 'internal_error' };
|
|
13
|
+
}
|
|
14
|
+
/** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
|
|
15
|
+
export function toolError(body) {
|
|
16
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Caller-safe database errors. Never serialize database messages, details or hints. */
|
|
2
|
+
export declare function mapDbError(error: unknown): {
|
|
3
|
+
error: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function dbErrorResult(error: unknown): {
|
|
7
|
+
isError: boolean;
|
|
8
|
+
content: {
|
|
9
|
+
type: 'text';
|
|
10
|
+
text: string;
|
|
11
|
+
}[];
|
|
12
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Caller-safe database errors. Never serialize database messages, details or hints. */
|
|
2
|
+
export function mapDbError(error) {
|
|
3
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
4
|
+
if (code === '23505')
|
|
5
|
+
return { error: 'A record with these values already exists.', code: 'conflict' };
|
|
6
|
+
if (code === '23503' || code === 'PGRST116') {
|
|
7
|
+
return { error: 'The requested or referenced record was not found.', code: 'not_found' };
|
|
8
|
+
}
|
|
9
|
+
if (code.startsWith('23') || code.startsWith('22')) {
|
|
10
|
+
return { error: 'The supplied values are not valid. Check the input and try again.', code: 'validation_failed' };
|
|
11
|
+
}
|
|
12
|
+
return { error: 'The operation could not be completed. Please try again later.', code: 'internal_error' };
|
|
13
|
+
}
|
|
14
|
+
export function dbErrorResult(error) {
|
|
15
|
+
console.warn('[mcp] Database operation failed:', error);
|
|
16
|
+
return {
|
|
17
|
+
isError: true,
|
|
18
|
+
content: [{ type: 'text', text: JSON.stringify(mapDbError(error)) }],
|
|
19
|
+
};
|
|
20
|
+
}
|
package/build/tools/advisor.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { countEmailRows, groupMetricRows, percentRate,
|
|
1
|
+
import { countEmailRows, groupMetricRows, percentRate, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, 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';
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
|
+
import { toolError } from '../lib/db-error.js';
|
|
6
7
|
import { buildVoicePrompt } from '../lib/voice.js';
|
|
7
8
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
8
9
|
import { dkimRemediationText } from '../lib/email-provider.js';
|
|
@@ -103,19 +104,19 @@ export function registerAdvisorTools(server) {
|
|
|
103
104
|
// 1. Current period email_log
|
|
104
105
|
let currentQuery = db()
|
|
105
106
|
.from('email_log')
|
|
106
|
-
.select("status,
|
|
107
|
+
.select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
107
108
|
.eq('project_id', projectId)
|
|
108
|
-
.gte('
|
|
109
|
+
.gte('sent_at', periodStart.toISOString());
|
|
109
110
|
if (site_id)
|
|
110
111
|
currentQuery = currentQuery.eq('site_id', site_id);
|
|
111
112
|
const currentRows = await readAllRows(currentQuery);
|
|
112
113
|
// 2. Prior period
|
|
113
114
|
let priorQuery = db()
|
|
114
115
|
.from('email_log')
|
|
115
|
-
.select("status,
|
|
116
|
+
.select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
|
|
116
117
|
.eq('project_id', projectId)
|
|
117
|
-
.gte('
|
|
118
|
-
.lt('
|
|
118
|
+
.gte('sent_at', priorStart.toISOString())
|
|
119
|
+
.lt('sent_at', periodStart.toISOString());
|
|
119
120
|
if (site_id)
|
|
120
121
|
priorQuery = priorQuery.eq('site_id', site_id);
|
|
121
122
|
const priorRows = await readAllRows(priorQuery);
|
|
@@ -124,8 +125,8 @@ export function registerAdvisorTools(server) {
|
|
|
124
125
|
// 3. Domain health (latest per site)
|
|
125
126
|
let healthQuery = db()
|
|
126
127
|
.from('domain_health')
|
|
127
|
-
.select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day')
|
|
128
|
-
.eq('project_id', projectId)
|
|
128
|
+
.select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day, sites!inner(project_id)')
|
|
129
|
+
.eq('sites.project_id', projectId)
|
|
129
130
|
.order('check_date', { ascending: false })
|
|
130
131
|
.limit(20);
|
|
131
132
|
if (site_id)
|
|
@@ -595,15 +596,19 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
595
596
|
.eq('id', site_id)
|
|
596
597
|
.eq('project_id', projectId)
|
|
597
598
|
.maybeSingle();
|
|
598
|
-
if (siteErr
|
|
599
|
-
return
|
|
599
|
+
if (siteErr)
|
|
600
|
+
return toolError({ error: 'Unable to load site', code: 'internal_error' });
|
|
601
|
+
if (!site)
|
|
602
|
+
return toolError({ error: 'Site not found', code: 'not_found' });
|
|
600
603
|
// 2. Fetch latest domain_health
|
|
601
|
-
const { data: healthRows } = await db()
|
|
604
|
+
const { data: healthRows, error: healthError } = await db()
|
|
602
605
|
.from('domain_health')
|
|
603
606
|
.select('spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
|
|
604
607
|
.eq('site_id', site_id)
|
|
605
608
|
.order('check_date', { ascending: false })
|
|
606
609
|
.limit(1);
|
|
610
|
+
if (healthError)
|
|
611
|
+
return toolError({ error: 'Unable to load domain health', code: 'internal_error' });
|
|
607
612
|
const health = (healthRows ?? [])[0];
|
|
608
613
|
const spfValid = health?.spf_valid ?? null; // null = not checked yet (#3904)
|
|
609
614
|
const spfFailed = spfValid === false;
|
|
@@ -615,18 +620,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
615
620
|
const warmupDailyLimit = health?.warmup_daily_limit ?? null;
|
|
616
621
|
// 3. Fetch recent 7-day email_log stats
|
|
617
622
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
618
|
-
const { data: recentLogs } = await db()
|
|
623
|
+
const { data: recentLogs, error: logsError } = await db()
|
|
619
624
|
.from('email_log')
|
|
620
625
|
.select("status, opened_at, clicked_at, bounced_at, contact_id, to_email")
|
|
621
626
|
.eq('project_id', projectId)
|
|
622
627
|
.eq('site_id', site_id)
|
|
623
628
|
.gte('sent_at', sevenDaysAgo);
|
|
629
|
+
if (logsError)
|
|
630
|
+
return toolError({ error: 'Unable to load send history', code: 'internal_error' });
|
|
624
631
|
const logs = recentLogs ?? [];
|
|
625
|
-
const totalSent = logs
|
|
626
|
-
const bounced = logs.filter(isBouncedRow).length;
|
|
627
|
-
const complained = logs.filter(isComplainedRow).length;
|
|
628
|
-
const delivered = logs.filter(isDeliveredRow).length;
|
|
629
|
-
const opened = countPeople(logs, isOpenedRow);
|
|
632
|
+
const { sent: totalSent, bounced, complained, delivered, opened } = countEmailRows(logs);
|
|
630
633
|
const bounceRate = percentRate(bounced, totalSent, 2) ?? 0;
|
|
631
634
|
const complaintRate = percentRate(complained, totalSent, 2) ?? 0;
|
|
632
635
|
const openRate = percentRate(opened, delivered, 2) ?? 0;
|
|
@@ -745,7 +748,8 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
745
748
|
estimatedRecovery = '3-5 days';
|
|
746
749
|
return j({
|
|
747
750
|
site: { id: site.id, slug: site.slug, domain: site.sending_domain },
|
|
748
|
-
health_score: healthScore,
|
|
751
|
+
health_score: totalSent === 0 ? null : healthScore,
|
|
752
|
+
...(totalSent === 0 ? { health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
|
|
749
753
|
recent_7d: {
|
|
750
754
|
total_sent: totalSent,
|
|
751
755
|
delivered,
|
|
@@ -765,7 +769,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
765
769
|
short_term: shortTerm.length > 0 ? shortTerm : ['Continue current sending practices'],
|
|
766
770
|
monitoring,
|
|
767
771
|
},
|
|
768
|
-
|
|
772
|
+
...(issues.some(issue => issue.type !== 'no_recent_sends') ? { estimated_recovery: estimatedRecovery } : {}),
|
|
769
773
|
});
|
|
770
774
|
});
|
|
771
775
|
}
|
|
@@ -28,10 +28,11 @@ export declare function computeAnalyticsMetrics(rows: EmailLogRow[], eventCounts
|
|
|
28
28
|
clicked: number;
|
|
29
29
|
}): AnalyticsMetrics;
|
|
30
30
|
export declare function computeSenderHealthScore(metrics: {
|
|
31
|
+
sent: number;
|
|
31
32
|
bounce_rate: number;
|
|
32
33
|
complaint_rate: number;
|
|
33
34
|
unsubscribe_rate: number;
|
|
34
35
|
open_rate: number;
|
|
35
|
-
}, trackingEnabled: boolean): number;
|
|
36
|
+
}, trackingEnabled: boolean): number | null;
|
|
36
37
|
export declare function registerAnalyticsTools(server: McpServer): void;
|
|
37
38
|
export {};
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
|
|
1
|
+
import { readPeriodCampaigns, campaignPeriodFields, filterPlanContacts } from "@sendinel/domain";
|
|
2
2
|
import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
|
|
3
3
|
import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
|
|
4
4
|
import { z } from 'zod';
|
|
@@ -7,6 +7,7 @@ import { getProjectId } from '../project.js';
|
|
|
7
7
|
import { resolveProjectClient } from '../byod-client.js';
|
|
8
8
|
import { decrypt } from '../crypto.js';
|
|
9
9
|
import { logAudit } from '../audit.js';
|
|
10
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
10
11
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
11
12
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
12
13
|
const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
|
|
@@ -21,6 +22,8 @@ export function computeAnalyticsMetrics(rows, eventCounts) {
|
|
|
21
22
|
complaint_rate: metrics.complaint_rate ?? 0, unsubscribe_rate: metrics.unsubscribe_rate ?? 0 };
|
|
22
23
|
}
|
|
23
24
|
export function computeSenderHealthScore(metrics, trackingEnabled) {
|
|
25
|
+
if (metrics.sent === 0)
|
|
26
|
+
return null;
|
|
24
27
|
let score = 100;
|
|
25
28
|
if (metrics.bounce_rate > 0.05)
|
|
26
29
|
score -= 40;
|
|
@@ -462,6 +465,11 @@ export function registerAnalyticsTools(server) {
|
|
|
462
465
|
}, async ({ site_id, start_date, end_date }) => {
|
|
463
466
|
const projectId = getProjectId();
|
|
464
467
|
const end = end_date ?? new Date().toISOString().slice(0, 10);
|
|
468
|
+
const validDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
|
469
|
+
Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
470
|
+
if (!validDate(start_date) || !validDate(end) || start_date > end) {
|
|
471
|
+
return toolError({ error: 'Use valid YYYY-MM-DD dates with start_date on or before end_date', code: 'validation_failed' });
|
|
472
|
+
}
|
|
465
473
|
const { data: site, error: siteError } = await db()
|
|
466
474
|
.from('sites')
|
|
467
475
|
.select('tracking_enabled')
|
|
@@ -469,7 +477,9 @@ export function registerAnalyticsTools(server) {
|
|
|
469
477
|
.eq('id', site_id)
|
|
470
478
|
.maybeSingle();
|
|
471
479
|
if (siteError)
|
|
472
|
-
return {
|
|
480
|
+
return toolError({ error: 'Unable to load site', code: 'internal_error' });
|
|
481
|
+
if (!site)
|
|
482
|
+
return toolError({ error: 'Site not found', code: 'not_found' });
|
|
473
483
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
474
484
|
const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
475
485
|
.from('email_log')
|
|
@@ -481,7 +491,7 @@ export function registerAnalyticsTools(server) {
|
|
|
481
491
|
.order('id', { ascending: true })
|
|
482
492
|
.range(offset, offset + limit - 1));
|
|
483
493
|
if (error)
|
|
484
|
-
return
|
|
494
|
+
return toolError(dbToolError(error));
|
|
485
495
|
const rows = (data ?? []);
|
|
486
496
|
let eventCounts = { opened: 0, clicked: 0 };
|
|
487
497
|
let eventRows = [];
|
|
@@ -498,7 +508,7 @@ export function registerAnalyticsTools(server) {
|
|
|
498
508
|
.order('id', { ascending: true })
|
|
499
509
|
.range(offset, offset + limit - 1));
|
|
500
510
|
if (eventsError)
|
|
501
|
-
return
|
|
511
|
+
return toolError(dbToolError(eventsError));
|
|
502
512
|
eventRows = events ?? [];
|
|
503
513
|
eventPageCount = pageCount;
|
|
504
514
|
const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
|
|
@@ -511,14 +521,14 @@ export function registerAnalyticsTools(server) {
|
|
|
511
521
|
const periodDays = daysBetween(start_date, end);
|
|
512
522
|
const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
|
|
513
523
|
const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
|
|
514
|
-
const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
524
|
+
const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => filterPlanContacts(db()
|
|
515
525
|
.from('subscribers')
|
|
516
526
|
.select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
|
|
517
|
-
.eq('project_id', projectId)
|
|
527
|
+
.eq('project_id', projectId))
|
|
518
528
|
.order('id', { ascending: true })
|
|
519
529
|
.range(offset, offset + limit - 1));
|
|
520
530
|
if (subscribersError)
|
|
521
|
-
return
|
|
531
|
+
return toolError(dbToolError(subscribersError));
|
|
522
532
|
const subscriberRows = (subscribers ?? []);
|
|
523
533
|
const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
|
|
524
534
|
const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
|
|
@@ -540,6 +550,7 @@ export function registerAnalyticsTools(server) {
|
|
|
540
550
|
list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
|
|
541
551
|
inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
|
|
542
552
|
sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
|
|
553
|
+
...(metrics.sent === 0 ? { sender_health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
|
|
543
554
|
coverage: {
|
|
544
555
|
log_rows_scanned: rows.length,
|
|
545
556
|
log_page_count: logPageCount,
|
|
@@ -890,7 +901,8 @@ export function registerAnalyticsTools(server) {
|
|
|
890
901
|
return txt(`Error: ${attributionError.message}`);
|
|
891
902
|
const scopedAttributionRows = attributionRows ?? [];
|
|
892
903
|
const computeMetrics = (set, counts) => {
|
|
893
|
-
const
|
|
904
|
+
const sharedCounts = counts ?? countEmailRows(set);
|
|
905
|
+
const total = sharedCounts.sent;
|
|
894
906
|
const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
|
|
895
907
|
const opened = counts?.opened ?? countPeople(set, isOpenedRow);
|
|
896
908
|
const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
|
package/build/tools/approvals.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
4
5
|
import { checkApproval } from '../lib/action-approvals.js';
|
|
5
6
|
import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
|
|
6
7
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
@@ -42,7 +43,7 @@ export function registerApprovalTools(server, options) {
|
|
|
42
43
|
idempotentHint: true,
|
|
43
44
|
openWorldHint: false,
|
|
44
45
|
},
|
|
45
|
-
description: 'List
|
|
46
|
+
description: 'List unexpired pending approval requests for this project. Expired rows are excluded even before the expiry cron runs.',
|
|
46
47
|
inputSchema: {},
|
|
47
48
|
}, async () => {
|
|
48
49
|
const { data, error } = await db()
|
|
@@ -54,7 +55,7 @@ export function registerApprovalTools(server, options) {
|
|
|
54
55
|
.order('requested_at', { ascending: false })
|
|
55
56
|
.limit(25);
|
|
56
57
|
if (error)
|
|
57
|
-
return
|
|
58
|
+
return toolError(dbToolError(error));
|
|
58
59
|
return j({ pending: data ?? [], count: data?.length ?? 0 });
|
|
59
60
|
});
|
|
60
61
|
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
2
3
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -12,6 +13,7 @@ import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/d
|
|
|
12
13
|
import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
|
|
13
14
|
import { adminDb, db, requestContext } from '../db.js';
|
|
14
15
|
import { getProjectId } from '../project.js';
|
|
16
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
15
17
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
16
18
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
17
19
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
@@ -382,10 +384,12 @@ async function campaignInProject(campaignId) {
|
|
|
382
384
|
return Boolean(data);
|
|
383
385
|
}
|
|
384
386
|
/** Mirrors validateTarget() in the branches REST route. */
|
|
385
|
-
async function validateBranchTarget(campaignId, action, targetStepId, targetCampaignId) {
|
|
387
|
+
async function validateBranchTarget(campaignId, fromStepId, action, targetStepId, targetCampaignId) {
|
|
386
388
|
if (action === 'goto_step') {
|
|
387
389
|
if (!targetStepId)
|
|
388
390
|
return 'target_step_id is required for goto_step';
|
|
391
|
+
if (targetStepId === fromStepId)
|
|
392
|
+
return 'goto_step cannot target its own source step';
|
|
389
393
|
const { data } = await db()
|
|
390
394
|
.from('campaign_steps')
|
|
391
395
|
.select('id')
|
|
@@ -398,6 +402,8 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
|
|
|
398
402
|
if (action === 'goto_campaign') {
|
|
399
403
|
if (!targetCampaignId)
|
|
400
404
|
return 'target_campaign_id is required for goto_campaign';
|
|
405
|
+
if (targetCampaignId === campaignId)
|
|
406
|
+
return 'goto_campaign cannot target its own campaign';
|
|
401
407
|
if (!(await campaignInProject(targetCampaignId)))
|
|
402
408
|
return 'target_campaign_id must be a campaign in this project';
|
|
403
409
|
}
|
|
@@ -882,40 +888,56 @@ export function registerCampaignTools(server) {
|
|
|
882
888
|
send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
|
|
883
889
|
},
|
|
884
890
|
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
891
|
+
try {
|
|
892
|
+
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
893
|
+
if (!properties.ok)
|
|
894
|
+
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
895
|
+
const { data: campaign, error: campaignError } = await db()
|
|
896
|
+
.schema('email')
|
|
897
|
+
.from('campaigns')
|
|
898
|
+
.select('id')
|
|
899
|
+
.eq('id', campaign_id)
|
|
900
|
+
.eq('project_id', getProjectId())
|
|
901
|
+
.maybeSingle();
|
|
902
|
+
if (campaignError)
|
|
903
|
+
return dbErrorResult(campaignError);
|
|
904
|
+
if (!campaign)
|
|
905
|
+
return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
|
|
906
|
+
const renderVars = {};
|
|
907
|
+
if (survey_id) {
|
|
908
|
+
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
909
|
+
if (!surveyLink)
|
|
910
|
+
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
911
|
+
renderVars.survey_link = surveyLink;
|
|
912
|
+
}
|
|
913
|
+
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
914
|
+
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
915
|
+
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
916
|
+
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
917
|
+
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
918
|
+
const { data, error } = await db()
|
|
919
|
+
.from('campaign_steps')
|
|
920
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
921
|
+
.select()
|
|
922
|
+
.single();
|
|
923
|
+
if (error)
|
|
924
|
+
return dbErrorResult(error);
|
|
925
|
+
await recordBrandAssetUsage({
|
|
926
|
+
client: db(),
|
|
927
|
+
projectId: getProjectId(),
|
|
928
|
+
source: 'campaign_step',
|
|
929
|
+
resourceId: data.id,
|
|
930
|
+
html: renderedHtml,
|
|
931
|
+
assetIds: brand_asset_ids,
|
|
932
|
+
}).catch((err) => {
|
|
933
|
+
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
934
|
+
});
|
|
935
|
+
await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
936
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
return dbErrorResult(error);
|
|
894
940
|
}
|
|
895
|
-
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
896
|
-
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
897
|
-
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
898
|
-
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
899
|
-
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
900
|
-
const { data, error } = await db()
|
|
901
|
-
.from('campaign_steps')
|
|
902
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
903
|
-
.select()
|
|
904
|
-
.single();
|
|
905
|
-
if (error)
|
|
906
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
907
|
-
await recordBrandAssetUsage({
|
|
908
|
-
client: db(),
|
|
909
|
-
projectId: getProjectId(),
|
|
910
|
-
source: 'campaign_step',
|
|
911
|
-
resourceId: data.id,
|
|
912
|
-
html: renderedHtml,
|
|
913
|
-
assetIds: brand_asset_ids,
|
|
914
|
-
}).catch((err) => {
|
|
915
|
-
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
916
|
-
});
|
|
917
|
-
await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
918
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
919
941
|
});
|
|
920
942
|
server.registerTool('update_campaign_status', {
|
|
921
943
|
title: 'Update Campaign Status',
|
|
@@ -1589,7 +1611,7 @@ export function registerCampaignTools(server) {
|
|
|
1589
1611
|
idempotentHint: false,
|
|
1590
1612
|
openWorldHint: false,
|
|
1591
1613
|
},
|
|
1592
|
-
description: 'Clone
|
|
1614
|
+
description: 'Clone campaign steps into a draft. Trigger, audience, send class, re-entry, goals and branches are NOT copied; fields_not_copied and warnings explicitly identify these omissions. Review and configure before activation.',
|
|
1593
1615
|
inputSchema: {
|
|
1594
1616
|
campaign_id: z.string().uuid().describe('Source campaign to clone'),
|
|
1595
1617
|
new_name: z.string().min(1).describe('Name for the cloned campaign'),
|
|
@@ -1603,11 +1625,13 @@ export function registerCampaignTools(server) {
|
|
|
1603
1625
|
.eq('id', campaign_id)
|
|
1604
1626
|
.eq('project_id', projectId)
|
|
1605
1627
|
.maybeSingle();
|
|
1606
|
-
if (fetchErr
|
|
1607
|
-
return
|
|
1628
|
+
if (fetchErr)
|
|
1629
|
+
return toolError(dbToolError(fetchErr));
|
|
1630
|
+
if (!original)
|
|
1631
|
+
return toolError({ error: 'Campaign not found', code: 'not_found' });
|
|
1608
1632
|
const limit = await checkPlanLimit('active_campaigns');
|
|
1609
1633
|
if (!limit.allowed)
|
|
1610
|
-
return
|
|
1634
|
+
return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
|
|
1611
1635
|
const { data: newCampaign, error: createErr } = await db()
|
|
1612
1636
|
.from('campaigns')
|
|
1613
1637
|
.insert({
|
|
@@ -1623,7 +1647,7 @@ export function registerCampaignTools(server) {
|
|
|
1623
1647
|
.select()
|
|
1624
1648
|
.single();
|
|
1625
1649
|
if (createErr)
|
|
1626
|
-
return
|
|
1650
|
+
return toolError(dbToolError(createErr));
|
|
1627
1651
|
const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
|
|
1628
1652
|
if (steps.length > 0) {
|
|
1629
1653
|
const stepRows = steps.map((s) => ({
|
|
@@ -1638,9 +1662,18 @@ export function registerCampaignTools(server) {
|
|
|
1638
1662
|
}));
|
|
1639
1663
|
const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
|
|
1640
1664
|
if (stepErr)
|
|
1641
|
-
return
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1665
|
+
return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
|
|
1666
|
+
}
|
|
1667
|
+
const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
|
|
1668
|
+
const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
|
|
1669
|
+
const fields_not_copied = [...new Set([
|
|
1670
|
+
...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
|
|
1671
|
+
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
|
|
1672
|
+
'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
|
|
1673
|
+
])];
|
|
1674
|
+
await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
|
|
1675
|
+
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
|
|
1676
|
+
warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
|
|
1644
1677
|
});
|
|
1645
1678
|
server.registerTool('list_sequence_templates', {
|
|
1646
1679
|
title: 'List Sequence Templates',
|
|
@@ -3831,14 +3864,20 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3831
3864
|
}, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
|
|
3832
3865
|
const conditionValue = campaignBranchConditionValue(condition_type, condition_value);
|
|
3833
3866
|
if (!conditionValue.ok)
|
|
3834
|
-
return { ...j({ error: conditionValue.error, code: '
|
|
3867
|
+
return { ...j({ error: conditionValue.error, code: 'validation_failed' }), isError: true };
|
|
3835
3868
|
if (!(await campaignInProject(campaign_id)))
|
|
3836
|
-
return
|
|
3869
|
+
return toolError({ error: 'Campaign not found in this project', code: 'not_found' });
|
|
3870
|
+
const { data: sourceStep, error: sourceError } = await db().from('campaign_steps').select('id')
|
|
3871
|
+
.eq('id', from_step_id).eq('campaign_id', campaign_id).maybeSingle();
|
|
3872
|
+
if (sourceError)
|
|
3873
|
+
return toolError(dbToolError(sourceError));
|
|
3874
|
+
if (!sourceStep)
|
|
3875
|
+
return toolError({ error: 'from_step_id must be a step in this campaign', code: 'validation_failed' });
|
|
3837
3876
|
// Same requirement the REST route enforces: an action naming a target must have one,
|
|
3838
3877
|
// and that target must belong to this project.
|
|
3839
|
-
const targetError = await validateBranchTarget(campaign_id, action, target_step_id ?? null, target_campaign_id ?? null);
|
|
3878
|
+
const targetError = await validateBranchTarget(campaign_id, from_step_id, action, target_step_id ?? null, target_campaign_id ?? null);
|
|
3840
3879
|
if (targetError)
|
|
3841
|
-
return
|
|
3880
|
+
return toolError({ error: targetError, code: 'validation_failed' });
|
|
3842
3881
|
const { data, error } = await db()
|
|
3843
3882
|
.from('campaign_step_branches')
|
|
3844
3883
|
.insert({
|
|
@@ -3854,9 +3893,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3854
3893
|
.select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority')
|
|
3855
3894
|
.single();
|
|
3856
3895
|
if (error)
|
|
3857
|
-
return
|
|
3896
|
+
return toolError(dbToolError(error));
|
|
3858
3897
|
await logAudit({ action: 'add_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id: data?.id, condition_type, branch_action: action } }).catch(() => { });
|
|
3859
|
-
|
|
3898
|
+
// Read after insertion so a tie already committed by a concurrent request is visible.
|
|
3899
|
+
const { data: peers, error: peerError } = await db().from('campaign_step_branches').select('id')
|
|
3900
|
+
.eq('campaign_id', campaign_id).eq('from_step_id', from_step_id).eq('priority', priority ?? 0).neq('id', data.id);
|
|
3901
|
+
const warnings = peerError ? ['Branch saved, but priority ties could not be checked.']
|
|
3902
|
+
: peers?.length ? ['Another branch on this step has the same priority; evaluation order may be ambiguous.'] : [];
|
|
3903
|
+
return j({ success: true, branch: data, warnings });
|
|
3860
3904
|
});
|
|
3861
3905
|
server.registerTool('remove_campaign_branch', {
|
|
3862
3906
|
title: 'Remove Campaign Branch',
|