@sendinel/mcp-server 1.0.140 → 1.0.142
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/tools/ab-testing.js +9 -2
- 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 +61 -20
- package/build/tools/contacts.js +119 -18
- 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 +5 -2
- 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
|
+
}
|
|
@@ -13,7 +13,7 @@ export function registerAbTestingTools(server) {
|
|
|
13
13
|
idempotentHint: false,
|
|
14
14
|
openWorldHint: false,
|
|
15
15
|
},
|
|
16
|
-
description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
|
|
16
|
+
description: 'Create an A/B test for a campaign step. Requires a non-blank subject and body on every variant, and at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
|
|
17
17
|
inputSchema: {
|
|
18
18
|
campaign_id: z.string().uuid(),
|
|
19
19
|
step_order: z.number().int().min(0).optional().default(0).describe('Which step to test (default: 0, the first email)'),
|
|
@@ -38,13 +38,20 @@ export function registerAbTestingTools(server) {
|
|
|
38
38
|
// Check variant steps exist
|
|
39
39
|
const { data: steps } = await db()
|
|
40
40
|
.from('campaign_steps')
|
|
41
|
-
.select('id, variant_label, subject')
|
|
41
|
+
.select('id, variant_label, subject, body_html, body_text')
|
|
42
42
|
.eq('campaign_id', campaign_id)
|
|
43
43
|
.eq('step_order', step_order ?? 0);
|
|
44
44
|
const variantLabels = (steps ?? []).map((s) => s.variant_label);
|
|
45
45
|
if (variantLabels.length < 2) {
|
|
46
46
|
return txt(`Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.`);
|
|
47
47
|
}
|
|
48
|
+
const emptyVariants = (steps ?? []).filter((step) => !step.subject?.trim() || !(step.body_html?.trim() || step.body_text?.trim()));
|
|
49
|
+
if (emptyVariants.length) {
|
|
50
|
+
return j({
|
|
51
|
+
error: `Variant(s) [${emptyVariants.map((step) => step.variant_label).join(', ')}] for step_order ${step_order ?? 0} need a non-blank subject and body. Add content with add_campaign_step before creating the test.`,
|
|
52
|
+
code: 'validation_failed',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
48
55
|
// Check no existing test for this step
|
|
49
56
|
const { data: existing } = await db()
|
|
50
57
|
.from('ab_tests')
|
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
|
@@ -13,6 +13,7 @@ import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/d
|
|
|
13
13
|
import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
|
|
14
14
|
import { adminDb, db, requestContext } from '../db.js';
|
|
15
15
|
import { getProjectId } from '../project.js';
|
|
16
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
16
17
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
17
18
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
18
19
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
@@ -136,6 +137,9 @@ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
|
|
|
136
137
|
if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
|
|
137
138
|
diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
|
|
138
139
|
}
|
|
140
|
+
if (campaign.type === 'triggered' && campaignTriggerKind(campaign) === 'manual' && !campaign.segment_id) {
|
|
141
|
+
diagnostics.push('campaign type is "triggered" but no trigger is set (it will never fire)');
|
|
142
|
+
}
|
|
139
143
|
if (campaign.status !== 'active') {
|
|
140
144
|
diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
|
|
141
145
|
}
|
|
@@ -383,10 +387,12 @@ async function campaignInProject(campaignId) {
|
|
|
383
387
|
return Boolean(data);
|
|
384
388
|
}
|
|
385
389
|
/** Mirrors validateTarget() in the branches REST route. */
|
|
386
|
-
async function validateBranchTarget(campaignId, action, targetStepId, targetCampaignId) {
|
|
390
|
+
async function validateBranchTarget(campaignId, fromStepId, action, targetStepId, targetCampaignId) {
|
|
387
391
|
if (action === 'goto_step') {
|
|
388
392
|
if (!targetStepId)
|
|
389
393
|
return 'target_step_id is required for goto_step';
|
|
394
|
+
if (targetStepId === fromStepId)
|
|
395
|
+
return 'goto_step cannot target its own source step';
|
|
390
396
|
const { data } = await db()
|
|
391
397
|
.from('campaign_steps')
|
|
392
398
|
.select('id')
|
|
@@ -399,6 +405,8 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
|
|
|
399
405
|
if (action === 'goto_campaign') {
|
|
400
406
|
if (!targetCampaignId)
|
|
401
407
|
return 'target_campaign_id is required for goto_campaign';
|
|
408
|
+
if (targetCampaignId === campaignId)
|
|
409
|
+
return 'goto_campaign cannot target its own campaign';
|
|
402
410
|
if (!(await campaignInProject(targetCampaignId)))
|
|
403
411
|
return 'target_campaign_id must be a campaign in this project';
|
|
404
412
|
}
|
|
@@ -850,7 +858,7 @@ export function registerCampaignTools(server) {
|
|
|
850
858
|
.single();
|
|
851
859
|
if (error)
|
|
852
860
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
853
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION }, null, 2) }] };
|
|
861
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) }, null, 2) }] };
|
|
854
862
|
});
|
|
855
863
|
const propertyConditions = z.array(z.union([
|
|
856
864
|
z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
|
|
@@ -864,7 +872,7 @@ export function registerCampaignTools(server) {
|
|
|
864
872
|
idempotentHint: false,
|
|
865
873
|
openWorldHint: false,
|
|
866
874
|
},
|
|
867
|
-
description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
875
|
+
description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
868
876
|
inputSchema: {
|
|
869
877
|
campaign_id: z.string().uuid(),
|
|
870
878
|
step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
|
|
@@ -887,6 +895,16 @@ export function registerCampaignTools(server) {
|
|
|
887
895
|
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
888
896
|
if (!properties.ok)
|
|
889
897
|
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
898
|
+
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
899
|
+
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
900
|
+
if (!subject.trim())
|
|
901
|
+
return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
|
|
902
|
+
const hasBody = body_html?.trim() || body_text?.trim()
|
|
903
|
+
|| htmlFromBlocks?.replace(/<[^>]*>/g, '').trim()
|
|
904
|
+
|| normalizedBlocks?.some((block) => block.type === 'image' && block.url);
|
|
905
|
+
if (!hasBody) {
|
|
906
|
+
return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
|
|
907
|
+
}
|
|
890
908
|
const { data: campaign, error: campaignError } = await db()
|
|
891
909
|
.schema('email')
|
|
892
910
|
.from('campaigns')
|
|
@@ -906,8 +924,6 @@ export function registerCampaignTools(server) {
|
|
|
906
924
|
renderVars.survey_link = surveyLink;
|
|
907
925
|
}
|
|
908
926
|
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
909
|
-
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
910
|
-
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
911
927
|
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
912
928
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
913
929
|
const { data, error } = await db()
|
|
@@ -970,12 +986,15 @@ export function registerCampaignTools(server) {
|
|
|
970
986
|
// Placeholder text and links to the sending domain never go out (#3853).
|
|
971
987
|
const { data: campRow, error: campErr } = await db()
|
|
972
988
|
.from('campaigns')
|
|
973
|
-
.select('id, name, site_id, trigger_event_name')
|
|
989
|
+
.select('id, name, site_id, type, trigger_type, trigger_event_name, trigger_tag, trigger_config, segment_id')
|
|
974
990
|
.eq('id', campaign_id)
|
|
975
991
|
.eq('project_id', getProjectId())
|
|
976
992
|
.maybeSingle();
|
|
977
993
|
if (campErr)
|
|
978
994
|
return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
|
|
995
|
+
if (campRow?.type === 'triggered' && campaignTriggerKind(campRow) === 'manual' && !campRow.segment_id) {
|
|
996
|
+
return j({ error: 'This triggered campaign has no trigger. Set an event, tag, date, contact event, or segment before activating it.', code: 'validation_failed' });
|
|
997
|
+
}
|
|
979
998
|
let contentWarnings = [];
|
|
980
999
|
if (campRow) {
|
|
981
1000
|
const { data: contentSteps, error: contentErr } = await db()
|
|
@@ -1606,7 +1625,7 @@ export function registerCampaignTools(server) {
|
|
|
1606
1625
|
idempotentHint: false,
|
|
1607
1626
|
openWorldHint: false,
|
|
1608
1627
|
},
|
|
1609
|
-
description: 'Clone
|
|
1628
|
+
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.',
|
|
1610
1629
|
inputSchema: {
|
|
1611
1630
|
campaign_id: z.string().uuid().describe('Source campaign to clone'),
|
|
1612
1631
|
new_name: z.string().min(1).describe('Name for the cloned campaign'),
|
|
@@ -1620,11 +1639,13 @@ export function registerCampaignTools(server) {
|
|
|
1620
1639
|
.eq('id', campaign_id)
|
|
1621
1640
|
.eq('project_id', projectId)
|
|
1622
1641
|
.maybeSingle();
|
|
1623
|
-
if (fetchErr
|
|
1624
|
-
return
|
|
1642
|
+
if (fetchErr)
|
|
1643
|
+
return toolError(dbToolError(fetchErr));
|
|
1644
|
+
if (!original)
|
|
1645
|
+
return toolError({ error: 'Campaign not found', code: 'not_found' });
|
|
1625
1646
|
const limit = await checkPlanLimit('active_campaigns');
|
|
1626
1647
|
if (!limit.allowed)
|
|
1627
|
-
return
|
|
1648
|
+
return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
|
|
1628
1649
|
const { data: newCampaign, error: createErr } = await db()
|
|
1629
1650
|
.from('campaigns')
|
|
1630
1651
|
.insert({
|
|
@@ -1640,7 +1661,7 @@ export function registerCampaignTools(server) {
|
|
|
1640
1661
|
.select()
|
|
1641
1662
|
.single();
|
|
1642
1663
|
if (createErr)
|
|
1643
|
-
return
|
|
1664
|
+
return toolError(dbToolError(createErr));
|
|
1644
1665
|
const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
|
|
1645
1666
|
if (steps.length > 0) {
|
|
1646
1667
|
const stepRows = steps.map((s) => ({
|
|
@@ -1655,9 +1676,18 @@ export function registerCampaignTools(server) {
|
|
|
1655
1676
|
}));
|
|
1656
1677
|
const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
|
|
1657
1678
|
if (stepErr)
|
|
1658
|
-
return
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1679
|
+
return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
|
|
1680
|
+
}
|
|
1681
|
+
const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
|
|
1682
|
+
const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
|
|
1683
|
+
const fields_not_copied = [...new Set([
|
|
1684
|
+
...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
|
|
1685
|
+
'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
|
|
1686
|
+
'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
|
|
1687
|
+
])];
|
|
1688
|
+
await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
|
|
1689
|
+
return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
|
|
1690
|
+
warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
|
|
1661
1691
|
});
|
|
1662
1692
|
server.registerTool('list_sequence_templates', {
|
|
1663
1693
|
title: 'List Sequence Templates',
|
|
@@ -3848,14 +3878,20 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3848
3878
|
}, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
|
|
3849
3879
|
const conditionValue = campaignBranchConditionValue(condition_type, condition_value);
|
|
3850
3880
|
if (!conditionValue.ok)
|
|
3851
|
-
return { ...j({ error: conditionValue.error, code: '
|
|
3881
|
+
return { ...j({ error: conditionValue.error, code: 'validation_failed' }), isError: true };
|
|
3852
3882
|
if (!(await campaignInProject(campaign_id)))
|
|
3853
|
-
return
|
|
3883
|
+
return toolError({ error: 'Campaign not found in this project', code: 'not_found' });
|
|
3884
|
+
const { data: sourceStep, error: sourceError } = await db().from('campaign_steps').select('id')
|
|
3885
|
+
.eq('id', from_step_id).eq('campaign_id', campaign_id).maybeSingle();
|
|
3886
|
+
if (sourceError)
|
|
3887
|
+
return toolError(dbToolError(sourceError));
|
|
3888
|
+
if (!sourceStep)
|
|
3889
|
+
return toolError({ error: 'from_step_id must be a step in this campaign', code: 'validation_failed' });
|
|
3854
3890
|
// Same requirement the REST route enforces: an action naming a target must have one,
|
|
3855
3891
|
// and that target must belong to this project.
|
|
3856
|
-
const targetError = await validateBranchTarget(campaign_id, action, target_step_id ?? null, target_campaign_id ?? null);
|
|
3892
|
+
const targetError = await validateBranchTarget(campaign_id, from_step_id, action, target_step_id ?? null, target_campaign_id ?? null);
|
|
3857
3893
|
if (targetError)
|
|
3858
|
-
return
|
|
3894
|
+
return toolError({ error: targetError, code: 'validation_failed' });
|
|
3859
3895
|
const { data, error } = await db()
|
|
3860
3896
|
.from('campaign_step_branches')
|
|
3861
3897
|
.insert({
|
|
@@ -3871,9 +3907,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3871
3907
|
.select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority')
|
|
3872
3908
|
.single();
|
|
3873
3909
|
if (error)
|
|
3874
|
-
return
|
|
3910
|
+
return toolError(dbToolError(error));
|
|
3875
3911
|
await logAudit({ action: 'add_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id: data?.id, condition_type, branch_action: action } }).catch(() => { });
|
|
3876
|
-
|
|
3912
|
+
// Read after insertion so a tie already committed by a concurrent request is visible.
|
|
3913
|
+
const { data: peers, error: peerError } = await db().from('campaign_step_branches').select('id')
|
|
3914
|
+
.eq('campaign_id', campaign_id).eq('from_step_id', from_step_id).eq('priority', priority ?? 0).neq('id', data.id);
|
|
3915
|
+
const warnings = peerError ? ['Branch saved, but priority ties could not be checked.']
|
|
3916
|
+
: peers?.length ? ['Another branch on this step has the same priority; evaluation order may be ambiguous.'] : [];
|
|
3917
|
+
return j({ success: true, branch: data, warnings });
|
|
3877
3918
|
});
|
|
3878
3919
|
server.registerTool('remove_campaign_branch', {
|
|
3879
3920
|
title: 'Remove Campaign Branch',
|
package/build/tools/contacts.js
CHANGED
|
@@ -7,6 +7,8 @@ import { z } from 'zod';
|
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { db, projectClientForWriteback } from '../db.js';
|
|
9
9
|
import { getProjectId } from '../project.js';
|
|
10
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
11
|
+
import { resolveSegmentContactIds } from '../lib/send-audience.js';
|
|
10
12
|
import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
|
|
11
13
|
import { logConsent, logAudit } from '../audit.js';
|
|
12
14
|
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
@@ -238,6 +240,49 @@ function normalizePhoneE164(value) {
|
|
|
238
240
|
function isPlainObject(value) {
|
|
239
241
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
240
242
|
}
|
|
243
|
+
const SUBSCRIBER_VIEW_COLUMNS = new Set([
|
|
244
|
+
'contact', 'status_strip', 'quick_send', 'site', 'company', 'status', 'last_activity',
|
|
245
|
+
'sms_opt_in', 'tags', 'score', 'stage', 'opens', 'clicks', 'joined', 'embed_actions',
|
|
246
|
+
'email', 'first_name', 'last_name', 'introduced_by',
|
|
247
|
+
]);
|
|
248
|
+
const SUBSCRIBER_VIEW_STAGES = new Set(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']);
|
|
249
|
+
function validateSubscriberViewInput(columns, filters) {
|
|
250
|
+
if (isPlainObject(filters)) {
|
|
251
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
252
|
+
if (!SUBSCRIBER_VIEW_FILTER_KEYS.has(key))
|
|
253
|
+
return `Unknown filter "${key}". Valid keys: ${[...SUBSCRIBER_VIEW_FILTER_KEYS].join(', ')}`;
|
|
254
|
+
if (key === 'show_test' ? typeof value !== 'boolean' : typeof value !== 'string' || value.length > 500)
|
|
255
|
+
return `Invalid value for filter "${key}"`;
|
|
256
|
+
if (key === 'stage' && value !== '' && !SUBSCRIBER_VIEW_STAGES.has(value))
|
|
257
|
+
return `Invalid stage. Valid stages: ${[...SUBSCRIBER_VIEW_STAGES].join(', ')}`;
|
|
258
|
+
if (key === 'dir' && value !== 'asc' && value !== 'desc')
|
|
259
|
+
return 'dir must be asc or desc';
|
|
260
|
+
if (key === 'sort' && value !== '' && !SUBSCRIBER_VIEW_SORT_FIELDS.has(value))
|
|
261
|
+
return 'Invalid sort field';
|
|
262
|
+
if (key === 'status' && !['', 'all', 'active', 'unsubscribed', 'not_subscribed'].includes(value))
|
|
263
|
+
return 'Invalid subscription status';
|
|
264
|
+
if (key === 'cp_key' && value !== '' && !SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(value))
|
|
265
|
+
return 'Invalid custom property key';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (isPlainObject(filters) && Boolean(filters.cp_key) !== Boolean(filters.cp_value))
|
|
269
|
+
return 'cp_key and cp_value must be provided together';
|
|
270
|
+
if (isPlainObject(columns)) {
|
|
271
|
+
for (const list of [columns.order, columns.hidden]) {
|
|
272
|
+
if (list === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
if (!Array.isArray(list) || list.length > SUBSCRIBER_VIEW_MAX_COLUMNS)
|
|
275
|
+
return 'Invalid column list';
|
|
276
|
+
for (const key of list) {
|
|
277
|
+
if (typeof key !== 'string' || !SUBSCRIBER_VIEW_KEY_RE.test(key) ||
|
|
278
|
+
(!SUBSCRIBER_VIEW_COLUMNS.has(key) && !(key.startsWith('prop:') && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(key.slice(5))))) {
|
|
279
|
+
return `Unknown column "${String(key)}". Use a subscriber column or prop:<custom property key>`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
241
286
|
function normalizeSubscriberViewKeys(value) {
|
|
242
287
|
if (!Array.isArray(value))
|
|
243
288
|
return [];
|
|
@@ -1075,9 +1120,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1075
1120
|
filters: z.record(z.string(), z.unknown()).optional(),
|
|
1076
1121
|
},
|
|
1077
1122
|
}, async ({ name, view_mode, columns, filters }) => {
|
|
1123
|
+
const inputError = validateSubscriberViewInput(columns, filters);
|
|
1124
|
+
if (inputError)
|
|
1125
|
+
return toolError({ error: inputError, code: 'validation_failed' });
|
|
1078
1126
|
const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
|
|
1079
1127
|
if (!normalizedName)
|
|
1080
|
-
return
|
|
1128
|
+
return toolError({ error: 'name is required', code: 'validation_failed' });
|
|
1081
1129
|
const { data, error } = await db()
|
|
1082
1130
|
.from('subscriber_list_views')
|
|
1083
1131
|
.insert({
|
|
@@ -1091,9 +1139,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1091
1139
|
.select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
|
|
1092
1140
|
.maybeSingle();
|
|
1093
1141
|
if (error?.code === '23505')
|
|
1094
|
-
return
|
|
1142
|
+
return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
|
|
1095
1143
|
if (error)
|
|
1096
|
-
return
|
|
1144
|
+
return toolError(dbToolError(error));
|
|
1145
|
+
await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
|
|
1097
1146
|
return j({ view: data });
|
|
1098
1147
|
});
|
|
1099
1148
|
server.registerTool('update_subscriber_view', {
|
|
@@ -1116,11 +1165,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1116
1165
|
if (name === undefined && view_mode === undefined && columns === undefined && filters === undefined) {
|
|
1117
1166
|
return txt('Nothing to update');
|
|
1118
1167
|
}
|
|
1168
|
+
const inputError = validateSubscriberViewInput(columns, filters);
|
|
1169
|
+
if (inputError)
|
|
1170
|
+
return toolError({ error: inputError, code: 'validation_failed' });
|
|
1119
1171
|
const updates = {};
|
|
1120
1172
|
if (name !== undefined) {
|
|
1121
1173
|
const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
|
|
1122
1174
|
if (!normalizedName)
|
|
1123
|
-
return
|
|
1175
|
+
return toolError({ error: 'name is required', code: 'validation_failed' });
|
|
1124
1176
|
updates.name = normalizedName;
|
|
1125
1177
|
}
|
|
1126
1178
|
if (view_mode !== undefined)
|
|
@@ -1137,11 +1189,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1137
1189
|
.select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
|
|
1138
1190
|
.maybeSingle();
|
|
1139
1191
|
if (error?.code === '23505')
|
|
1140
|
-
return
|
|
1192
|
+
return toolError({ error: 'A view with that name already exists', code: 'conflict' });
|
|
1141
1193
|
if (error)
|
|
1142
|
-
return
|
|
1194
|
+
return toolError(dbToolError(error));
|
|
1143
1195
|
if (!data)
|
|
1144
|
-
return
|
|
1196
|
+
return toolError({ error: 'View not found', code: 'not_found' });
|
|
1197
|
+
await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
|
|
1145
1198
|
return j({ view: data });
|
|
1146
1199
|
});
|
|
1147
1200
|
server.registerTool('delete_subscriber_view', {
|
|
@@ -1649,6 +1702,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1649
1702
|
},
|
|
1650
1703
|
description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
|
|
1651
1704
|
inputSchema: {
|
|
1705
|
+
segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
|
|
1652
1706
|
lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
|
|
1653
1707
|
min_score: z.number().int().min(0).max(100).optional(),
|
|
1654
1708
|
max_score: z.number().int().min(0).max(100).optional(),
|
|
@@ -1657,8 +1711,28 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1657
1711
|
company_domain: z.string().optional(),
|
|
1658
1712
|
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
1659
1713
|
},
|
|
1660
|
-
}, async ({ lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
|
|
1714
|
+
}, async ({ segment_id, lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
|
|
1661
1715
|
const projectId = getProjectId();
|
|
1716
|
+
let segmentIds;
|
|
1717
|
+
if (segment_id) {
|
|
1718
|
+
if ([lifecycle_stage, min_score, max_score, site_id, company_id, company_domain].some(value => value !== undefined)) {
|
|
1719
|
+
return toolError({ error: 'Provide segment_id or inline filters, not both', code: 'validation_failed' });
|
|
1720
|
+
}
|
|
1721
|
+
const { data: segment, error } = await db().from('segments').select('rules')
|
|
1722
|
+
.eq('project_id', projectId).eq('id', segment_id).maybeSingle();
|
|
1723
|
+
if (error)
|
|
1724
|
+
return toolError(dbToolError(error));
|
|
1725
|
+
if (!segment)
|
|
1726
|
+
return toolError({ error: 'Segment not found', code: 'not_found' });
|
|
1727
|
+
try {
|
|
1728
|
+
segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
|
|
1729
|
+
}
|
|
1730
|
+
catch (error) {
|
|
1731
|
+
return toolError(isSegmentRulesError(error) ? { error: error.message, code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
|
|
1732
|
+
}
|
|
1733
|
+
if (!segmentIds.length)
|
|
1734
|
+
return j([]);
|
|
1735
|
+
}
|
|
1662
1736
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1663
1737
|
let q = db()
|
|
1664
1738
|
.from('subscribers')
|
|
@@ -1697,9 +1771,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1697
1771
|
return { content: [{ type: 'text', text: '[]' }] };
|
|
1698
1772
|
q = q.in('company_id', ids);
|
|
1699
1773
|
}
|
|
1774
|
+
if (segmentIds) {
|
|
1775
|
+
const rows = [];
|
|
1776
|
+
for (const ids of chunkIds(segmentIds)) {
|
|
1777
|
+
// A Supabase builder is mutable; build a fresh query for each chunk.
|
|
1778
|
+
const { data, error } = await db().from('subscribers')
|
|
1779
|
+
.select(embedOk ? SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY : SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1780
|
+
.eq('project_id', projectId).eq('global_unsubscribed', false).in('id', ids)
|
|
1781
|
+
.order('engagement_score', { ascending: false }).limit(limit ?? 50);
|
|
1782
|
+
if (error)
|
|
1783
|
+
return toolError(dbToolError(error));
|
|
1784
|
+
rows.push(...(data ?? []));
|
|
1785
|
+
}
|
|
1786
|
+
return j(rows.sort((a, b) => b.engagement_score - a.engagement_score).slice(0, limit ?? 50));
|
|
1787
|
+
}
|
|
1700
1788
|
const { data, error } = await q;
|
|
1701
1789
|
if (error)
|
|
1702
|
-
return
|
|
1790
|
+
return toolError(dbToolError(error));
|
|
1703
1791
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1704
1792
|
});
|
|
1705
1793
|
server.registerTool('update_subscriber', {
|
|
@@ -2056,18 +2144,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2056
2144
|
},
|
|
2057
2145
|
}, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
|
|
2058
2146
|
if (!add_tags.length && !remove_tags.length)
|
|
2059
|
-
return
|
|
2147
|
+
return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
|
|
2060
2148
|
const projectId = getProjectId();
|
|
2061
2149
|
let updated = 0;
|
|
2150
|
+
const missing_contact_ids = [];
|
|
2062
2151
|
for (const cid of contact_ids) {
|
|
2063
|
-
const { data: contact } = await db()
|
|
2152
|
+
const { data: contact, error: readError } = await db()
|
|
2064
2153
|
.from('subscribers')
|
|
2065
2154
|
.select('id, email, first_name, last_name, custom_properties, tags')
|
|
2066
2155
|
.eq('id', cid)
|
|
2067
2156
|
.eq('project_id', projectId)
|
|
2068
2157
|
.maybeSingle();
|
|
2069
|
-
if (
|
|
2158
|
+
if (readError)
|
|
2159
|
+
return toolError({ ...dbToolError(readError), updated });
|
|
2160
|
+
if (!contact) {
|
|
2161
|
+
missing_contact_ids.push(cid);
|
|
2070
2162
|
continue;
|
|
2163
|
+
}
|
|
2071
2164
|
const currentTags = contact.tags ?? [];
|
|
2072
2165
|
const removeSet = new Set(remove_tags);
|
|
2073
2166
|
const merged = currentTags.filter((t) => !removeSet.has(t));
|
|
@@ -2075,17 +2168,25 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2075
2168
|
if (!merged.includes(t))
|
|
2076
2169
|
merged.push(t);
|
|
2077
2170
|
}
|
|
2078
|
-
const { error } = await db()
|
|
2171
|
+
const { data: saved, error } = await db()
|
|
2079
2172
|
.from('subscribers')
|
|
2080
2173
|
.update({ tags: merged })
|
|
2081
2174
|
.eq('id', cid)
|
|
2082
|
-
.eq('project_id', projectId)
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2175
|
+
.eq('project_id', projectId)
|
|
2176
|
+
.select('id').maybeSingle();
|
|
2177
|
+
if (error)
|
|
2178
|
+
return toolError({ ...dbToolError(error), updated });
|
|
2179
|
+
if (!saved) {
|
|
2180
|
+
missing_contact_ids.push(cid);
|
|
2181
|
+
continue;
|
|
2086
2182
|
}
|
|
2183
|
+
updated++;
|
|
2184
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
2185
|
+
await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
|
|
2087
2186
|
}
|
|
2088
|
-
|
|
2187
|
+
if (!updated)
|
|
2188
|
+
return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
|
|
2189
|
+
return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
|
|
2089
2190
|
});
|
|
2090
2191
|
server.registerTool('bulk_update_subscribers', {
|
|
2091
2192
|
title: 'Bulk Update Subscribers',
|
package/build/tools/content.js
CHANGED
|
@@ -2,6 +2,9 @@ import { withApproval } from '../lib/action-approvals.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { adminDb, db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
|
|
6
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
5
8
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
6
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
7
10
|
const BLOG_IMAGE_FORMATS = ['square_image', 'story_image', 'animated_gif', 'short_video', 'carousel'];
|
|
@@ -219,7 +222,7 @@ export function registerContentTools(server) {
|
|
|
219
222
|
idempotentHint: false,
|
|
220
223
|
openWorldHint: false,
|
|
221
224
|
},
|
|
222
|
-
description: 'Create a reusable content block.',
|
|
225
|
+
description: 'Create a reusable content block. Active HTML is rejected; footer blocks require an unsubscribe link or merge tag.',
|
|
223
226
|
inputSchema: {
|
|
224
227
|
name: z.string().min(1),
|
|
225
228
|
category: z.enum(['header', 'footer', 'cta', 'legal', 'general']).optional().default('general'),
|
|
@@ -227,12 +230,19 @@ export function registerContentTools(server) {
|
|
|
227
230
|
description: z.string().optional(),
|
|
228
231
|
},
|
|
229
232
|
}, async ({ name, category = 'general', html, description }) => {
|
|
233
|
+
const active = stripActiveEmailContent(html);
|
|
234
|
+
if (active.removed.length)
|
|
235
|
+
return toolError({ error: 'Active HTML is not allowed in content blocks', code: 'validation_failed' });
|
|
236
|
+
if (category === 'footer' && !/href\s*=\s*["']\s*\{\{\s*unsubscribe_url\s*\}\}/i.test(html) && !validateUnsubscribeFooterTemplate(html).ok) {
|
|
237
|
+
return toolError({ error: 'Footer blocks require {{unsubscribe_url}} in a link or <unsubscribe>link text</unsubscribe>', code: 'validation_failed' });
|
|
238
|
+
}
|
|
230
239
|
const { data, error } = await db().from('content_blocks')
|
|
231
240
|
.insert({ project_id: getProjectId(), name, category, html, description: description ?? null })
|
|
232
241
|
.select('id, name, category, html, description, created_at')
|
|
233
242
|
.single();
|
|
234
243
|
if (error)
|
|
235
|
-
return
|
|
244
|
+
return toolError(dbToolError(error));
|
|
245
|
+
await logAudit({ action: 'content_block.created', resourceType: 'content_block', resourceId: data.id });
|
|
236
246
|
return j({ block: data });
|
|
237
247
|
});
|
|
238
248
|
server.registerTool('delete_content_block', {
|
|
@@ -6,6 +6,7 @@ import { adminDb, db } from '../db.js';
|
|
|
6
6
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
9
10
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
11
|
function asRecord(value) {
|
|
11
12
|
return value && typeof value === 'object' ? value : {};
|
|
@@ -154,18 +155,18 @@ export function registerDeliveryOpsTools(server) {
|
|
|
154
155
|
.eq('id', domain_id)
|
|
155
156
|
.maybeSingle();
|
|
156
157
|
if (error)
|
|
157
|
-
return
|
|
158
|
+
return toolError(dbToolError(error, 'Unable to load domain'));
|
|
158
159
|
if (!data)
|
|
159
|
-
return
|
|
160
|
+
return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
160
161
|
const { data: health, error: healthError } = await db().from('domain_health')
|
|
161
162
|
.select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
|
|
162
|
-
.eq('project_id', getProjectId())
|
|
163
|
+
.eq('sites.project_id', getProjectId())
|
|
163
164
|
.eq('sites.domain_id', domain_id)
|
|
164
165
|
.order('check_date', { ascending: false })
|
|
165
166
|
.limit(1)
|
|
166
167
|
.maybeSingle();
|
|
167
168
|
if (healthError)
|
|
168
|
-
return
|
|
169
|
+
return toolError(dbToolError(healthError, 'Unable to load domain health'));
|
|
169
170
|
const spf = health?.spf_valid ?? null;
|
|
170
171
|
const dkim = health?.dkim_valid ?? null;
|
|
171
172
|
const dmarc = health?.dmarc_valid ?? null;
|
|
@@ -3,10 +3,55 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
6
|
+
import { stripActiveEmailContent } from '@sendinel/domain';
|
|
7
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
6
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
7
9
|
const SLUG_RE = /^[a-z0-9][a-z0-9\-]{0,62}$/;
|
|
8
10
|
const VALID_STATUSES = ['draft', 'published', 'archived'];
|
|
9
11
|
const LIST_COLUMNS = 'id, title, slug, site_id, status, form_id, blocks, meta_title, meta_description, conversion_event, views, conversions, background_color, accent_color, custom_css, created_at, updated_at, published_at';
|
|
12
|
+
// Types rendered by LandingPageRenderer. Reuse the domain active-content pass.
|
|
13
|
+
const BLOCK_TYPES = new Set(['hero', 'text', 'image', 'form', 'features', 'testimonial', 'cta', 'spacer']);
|
|
14
|
+
const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
15
|
+
function validatePageContent(input) {
|
|
16
|
+
for (const key of ['background_color', 'accent_color']) {
|
|
17
|
+
if (input[key] !== undefined && (typeof input[key] !== 'string' || !COLOR_RE.test(input[key]))) {
|
|
18
|
+
return `${key} must be a three- or six-digit hex color`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (input.custom_css != null && (typeof input.custom_css !== 'string' || /<\s*\/\s*style|<\s*script|javascript\s*:|expression\s*\(|@import/i.test(input.custom_css))) {
|
|
22
|
+
return 'custom_css contains unsafe content';
|
|
23
|
+
}
|
|
24
|
+
if (input.blocks !== undefined && (!Array.isArray(input.blocks) || input.blocks.some(block => !block || typeof block !== 'object' || !BLOCK_TYPES.has(block.type)))) {
|
|
25
|
+
return `Each block must have a recognized type: ${[...BLOCK_TYPES].join(', ')}`;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function sanitizeBlockValue(value) {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
// Also inspect bare URL fields (ctaUrl/url/src), using the same URL decoder
|
|
32
|
+
// as HTML attributes rather than a second scheme blacklist.
|
|
33
|
+
const url = stripActiveEmailContent(`<a href="${value.replace(/"/g, '"')}">`);
|
|
34
|
+
if (url.removed.some(finding => finding.endsWith(' URL')))
|
|
35
|
+
return '';
|
|
36
|
+
return stripActiveEmailContent(value).html;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map(sanitizeBlockValue);
|
|
40
|
+
if (value && typeof value === 'object')
|
|
41
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeBlockValue(item)]));
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
async function validatePageForm(formId) {
|
|
45
|
+
if (!formId)
|
|
46
|
+
return null;
|
|
47
|
+
const { data, error } = await db().from('signup_forms').select('id')
|
|
48
|
+
.eq('project_id', getProjectId()).eq('id', formId).maybeSingle();
|
|
49
|
+
if (error)
|
|
50
|
+
return dbToolError(error, 'Unable to load form');
|
|
51
|
+
if (!data)
|
|
52
|
+
return { error: 'form_id not found in this project', code: 'not_found' };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
10
55
|
function slugify(text) {
|
|
11
56
|
return text
|
|
12
57
|
.toLowerCase()
|
|
@@ -53,7 +98,7 @@ export function registerLandingPageTools(server) {
|
|
|
53
98
|
server.registerTool('create_landing_page', {
|
|
54
99
|
title: 'Create Landing Page',
|
|
55
100
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
56
|
-
description: 'Create a draft landing page.
|
|
101
|
+
description: 'Create a draft landing page. Blocks require a renderer type and have active content stripped; colors must be hex and custom_css must be safe. Publish with update_landing_page.',
|
|
57
102
|
inputSchema: {
|
|
58
103
|
title: z.string().trim().min(1).max(200),
|
|
59
104
|
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
@@ -72,7 +117,13 @@ export function registerLandingPageTools(server) {
|
|
|
72
117
|
const normalizedTitle = title.trim();
|
|
73
118
|
const normalizedSlug = (slug ?? slugify(normalizedTitle)).trim();
|
|
74
119
|
if (!SLUG_RE.test(normalizedSlug))
|
|
75
|
-
return
|
|
120
|
+
return toolError({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)', code: 'validation_failed' });
|
|
121
|
+
const contentError = validatePageContent({ blocks, background_color, accent_color, custom_css });
|
|
122
|
+
if (contentError)
|
|
123
|
+
return toolError({ error: contentError, code: 'validation_failed' });
|
|
124
|
+
const formError = await validatePageForm(form_id);
|
|
125
|
+
if (formError)
|
|
126
|
+
return toolError(formError);
|
|
76
127
|
const insert = {
|
|
77
128
|
project_id: projectId,
|
|
78
129
|
title: normalizedTitle,
|
|
@@ -83,7 +134,7 @@ export function registerLandingPageTools(server) {
|
|
|
83
134
|
if (form_id)
|
|
84
135
|
insert.form_id = form_id;
|
|
85
136
|
if (blocks)
|
|
86
|
-
insert.blocks = blocks;
|
|
137
|
+
insert.blocks = sanitizeBlockValue(blocks);
|
|
87
138
|
if (meta_title)
|
|
88
139
|
insert.meta_title = meta_title;
|
|
89
140
|
if (meta_description)
|
|
@@ -103,8 +154,10 @@ export function registerLandingPageTools(server) {
|
|
|
103
154
|
.single();
|
|
104
155
|
if (error) {
|
|
105
156
|
if (error.code === '23505')
|
|
106
|
-
return
|
|
107
|
-
|
|
157
|
+
return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
|
|
158
|
+
if (error.code === '23503')
|
|
159
|
+
return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
|
|
160
|
+
return toolError(dbToolError(error));
|
|
108
161
|
}
|
|
109
162
|
await logAudit({
|
|
110
163
|
action: 'landing_page.created',
|
|
@@ -136,6 +189,24 @@ export function registerLandingPageTools(server) {
|
|
|
136
189
|
},
|
|
137
190
|
}, withApproval('update_landing_page', async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
138
191
|
const projectId = getProjectId();
|
|
192
|
+
// Validate the effective document on publish, including unsafe older drafts.
|
|
193
|
+
let content = { blocks, background_color, accent_color, custom_css };
|
|
194
|
+
if (status === 'published') {
|
|
195
|
+
const { data: existing, error: readError } = await db().from('landing_pages')
|
|
196
|
+
.select('blocks, background_color, accent_color, custom_css, form_id')
|
|
197
|
+
.eq('id', page_id).eq('project_id', projectId).maybeSingle();
|
|
198
|
+
if (readError)
|
|
199
|
+
return toolError(dbToolError(readError));
|
|
200
|
+
if (!existing)
|
|
201
|
+
return toolError({ error: 'Landing page not found', code: 'not_found' });
|
|
202
|
+
content = { ...existing, ...Object.fromEntries(Object.entries(content).filter(([, value]) => value !== undefined)) };
|
|
203
|
+
}
|
|
204
|
+
const contentError = validatePageContent(content);
|
|
205
|
+
if (contentError)
|
|
206
|
+
return toolError({ error: contentError, code: 'validation_failed' });
|
|
207
|
+
const formError = await validatePageForm(form_id === undefined ? content.form_id : form_id);
|
|
208
|
+
if (formError)
|
|
209
|
+
return toolError(formError);
|
|
139
210
|
const updates = { updated_at: new Date().toISOString() };
|
|
140
211
|
if (title !== undefined)
|
|
141
212
|
updates.title = title.trim();
|
|
@@ -151,7 +222,7 @@ export function registerLandingPageTools(server) {
|
|
|
151
222
|
if (form_id !== undefined)
|
|
152
223
|
updates.form_id = form_id;
|
|
153
224
|
if (blocks !== undefined)
|
|
154
|
-
updates.blocks = blocks;
|
|
225
|
+
updates.blocks = sanitizeBlockValue(blocks);
|
|
155
226
|
if (meta_title !== undefined)
|
|
156
227
|
updates.meta_title = meta_title;
|
|
157
228
|
if (meta_description !== undefined)
|
|
@@ -164,18 +235,24 @@ export function registerLandingPageTools(server) {
|
|
|
164
235
|
updates.accent_color = accent_color;
|
|
165
236
|
if (custom_css !== undefined)
|
|
166
237
|
updates.custom_css = custom_css;
|
|
238
|
+
if (status === 'published' && content.blocks !== undefined)
|
|
239
|
+
updates.blocks = sanitizeBlockValue(content.blocks);
|
|
167
240
|
const { data, error } = await db()
|
|
168
241
|
.from('landing_pages')
|
|
169
242
|
.update(updates)
|
|
170
243
|
.eq('id', page_id)
|
|
171
244
|
.eq('project_id', projectId)
|
|
172
245
|
.select()
|
|
173
|
-
.
|
|
246
|
+
.maybeSingle();
|
|
174
247
|
if (error) {
|
|
175
248
|
if (error.code === '23505')
|
|
176
|
-
return
|
|
177
|
-
|
|
249
|
+
return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
|
|
250
|
+
if (error.code === '23503')
|
|
251
|
+
return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
|
|
252
|
+
return toolError(dbToolError(error));
|
|
178
253
|
}
|
|
254
|
+
if (!data)
|
|
255
|
+
return toolError({ error: 'Landing page not found', code: 'not_found' });
|
|
179
256
|
await logAudit({
|
|
180
257
|
action: 'landing_page.updated',
|
|
181
258
|
resourceType: 'landing_page',
|
package/build/tools/segments.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
5
5
|
import { db } from '../db.js';
|
|
6
6
|
import { getProjectId } from '../project.js';
|
|
7
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
7
8
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
8
9
|
import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
|
|
9
10
|
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
|
|
@@ -89,7 +90,7 @@ export function registerSegmentTools(server) {
|
|
|
89
90
|
catch (rulesError) {
|
|
90
91
|
const invalid = invalidRules(rulesError);
|
|
91
92
|
if (invalid)
|
|
92
|
-
return
|
|
93
|
+
return toolError({ error: rulesError instanceof Error ? rulesError.message : 'Invalid segment rules', code: 'validation_failed' });
|
|
93
94
|
throw rulesError;
|
|
94
95
|
}
|
|
95
96
|
const { data, error } = await db()
|
|
@@ -97,8 +98,10 @@ export function registerSegmentTools(server) {
|
|
|
97
98
|
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
98
99
|
.select('id, name, description, rules, created_at')
|
|
99
100
|
.single();
|
|
101
|
+
if (error?.code === '23505')
|
|
102
|
+
return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
|
|
100
103
|
if (error)
|
|
101
|
-
return
|
|
104
|
+
return toolError(dbToolError(error));
|
|
102
105
|
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
103
106
|
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
104
107
|
// does for this same segment a moment later. Pushing the flat rules down instead
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.142",
|
|
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",
|