@sendinel/mcp-server 1.0.107 → 1.0.108

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' });
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.108",
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",