@sendinel/mcp-server 1.0.129 → 1.0.130

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.
@@ -1,5 +1,5 @@
1
1
  type Row = Record<string, any>;
2
- export declare const TRIGGER_EDIT_SELECT = "id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at";
2
+ export declare const TRIGGER_EDIT_SELECT = "id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, period_key, updated_at";
3
3
  export declare const campaignEditError: (error: string, code: string) => {
4
4
  error: string;
5
5
  code: string;
@@ -5,7 +5,7 @@ import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { logAudit } from '../audit.js';
7
7
  import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
8
- export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at';
8
+ export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, period_key, updated_at';
9
9
  export const campaignEditError = (error, code) => ({ error, code });
10
10
  /** All writes include the revision used for the approval decision, including ungated drafts. */
11
11
  async function writeEdit(campaignId, previous, updates) {
@@ -1,4 +1,4 @@
1
- import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
1
+ import { resolveCampaignReentry, 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';
@@ -32,6 +32,11 @@ import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
32
32
  const txt = (text) => ({ content: [{ type: 'text', text }] });
33
33
  const j = (data) => txt(JSON.stringify(data, null, 2));
34
34
  const ACTIVATION_APPROVAL_DESCRIPTION = 'Activation-only: requires_approval gates campaign activation. It does not stop sends of an already-active campaign or sends triggered by other paths.';
35
+ const REENTRY_DESCRIPTION = 'Re-entry defaults to once ever. per_event_period uses a flat event-data period_key on event-triggered campaigns only. Changing a live campaign returns an approval request. Existing enrollments without a period cannot re-enter; sends wait for campaign-cycle approval.';
36
+ const reentryFields = {
37
+ reentry_mode: z.enum(['once_ever', 'per_event_period']).optional().describe(REENTRY_DESCRIPTION),
38
+ period_key: z.string().min(1).max(64).nullable().optional().describe('Flat event-data key naming the period (starts with a letter; letters, digits and underscores only). Key alone selects per_event_period; null clears to once ever. ' + REENTRY_DESCRIPTION),
39
+ };
35
40
  const SENT_EMAIL_STATUSES = ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed'];
36
41
  function stripHtml(value) {
37
42
  return value
@@ -670,9 +675,9 @@ export function registerCampaignTools(server) {
670
675
  const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
671
676
  const withPeople = await Promise.all(rows.map(async (row) => {
672
677
  const periods = await readCampaignPeriods(db(), getProjectId(), row);
673
- const { period_key: _periodKey, ...legacy } = row;
674
678
  return {
675
- ...legacy,
679
+ ...row,
680
+ reentry_mode: row.period_key ? "per_event_period" : "once_ever",
676
681
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
677
682
  fires_on: buildCampaignFiresOn(row),
678
683
  trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
@@ -732,6 +737,7 @@ export function registerCampaignTools(server) {
732
737
  },
733
738
  description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the campaign must still be activated via update_campaign_status.',
734
739
  inputSchema: {
740
+ ...reentryFields,
735
741
  site_id: z.string().uuid(),
736
742
  name: z.string().min(1).max(200),
737
743
  type: z.enum(['sequence', 'broadcast', 'triggered']),
@@ -746,7 +752,7 @@ export function registerCampaignTools(server) {
746
752
  send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. The campaign must still be activated via update_campaign_status.'),
747
753
  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)."),
748
754
  },
749
- }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class }) => {
755
+ }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key }) => {
750
756
  const limit = await checkPlanLimit('active_campaigns');
751
757
  if (!limit.allowed) {
752
758
  return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
@@ -762,6 +768,11 @@ export function registerCampaignTools(server) {
762
768
  requires_approval,
763
769
  ...(send_class ? { send_class } : {}),
764
770
  };
771
+ const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
772
+ if (!reentry.ok)
773
+ return j({ error: reentry.error, code: 'INVALID_FIELD' });
774
+ if (reentry.period_key !== undefined)
775
+ insertPayload.period_key = reentry.period_key;
765
776
  const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
766
777
  if (scheduleError)
767
778
  return j({ error: scheduleError, code: 'INVALID_FIELD' });
@@ -2955,6 +2966,7 @@ Return ONLY valid JSON with this exact shape:
2955
2966
  },
2956
2967
  description: 'Create billing, dunning, trial ending or transactional mail with send_class=product (broadcast is the marketing default). Build a sequence, broadcast or triggered campaign with pre-built steps. Override unsubscribe text with unsub_footer_template. Set trigger_event_name for dunning (invoice_payment_failed, trial_ending), trigger_tag, or date_field/contact_event trigger_config; events/tags and where filters are supported. Triggered campaigns need no segment. Returns fires_on and trigger_diagnostics. Adds email steps, optionally enrolls a segment, and activates behind standard approval. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
2957
2968
  inputSchema: {
2969
+ ...reentryFields,
2958
2970
  site_id: z.string().uuid().describe('Site to send from'),
2959
2971
  name: z.string().min(1).max(200).describe('Campaign name'),
2960
2972
  type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
@@ -2978,7 +2990,7 @@ Return ONLY valid JSON with this exact shape:
2978
2990
  confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
2979
2991
  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.'),
2980
2992
  },
2981
- }, 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 }) => {
2993
+ }, 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, reentry_mode, period_key }) => {
2982
2994
  const footerTemplate = unsub_footer_template?.trim() ?? null;
2983
2995
  if (footerTemplate !== null) {
2984
2996
  const footerError = validateUnsubFooterTemplate(footerTemplate);
@@ -2994,10 +3006,15 @@ Return ONLY valid JSON with this exact shape:
2994
3006
  const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
2995
3007
  const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
2996
3008
  const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
3009
+ const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type: campaignType, trigger_type: targeting.trigger_type });
3010
+ if (!reentry.ok)
3011
+ return j({ error: reentry.error, code: 'INVALID_FIELD' });
3012
+ if (reentry.period_key !== undefined)
3013
+ targeting.period_key = reentry.period_key;
2997
3014
  const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
