@sendinel/mcp-server 1.0.138 → 1.0.139
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/README.md +2 -2
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +15 -0
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.d.ts +2 -1
- package/build/tools/campaigns.js +72 -11
- package/build/tools/contacts.js +110 -13
- package/build/tools/content.js +9 -0
- package/build/tools/delivery-ops.js +6 -2
- package/build/tools/forms.js +30 -3
- package/build/tools/newsletters.js +41 -9
- package/build/tools/short-links.js +20 -14
- package/build/tools/surveys.js +41 -4
- package/build/tools/tool-search.js +1 -0
- package/build/tools/webhooks.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -34,7 +34,7 @@ Sendinel currently exposes 35 tool groups and 343 tools (gateway mode exposes 4
|
|
|
34
34
|
| `cloud-import` | 6 | Cloud storage import |
|
|
35
35
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
36
36
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
37
|
-
| `contacts` |
|
|
37
|
+
| `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
|
|
38
38
|
| `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
39
39
|
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
40
40
|
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
|
|
2
|
+
export declare function assertHttpUrl(raw: string): string;
|
|
3
|
+
export declare const RESERVED_LINK_SLUGS: Set<string>;
|
|
1
4
|
export declare function isSafeRedirectUrl(url: unknown): url is string;
|
|
2
5
|
export declare function isAllowedHttpsHostUrl(url: unknown, allowedDomains: readonly string[]): url is string;
|
|
3
6
|
export declare function normalizeActiveCampaignAccountUrl(accountUrl: string): string;
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
|
|
2
|
+
export function assertHttpUrl(raw) {
|
|
3
|
+
const url = new URL(raw);
|
|
4
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
5
|
+
throw new Error('Only http and https URLs are supported.');
|
|
6
|
+
}
|
|
7
|
+
return url.toString();
|
|
8
|
+
}
|
|
9
|
+
// Shared /s namespace: system routes cannot be claimed by a tenant.
|
|
10
|
+
export const RESERVED_LINK_SLUGS = new Set([
|
|
11
|
+
'admin', 'api', 'app', 'login', 'dashboard', 's', 'a', 'www', 'support', 'billing',
|
|
12
|
+
'auth', 'static', 'assets', 'aup', 'ccpa', 'changelog', 'cloud-storage', 'demo-video',
|
|
13
|
+
'developer', 'embed', 'gdpr', 'getting-started', 'invite', 'legal', 'lp', 'oauth',
|
|
14
|
+
'pricing', 'privacy', 'reset-password', 'sign-in', 'sign-up', 'terms',
|
|
15
|
+
]);
|
|
1
16
|
export function isSafeRedirectUrl(url) {
|
|
2
17
|
if (typeof url !== 'string' || !url)
|
|
3
18
|
return false;
|
package/build/tools/approvals.js
CHANGED
|
@@ -42,7 +42,7 @@ export function registerApprovalTools(server, options) {
|
|
|
42
42
|
idempotentHint: true,
|
|
43
43
|
openWorldHint: false,
|
|
44
44
|
},
|
|
45
|
-
description: 'List all pending approval requests for this project. Shows what actions are waiting for human approval.',
|
|
45
|
+
description: 'List all pending approval requests for this project. Excludes expired requests without mutating them. Shows what actions are waiting for human approval.',
|
|
46
46
|
inputSchema: {},
|
|
47
47
|
}, async () => {
|
|
48
48
|
const { data, error } = await db()
|
|
@@ -50,10 +50,11 @@ export function registerApprovalTools(server, options) {
|
|
|
50
50
|
.select('id, action, tier, channel, summary, status, requested_at, expires_at')
|
|
51
51
|
.eq('project_id', getProjectId())
|
|
52
52
|
.eq('status', 'pending')
|
|
53
|
+
.gt('expires_at', new Date().toISOString())
|
|
53
54
|
.order('requested_at', { ascending: false })
|
|
54
55
|
.limit(25);
|
|
55
56
|
if (error)
|
|
56
|
-
return
|
|
57
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
57
58
|
return j({ pending: data ?? [], count: data?.length ?? 0 });
|
|
58
59
|
});
|
|
59
60
|
}
|
|
@@ -7,7 +7,8 @@ export declare function buildCampaignFiresOn(campaign: Record<string, unknown>):
|
|
|
7
7
|
/**
|
|
8
8
|
* Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
|
|
9
9
|
*/
|
|
10
|
-
export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>): string[];
|
|
10
|
+
export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>, seenEvents?: ReadonlySet<string> | null): string[];
|
|
11
|
+
export declare function campaignTriggerKind(campaign: Record<string, unknown>): 'event' | 'tag' | 'date_field' | 'contact_event' | 'manual';
|
|
11
12
|
export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
|
|
12
13
|
trigger_event_name?: string | null;
|
|
13
14
|
trigger_tag?: string | null;
|
package/build/tools/campaigns.js
CHANGED
|
@@ -105,11 +105,18 @@ export function buildCampaignFiresOn(campaign) {
|
|
|
105
105
|
/**
|
|
106
106
|
* Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
|
|
107
107
|
*/
|
|
108
|
-
export function buildCampaignTriggerDiagnostics(campaign) {
|
|
108
|
+
export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
|
|
109
109
|
const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
|
|
110
110
|
const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
|
|
111
111
|
const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
|
|
112
112
|
const diagnostics = [];
|
|
113
|
+
if (events.length && seenEvents === null)
|
|
114
|
+
diagnostics.push('Event history unavailable; prior receipt of these events is unknown.');
|
|
115
|
+
if (seenEvents)
|
|
116
|
+
for (const event of events) {
|
|
117
|
+
if (!seenEvents.has(event))
|
|
118
|
+
diagnostics.push(`Event "${event}" has never been seen in this project; check the event name or send a test event.`);
|
|
119
|
+
}
|
|
113
120
|
if ((events.length || tags.length) && campaign.type !== 'triggered') {
|
|
114
121
|
diagnostics.push(`campaign type is "${String(campaign.type)}"; event and tag triggers only start campaigns of type "triggered" (it will never fire)`);
|
|
115
122
|
}
|
|
@@ -133,6 +140,37 @@ export function buildCampaignTriggerDiagnostics(campaign) {
|
|
|
133
140
|
}
|
|
134
141
|
return diagnostics;
|
|
135
142
|
}
|
|
143
|
+
export function campaignTriggerKind(campaign) {
|
|
144
|
+
if (campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event')
|
|
145
|
+
return campaign.trigger_type;
|
|
146
|
+
const config = normalizeTriggerConfig(campaign.trigger_config);
|
|
147
|
+
if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length)
|
|
148
|
+
return 'event';
|
|
149
|
+
if (normalizeTriggerValue(campaign.trigger_tag) || config.tags.length)
|
|
150
|
+
return 'tag';
|
|
151
|
+
return 'manual';
|
|
152
|
+
}
|
|
153
|
+
async function campaignTriggerOutput(campaign) {
|
|
154
|
+
let seenEvents;
|
|
155
|
+
const config = normalizeTriggerConfig(campaign.trigger_config);
|
|
156
|
+
if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length) {
|
|
157
|
+
try {
|
|
158
|
+
// /v1/events -> intakeContactEvent stores event_type=custom and title=event.
|
|
159
|
+
// Read all pages: a capped sample cannot establish that an event was never seen.
|
|
160
|
+
const rows = await readAllRows(db().from('contact_events').select('id, title')
|
|
161
|
+
.eq('project_id', getProjectId()).eq('event_type', 'custom'));
|
|
162
|
+
seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
seenEvents = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
trigger_kind: campaignTriggerKind(campaign),
|
|
170
|
+
fires_on: buildCampaignFiresOn(campaign),
|
|
171
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
136
174
|
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
137
175
|
if (sendAt === undefined)
|
|
138
176
|
return null;
|
|
@@ -165,13 +203,20 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
|
|
|
165
203
|
if (hasTriggerEvent)
|
|
166
204
|
return 'cannot set trigger_event_name on a date_field campaign';
|
|
167
205
|
const config = input.trigger_config ?? {};
|
|
206
|
+
const unknown = Object.keys(config).filter(key => !['field', 'days_before', 'recurring'].includes(key));
|
|
207
|
+
if (unknown.length)
|
|
208
|
+
return `Unknown date_field keys: ${unknown.join(', ')}; allowed: field, days_before, recurring; use days_before, not offset_days`;
|
|
209
|
+
if (config.days_before !== undefined && (typeof config.days_before !== 'number' || !Number.isInteger(config.days_before) || config.days_before < 0 || config.days_before > 30))
|
|
210
|
+
return 'days_before must be an integer from 0 to 30; use days_before, not offset_days';
|
|
211
|
+
if (config.recurring !== undefined && typeof config.recurring !== 'boolean')
|
|
212
|
+
return 'recurring must be a boolean';
|
|
168
213
|
const field = typeof config.field === 'string' ? config.field.trim() : '';
|
|
169
214
|
if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
|
|
170
215
|
return 'date field is required';
|
|
171
216
|
updates.trigger_type = 'date_field';
|
|
172
217
|
updates.trigger_config = {
|
|
173
218
|
field,
|
|
174
|
-
days_before:
|
|
219
|
+
days_before: config.days_before ?? 0,
|
|
175
220
|
recurring: config.recurring !== false,
|
|
176
221
|
};
|
|
177
222
|
}
|
|
@@ -197,6 +242,24 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
|
|
|
197
242
|
updates.trigger_config = null;
|
|
198
243
|
}
|
|
199
244
|
else {
|
|
245
|
+
const unknown = Object.keys(input.trigger_config).filter(key => !['events', 'tags', 'where'].includes(key));
|
|
246
|
+
if (unknown.length)
|
|
247
|
+
return `Unknown trigger_config keys: ${unknown.join(', ')}; allowed: events, tags, where`;
|
|
248
|
+
if ('where' in input.trigger_config) {
|
|
249
|
+
const where = input.trigger_config.where;
|
|
250
|
+
if (!where || typeof where !== 'object' || Array.isArray(where))
|
|
251
|
+
return 'where must be an object of string, number or boolean values';
|
|
252
|
+
const keys = new Set();
|
|
253
|
+
for (const [key, value] of Object.entries(where)) {
|
|
254
|
+
const normalizedKey = key.trim().replace(/^ctx\./, '');
|
|
255
|
+
if (!normalizedKey || normalizedKey === '__proto__' || keys.has(normalizedKey))
|
|
256
|
+
return `where.${key} has an empty or duplicate normalized key`;
|
|
257
|
+
keys.add(normalizedKey);
|
|
258
|
+
if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) {
|
|
259
|
+
return `where.${key} must be a string, number or boolean; operators like $gt are not supported`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
200
263
|
const config = buildCampaignTriggerConfig(input.trigger_config);
|
|
201
264
|
if (!config)
|
|
202
265
|
return 'trigger_config must include events or tags';
|
|
@@ -3188,8 +3251,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3188
3251
|
send_class: campaign.send_class,
|
|
3189
3252
|
unsub_footer_template: campaign.unsub_footer_template,
|
|
3190
3253
|
...targeting,
|
|
3191
|
-
|
|
3192
|
-
trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3254
|
+
...await campaignTriggerOutput({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3193
3255
|
steps_created: steps.length,
|
|
3194
3256
|
contacts_enrolled: enrolled,
|
|
3195
3257
|
status: willActivate ? 'active' : 'draft',
|
|
@@ -3381,7 +3443,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3381
3443
|
server.registerTool('set_campaign_trigger', {
|
|
3382
3444
|
title: 'Set Campaign Trigger',
|
|
3383
3445
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3384
|
-
description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
|
|
3446
|
+
description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, derived trigger_kind, fires_on and trigger_diagnostics (including unseen-event warnings).',
|
|
3385
3447
|
inputSchema: {
|
|
3386
3448
|
...reentryFields,
|
|
3387
3449
|
campaign_id: z.string().uuid().optional(),
|
|
@@ -3395,7 +3457,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3395
3457
|
}, async ({ approval_id, ...args }) => {
|
|
3396
3458
|
if (approval_id) {
|
|
3397
3459
|
const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
|
|
3398
|
-
return j(result.error ? result : { ...result,
|
|
3460
|
+
return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
|
|
3399
3461
|
}
|
|
3400
3462
|
if (!args.campaign_id)
|
|
3401
3463
|
return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
|
|
@@ -3435,7 +3497,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3435
3497
|
if (reentry.period_key !== undefined)
|
|
3436
3498
|
updates.period_key = reentry.period_key;
|
|
3437
3499
|
const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
|
|
3438
|
-
return j(result.error || result.requires_approval ? result : { ...result,
|
|
3500
|
+
return j(result.error || result.requires_approval ? result : { ...result, ...await campaignTriggerOutput(result) });
|
|
3439
3501
|
});
|
|
3440
3502
|
server.registerTool('update_campaign', {
|
|
3441
3503
|
title: 'Update Campaign',
|
|
@@ -3468,7 +3530,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3468
3530
|
}, async ({ approval_id, ...input }) => {
|
|
3469
3531
|
if (approval_id) {
|
|
3470
3532
|
const result = await resumeCampaignEdit('update_campaign', approval_id, input);
|
|
3471
|
-
return j(result.error ? result : { ...result,
|
|
3533
|
+
return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
|
|
3472
3534
|
}
|
|
3473
3535
|
const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
|
|
3474
3536
|
if (!campaign_id)
|
|
@@ -3599,7 +3661,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3599
3661
|
idempotentHint: true,
|
|
3600
3662
|
openWorldHint: false,
|
|
3601
3663
|
},
|
|
3602
|
-
description: 'Get full campaign details including all steps, enrollment counts and saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
|
|
3664
|
+
description: 'Get full campaign details including all steps, enrollment counts and derived trigger_kind (event/tag/date_field/contact_event/manual), saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
|
|
3603
3665
|
inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
|
|
3604
3666
|
}, async ({ campaign_id, period }) => {
|
|
3605
3667
|
const projectId = getProjectId();
|
|
@@ -3672,8 +3734,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3672
3734
|
...campaign,
|
|
3673
3735
|
reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
|
|
3674
3736
|
requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
|
|
3675
|
-
|
|
3676
|
-
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
|
|
3737
|
+
...await campaignTriggerOutput(campaign),
|
|
3677
3738
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3678
3739
|
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3679
3740
|
...campaignPeriodFields(periods, period),
|
package/build/tools/contacts.js
CHANGED
|
@@ -24,6 +24,40 @@ const CUSTOM_FIELD_TYPES = [
|
|
|
24
24
|
'multi_select', 'contact_reference',
|
|
25
25
|
];
|
|
26
26
|
const CUSTOM_FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,49}$/;
|
|
27
|
+
// Core contact columns from 001_core_schema.sql and subsequent subscriber migrations.
|
|
28
|
+
const RESERVED_CUSTOM_FIELD_KEYS = new Set([
|
|
29
|
+
'email', 'first_name', 'last_name', 'id', 'phone', 'status', 'tags', 'project_id', 'org_id', 'user_id',
|
|
30
|
+
'created_at', 'updated_at', 'clerk_user_id', 'global_unsubscribed', 'global_unsubscribed_at',
|
|
31
|
+
'source', 'source_site', 'ip_address', 'opted_in_at', 'last_emailed_at', 'last_opened_at',
|
|
32
|
+
'last_clicked_at', 'total_emails_sent', 'total_opens', 'total_clicks', 'engagement_score',
|
|
33
|
+
'lifecycle_stage', 'score_updated_at', 'custom_properties', 'timezone', 'last_engaged_at',
|
|
34
|
+
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'best_send_hour',
|
|
35
|
+
'best_send_hour_confidence', 'best_send_hour_updated_at', 'notes', 'birthday', 'anniversary',
|
|
36
|
+
'external_id', 'imported_from', 'phone_e164', 'sms_opt_in_status', 'sms_opt_in_at',
|
|
37
|
+
'sms_opt_out_at', 'is_test', 'company_id', 'source_deleted_at', 'cooled_at', 'cooled_reason',
|
|
38
|
+
'introduced_by_subscriber_id', 'ccpa_opted_out', 'last_transactional_at',
|
|
39
|
+
'total_transactional_sent', 'last_activity_at',
|
|
40
|
+
]);
|
|
41
|
+
const CUSTOM_FIELD_COLUMNS = 'id, key, label, type, required, options, display_order';
|
|
42
|
+
async function registeredCustomFields(projectId) {
|
|
43
|
+
return readAllRows(db().from('custom_field_definitions').select(CUSTOM_FIELD_COLUMNS).eq('project_id', projectId));
|
|
44
|
+
}
|
|
45
|
+
function keyDistance(left, right) {
|
|
46
|
+
let row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
47
|
+
for (let i = 0; i < left.length; i++) {
|
|
48
|
+
const next = [i + 1];
|
|
49
|
+
for (let j = 0; j < right.length; j++)
|
|
50
|
+
next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (left[i] === right[j] ? 0 : 1)));
|
|
51
|
+
row = next;
|
|
52
|
+
}
|
|
53
|
+
return row[right.length];
|
|
54
|
+
}
|
|
55
|
+
function unknownCustomPropertyWarnings(patch, fields) {
|
|
56
|
+
return Object.keys(patch).filter(key => !Object.hasOwn(fields, key)).map(key => {
|
|
57
|
+
const near = Object.keys(fields).filter(known => keyDistance(key, known) <= 2);
|
|
58
|
+
return `Unknown custom property "${key}"${near.length ? `; did you mean ${near.map(value => `"${value}"`).join(' or ')}?` : '; no registered definition'}`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
27
61
|
const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
|
|
28
62
|
const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
|
|
29
63
|
const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
|
|
@@ -1670,7 +1704,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1670
1704
|
idempotentHint: true,
|
|
1671
1705
|
openWorldHint: false,
|
|
1672
1706
|
},
|
|
1673
|
-
description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags.',
|
|
1707
|
+
description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags. Registered custom properties must match their type/options; unknown keys remain allowed with warnings and near-miss suggestions.',
|
|
1674
1708
|
inputSchema: {
|
|
1675
1709
|
contact_id: z.string().uuid(),
|
|
1676
1710
|
email: z.string().email().optional(),
|
|
@@ -1686,6 +1720,33 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1686
1720
|
},
|
|
1687
1721
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1688
1722
|
const projectId = getProjectId();
|
|
1723
|
+
let propertyWarnings = [];
|
|
1724
|
+
let fields;
|
|
1725
|
+
if (custom_properties !== undefined) {
|
|
1726
|
+
let definitions;
|
|
1727
|
+
try {
|
|
1728
|
+
definitions = await registeredCustomFields(projectId);
|
|
1729
|
+
}
|
|
1730
|
+
catch {
|
|
1731
|
+
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1732
|
+
}
|
|
1733
|
+
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1734
|
+
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1735
|
+
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1736
|
+
if (!Object.hasOwn(fields, key))
|
|
1737
|
+
return false;
|
|
1738
|
+
const type = fields[key].type;
|
|
1739
|
+
if (type === 'multi_select')
|
|
1740
|
+
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1741
|
+
return typeof value === 'object' && type !== 'contact_reference';
|
|
1742
|
+
});
|
|
1743
|
+
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1744
|
+
if (invalidShape || anomalies.length) {
|
|
1745
|
+
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1746
|
+
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1747
|
+
}
|
|
1748
|
+
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1749
|
+
}
|
|
1689
1750
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1690
1751
|
const updates = {};
|
|
1691
1752
|
let customPropertyChanges = [];
|
|
@@ -1707,18 +1768,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1707
1768
|
if (tags !== undefined)
|
|
1708
1769
|
updates.tags = tags;
|
|
1709
1770
|
if (custom_properties !== undefined) {
|
|
1710
|
-
const { data: existing } = await db()
|
|
1771
|
+
const { data: existing, error: existingError } = await db()
|
|
1711
1772
|
.from('subscribers')
|
|
1712
1773
|
.select('custom_properties')
|
|
1713
1774
|
.eq('id', contact_id)
|
|
1714
1775
|
.eq('project_id', projectId)
|
|
1715
1776
|
.maybeSingle();
|
|
1777
|
+
if (existingError)
|
|
1778
|
+
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1779
|
+
if (!existing)
|
|
1780
|
+
return contactInputError('not_found', 'Contact not found');
|
|
1716
1781
|
const orgId = await getProjectOrgId(projectId);
|
|
1717
1782
|
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1718
1783
|
orgId,
|
|
1719
1784
|
projectId,
|
|
1720
1785
|
subscriberId: contact_id,
|
|
1721
1786
|
preserveNullDeletions: true,
|
|
1787
|
+
fields,
|
|
1722
1788
|
});
|
|
1723
1789
|
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1724
1790
|
updates.custom_properties = {
|
|
@@ -1780,7 +1846,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1780
1846
|
}
|
|
1781
1847
|
}
|
|
1782
1848
|
if (!Object.keys(updates).length)
|
|
1783
|
-
return
|
|
1849
|
+
return contactInputError('validation_failed', 'No fields to update.');
|
|
1784
1850
|
const tagResult = {};
|
|
1785
1851
|
if (tags !== undefined) {
|
|
1786
1852
|
let previousTags;
|
|
@@ -1820,10 +1886,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1820
1886
|
.select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1821
1887
|
.single();
|
|
1822
1888
|
if (error?.code === '23505' && updates.phone_e164 !== undefined) {
|
|
1823
|
-
return contactInputError('
|
|
1889
|
+
return contactInputError('conflict', 'Another contact in this project already has this phone number.');
|
|
1824
1890
|
}
|
|
1825
1891
|
if (error)
|
|
1826
|
-
return
|
|
1892
|
+
return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
|
|
1827
1893
|
if (customPropertyChanges.length > 0) {
|
|
1828
1894
|
logAudit({
|
|
1829
1895
|
action: 'contact.custom_properties_changed',
|
|
@@ -1839,7 +1905,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1839
1905
|
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1840
1906
|
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1841
1907
|
});
|
|
1842
|
-
|
|
1908
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
1909
|
+
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
1843
1910
|
});
|
|
1844
1911
|
server.registerTool('get_subscriber', {
|
|
1845
1912
|
title: 'Get Subscriber',
|
|
@@ -2515,7 +2582,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2515
2582
|
idempotentHint: true,
|
|
2516
2583
|
openWorldHint: false,
|
|
2517
2584
|
},
|
|
2518
|
-
description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
|
|
2585
|
+
description: 'Discover registered custom field definitions and all custom_properties keys used across contacts for a site, including unused definitions. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
|
|
2519
2586
|
inputSchema: {
|
|
2520
2587
|
site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
|
|
2521
2588
|
},
|
|
@@ -2539,20 +2606,34 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2539
2606
|
// site-scoped; project scope is only for a site with no members at all.
|
|
2540
2607
|
let loaded = await loadSchemaKeys(site_id);
|
|
2541
2608
|
if ('error' in loaded)
|
|
2542
|
-
return
|
|
2609
|
+
return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
|
|
2543
2610
|
let scope = 'site';
|
|
2544
2611
|
if (!loaded.has_membership) {
|
|
2545
2612
|
loaded = await loadSchemaKeys(null);
|
|
2546
2613
|
if ('error' in loaded)
|
|
2547
|
-
return
|
|
2614
|
+
return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
|
|
2548
2615
|
scope = 'project';
|
|
2549
2616
|
}
|
|
2550
|
-
|
|
2617
|
+
let definitions;
|
|
2618
|
+
try {
|
|
2619
|
+
definitions = await registeredCustomFields(projectId);
|
|
2620
|
+
}
|
|
2621
|
+
catch {
|
|
2622
|
+
return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
|
|
2623
|
+
}
|
|
2624
|
+
const byKey = new Map(definitions.map(field => [field.key, field]));
|
|
2625
|
+
const keys = [...loaded.keys];
|
|
2626
|
+
const observed = new Set(keys.map(row => row.key));
|
|
2627
|
+
for (const field of definitions)
|
|
2628
|
+
if (!observed.has(field.key))
|
|
2629
|
+
keys.push({ key: field.key, contacts_with_this_key: 0, example_values: [] });
|
|
2630
|
+
const schema = keys.map(({ key, contacts_with_this_key, example_values }) => ({
|
|
2551
2631
|
key,
|
|
2552
2632
|
merge_tag: `{{custom.${key}}}`,
|
|
2553
2633
|
merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
|
|
2554
2634
|
example_values,
|
|
2555
2635
|
contacts_with_this_key,
|
|
2636
|
+
...(byKey.has(key) ? { definition: byKey.get(key) } : {}),
|
|
2556
2637
|
}));
|
|
2557
2638
|
return j({
|
|
2558
2639
|
site_id,
|
|
@@ -2564,6 +2645,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2564
2645
|
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2565
2646
|
});
|
|
2566
2647
|
});
|
|
2648
|
+
server.registerTool('list_custom_fields', {
|
|
2649
|
+
title: 'List Custom Fields',
|
|
2650
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2651
|
+
description: 'List all registered custom field definitions for the current project, including fields with no contact values yet.',
|
|
2652
|
+
inputSchema: {},
|
|
2653
|
+
}, async () => {
|
|
2654
|
+
try {
|
|
2655
|
+
const fields = await registeredCustomFields(getProjectId());
|
|
2656
|
+
return j({ fields, count: fields.length });
|
|
2657
|
+
}
|
|
2658
|
+
catch {
|
|
2659
|
+
return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2567
2662
|
server.registerTool('create_custom_field', {
|
|
2568
2663
|
title: 'Create Custom Field',
|
|
2569
2664
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -2577,9 +2672,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2577
2672
|
display_order: z.number().int().optional().default(0),
|
|
2578
2673
|
},
|
|
2579
2674
|
}, async ({ key, label, type, required, options, display_order }) => {
|
|
2675
|
+
if (RESERVED_CUSTOM_FIELD_KEYS.has(key))
|
|
2676
|
+
return contactInputError('validation_failed', `Custom field key "${key}" is reserved for a core contact column`);
|
|
2580
2677
|
const optionsError = validateCustomFieldOptions(type, options);
|
|
2581
2678
|
if (optionsError)
|
|
2582
|
-
return
|
|
2679
|
+
return contactInputError('validation_failed', `Error: ${optionsError}`);
|
|
2583
2680
|
const normalizedOptions = type === 'select' || type === 'multi_select'
|
|
2584
2681
|
? normalizeCustomFieldOptions(options)
|
|
2585
2682
|
: null;
|
|
@@ -2601,8 +2698,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2601
2698
|
.single();
|
|
2602
2699
|
if (error) {
|
|
2603
2700
|
if (error.code === '23505')
|
|
2604
|
-
return
|
|
2605
|
-
return
|
|
2701
|
+
return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
|
|
2702
|
+
return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
|
|
2606
2703
|
}
|
|
2607
2704
|
await logAudit({
|
|
2608
2705
|
action: 'custom_field.created',
|
package/build/tools/content.js
CHANGED
|
@@ -2,6 +2,7 @@ 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 { assertHttpUrl } from '../lib/url-validation.js';
|
|
5
6
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
6
7
|
const BLOG_IMAGE_FORMATS = ['square_image', 'story_image', 'animated_gif', 'short_video', 'carousel'];
|
|
7
8
|
const BLOG_IMAGE_GOALS = ['generate_leads', 'educate', 'launch_offer', 'build_trust'];
|
|
@@ -106,6 +107,12 @@ export function registerContentTools(server) {
|
|
|
106
107
|
gate_body: z.string().max(1000).optional(),
|
|
107
108
|
},
|
|
108
109
|
}, async ({ title, slug, asset_url, asset_type = 'page', site_id, source_attribution, gate_headline, gate_body }) => {
|
|
110
|
+
try {
|
|
111
|
+
assertHttpUrl(asset_url);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return j({ error: 'asset_url must use http or https', code: 'validation_failed' });
|
|
115
|
+
}
|
|
109
116
|
const { data, error } = await db().from('asset_links')
|
|
110
117
|
.insert({
|
|
111
118
|
project_id: getProjectId(),
|
|
@@ -120,6 +127,8 @@ export function registerContentTools(server) {
|
|
|
120
127
|
})
|
|
121
128
|
.select('id, title, slug, asset_type, status, source_attribution, gate_headline, gate_body, total_views, total_captures, created_at')
|
|
122
129
|
.single();
|
|
130
|
+
if (error?.code === '23505')
|
|
131
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
123
132
|
if (error)
|
|
124
133
|
return j({ error: error.message, code: error.code });
|
|
125
134
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
|
|
@@ -97,8 +97,12 @@ export function registerDeliveryOpsTools(server) {
|
|
|
97
97
|
reason: z.string().optional().describe('Optional human-readable reason'),
|
|
98
98
|
},
|
|
99
99
|
}, async ({ date, reason }) => {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const parsed = new Date(`${date}T00:00:00Z`);
|
|
101
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
|
|
102
|
+
return j({ error: 'date must be a valid YYYY-MM-DD date', code: 'validation_failed' });
|
|
103
|
+
}
|
|
104
|
+
if (date < new Date().toISOString().slice(0, 10)) {
|
|
105
|
+
return j({ error: 'date must be today or later (UTC)', code: 'validation_failed' });
|
|
102
106
|
}
|
|
103
107
|
const { data, error } = await db()
|
|
104
108
|
.from('blackout_dates')
|
package/build/tools/forms.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 { assertHttpUrl } from '../lib/url-validation.js';
|
|
4
5
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
5
6
|
export function registerFormTools(server) {
|
|
6
7
|
server.registerTool('list_forms', {
|
|
@@ -14,7 +15,7 @@ export function registerFormTools(server) {
|
|
|
14
15
|
description: 'List forms with destination, field, and submission counts.',
|
|
15
16
|
inputSchema: { site_id: z.string().uuid().optional() },
|
|
16
17
|
}, async ({ site_id }) => {
|
|
17
|
-
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
|
+
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, settings, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
19
|
if (site_id)
|
|
19
20
|
query = query.eq('site_id', site_id);
|
|
20
21
|
const { data, error } = await query;
|
|
@@ -68,13 +69,39 @@ export function registerFormTools(server) {
|
|
|
68
69
|
form_type: z.enum(['signup', 'management']).optional(),
|
|
69
70
|
},
|
|
70
71
|
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
72
|
+
if (!fields.length)
|
|
73
|
+
return j({ error: 'fields must contain at least one field', code: 'validation_failed' });
|
|
74
|
+
const names = new Set();
|
|
75
|
+
for (const field of fields) {
|
|
76
|
+
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
77
|
+
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
78
|
+
return j({ error: 'Each field needs a name (lowercase snake_case) and a supported type', code: 'validation_failed' });
|
|
79
|
+
}
|
|
80
|
+
if (names.has(field.name))
|
|
81
|
+
return j({ error: `Duplicate field name: ${field.name}`, code: 'validation_failed' });
|
|
82
|
+
names.add(field.name);
|
|
83
|
+
}
|
|
84
|
+
// The public handler always reads fields.email to identify the subscriber.
|
|
85
|
+
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
86
|
+
return j({ error: 'fields must include an email field named email', code: 'validation_failed' });
|
|
87
|
+
}
|
|
88
|
+
if (redirect_url !== undefined) {
|
|
89
|
+
try {
|
|
90
|
+
assertHttpUrl(redirect_url);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return j({ error: 'redirect_url must use http or https', code: 'validation_failed' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
71
96
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
72
97
|
const { data, error } = await db().from('signup_forms')
|
|
73
98
|
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, form_type: form_type ?? 'signup', status: 'active' })
|
|
74
|
-
.select('id, name, slug, fields, status, form_type')
|
|
99
|
+
.select('id, name, slug, fields, status, form_type, settings')
|
|
75
100
|
.single();
|
|
101
|
+
if (error?.code === '23505')
|
|
102
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
76
103
|
if (error)
|
|
77
|
-
return j({ error: error.message });
|
|
104
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
78
105
|
return j({ form: data });
|
|
79
106
|
});
|
|
80
107
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { stripActiveEmailContent } from '@sendinel/domain';
|
|
3
4
|
import { db } from '../db.js';
|
|
4
5
|
import { getProjectId } from '../project.js';
|
|
5
6
|
import { parseFutureDate } from '../lib/schedule.js';
|
|
@@ -23,6 +24,20 @@ async function getProjectOrgId(projectId) {
|
|
|
23
24
|
const { data } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
|
|
24
25
|
return data?.org_id ?? null;
|
|
25
26
|
}
|
|
27
|
+
async function validateNewsletterSender(projectId, email) {
|
|
28
|
+
const domain = email.slice(email.lastIndexOf('@') + 1).toLowerCase();
|
|
29
|
+
const { data: sendingDomain, error } = await db().from('domains').select('id')
|
|
30
|
+
.eq('project_id', projectId).eq('name', domain).eq('status', 'verified').maybeSingle();
|
|
31
|
+
if (error)
|
|
32
|
+
return { error: 'Sending domain verification unavailable', code: 'INTERNAL_ERROR' };
|
|
33
|
+
if (!sendingDomain)
|
|
34
|
+
return { error: 'sender_email must use a verified sending domain of a project site', code: 'validation_failed' };
|
|
35
|
+
const { data: site, error: siteError } = await db().from('sites').select('id')
|
|
36
|
+
.eq('project_id', projectId).eq('domain_id', sendingDomain.id).limit(1).maybeSingle();
|
|
37
|
+
if (siteError)
|
|
38
|
+
return { error: 'Sending site verification unavailable', code: 'INTERNAL_ERROR' };
|
|
39
|
+
return site ? null : { error: 'sender_email must use a verified sending domain of a project site', code: 'validation_failed' };
|
|
40
|
+
}
|
|
26
41
|
async function loadSendableIssue(newsletterId, issueId, projectId) {
|
|
27
42
|
const [{ data: newsletter, error: newsletterError }, { data: issue, error: issueError }] = await Promise.all([
|
|
28
43
|
db().from('newsletters')
|
|
@@ -195,15 +210,15 @@ export function registerNewsletterTools(server) {
|
|
|
195
210
|
idempotentHint: false,
|
|
196
211
|
openWorldHint: false,
|
|
197
212
|
},
|
|
198
|
-
description: 'Create a newsletter publication for the current project.',
|
|
213
|
+
description: 'Create a newsletter publication for the current project. Explicit sender_email must use a verified domain linked to a project site.',
|
|
199
214
|
inputSchema: {
|
|
200
215
|
name: z.string().min(1).max(200),
|
|
201
216
|
slug: z.string().optional(),
|
|
202
217
|
description: z.string().optional(),
|
|
203
218
|
site_id: z.string().uuid().nullable().optional(),
|
|
204
219
|
sender_name: z.string().optional(),
|
|
205
|
-
sender_email: z.string().optional(),
|
|
206
|
-
reply_to: z.string().optional(),
|
|
220
|
+
sender_email: z.string().email().optional(),
|
|
221
|
+
reply_to: z.string().email().optional(),
|
|
207
222
|
cadence: z.string().optional(),
|
|
208
223
|
branding: z.record(z.string(), z.unknown()).optional(),
|
|
209
224
|
public_signup_enabled: z.boolean().default(true).optional(),
|
|
@@ -212,6 +227,11 @@ export function registerNewsletterTools(server) {
|
|
|
212
227
|
},
|
|
213
228
|
}, async (args) => {
|
|
214
229
|
const projectId = getProjectId();
|
|
230
|
+
if (args.sender_email) {
|
|
231
|
+
const senderError = await validateNewsletterSender(projectId, args.sender_email);
|
|
232
|
+
if (senderError)
|
|
233
|
+
return j(senderError);
|
|
234
|
+
}
|
|
215
235
|
const orgId = await getProjectOrgId(projectId);
|
|
216
236
|
if (!orgId)
|
|
217
237
|
return j({ error: 'Organization not found for project', code: 'NOT_FOUND' });
|
|
@@ -233,8 +253,10 @@ export function registerNewsletterTools(server) {
|
|
|
233
253
|
source: 'mcp',
|
|
234
254
|
source_payload: args,
|
|
235
255
|
}).select(NEWSLETTER_COLUMNS).single();
|
|
256
|
+
if (error?.code === '23505')
|
|
257
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
236
258
|
if (error)
|
|
237
|
-
return j({ error: error.message, code:
|
|
259
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
238
260
|
return j(data);
|
|
239
261
|
});
|
|
240
262
|
server.registerTool('update_newsletter', {
|
|
@@ -245,7 +267,7 @@ export function registerNewsletterTools(server) {
|
|
|
245
267
|
idempotentHint: true,
|
|
246
268
|
openWorldHint: false,
|
|
247
269
|
},
|
|
248
|
-
description: 'Update newsletter publication settings.',
|
|
270
|
+
description: 'Update newsletter publication settings. Explicit sender_email must use a verified domain linked to a project site.',
|
|
249
271
|
inputSchema: {
|
|
250
272
|
newsletter_id: z.string().uuid(),
|
|
251
273
|
name: z.string().max(200).optional(),
|
|
@@ -253,8 +275,8 @@ export function registerNewsletterTools(server) {
|
|
|
253
275
|
description: z.string().nullable().optional(),
|
|
254
276
|
site_id: z.string().uuid().nullable().optional(),
|
|
255
277
|
sender_name: z.string().nullable().optional(),
|
|
256
|
-
sender_email: z.string().nullable().optional(),
|
|
257
|
-
reply_to: z.string().nullable().optional(),
|
|
278
|
+
sender_email: z.string().email().nullable().optional(),
|
|
279
|
+
reply_to: z.string().email().nullable().optional(),
|
|
258
280
|
cadence: z.string().nullable().optional(),
|
|
259
281
|
branding: z.record(z.string(), z.unknown()).optional(),
|
|
260
282
|
public_signup_enabled: z.boolean().optional(),
|
|
@@ -262,6 +284,11 @@ export function registerNewsletterTools(server) {
|
|
|
262
284
|
status: z.enum(['active', 'paused', 'archived']).optional(),
|
|
263
285
|
},
|
|
264
286
|
}, async ({ newsletter_id, ...fields }) => {
|
|
287
|
+
if (fields.sender_email) {
|
|
288
|
+
const senderError = await validateNewsletterSender(getProjectId(), fields.sender_email);
|
|
289
|
+
if (senderError)
|
|
290
|
+
return j(senderError);
|
|
291
|
+
}
|
|
265
292
|
const updates = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
266
293
|
if (updates.slug && typeof updates.slug === 'string')
|
|
267
294
|
updates.slug = slugifyNewsletter(updates.slug);
|
|
@@ -273,8 +300,10 @@ export function registerNewsletterTools(server) {
|
|
|
273
300
|
.eq('project_id', getProjectId())
|
|
274
301
|
.select(NEWSLETTER_COLUMNS)
|
|
275
302
|
.maybeSingle();
|
|
303
|
+
if (error?.code === '23505')
|
|
304
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
276
305
|
if (error)
|
|
277
|
-
return j({ error: error.message, code:
|
|
306
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
278
307
|
if (!data)
|
|
279
308
|
return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
|
|
280
309
|
return j(data);
|
|
@@ -287,7 +316,7 @@ export function registerNewsletterTools(server) {
|
|
|
287
316
|
idempotentHint: false,
|
|
288
317
|
openWorldHint: false,
|
|
289
318
|
},
|
|
290
|
-
description: 'Create a draft issue for a newsletter.',
|
|
319
|
+
description: 'Create a draft issue for a newsletter. Refuses active HTML or executable URLs using the shared email safety filter.',
|
|
291
320
|
inputSchema: {
|
|
292
321
|
newsletter_id: z.string().uuid(),
|
|
293
322
|
subject: z.string().min(1).max(300),
|
|
@@ -295,6 +324,9 @@ export function registerNewsletterTools(server) {
|
|
|
295
324
|
preheader: z.string().optional(),
|
|
296
325
|
},
|
|
297
326
|
}, async ({ newsletter_id, subject, content, preheader }) => {
|
|
327
|
+
if (content !== undefined && stripActiveEmailContent(content).removed.length) {
|
|
328
|
+
return j({ error: 'Issue content contains active HTML or executable URLs; remove them before saving', code: 'validation_failed' });
|
|
329
|
+
}
|
|
298
330
|
const projectId = getProjectId();
|
|
299
331
|
const { data: newsletter, error: newsletterError } = await db().from('newsletters').select('id, org_id').eq('id', newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
300
332
|
if (newsletterError)
|
|
@@ -2,6 +2,7 @@ import QRCode from 'qrcode';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { assertHttpUrl, RESERVED_LINK_SLUGS } from '../lib/url-validation.js';
|
|
5
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
8
|
const VANITY_SLUG_RE = /^[a-zA-Z0-9-]{3,40}$/;
|
|
@@ -9,13 +10,6 @@ const PAID_PLANS = new Set(['byod', 'managed', 'agency-plus', 'enterprise']);
|
|
|
9
10
|
function appBaseUrl() {
|
|
10
11
|
return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
11
12
|
}
|
|
12
|
-
function assertHttpUrl(raw) {
|
|
13
|
-
const url = new URL(raw);
|
|
14
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
15
|
-
throw new Error('Only http and https URLs are supported.');
|
|
16
|
-
}
|
|
17
|
-
return url.toString();
|
|
18
|
-
}
|
|
19
13
|
function normalizeColor(input, fallback) {
|
|
20
14
|
const value = input?.trim() || fallback;
|
|
21
15
|
if (!/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value))
|
|
@@ -41,21 +35,31 @@ export function registerShortLinkTools(server) {
|
|
|
41
35
|
const projectId = getProjectId();
|
|
42
36
|
const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
|
|
43
37
|
if (!project?.org_id)
|
|
44
|
-
return j({ error: 'Project not found', code: '
|
|
38
|
+
return j({ error: 'Project not found', code: 'not_found' });
|
|
45
39
|
let destination;
|
|
46
40
|
try {
|
|
47
41
|
destination = assertHttpUrl(destination_url);
|
|
48
42
|
}
|
|
49
43
|
catch (err) {
|
|
50
|
-
return j({ error: err instanceof Error ? err.message : 'Invalid destination URL', code: '
|
|
44
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid destination URL', code: 'validation_failed' });
|
|
45
|
+
}
|
|
46
|
+
if (campaign_id) {
|
|
47
|
+
const { data: campaign, error } = await db().from('campaigns').select('id').eq('id', campaign_id).eq('project_id', projectId).maybeSingle();
|
|
48
|
+
if (error)
|
|
49
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
50
|
+
if (!campaign)
|
|
51
|
+
return j({ error: 'Campaign not found in this project', code: 'not_found' });
|
|
51
52
|
}
|
|
52
53
|
const requestedSlug = slug?.trim() || null;
|
|
53
54
|
if (requestedSlug) {
|
|
55
|
+
if (RESERVED_LINK_SLUGS.has(requestedSlug.toLowerCase())) {
|
|
56
|
+
return j({ error: 'This slug is reserved for system routes.', code: 'validation_failed' });
|
|
57
|
+
}
|
|
54
58
|
if (!VANITY_SLUG_RE.test(requestedSlug)) {
|
|
55
|
-
return j({ error: 'Custom slug must be 3-40 characters and contain only letters, numbers, or hyphens.', code: '
|
|
59
|
+
return j({ error: 'Custom slug must be 3-40 characters and contain only letters, numbers, or hyphens.', code: 'validation_failed' });
|
|
56
60
|
}
|
|
57
61
|
if (!PAID_PLANS.has(project.plan ?? 'free')) {
|
|
58
|
-
return j({ error: 'Custom slugs require vanity_slug_access on a paid plan.', code: '
|
|
62
|
+
return j({ error: 'Custom slugs require vanity_slug_access on a paid plan.', code: 'plan_limit' });
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
const finalSlug = requestedSlug ?? (await db().rpc('next_short_link_slug')).data;
|
|
@@ -74,8 +78,10 @@ export function registerShortLinkTools(server) {
|
|
|
74
78
|
})
|
|
75
79
|
.select()
|
|
76
80
|
.single();
|
|
81
|
+
if (error?.code === '23505')
|
|
82
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
77
83
|
if (error)
|
|
78
|
-
return j({ error: error.message });
|
|
84
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
79
85
|
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
80
86
|
});
|
|
81
87
|
server.registerTool('get_short_link_stats', {
|
|
@@ -99,7 +105,7 @@ export function registerShortLinkTools(server) {
|
|
|
99
105
|
if (linkError)
|
|
100
106
|
return j({ error: linkError.message });
|
|
101
107
|
if (!link)
|
|
102
|
-
return j({ error: 'Short link not found', code: '
|
|
108
|
+
return j({ error: 'Short link not found', code: 'not_found' });
|
|
103
109
|
const { data: clicks, error: clickError } = await db()
|
|
104
110
|
.from('short_link_clicks')
|
|
105
111
|
.select('clicked_at, ip_hash, referer')
|
|
@@ -140,7 +146,7 @@ export function registerShortLinkTools(server) {
|
|
|
140
146
|
normalizedUrl = assertHttpUrl(url);
|
|
141
147
|
}
|
|
142
148
|
catch (err) {
|
|
143
|
-
return j({ error: err instanceof Error ? err.message : 'Invalid QR URL', code: '
|
|
149
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid QR URL', code: 'validation_failed' });
|
|
144
150
|
}
|
|
145
151
|
const dataUri = await QRCode.toDataURL(normalizedUrl, {
|
|
146
152
|
type: 'image/png',
|
package/build/tools/surveys.js
CHANGED
|
@@ -22,10 +22,25 @@ function normalizeQuestions(questions) {
|
|
|
22
22
|
...question,
|
|
23
23
|
id,
|
|
24
24
|
label,
|
|
25
|
-
type: question.type
|
|
25
|
+
type: question.type,
|
|
26
26
|
};
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
function validateQuestions(questions) {
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const question of questions) {
|
|
32
|
+
if (!question.label.trim())
|
|
33
|
+
return 'Each question needs a non-empty label or text';
|
|
34
|
+
if (ids.has(question.id))
|
|
35
|
+
return `Duplicate question id: ${question.id}`;
|
|
36
|
+
ids.add(question.id);
|
|
37
|
+
if (['single_choice', 'multi_choice', 'multiple_choice'].includes(question.type) &&
|
|
38
|
+
(!Array.isArray(question.options) || question.options.length < 2 || question.options.some(option => !option.trim()))) {
|
|
39
|
+
return `Question ${question.id} needs at least two non-empty options`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
29
44
|
function tallyResponses(responses, questions) {
|
|
30
45
|
return questions.map((question) => {
|
|
31
46
|
const values = responses
|
|
@@ -69,13 +84,17 @@ export function registerSurveyTools(server) {
|
|
|
69
84
|
status: z.enum(['draft', 'active', 'closed']).optional().default('draft'),
|
|
70
85
|
},
|
|
71
86
|
}, async ({ title, questions, site_id, status }) => {
|
|
87
|
+
const normalized = normalizeQuestions(questions);
|
|
88
|
+
const validationError = validateQuestions(normalized);
|
|
89
|
+
if (validationError)
|
|
90
|
+
return j({ error: validationError, code: 'validation_failed' });
|
|
72
91
|
const projectId = getProjectId();
|
|
73
92
|
const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
|
|
74
93
|
if (!project?.org_id)
|
|
75
94
|
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
76
95
|
const { data: survey, error } = await db()
|
|
77
96
|
.from('surveys')
|
|
78
|
-
.insert({ project_id: projectId, title, questions:
|
|
97
|
+
.insert({ project_id: projectId, title, questions: normalized, status })
|
|
79
98
|
.select()
|
|
80
99
|
.single();
|
|
81
100
|
if (error)
|
|
@@ -200,8 +219,26 @@ export function registerSurveyTools(server) {
|
|
|
200
219
|
const updates = {};
|
|
201
220
|
if (title !== undefined)
|
|
202
221
|
updates.title = title;
|
|
203
|
-
if (questions !== undefined)
|
|
204
|
-
|
|
222
|
+
if (questions !== undefined || status === 'active') {
|
|
223
|
+
let candidate = questions;
|
|
224
|
+
if (candidate === undefined) {
|
|
225
|
+
const { data: existing, error } = await db().from('surveys').select('questions').eq('id', survey_id).eq('project_id', projectId).maybeSingle();
|
|
226
|
+
if (error)
|
|
227
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
228
|
+
if (!existing)
|
|
229
|
+
return j({ error: 'Survey not found', code: 'not_found' });
|
|
230
|
+
candidate = existing.questions;
|
|
231
|
+
}
|
|
232
|
+
const parsed = z.array(QUESTION_SCHEMA).min(1).safeParse(candidate);
|
|
233
|
+
if (!parsed.success)
|
|
234
|
+
return j({ error: 'Survey questions are invalid', code: 'validation_failed' });
|
|
235
|
+
const normalized = normalizeQuestions(parsed.data);
|
|
236
|
+
const validationError = validateQuestions(normalized);
|
|
237
|
+
if (validationError)
|
|
238
|
+
return j({ error: validationError, code: 'validation_failed' });
|
|
239
|
+
if (questions !== undefined)
|
|
240
|
+
updates.questions = normalized;
|
|
241
|
+
}
|
|
205
242
|
if (status !== undefined)
|
|
206
243
|
updates.status = status;
|
|
207
244
|
if (Object.keys(updates).length === 0)
|
|
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
|
|
|
12
12
|
* Only tools that exist in the registry belong here.
|
|
13
13
|
*/
|
|
14
14
|
export const TOOL_ALIASES = {
|
|
15
|
+
list_custom_fields: ['custom fields', 'field definitions', 'contact schema', 'unused fields'],
|
|
15
16
|
update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
|
|
16
17
|
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
|
|
17
18
|
create_campaign_with_content: ['billing', 'dunning', 'trial ending', 'transactional', 'payment failed', 'product email', 'event-driven billing mail'],
|
package/build/tools/webhooks.js
CHANGED
|
@@ -29,14 +29,14 @@ export function registerWebhookTools(server) {
|
|
|
29
29
|
server.registerTool('list_sandbox_webhook_events', {
|
|
30
30
|
title: 'List Sandbox Webhook Events',
|
|
31
31
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
32
|
-
description: 'Read recent captured webhook requests for the active sandbox project (seven-day retention).
|
|
32
|
+
description: 'Read recent captured webhook requests for the active sandbox project (seven-day retention). Returns only allowlisted safe headers; never returns sink tokens.',
|
|
33
33
|
inputSchema: { limit: z.number().int().min(1).max(100).optional().default(20) },
|
|
34
34
|
}, async ({ limit }) => {
|
|
35
35
|
try {
|
|
36
36
|
return j({ events: await listSandboxWebhookEvents(adminDb(), getProjectId(), limit) });
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
|
-
return
|
|
39
|
+
return j({ error: 'Sandbox webhook events unavailable.', code: 'forbidden' });
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
server.registerTool('subscribe_webhook', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.139",
|
|
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",
|