@sendinel/mcp-server 1.0.107 → 1.0.109
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/tools/campaigns.js +27 -6
- package/build/tools/sites.js +55 -9
- package/build/tools/tool-search.js +1 -0
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
|
2
2
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
-
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, } from '@sendinel/domain';
|
|
5
|
+
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
|
|
6
6
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
7
7
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
8
8
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -652,7 +652,7 @@ export function registerCampaignTools(server) {
|
|
|
652
652
|
}, async ({ site_id, status, limit, period }) => {
|
|
653
653
|
let q = db()
|
|
654
654
|
.from('campaigns')
|
|
655
|
-
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config')
|
|
655
|
+
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template')
|
|
656
656
|
.eq('project_id', getProjectId())
|
|
657
657
|
.order('created_at', { ascending: false })
|
|
658
658
|
.limit(limit ?? 25);
|
|
@@ -2966,6 +2966,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2966
2966
|
site_id: z.string().uuid().describe('Site to send from'),
|
|
2967
2967
|
name: z.string().min(1).max(200).describe('Campaign name'),
|
|
2968
2968
|
type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
|
|
2969
|
+
send_class: z.enum(['broadcast', 'product']).optional().describe("Consent class: broadcast (marketing, default) or product (account, onboarding, trial and billing mail)."),
|
|
2970
|
+
unsub_footer_template: z.string().nullable().optional().describe('Campaign unsubscribe footer override. Must contain <unsubscribe>link text</unsubscribe>. Null uses the site footer.'),
|
|
2969
2971
|
trigger_event_name: z.string().nullable().optional(),
|
|
2970
2972
|
trigger_tag: z.string().nullable().optional(),
|
|
2971
2973
|
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
@@ -2984,7 +2986,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2984
2986
|
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
2985
2987
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
|
|
2986
2988
|
},
|
|
2987
|
-
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config }) => {
|
|
2989
|
+
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template }) => {
|
|
2990
|
+
const footerTemplate = unsub_footer_template?.trim() ?? null;
|
|
2991
|
+
if (footerTemplate !== null) {
|
|
2992
|
+
const footerError = validateUnsubFooterTemplate(footerTemplate);
|
|
2993
|
+
if (footerError)
|
|
2994
|
+
return j({ error: footerError, code: 'INVALID_FIELD' });
|
|
2995
|
+
}
|
|
2996
|
+
const sendClass = send_class ?? 'broadcast';
|
|
2988
2997
|
const targeting = {};
|
|
2989
2998
|
const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
|
|
2990
2999
|
const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
|
|
@@ -2996,7 +3005,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2996
3005
|
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
2997
3006
|
// Human-in-the-loop gate for activation
|
|
2998
3007
|
if (activate && confirmed) {
|
|
2999
|
-
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, ...triggerInput };
|
|
3008
|
+
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, ...triggerInput };
|
|
3000
3009
|
const channelContext = currentChannelContext();
|
|
3001
3010
|
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
3002
3011
|
if (!decision.allowed)
|
|
@@ -3042,7 +3051,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3042
3051
|
if (!confirmed) {
|
|
3043
3052
|
return j({
|
|
3044
3053
|
confirmation_required: true,
|
|
3045
|
-
campaign: { name, type: campaignType, site: site.name, ...targeting },
|
|
3054
|
+
campaign: { name, type: campaignType, site: site.name, send_class: sendClass, unsub_footer_template: footerTemplate, ...targeting },
|
|
3046
3055
|
steps: steps.map((s, i) => ({
|
|
3047
3056
|
step: i + 1,
|
|
3048
3057
|
subject: s.subject,
|
|
@@ -3066,6 +3075,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3066
3075
|
site_id,
|
|
3067
3076
|
name,
|
|
3068
3077
|
type: campaignType,
|
|
3078
|
+
send_class: sendClass,
|
|
3079
|
+
unsub_footer_template: footerTemplate,
|
|
3069
3080
|
...targeting,
|
|
3070
3081
|
status: 'draft',
|
|
3071
3082
|
segment_id: segment_id ?? null,
|
|
@@ -3150,6 +3161,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3150
3161
|
campaign_id: campaign.id,
|
|
3151
3162
|
name,
|
|
3152
3163
|
type: campaignType,
|
|
3164
|
+
send_class: campaign.send_class,
|
|
3165
|
+
unsub_footer_template: campaign.unsub_footer_template,
|
|
3153
3166
|
...targeting,
|
|
3154
3167
|
fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3155
3168
|
trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
@@ -3415,6 +3428,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3415
3428
|
type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
|
|
3416
3429
|
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
|
|
3417
3430
|
send_in_contact_timezone: z.boolean().optional(),
|
|
3431
|
+
unsub_footer_template: z.string().nullable().optional().describe('Campaign unsubscribe footer override. Must contain <unsubscribe>link text</unsubscribe>. Set null to use the site footer.'),
|
|
3418
3432
|
send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
|
|
3419
3433
|
},
|
|
3420
3434
|
}, async ({ approval_id, ...input }) => {
|
|
@@ -3422,9 +3436,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3422
3436
|
const result = await resumeCampaignEdit('update_campaign', approval_id, input);
|
|
3423
3437
|
return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
|
|
3424
3438
|
}
|
|
3425
|
-
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 } = input;
|
|
3439
|
+
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 } = input;
|
|
3426
3440
|
if (!campaign_id)
|
|
3427
3441
|
return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
|
|
3442
|
+
if (unsub_footer_template !== undefined && unsub_footer_template !== null) {
|
|
3443
|
+
const footerError = validateUnsubFooterTemplate(unsub_footer_template);
|
|
3444
|
+
if (footerError)
|
|
3445
|
+
return j({ error: footerError, code: 'INVALID_FIELD' });
|
|
3446
|
+
}
|
|
3428
3447
|
const projectId = getProjectId();
|
|
3429
3448
|
let campaignType;
|
|
3430
3449
|
const loadExisting = () => db()
|
|
@@ -3491,6 +3510,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3491
3510
|
updates.send_in_contact_timezone = send_in_contact_timezone;
|
|
3492
3511
|
if (send_class !== undefined)
|
|
3493
3512
|
updates.send_class = send_class;
|
|
3513
|
+
if (unsub_footer_template !== undefined)
|
|
3514
|
+
updates.unsub_footer_template = unsub_footer_template?.trim() ?? null;
|
|
3494
3515
|
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
|
|
3495
3516
|
if (targetingError)
|
|
3496
3517
|
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
package/build/tools/sites.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { validateSiteUnsubscribeSettings } from '@sendinel/domain';
|
|
3
|
+
import { logAudit } from '../audit.js';
|
|
2
4
|
import { db } from '../db.js';
|
|
3
5
|
import { getProjectId } from '../project.js';
|
|
4
6
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
@@ -112,14 +114,14 @@ export function registerSiteTools(server) {
|
|
|
112
114
|
idempotentHint: true,
|
|
113
115
|
openWorldHint: false,
|
|
114
116
|
},
|
|
115
|
-
description: 'Read the brand voice configuration for a sender/site.',
|
|
117
|
+
description: 'Read the brand voice configuration and current site-wide footer, unsubscribe link settings, unsubscribe text, and physical mailing address for a sender/site.',
|
|
116
118
|
inputSchema: {
|
|
117
119
|
site_id: z.string().uuid(),
|
|
118
120
|
},
|
|
119
121
|
}, async ({ site_id }) => {
|
|
120
122
|
const { data, error } = await db()
|
|
121
123
|
.from('sites')
|
|
122
|
-
.select('id, name, brand_voice')
|
|
124
|
+
.select('id, name, brand_voice, physical_address, unsub_link_enabled, unsub_one_click_enabled, unsub_custom_url, unsub_footer_template, unsub_custom_message, unsub_brand_logo_url, unsub_brand_color')
|
|
123
125
|
.eq('project_id', getProjectId())
|
|
124
126
|
.eq('id', site_id)
|
|
125
127
|
.maybeSingle();
|
|
@@ -184,15 +186,23 @@ export function registerSiteTools(server) {
|
|
|
184
186
|
idempotentHint: true,
|
|
185
187
|
openWorldHint: false,
|
|
186
188
|
},
|
|
187
|
-
description: 'Update
|
|
189
|
+
description: 'Update site-wide email footer, unsubscribe text, unsubscribe link, one-click unsubscribe, unsubscribe page branding, sender name/from address, active status, or physical mailing address. unsub_footer_template must be non-empty and include <unsubscribe>link text</unsubscribe>. Omitted settings and physical_address stay unchanged. Returns stored settings and previous_unsub_footer_template when the footer changes, so callers can undo.',
|
|
188
190
|
inputSchema: {
|
|
189
191
|
site_id: z.string().uuid(),
|
|
190
192
|
name: z.string().optional(),
|
|
191
193
|
from_email: z.string().email().optional(),
|
|
192
194
|
from_name: z.string().optional(),
|
|
193
195
|
active: z.boolean().optional(),
|
|
196
|
+
physical_address: z.string().trim().min(1).optional().describe('Physical mailing address; unchanged unless explicitly supplied.'),
|
|
197
|
+
unsub_link_enabled: z.boolean().optional().describe('Enable the unsubscribe link in the email footer.'),
|
|
198
|
+
unsub_one_click_enabled: z.boolean().optional().describe('Enable one-click unsubscribe headers.'),
|
|
199
|
+
unsub_custom_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe URL; null or blank clears it.'),
|
|
200
|
+
unsub_footer_template: z.string().nullable().optional().describe('Non-empty site-wide footer including <unsubscribe>link text</unsubscribe>.'),
|
|
201
|
+
unsub_custom_message: z.string().nullable().optional().describe('Unsubscribe page message, up to 280 characters; null or blank clears it.'),
|
|
202
|
+
unsub_brand_logo_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe page logo URL; null or blank clears it.'),
|
|
203
|
+
unsub_brand_color: z.string().nullable().optional().describe('Six-digit hex unsubscribe page color; null or blank clears it.'),
|
|
194
204
|
},
|
|
195
|
-
}, async ({ site_id, name, from_email, from_name, active }) => {
|
|
205
|
+
}, async ({ site_id, name, from_email, from_name, active, physical_address, ...unsubscribeInput }) => {
|
|
196
206
|
const updates = {};
|
|
197
207
|
if (name)
|
|
198
208
|
updates.name = name;
|
|
@@ -202,18 +212,54 @@ export function registerSiteTools(server) {
|
|
|
202
212
|
updates.from_name = from_name;
|
|
203
213
|
if (active !== undefined)
|
|
204
214
|
updates.active = active;
|
|
215
|
+
if (physical_address !== undefined)
|
|
216
|
+
updates.physical_address = physical_address.trim().slice(0, 500);
|
|
217
|
+
const unsubscribe = validateSiteUnsubscribeSettings(unsubscribeInput);
|
|
218
|
+
if (!unsubscribe.ok)
|
|
219
|
+
return txt(`Error: ${unsubscribe.error}`);
|
|
220
|
+
Object.assign(updates, unsubscribe.value);
|
|
205
221
|
if (!Object.keys(updates).length)
|
|
206
222
|
return txt('No fields to update.');
|
|
223
|
+
// Read only when a footer is supplied, and guard the write so the undo value is exact.
|
|
224
|
+
const sb = db();
|
|
225
|
+
const projectId = getProjectId();
|
|
226
|
+
let previousTemplate;
|
|
227
|
+
if (updates.unsub_footer_template !== undefined) {
|
|
228
|
+
const { data: current, error } = await sb
|
|
229
|
+
.from('sites')
|
|
230
|
+
.select('unsub_footer_template')
|
|
231
|
+
.eq('id', site_id)
|
|
232
|
+
.eq('project_id', projectId)
|
|
233
|
+
.maybeSingle();
|
|
234
|
+
if (error)
|
|
235
|
+
return txt(`Error: ${error.message}`);
|
|
236
|
+
if (!current)
|
|
237
|
+
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
238
|
+
previousTemplate = current.unsub_footer_template;
|
|
239
|
+
}
|
|
207
240
|
updates.updated_at = new Date().toISOString();
|
|
208
|
-
|
|
241
|
+
let query = sb
|
|
209
242
|
.from('sites')
|
|
210
243
|
.update(updates)
|
|
211
244
|
.eq('id', site_id)
|
|
212
|
-
.eq('project_id',
|
|
213
|
-
|
|
214
|
-
.
|
|
245
|
+
.eq('project_id', projectId);
|
|
246
|
+
if (previousTemplate === null)
|
|
247
|
+
query = query.is('unsub_footer_template', null);
|
|
248
|
+
else if (previousTemplate !== undefined)
|
|
249
|
+
query = query.eq('unsub_footer_template', previousTemplate);
|
|
250
|
+
const { data, error } = await query.select().maybeSingle();
|
|
215
251
|
if (error)
|
|
216
252
|
return txt(`Error: ${error.message}`);
|
|
217
|
-
|
|
253
|
+
if (!data)
|
|
254
|
+
return j({ error: 'Site not found or footer changed during update. Read the site and retry.', code: 'SITE_CHANGED_OR_NOT_FOUND' });
|
|
255
|
+
await logAudit({
|
|
256
|
+
action: 'site.updated', resourceType: 'site', resourceId: site_id,
|
|
257
|
+
details: { fields: Object.keys(updates).filter((field) => field !== 'updated_at') },
|
|
258
|
+
});
|
|
259
|
+
return j({
|
|
260
|
+
...data,
|
|
261
|
+
...(previousTemplate !== undefined && previousTemplate !== data.unsub_footer_template
|
|
262
|
+
? { previous_unsub_footer_template: previousTemplate } : {}),
|
|
263
|
+
});
|
|
218
264
|
});
|
|
219
265
|
}
|
|
@@ -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
|
+
update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
|
|
15
16
|
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
|
|
16
17
|
send_broadcast: [
|
|
17
18
|
'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.109",
|
|
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",
|