2998
3015
  // Human-in-the-loop gate for activation
2999
3016
  if (activate && confirmed) {
3000
- const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, ...triggerInput };
3017
+ const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
3001
3018
  const channelContext = currentChannelContext();
3002
3019
  const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
3003
3020
  if (!decision.allowed)
@@ -3351,6 +3368,7 @@ Return ONLY valid JSON with this exact shape:
3351
3368
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3352
3369
  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.',
3353
3370
  inputSchema: {
3371
+ ...reentryFields,
3354
3372
  campaign_id: z.string().uuid().optional(),
3355
3373
  trigger_event_name: z.string().nullable().optional(),
3356
3374
  trigger_tag: z.string().nullable().optional(),
@@ -3377,7 +3395,7 @@ Return ONLY valid JSON with this exact shape:
3377
3395
  if (!['draft', 'active', 'paused'].includes(existing.status)) {
3378
3396
  return j({ error: `Cannot change a ${existing.status} campaign trigger`, code: 'INVALID_STATUS' });
3379
3397
  }
3380
- const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config']
3398
+ const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config', 'reentry_mode', 'period_key']
3381
3399
  .some((key) => args[key] !== undefined);
3382
3400
  if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
3383
3401
  return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'INVALID_FIELD' });
@@ -3393,6 +3411,14 @@ Return ONLY valid JSON with this exact shape:
3393
3411
  && (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
3394
3412
  updates.type = 'triggered';
3395
3413
  }
3414
+ const reentry = resolveCampaignReentry(args, {
3415
+ type: (updates.type ?? existing.type),
3416
+ trigger_type: (updates.trigger_type ?? existing.trigger_type),
3417
+ });
3418
+ if (!reentry.ok)
3419
+ return j({ error: reentry.error, code: 'INVALID_FIELD' });
3420
+ if (reentry.period_key !== undefined)
3421
+ updates.period_key = reentry.period_key;
3396
3422
  const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
3397
3423
  return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3398
3424
  });
@@ -3406,6 +3432,7 @@ Return ONLY valid JSON with this exact shape:
3406
3432
  },
3407
3433
  description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated. Changed triggers on active/paused campaigns return an approval request; retry with only approval_id to apply the frozen edit.',
3408
3434
  inputSchema: {
3435
+ ...reentryFields,
3409
3436
  campaign_id: z.string().uuid().optional(),
3410
3437
  approval_id: z.string().uuid().optional(),
3411
3438
  name: z.string().min(1).max(200).optional(),
@@ -3428,7 +3455,7 @@ Return ONLY valid JSON with this exact shape:
3428
3455
  const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3429
3456
  return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3430
3457
  }
3431
- 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;
3458
+ 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;
3432
3459
  if (!campaign_id)
3433
3460
  return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3434
3461
  if (unsub_footer_template !== undefined && unsub_footer_template !== null) {
@@ -3474,7 +3501,7 @@ Return ONLY valid JSON with this exact shape:
3474
3501
  return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
3475
3502
  }
3476
3503
  }
3477
- const hasTriggerUpdate = trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
3504
+ const hasTriggerUpdate = reentry_mode !== undefined || period_key !== undefined || trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
3478
3505
  let existingCampaignForTargeting = null;
3479
3506
  if (hasTriggerUpdate) {
3480
3507
  const { data: existing, error: fetchError } = await getExisting();
@@ -3517,6 +3544,14 @@ Return ONLY valid JSON with this exact shape:
3517
3544
  updates.type = 'triggered';
3518
3545
  }
3519
3546
  }
3547
+ const reentry = resolveCampaignReentry({ reentry_mode, period_key }, {
3548
+ type: (updates.type ?? existingCampaignForTargeting?.type),
3549
+ trigger_type: (updates.trigger_type ?? existingCampaignForTargeting?.trigger_type),
3550
+ });
3551
+ if (!reentry.ok)
3552
+ return j({ error: reentry.error, code: 'INVALID_FIELD' });
3553
+ if (reentry.period_key !== undefined)
3554
+ updates.period_key = reentry.period_key;
3520
3555
  let data;
3521
3556
  if (existingCampaignForTargeting) {
3522
3557
  data = await finishCampaignEdit('update_campaign', input, existingCampaignForTargeting, updates, params => requestApproval('update_campaign', params));
@@ -3620,6 +3655,7 @@ Return ONLY valid JSON with this exact shape:
3620
3655
  const unresolvedCount = dlqUnresolvedCount ?? 0;
3621
3656
  return j({
3622
3657
  ...campaign,
3658
+ reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
3623
3659
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
3624
3660
  fires_on: buildCampaignFiresOn(campaign),
3625
3661
  trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.129",
3
+ "version": "1.0.130",
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",