@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) {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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
|
-
...
|
|
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.
|
|
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",
|