@sendinel/mcp-server 1.0.139 → 1.0.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 j({ error: error.message });
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 j({ error: error.message });
158
+ return toolError(dbToolError(error, 'Unable to load domain'));
158
159
  if (!data)
159
- return j({ error: 'Domain not found', code: 'NOT_FOUND' });
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 j({ error: healthError.message });
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, '&quot;')}">`);
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. Publish it with update_landing_page by setting status to published.',
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 j({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)' });
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 j({ error: 'A page with this slug already exists' });
107
- return j({ error: error.message });
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
- .single();
246
+ .maybeSingle();
174
247
  if (error) {
175
248
  if (error.code === '23505')
176
- return j({ error: 'A page with this slug already exists' });
177
- return j({ error: error.message });
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',
@@ -1,8 +1,10 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
4
5
  import { db } from '../db.js';
5
6
  import { getProjectId } from '../project.js';
7
+ import { dbToolError, toolError } from '../lib/db-error.js';
6
8
  import { getAnthropicModelId } from '../anthropic-model.js';
7
9
  import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
8
10
  import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
@@ -80,31 +82,38 @@ export function registerSegmentTools(server) {
80
82
  rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>, email_sends (eq only, value: {min_count: positive integer, within_days: positive number}; at most one per segment). tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
81
83
  },
82
84
  }, async ({ name, description, rules }) => {
83
- let storedRules;
84
85
  try {
85
- storedRules = prepareSegmentRulesForStorage(rules);
86
+ let storedRules;
87
+ try {
88
+ storedRules = prepareSegmentRulesForStorage(rules);
89
+ }
90
+ catch (rulesError) {
91
+ const invalid = invalidRules(rulesError);
92
+ if (invalid)
93
+ return toolError({ error: rulesError instanceof Error ? rulesError.message : 'Invalid segment rules', code: 'validation_failed' });
94
+ throw rulesError;
95
+ }
96
+ const { data, error } = await db()
97
+ .from('segments')
98
+ .insert({ project_id: getProjectId(), name, description, rules: storedRules })
99
+ .select('id, name, description, rules, created_at')
100
+ .single();
101
+ if (error?.code === '23505')
102
+ return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
103
+ if (error)
104
+ return toolError(dbToolError(error));
105
+ // Count against the stored (normalized) tree, not the raw input: a tree skips
106
+ // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
107
+ // does for this same segment a moment later. Pushing the flat rules down instead
108
+ // makes the two disagree wherever SQL and JS compare differently -- notably
109
+ // custom_property against a non-string JSONB value (boolean/number), where
110
+ // `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
111
+ const ids = await getSegmentContactIds(data.rules);
112
+ return j({ ...data, estimated_contacts: ids.length });
86
113
  }
87
- catch (rulesError) {
88
- const invalid = invalidRules(rulesError);
89
- if (invalid)
90
- return invalid;
91
- throw rulesError;
114
+ catch (error) {
115
+ return dbErrorResult(error);
92
116
  }
93
- const { data, error } = await db()
94
- .from('segments')
95
- .insert({ project_id: getProjectId(), name, description, rules: storedRules })
96
- .select('id, name, description, rules, created_at')
97
- .single();
98
- if (error)
99
- return txt(`Error: ${error.message}`);
100
- // Count against the stored (normalized) tree, not the raw input: a tree skips
101
- // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
102
- // does for this same segment a moment later. Pushing the flat rules down instead
103
- // makes the two disagree wherever SQL and JS compare differently -- notably
104
- // custom_property against a non-string JSONB value (boolean/number), where
105
- // `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
106
- const ids = await getSegmentContactIds(data.rules);
107
- return j({ ...data, estimated_contacts: ids.length });
108
117
  });
109
118
  server.registerTool('list_segments', {
110
119
  title: 'List Segments',
@@ -157,32 +166,37 @@ export function registerSegmentTools(server) {
157
166
  rules: z.record(z.string(), z.unknown()).optional(),
158
167
  },
159
168
  }, async ({ segment_id, name, description, rules }) => {
160
- const updates = { updated_at: new Date().toISOString() };
161
- if (name)
162
- updates.name = name;
163
- if (description !== undefined)
164
- updates.description = description;
165
- if (rules) {
166
- try {
167
- updates.rules = prepareSegmentRulesForStorage(rules);
168
- }
169
- catch (rulesError) {
170
- const invalid = invalidRules(rulesError);
171
- if (invalid)
172
- return invalid;
173
- throw rulesError;
169
+ try {
170
+ const updates = { updated_at: new Date().toISOString() };
171
+ if (name)
172
+ updates.name = name;
173
+ if (description !== undefined)
174
+ updates.description = description;
175
+ if (rules) {
176
+ try {
177
+ updates.rules = prepareSegmentRulesForStorage(rules);
178
+ }
179
+ catch (rulesError) {
180
+ const invalid = invalidRules(rulesError);
181
+ if (invalid)
182
+ return invalid;
183
+ throw rulesError;
184
+ }
174
185
  }
186
+ const { data, error } = await db()
187
+ .from('segments')
188
+ .update(updates)
189
+ .eq('id', segment_id)
190
+ .eq('project_id', getProjectId())
191
+ .select('id, name, description, rules, updated_at')
192
+ .single();
193
+ if (error)
194
+ return dbErrorResult(error);
195
+ return j(data);
196
+ }
197
+ catch (error) {
198
+ return dbErrorResult(error);
175
199
  }
176
- const { data, error } = await db()
177
- .from('segments')
178
- .update(updates)
179
- .eq('id', segment_id)
180
- .eq('project_id', getProjectId())
181
- .select('id, name, description, rules, updated_at')
182
- .single();
183
- if (error)
184
- return txt(`Error: ${error.message}`);
185
- return j(data);
186
200
  });
187
201
  server.registerTool('delete_segment', {
188
202
  title: 'Delete Segment',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.139",
3
+ "version": "1.0.141",
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",