@sendinel/mcp-server 1.0.129 → 1.0.131
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/lib/action-approvals.js +5 -2
- package/build/lib/approval-snapshot.d.ts +7 -0
- package/build/lib/approval-snapshot.js +135 -4
- package/build/lib/campaign-trigger-approval.d.ts +1 -1
- package/build/lib/campaign-trigger-approval.js +1 -1
- package/build/tools/campaigns.js +45 -9
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
|
|
|
11
11
|
import { approvalSetupSummary, buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
12
12
|
import { requestContext } from '../db.js';
|
|
13
13
|
import { getProjectId } from '../project.js';
|
|
14
|
-
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
14
|
+
import { captureApprovalSnapshot, withoutApprovalSnapshot, withoutApprovalDisplay } from './approval-snapshot.js';
|
|
15
15
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
16
16
|
import { offerApprovalApp } from './approval-app.js';
|
|
17
17
|
import { elicitApproval } from './approval-elicitation.js';
|
|
@@ -161,6 +161,9 @@ function launchSummary(p, d) {
|
|
|
161
161
|
}
|
|
162
162
|
/** Build a human-readable summary of the action for the approval screen */
|
|
163
163
|
export function buildApprovalSummary(action, params, display = {}) {
|
|
164
|
+
if (params.__approval_snapshot?.display?.target_name) {
|
|
165
|
+
return buildApprovalPreview({ action, params }).plain_summary;
|
|
166
|
+
}
|
|
164
167
|
const setup = approvalSetupSummary(action, params);
|
|
165
168
|
if (setup)
|
|
166
169
|
return setup;
|
|
@@ -425,7 +428,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
425
428
|
if (data.params?.__approval_snapshot) {
|
|
426
429
|
try {
|
|
427
430
|
const current = await captureSnapshot(data.action, withoutApprovalSnapshot(data.params));
|
|
428
|
-
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
431
|
+
if (!approvalParamsEqual(withoutApprovalDisplay(current), withoutApprovalDisplay(data.params.__approval_snapshot))) {
|
|
429
432
|
return { approved: false, status: 'target_changed_after_approval' };
|
|
430
433
|
}
|
|
431
434
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
type Params = Record<string, any>;
|
|
2
|
+
/** Optional display facts only. Failures must never prevent requesting or using an approval. */
|
|
3
|
+
export declare function captureApprovalDisplay(action: string, params: Params, projectId: string): Promise<{
|
|
4
|
+
target_name: string;
|
|
5
|
+
target_detail?: string | undefined;
|
|
6
|
+
} | undefined>;
|
|
7
|
+
/** Exclude only optional display facts from revalidation, never from the payload hash. */
|
|
8
|
+
export declare function withoutApprovalDisplay(snapshot: Params): Params;
|
|
2
9
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
3
10
|
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
|
|
4
11
|
export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
|
|
@@ -1,12 +1,141 @@
|
|
|
1
1
|
import { isTesterRequest } from './tester-policy.js';
|
|
2
2
|
import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
|
|
3
|
-
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
3
|
+
import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
|
|
4
4
|
import { callSendinelV1 } from './v1-client.js';
|
|
5
|
-
import { db } from '../db.js';
|
|
5
|
+
import { adminDb, db } from '../db.js';
|
|
6
6
|
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
7
7
|
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
8
8
|
'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
|
|
9
9
|
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
10
|
+
// Columns and scope paths verified against the SQL migrations; never select credentials.
|
|
11
|
+
const DISPLAY_TARGETS = {
|
|
12
|
+
delete_segment: ['segments', 'segment_id', 'name'],
|
|
13
|
+
delete_template: ['campaign_templates', 'id', 'name'],
|
|
14
|
+
delete_signature: ['signature_blocks', 'id', 'name'],
|
|
15
|
+
delete_campaign: ['campaigns', 'campaign_id', 'name'],
|
|
16
|
+
delete_content_block: ['content_blocks', 'block_id', 'name'],
|
|
17
|
+
delete_custom_field: ['custom_field_definitions', 'id', 'label', true],
|
|
18
|
+
delete_landing_page: ['landing_pages', 'page_id', 'title'],
|
|
19
|
+
delete_list: ['contact_lists', 'list_id', 'name'],
|
|
20
|
+
delete_social_post: ['social_posts', 'post_id', 'title', true],
|
|
21
|
+
delete_subscriber_view: ['subscriber_list_views', 'view_id', 'name'],
|
|
22
|
+
};
|
|
23
|
+
const MEMBER_ACTIONS = new Set(['remove_team_member', 'set_team_member_role', 'set_member_project_access']);
|
|
24
|
+
/** Optional display facts only. Failures must never prevent requesting or using an approval. */
|
|
25
|
+
export async function captureApprovalDisplay(action, params, projectId) {
|
|
26
|
+
try {
|
|
27
|
+
if (!DISPLAY_TARGETS[action] && !MEMBER_ACTIONS.has(action) && ![
|
|
28
|
+
'revoke_api_key', 'revoke_invitation', 'rotate_custom_webhook_secret',
|
|
29
|
+
'delete_webhook_subscription', 'update_webhook_subscription',
|
|
30
|
+
'delete_campaign_step', 'update_campaign_step', 'reply_to_comment',
|
|
31
|
+
].includes(action))
|
|
32
|
+
return undefined;
|
|
33
|
+
const source = db();
|
|
34
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
35
|
+
const read = async (query) => {
|
|
36
|
+
const { data, error } = await query.maybeSingle();
|
|
37
|
+
return error ? null : data;
|
|
38
|
+
};
|
|
39
|
+
const fact = (value, max = 45) => {
|
|
40
|
+
const text = approvalDisplayFact(value);
|
|
41
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
42
|
+
};
|
|
43
|
+
const facts = (name, detail) => {
|
|
44
|
+
const target_name = fact(name);
|
|
45
|
+
const target_detail = fact(detail, 25);
|
|
46
|
+
return target_name ? { target_name, ...(target_detail ? { target_detail } : {}) } : undefined;
|
|
47
|
+
};
|
|
48
|
+
// Org rows have no project_id: derive their scope from the current project first.
|
|
49
|
+
const projectOrg = async () => {
|
|
50
|
+
const project = await read(client.from('projects').select('org_id').eq('id', projectId));
|
|
51
|
+
return typeof project?.org_id === 'string' && (!params.org_id || params.org_id === project.org_id)
|
|
52
|
+
? project.org_id : null;
|
|
53
|
+
};
|
|
54
|
+
const target = DISPLAY_TARGETS[action];
|
|
55
|
+
if (target) {
|
|
56
|
+
const [table, param, column, orgScoped] = target;
|
|
57
|
+
if (typeof params[param] !== 'string')
|
|
58
|
+
return undefined;
|
|
59
|
+
let query = client.from(table).select(column).eq('id', params[param]).eq('project_id', projectId);
|
|
60
|
+
if (orgScoped) {
|
|
61
|
+
const orgId = await projectOrg();
|
|
62
|
+
if (!orgId)
|
|
63
|
+
return undefined;
|
|
64
|
+
query = query.eq('org_id', orgId);
|
|
65
|
+
}
|
|
66
|
+
return facts((await read(query))?.[column]);
|
|
67
|
+
}
|
|
68
|
+
if (action === 'revoke_api_key') {
|
|
69
|
+
const key = await read(client.from('api_keys').select('id, name')
|
|
70
|
+
.eq('id', params.key_id).eq('project_id', projectId));
|
|
71
|
+
// The UUID suffix is public identity, never key material, a secret or a hash.
|
|
72
|
+
return key ? facts(key.name, `…${String(key.id).slice(-4)}`) : undefined;
|
|
73
|
+
}
|
|
74
|
+
if (MEMBER_ACTIONS.has(action) || action === 'revoke_invitation') {
|
|
75
|
+
const orgId = await projectOrg();
|
|
76
|
+
if (!orgId)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (action === 'revoke_invitation') {
|
|
79
|
+
return facts((await read(client.from('org_invitations').select('email')
|
|
80
|
+
.eq('id', params.invitation_id).eq('org_id', orgId)))?.email);
|
|
81
|
+
}
|
|
82
|
+
const member = await read(client.from('org_members').select('user_id')
|
|
83
|
+
.eq('user_id', params.user_id).eq('org_id', orgId));
|
|
84
|
+
if (!member)
|
|
85
|
+
return undefined;
|
|
86
|
+
// Only read Auth after proving this user belongs to the project's organization.
|
|
87
|
+
const { data, error } = await adminDb().auth.admin.getUserById(member.user_id);
|
|
88
|
+
return error ? undefined : facts(data?.user?.email);
|
|
89
|
+
}
|
|
90
|
+
if (action === 'delete_campaign_step' || action === 'update_campaign_step') {
|
|
91
|
+
// Steps inherit project scope from their campaign; inner join scopes the read itself.
|
|
92
|
+
const step = await read(client.from('campaign_steps')
|
|
93
|
+
.select('step_order, subject, campaign:campaigns!inner(name)')
|
|
94
|
+
.eq('id', params.step_id).eq('campaign.project_id', projectId));
|
|
95
|
+
if (!step)
|
|
96
|
+
return undefined;
|
|
97
|
+
const name = fact(step.campaign?.name, 25);
|
|
98
|
+
const number = Number.isInteger(step.step_order) && step.step_order >= 0 ? `email ${step.step_order + 1}` : 'email';
|
|
99
|
+
return facts(name ? `${name}: ${number}` : '', step.subject);
|
|
100
|
+
}
|
|
101
|
+
if (action === 'reply_to_comment') {
|
|
102
|
+
const { postUrn, commentUrn } = parseLinkedInCommentUrl(params.comment_url_or_urn);
|
|
103
|
+
const orgId = await projectOrg();
|
|
104
|
+
if (!orgId)
|
|
105
|
+
return undefined;
|
|
106
|
+
// Upload Post stores its returned id verbatim: it may be a full URN, a
|
|
107
|
+
// share-link URN or a bare comment id. The thread/project/org still bind it.
|
|
108
|
+
const commentIds = [...new Set([commentUrn, commentUrn.replace('(urn:li:', '('),
|
|
109
|
+
commentUrn.match(/,(\d+)\)$/)?.[1]].filter((id) => typeof id === 'string'))];
|
|
110
|
+
const comment = await read(client.from('social_engagements').select('sender_name, body')
|
|
111
|
+
.eq('project_id', projectId).eq('org_id', orgId).eq('platform', 'linkedin')
|
|
112
|
+
.eq('external_thread_id', postUrn).in('external_message_id', commentIds));
|
|
113
|
+
return comment ? facts([fact(comment.sender_name, 18), fact(comment.body, 40)].filter(Boolean).join(': ')) : undefined;
|
|
114
|
+
}
|
|
115
|
+
if (action === 'rotate_custom_webhook_secret') {
|
|
116
|
+
const orgId = await projectOrg();
|
|
117
|
+
if (!orgId)
|
|
118
|
+
return undefined;
|
|
119
|
+
return facts((await read(client.from('platform_connections').select('display_name')
|
|
120
|
+
.eq('id', params.connection_id).eq('project_id', projectId).eq('org_id', orgId)
|
|
121
|
+
.eq('platform', 'custom')))?.display_name);
|
|
122
|
+
}
|
|
123
|
+
const webhook = await read(client.from('webhook_subscriptions').select('description, url')
|
|
124
|
+
.eq('id', params.subscription_id).eq('project_id', projectId));
|
|
125
|
+
if (!webhook)
|
|
126
|
+
return undefined;
|
|
127
|
+
return facts(fact(webhook.description) || new URL(webhook.url).hostname);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Optional names cannot change approval eligibility or target validation.
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Exclude only optional display facts from revalidation, never from the payload hash. */
|
|
135
|
+
export function withoutApprovalDisplay(snapshot) {
|
|
136
|
+
const { display: _display, ...rest } = snapshot;
|
|
137
|
+
return rest;
|
|
138
|
+
}
|
|
10
139
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
11
140
|
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
12
141
|
if (action === 'invite_team_member') {
|
|
@@ -17,10 +146,12 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
17
146
|
}
|
|
18
147
|
return { version: 1, invitation: preview, recipient_count: 1 };
|
|
19
148
|
}
|
|
149
|
+
const display = await captureApprovalDisplay(action, params, projectId);
|
|
150
|
+
const withDisplay = (value) => display ? { ...value, display } : value;
|
|
20
151
|
const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
|
|
21
152
|
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
22
153
|
if (!TARGET_ACTIONS.has(action))
|
|
23
|
-
return setup ?? snapshot;
|
|
154
|
+
return withDisplay(setup ?? snapshot);
|
|
24
155
|
const source = db();
|
|
25
156
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
26
157
|
const body = params.payload ?? params;
|
|
@@ -110,7 +241,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
110
241
|
}
|
|
111
242
|
snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
112
243
|
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
113
|
-
return setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot;
|
|
244
|
+
return withDisplay(setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot);
|
|
114
245
|
}
|
|
115
246
|
export function withoutApprovalSnapshot(params) {
|
|
116
247
|
const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
|
|
@@ -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.131",
|
|
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",
|