@sendinel/mcp-server 1.0.103 → 1.0.104
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,4 +1,4 @@
|
|
|
1
|
-
import { decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
1
|
+
import { triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
2
2
|
import { db, requestContext } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
@@ -28,6 +28,7 @@ const GATED_ACTIONS = new Set([
|
|
|
28
28
|
'import_subscribers', // bulk data ingestion
|
|
29
29
|
'subscribe_webhook', // outbound data channel
|
|
30
30
|
'send_transactional', // sends a real email
|
|
31
|
+
'update_campaign', // live trigger edits only; the domain rule gates callers
|
|
31
32
|
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
32
33
|
'update_campaign_status', // activation sends real emails (see special case below)
|
|
33
34
|
'add_list_members', // bulk contact-list membership write
|
|
@@ -230,6 +231,8 @@ function launchSummary(p, d) {
|
|
|
230
231
|
}
|
|
231
232
|
/** Build a human-readable summary of the action for the approval screen */
|
|
232
233
|
export function buildApprovalSummary(action, params, display = {}) {
|
|
234
|
+
if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
|
|
235
|
+
return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
|
|
233
236
|
const summaries = {
|
|
234
237
|
launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
235
238
|
install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
|
|
@@ -116,7 +116,6 @@ export const APPROVAL_EXEMPT = {
|
|
|
116
116
|
trigger_automation: 'generates a draft into the approval queue (or returns it with dry_run); nothing is sent until a person approves the draft',
|
|
117
117
|
unsubscribe_subscriber: "only stops mail to a contact; reduces sending",
|
|
118
118
|
update_brand_voice: "edits brand voice text on the caller own project; internal",
|
|
119
|
-
update_campaign: "edits campaign settings; status changes go through update_campaign_status (gated). FOLLOW-UP: same live-edit caveat as update_campaign_step",
|
|
120
119
|
update_campaign_step: "edits email copy; sends only through an already-gated activation. FOLLOW-UP: editing a live campaign changes what goes out (#4005 summary)",
|
|
121
120
|
update_custom_field: "renames or edits a field definition; values are untouched",
|
|
122
121
|
update_hygiene_config: "changes hygiene settings; suppression runs are gated (identify_inactive) or only reduce sending",
|
|
@@ -0,0 +1,10 @@
|
|
|
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";
|
|
3
|
+
export declare const campaignEditError: (error: string, code: string) => {
|
|
4
|
+
error: string;
|
|
5
|
+
code: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function finishCampaignEdit(action: string, input: Row, existing: Row, updates: Row, request: (params: Row) => Promise<Row>): Promise<any>;
|
|
8
|
+
/** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
|
|
9
|
+
export declare function resumeCampaignEdit(action: string, approvalId: string, input: Row): Promise<any>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
|
|
3
|
+
import { db } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
import { logAudit } from '../audit.js';
|
|
6
|
+
import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
|
|
7
|
+
export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at';
|
|
8
|
+
export const campaignEditError = (error, code) => ({ error, code });
|
|
9
|
+
/** All writes include the revision used for the approval decision, including ungated drafts. */
|
|
10
|
+
async function writeEdit(campaignId, previous, updates) {
|
|
11
|
+
let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
|
|
12
|
+
.eq('id', campaignId).eq('project_id', getProjectId()).eq('status', previous.status);
|
|
13
|
+
if (previous.updated_at)
|
|
14
|
+
query = query.eq('updated_at', previous.updated_at);
|
|
15
|
+
const { data, error } = await query.select().single();
|
|
16
|
+
if (error || !data)
|
|
17
|
+
return campaignEditError(error?.message ?? 'Campaign changed; read it and request a new approval', 'CAMPAIGN_CHANGED');
|
|
18
|
+
await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaignId, details: { previous, updates } });
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
export async function finishCampaignEdit(action, input, existing, updates, request) {
|
|
22
|
+
if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
|
|
23
|
+
return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
|
|
24
|
+
}
|
|
25
|
+
if (!triggerChangeNeedsApproval(existing, updates))
|
|
26
|
+
return writeEdit(input.campaign_id, existing, updates);
|
|
27
|
+
const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
|
|
28
|
+
const context = currentChannelContext();
|
|
29
|
+
const decision = assertChannelCanExecute(action, params, context);
|
|
30
|
+
if (!decision.allowed)
|
|
31
|
+
return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
|
|
32
|
+
// This trigger policy requires a human decision even if the integration uses autonomous mode.
|
|
33
|
+
return request(params);
|
|
34
|
+
}
|
|
35
|
+
/** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
|
|
36
|
+
export async function resumeCampaignEdit(action, approvalId, input) {
|
|
37
|
+
const { data: approval, error } = await db().from('action_approvals').select('*')
|
|
38
|
+
.eq('id', approvalId).eq('project_id', getProjectId()).maybeSingle();
|
|
39
|
+
if (error || !approval || approval.action !== action)
|
|
40
|
+
return campaignEditError('Approval not found for this action', 'APPROVAL_MISMATCH');
|
|
41
|
+
if (approval.used_at || approval.status === 'used')
|
|
42
|
+
return campaignEditError('Approval consumed', 'APPROVAL_CONSUMED');
|
|
43
|
+
const params = approval.params;
|
|
44
|
+
if (!params?.previous_campaign || !params.updates || !params.campaign_id)
|
|
45
|
+
return campaignEditError('Approval has no frozen campaign edit', 'APPROVAL_MISMATCH');
|
|
46
|
+
for (const [key, value] of Object.entries(input)) {
|
|
47
|
+
if (value !== undefined && !isDeepStrictEqual(value, params.input?.[key]))
|
|
48
|
+
return campaignEditError('params_mismatch: request differs from the frozen edit', 'APPROVAL_MISMATCH');
|
|
49
|
+
}
|
|
50
|
+
const { data: existing, error: loadError } = await db().from('campaigns').select(TRIGGER_EDIT_SELECT)
|
|
51
|
+
.eq('id', params.campaign_id).eq('project_id', getProjectId()).maybeSingle();
|
|
52
|
+
if (loadError || !existing)
|
|
53
|
+
return campaignEditError('Campaign not found', 'NOT_FOUND');
|
|
54
|
+
if (campaignTriggerRevision(existing) !== campaignTriggerRevision(params.previous_campaign)) {
|
|
55
|
+
return campaignEditError('Campaign changed after approval was requested; request a new approval', 'CAMPAIGN_CHANGED');
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
|
|
58
|
+
return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
|
|
59
|
+
const decision = assertChannelCanExecute(action, params, currentChannelContext());
|
|
60
|
+
if (!decision.allowed)
|
|
61
|
+
return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
|
|
62
|
+
const check = await checkApproval(approvalId, { consume: true, action, params });
|
|
63
|
+
if (!check.approved)
|
|
64
|
+
return campaignEditError(`Approval ${approvalId} is ${check.status}`, 'APPROVAL_REQUIRED');
|
|
65
|
+
return writeEdit(params.campaign_id, existing, params.updates);
|
|
66
|
+
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -9,6 +9,7 @@ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLO
|
|
|
9
9
|
// bundling problem this subpath was introduced to fix. See the comment in
|
|
10
10
|
// packages/domain/src/index.ts.
|
|
11
11
|
import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/domain/reply-actions';
|
|
12
|
+
import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
|
|
12
13
|
import { adminDb, db, requestContext } from '../db.js';
|
|
13
14
|
import { getProjectId } from '../project.js';
|
|
14
15
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
@@ -3345,7 +3346,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3345
3346
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3346
3347
|
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. Use for dunning on 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.',
|
|
3347
3348
|
inputSchema: {
|
|
3348
|
-
campaign_id: z.string().uuid(),
|
|
3349
|
+
campaign_id: z.string().uuid().optional(),
|
|
3349
3350
|
trigger_event_name: z.string().nullable().optional(),
|
|
3350
3351
|
trigger_tag: z.string().nullable().optional(),
|
|
3351
3352
|
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
@@ -3354,9 +3355,15 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3354
3355
|
approval_id: z.string().uuid().optional(),
|
|
3355
3356
|
},
|
|
3356
3357
|
}, async ({ approval_id, ...args }) => {
|
|
3358
|
+
if (approval_id) {
|
|
3359
|
+
const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
|
|
3360
|
+
return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
|
|
3361
|
+
}
|
|
3362
|
+
if (!args.campaign_id)
|
|
3363
|
+
return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
|
|
3357
3364
|
const projectId = getProjectId();
|
|
3358
3365
|
const { data: existing, error: loadError } = await db().from('campaigns')
|
|
3359
|
-
.select(
|
|
3366
|
+
.select(TRIGGER_EDIT_SELECT)
|
|
3360
3367
|
.eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
|
|
3361
3368
|
if (loadError)
|
|
3362
3369
|
return j({ error: loadError.message });
|
|
@@ -3381,36 +3388,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3381
3388
|
&& (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
|
|
3382
3389
|
updates.type = 'triggered';
|
|
3383
3390
|
}
|
|
3384
|
-
const
|
|
3385
|
-
|
|
3386
|
-
// guards against overwriting edits made while an approved call is executing.
|
|
3387
|
-
let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
|
|
3388
|
-
.eq('id', args.campaign_id).eq('project_id', projectId).eq('status', existing.status);
|
|
3389
|
-
if (existing.updated_at)
|
|
3390
|
-
query = query.eq('updated_at', existing.updated_at);
|
|
3391
|
-
const { data, error } = await query.select().maybeSingle();
|
|
3392
|
-
if (error)
|
|
3393
|
-
return j({ error: error.message });
|
|
3394
|
-
if (!data)
|
|
3395
|
-
return j({ error: 'Campaign changed; read it and retry', code: 'CAMPAIGN_CHANGED' });
|
|
3396
|
-
await logAudit({ action: 'campaign.trigger_updated', resourceType: 'campaign', resourceId: args.campaign_id, details: { previous: existing, updates } });
|
|
3397
|
-
return j({ ...data, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) });
|
|
3398
|
-
};
|
|
3399
|
-
if (existing.status === 'draft')
|
|
3400
|
-
return execute();
|
|
3401
|
-
// The saved state is internal, never caller-supplied. Approval is for this exact before/after.
|
|
3402
|
-
return withApproval('set_campaign_trigger', async (_params) => execute(), {
|
|
3403
|
-
display: async () => {
|
|
3404
|
-
const { count, error } = await db().from('subscribers').select('id', { count: 'exact', head: true })
|
|
3405
|
-
.eq('project_id', projectId).eq('global_unsubscribed', false);
|
|
3406
|
-
return {
|
|
3407
|
-
campaign_name: existing.name,
|
|
3408
|
-
from_trigger: `${buildCampaignFiresOn(existing)} Config: ${JSON.stringify(existing.trigger_config)}`,
|
|
3409
|
-
to_trigger: `${buildCampaignFiresOn({ ...existing, ...updates })} Config: ${JSON.stringify(updates.trigger_config === undefined ? existing.trigger_config : updates.trigger_config)}`,
|
|
3410
|
-
recipient_count: error ? null : count,
|
|
3411
|
-
};
|
|
3412
|
-
},
|
|
3413
|
-
})({ ...args, previous_trigger: existing, approval_id });
|
|
3391
|
+
const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
|
|
3392
|
+
return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
|
|
3414
3393
|
});
|
|
3415
3394
|
server.registerTool('update_campaign', {
|
|
3416
3395
|
title: 'Update Campaign',
|
|
@@ -3420,9 +3399,10 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3420
3399
|
idempotentHint: true,
|
|
3421
3400
|
openWorldHint: false,
|
|
3422
3401
|
},
|
|
3423
|
-
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.',
|
|
3402
|
+
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.',
|
|
3424
3403
|
inputSchema: {
|
|
3425
|
-
campaign_id: z.string().uuid(),
|
|
3404
|
+
campaign_id: z.string().uuid().optional(),
|
|
3405
|
+
approval_id: z.string().uuid().optional(),
|
|
3426
3406
|
name: z.string().min(1).max(200).optional(),
|
|
3427
3407
|
site_id: z.string().uuid().optional(),
|
|
3428
3408
|
segment_id: z.string().uuid().nullable().optional(),
|
|
@@ -3437,12 +3417,19 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3437
3417
|
send_in_contact_timezone: z.boolean().optional(),
|
|
3438
3418
|
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)."),
|
|
3439
3419
|
},
|
|
3440
|
-
}, async ({
|
|
3420
|
+
}, async ({ approval_id, ...input }) => {
|
|
3421
|
+
if (approval_id) {
|
|
3422
|
+
const result = await resumeCampaignEdit('update_campaign', approval_id, input);
|
|
3423
|
+
return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
|
|
3424
|
+
}
|
|
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;
|
|
3426
|
+
if (!campaign_id)
|
|
3427
|
+
return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
|
|
3441
3428
|
const projectId = getProjectId();
|
|
3442
3429
|
let campaignType;
|
|
3443
3430
|
const loadExisting = () => db()
|
|
3444
3431
|
.from('campaigns')
|
|
3445
|
-
.select(
|
|
3432
|
+
.select(TRIGGER_EDIT_SELECT)
|
|
3446
3433
|
.eq('id', campaign_id)
|
|
3447
3434
|
.eq('project_id', projectId)
|
|
3448
3435
|
.maybeSingle();
|
|
@@ -3486,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3486
3473
|
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3487
3474
|
existingCampaignForTargeting = existing;
|
|
3488
3475
|
}
|
|
3489
|
-
const updates = {
|
|
3476
|
+
const updates = {};
|
|
3490
3477
|
if (name !== undefined)
|
|
3491
3478
|
updates.name = name;
|
|
3492
3479
|
if (site_id !== undefined)
|
|
@@ -3517,15 +3504,20 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3517
3504
|
updates.type = 'triggered';
|
|
3518
3505
|
}
|
|
3519
3506
|
}
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
.
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3507
|
+
let data;
|
|
3508
|
+
if (existingCampaignForTargeting) {
|
|
3509
|
+
data = await finishCampaignEdit('update_campaign', input, existingCampaignForTargeting, updates, params => requestApproval('update_campaign', params));
|
|
3510
|
+
if (data.error || data.requires_approval)
|
|
3511
|
+
return j(data);
|
|
3512
|
+
}
|
|
3513
|
+
else {
|
|
3514
|
+
const result = await db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
|
|
3515
|
+
.eq('id', campaign_id).eq('project_id', projectId).select().single();
|
|
3516
|
+
if (result.error)
|
|
3517
|
+
return j({ error: result.error.message });
|
|
3518
|
+
data = result.data;
|
|
3519
|
+
await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaign_id, details: { updates } });
|
|
3520
|
+
}
|
|
3529
3521
|
const diagnostics = buildCampaignTriggerDiagnostics(data);
|
|
3530
3522
|
const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
|
|
3531
3523
|
return j({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.104",
|
|
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",
|