@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.
@@ -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' });
@@ -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 a site name, from address, or other settings',
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
- const { data, error } = await db()
241
+ let query = sb
209
242
  .from('sites')
210
243
  .update(updates)
211
244
  .eq('id', site_id)
212
- .eq('project_id', getProjectId())
213
- .select()
214
- .single();
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
- return j(data);
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.107",
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",