@sendinel/mcp-server 1.0.103 → 1.0.105
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.d.ts +3 -0
- package/build/lib/action-approvals.js +66 -16
- package/build/lib/approval-capabilities.d.ts +17 -0
- package/build/lib/approval-capabilities.js +40 -0
- package/build/lib/approval-exempt.js +0 -1
- package/build/lib/approval-human-callback.d.ts +19 -0
- package/build/lib/approval-human-callback.js +41 -0
- package/build/lib/approval-snapshot.d.ts +5 -0
- package/build/lib/approval-snapshot.js +106 -0
- package/build/lib/campaign-trigger-approval.d.ts +10 -0
- package/build/lib/campaign-trigger-approval.js +66 -0
- package/build/server.js +3 -0
- package/build/tools/campaigns.js +38 -46
- package/package.json +1 -1
|
@@ -46,9 +46,12 @@ export interface ApprovalResult {
|
|
|
46
46
|
approve_via: string;
|
|
47
47
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
48
48
|
approve_url: string;
|
|
49
|
+
preview_text: string;
|
|
49
50
|
message: string;
|
|
50
51
|
}
|
|
51
52
|
export declare function approvalPageUrl(approvalId: string): string;
|
|
53
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
54
|
+
export declare function approvalPayloadHash(action: string, params: unknown): string;
|
|
52
55
|
/**
|
|
53
56
|
* Request human approval for an action. Creates a pending approval token
|
|
54
57
|
* and returns instructions for the agent to wait.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
2
3
|
import { db, requestContext } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
4
6
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
5
7
|
/**
|
|
6
8
|
* Action approval tiers:
|
|
@@ -28,6 +30,7 @@ const GATED_ACTIONS = new Set([
|
|
|
28
30
|
'import_subscribers', // bulk data ingestion
|
|
29
31
|
'subscribe_webhook', // outbound data channel
|
|
30
32
|
'send_transactional', // sends a real email
|
|
33
|
+
'update_campaign', // live trigger edits only; the domain rule gates callers
|
|
31
34
|
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
32
35
|
'update_campaign_status', // activation sends real emails (see special case below)
|
|
33
36
|
'add_list_members', // bulk contact-list membership write
|
|
@@ -230,6 +233,8 @@ function launchSummary(p, d) {
|
|
|
230
233
|
}
|
|
231
234
|
/** Build a human-readable summary of the action for the approval screen */
|
|
232
235
|
export function buildApprovalSummary(action, params, display = {}) {
|
|
236
|
+
if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
|
|
237
|
+
return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
|
|
233
238
|
const summaries = {
|
|
234
239
|
launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
235
240
|
install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
|
|
@@ -300,11 +305,15 @@ function normalizeApprovalParams(value) {
|
|
|
300
305
|
if (value && typeof value === 'object') {
|
|
301
306
|
return Object.fromEntries(Object.entries(value)
|
|
302
307
|
.filter(([, entryValue]) => entryValue !== undefined)
|
|
303
|
-
.sort(([a], [b]) => a
|
|
308
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
304
309
|
.map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
|
|
305
310
|
}
|
|
306
311
|
return value;
|
|
307
312
|
}
|
|
313
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
314
|
+
export function approvalPayloadHash(action, params) {
|
|
315
|
+
return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
|
|
316
|
+
}
|
|
308
317
|
function approvalParamsEqual(expected, actual) {
|
|
309
318
|
return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
|
|
310
319
|
}
|
|
@@ -315,9 +324,18 @@ function approvalParamsEqual(expected, actual) {
|
|
|
315
324
|
export async function requestApproval(action, params, apiKeyId, channelContext, display) {
|
|
316
325
|
const tier = getApprovalTier(action, params);
|
|
317
326
|
const summary = buildApprovalSummary(action, params, display);
|
|
318
|
-
const expiresAt = new Date(Date.now() +
|
|
327
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
319
328
|
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
320
329
|
const actor = requestContext();
|
|
330
|
+
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
331
|
+
const cleanParams = withoutApprovalSnapshot(params);
|
|
332
|
+
const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
|
|
333
|
+
const payload = cleanParams.payload;
|
|
334
|
+
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
335
|
+
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
336
|
+
throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
|
|
337
|
+
}
|
|
338
|
+
const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
|
|
321
339
|
const { data, error } = await db()
|
|
322
340
|
.from('action_approvals')
|
|
323
341
|
.insert({
|
|
@@ -327,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
327
345
|
agent_label: actor?.agentLabel ?? null,
|
|
328
346
|
action,
|
|
329
347
|
tier,
|
|
330
|
-
params,
|
|
348
|
+
params: frozenParams,
|
|
349
|
+
payload_hash: approvalPayloadHash(action, frozenParams),
|
|
331
350
|
summary,
|
|
332
351
|
expires_at: expiresAt,
|
|
333
352
|
channel,
|
|
@@ -352,7 +371,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
352
371
|
expires_at: expiresAt,
|
|
353
372
|
approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
|
|
354
373
|
approve_url: approvalPageUrl(data.id),
|
|
355
|
-
|
|
374
|
+
preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
|
|
375
|
+
message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 24 hours.`,
|
|
356
376
|
};
|
|
357
377
|
}
|
|
358
378
|
/**
|
|
@@ -362,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
362
382
|
export async function checkApproval(approvalId, options = {}) {
|
|
363
383
|
const { data, error } = await db()
|
|
364
384
|
.from('action_approvals')
|
|
365
|
-
.select('action, status, decided_by, decided_via, expires_at, used_at, params')
|
|
385
|
+
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
366
386
|
.eq('id', approvalId)
|
|
367
387
|
.eq('project_id', getProjectId())
|
|
368
388
|
.maybeSingle();
|
|
@@ -371,17 +391,29 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
371
391
|
if (options.action && data.action !== options.action) {
|
|
372
392
|
return { approved: false, status: 'action_mismatch' };
|
|
373
393
|
}
|
|
374
|
-
if (options.
|
|
394
|
+
if (options.consume && data.payload_hash) {
|
|
395
|
+
if (!options.action || !options.params)
|
|
396
|
+
return { approved: false, status: 'payload_required' };
|
|
397
|
+
if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
|
|
398
|
+
approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
|
|
399
|
+
return { approved: false, status: 'payload_changed_after_approval' };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
|
|
375
403
|
return { approved: false, status: 'params_mismatch' };
|
|
376
404
|
}
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
.eq('id', approvalId);
|
|
405
|
+
// Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
|
|
406
|
+
// shorter lifetime; no row can authorize an apply more than 24 hours after its request.
|
|
407
|
+
const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
|
|
408
|
+
const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
|
|
409
|
+
if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
|
|
383
410
|
return { approved: false, status: 'expired' };
|
|
384
411
|
}
|
|
412
|
+
if (data.used_at || data.status === 'used')
|
|
413
|
+
return { approved: false, status: 'consumed' };
|
|
414
|
+
if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
|
|
415
|
+
return { approved: false, status: 'human_decision_required' };
|
|
416
|
+
}
|
|
385
417
|
if (data.status !== 'approved' || !options.consume) {
|
|
386
418
|
if (data.used_at)
|
|
387
419
|
return { approved: false, status: 'consumed' };
|
|
@@ -391,15 +423,33 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
391
423
|
decided_by: data.decided_by,
|
|
392
424
|
};
|
|
393
425
|
}
|
|
394
|
-
|
|
426
|
+
if (data.params?.__approval_snapshot) {
|
|
427
|
+
try {
|
|
428
|
+
const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
|
|
429
|
+
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
430
|
+
return { approved: false, status: 'target_changed_after_approval' };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { approved: false, status: 'target_revalidation_failed' };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (expiry <= Date.now())
|
|
438
|
+
return { approved: false, status: 'expired' };
|
|
439
|
+
const claimQuery = db()
|
|
395
440
|
.from('action_approvals')
|
|
396
441
|
.update({ status: 'used', used_at: new Date().toISOString() })
|
|
397
442
|
.eq('id', approvalId)
|
|
398
443
|
.eq('project_id', getProjectId())
|
|
399
444
|
.eq('status', 'approved')
|
|
400
445
|
.is('used_at', null)
|
|
401
|
-
.
|
|
402
|
-
.
|
|
446
|
+
.eq('expires_at', data.expires_at)
|
|
447
|
+
.gt('expires_at', new Date().toISOString())
|
|
448
|
+
.eq('decided_via', data.decided_via)
|
|
449
|
+
.eq('params', JSON.stringify(data.params))
|
|
450
|
+
.gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
|
451
|
+
const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
|
|
452
|
+
const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
|
|
403
453
|
if (claimError || !claimed) {
|
|
404
454
|
return { approved: false, status: 'consumed' };
|
|
405
455
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
|
|
2
|
+
export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
+
export type ApprovalClientCapabilities = Readonly<{
|
|
4
|
+
elicitation_form: boolean;
|
|
5
|
+
elicitation_url: boolean;
|
|
6
|
+
mcp_app: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
|
|
9
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
10
|
+
export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
|
|
11
|
+
export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
|
|
12
|
+
export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
|
|
13
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
14
|
+
export declare function installApprovalCapabilityProbe(session: {
|
|
15
|
+
oninitialized?: () => void;
|
|
16
|
+
getClientCapabilities(): unknown;
|
|
17
|
+
}): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
|
|
2
|
+
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
|
3
|
+
const sessions = new WeakMap();
|
|
4
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
6
|
+
export function probeApprovalCapabilities(declared) {
|
|
7
|
+
const capabilities = object(declared);
|
|
8
|
+
const elicitation = object(capabilities?.elicitation);
|
|
9
|
+
const extensions = object(capabilities?.extensions);
|
|
10
|
+
const app = object(extensions?.[MCP_APP_EXTENSION]);
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
// The MCP spec defines elicitation:{} as form support for older clients.
|
|
13
|
+
elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
|
|
14
|
+
elicitation_url: !!object(elicitation?.url),
|
|
15
|
+
mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function rememberApprovalCapabilities(session, declared) {
|
|
19
|
+
const capabilities = probeApprovalCapabilities(declared);
|
|
20
|
+
sessions.set(session, capabilities);
|
|
21
|
+
return capabilities;
|
|
22
|
+
}
|
|
23
|
+
export function approval_channel_available(session) {
|
|
24
|
+
const capabilities = sessions.get(session);
|
|
25
|
+
if (capabilities?.mcp_app)
|
|
26
|
+
return 'mcp_app';
|
|
27
|
+
if (capabilities?.elicitation_form)
|
|
28
|
+
return 'mcp_elicitation_form';
|
|
29
|
+
if (capabilities?.elicitation_url)
|
|
30
|
+
return 'mcp_elicitation_url';
|
|
31
|
+
return 'plain_text';
|
|
32
|
+
}
|
|
33
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
34
|
+
export function installApprovalCapabilityProbe(session) {
|
|
35
|
+
const previous = session.oninitialized;
|
|
36
|
+
session.oninitialized = () => {
|
|
37
|
+
rememberApprovalCapabilities(session, session.getClientCapabilities());
|
|
38
|
+
previous?.call(session);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -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,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
3
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
4
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createHumanApprovalCallback(binding: {
|
|
7
|
+
approvalId: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
connectionId: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
channel: 'mcp_elicitation' | 'mcp_app';
|
|
12
|
+
}): {
|
|
13
|
+
interactionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
14
|
+
complete(response: {
|
|
15
|
+
interactionId: string;
|
|
16
|
+
connectionId: string;
|
|
17
|
+
decision: 'approve' | 'reject';
|
|
18
|
+
}): Promise<boolean>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
/**
|
|
4
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
5
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
6
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
7
|
+
*/
|
|
8
|
+
export function createHumanApprovalCallback(binding) {
|
|
9
|
+
const interactionId = randomUUID();
|
|
10
|
+
let completed = false;
|
|
11
|
+
return {
|
|
12
|
+
interactionId,
|
|
13
|
+
async complete(response) {
|
|
14
|
+
if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
|
|
15
|
+
return false;
|
|
16
|
+
if (response.decision !== 'approve' && response.decision !== 'reject')
|
|
17
|
+
return false;
|
|
18
|
+
completed = true;
|
|
19
|
+
const source = db();
|
|
20
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
21
|
+
const now = new Date().toISOString();
|
|
22
|
+
const { data, error } = await client.from('action_approvals')
|
|
23
|
+
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
24
|
+
decided_by: binding.userId, decided_at: now })
|
|
25
|
+
.eq('id', binding.approvalId).eq('project_id', binding.projectId)
|
|
26
|
+
.eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
27
|
+
.select('id, payload_hash').maybeSingle();
|
|
28
|
+
if (error || !data)
|
|
29
|
+
return false;
|
|
30
|
+
const { error: auditError } = await client.from('audit_log').insert({
|
|
31
|
+
project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
|
|
32
|
+
action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
|
|
33
|
+
details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
|
|
34
|
+
connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
|
|
35
|
+
});
|
|
36
|
+
if (auditError)
|
|
37
|
+
throw new Error('Approval recorded, but decision audit failed');
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type Params = Record<string, any>;
|
|
2
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
3
|
+
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
|
|
4
|
+
export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
4
|
+
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
5
|
+
'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
|
|
6
|
+
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
7
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
8
|
+
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
9
|
+
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
10
|
+
if (!TARGET_ACTIONS.has(action))
|
|
11
|
+
return snapshot;
|
|
12
|
+
const source = db();
|
|
13
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
14
|
+
const body = params.payload ?? params;
|
|
15
|
+
const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
|
|
16
|
+
typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
|
|
17
|
+
if (ids.length) {
|
|
18
|
+
snapshot.campaigns = await readAllRows(client.from('campaigns')
|
|
19
|
+
.select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
|
|
20
|
+
.eq('project_id', projectId).in('id', ids));
|
|
21
|
+
if (snapshot.campaigns.length !== ids.length)
|
|
22
|
+
throw new Error('Approval target not found');
|
|
23
|
+
snapshot.steps = await readAllRows(client.from('campaign_steps')
|
|
24
|
+
.select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
|
|
25
|
+
.in('campaign_id', ids));
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const id of ids) {
|
|
28
|
+
const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
|
|
29
|
+
if (result.error || result.count == null)
|
|
30
|
+
throw new Error('Approval audience count unavailable');
|
|
31
|
+
count += result.count;
|
|
32
|
+
}
|
|
33
|
+
snapshot.recipient_count = count;
|
|
34
|
+
}
|
|
35
|
+
if (typeof params.issue_id === 'string') {
|
|
36
|
+
const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
|
|
37
|
+
.eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
38
|
+
const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
|
|
39
|
+
.eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
40
|
+
if (issue.error || !issue.data || newsletter.error || !newsletter.data)
|
|
41
|
+
throw new Error('Approval newsletter unavailable');
|
|
42
|
+
snapshot.issue = issue.data;
|
|
43
|
+
snapshot.newsletter = newsletter.data;
|
|
44
|
+
snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
|
|
45
|
+
.eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
|
|
46
|
+
snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
47
|
+
snapshot.recipient_count = snapshot.subscriptions.length;
|
|
48
|
+
}
|
|
49
|
+
const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
|
|
50
|
+
.filter((id) => typeof id === 'string' && id.length > 0))].sort();
|
|
51
|
+
for (const id of siteIds) {
|
|
52
|
+
const { data, error } = await client.from('sites')
|
|
53
|
+
.select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
|
|
54
|
+
.eq('id', id).eq('project_id', projectId).maybeSingle();
|
|
55
|
+
if (error || !data)
|
|
56
|
+
throw new Error('Approval sender/footer unavailable');
|
|
57
|
+
snapshot.sites.push(data);
|
|
58
|
+
}
|
|
59
|
+
async function readFrozenContacts(contacts) {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
|
|
62
|
+
const values = await readAllRows(client.from('subscribers').select('id')
|
|
63
|
+
.eq('project_id', projectId).in('id', chunk));
|
|
64
|
+
found.push(...values.map(value => value.id));
|
|
65
|
+
}
|
|
66
|
+
if (found.length !== new Set(contacts).size)
|
|
67
|
+
throw new Error('Approval recipients changed or unavailable');
|
|
68
|
+
return found.sort();
|
|
69
|
+
}
|
|
70
|
+
if (typeof body.segment_id === 'string') {
|
|
71
|
+
const { data, error } = await client.from('segments').select('id, name, rules, site_id')
|
|
72
|
+
.eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
|
|
73
|
+
if (error || !data)
|
|
74
|
+
throw new Error('Approval segment unavailable');
|
|
75
|
+
snapshot.segment = data;
|
|
76
|
+
// launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
|
|
77
|
+
// The saved segment definition is still compared; deleted original recipients invalidate it.
|
|
78
|
+
const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
|
|
79
|
+
snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
|
|
80
|
+
? await readFrozenContacts(params.generated.contact_ids)
|
|
81
|
+
: typeof body.site_id === 'string'
|
|
82
|
+
? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
|
|
83
|
+
: (await resolveSegmentContactIds(projectId, rules)).sort();
|
|
84
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
85
|
+
}
|
|
86
|
+
else if (body.rules && typeof body.site_id === 'string') {
|
|
87
|
+
snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
|
|
88
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const contacts = body.contact_ids ?? params.generated?.contact_ids;
|
|
92
|
+
if (Array.isArray(contacts)) {
|
|
93
|
+
snapshot.contact_ids = await readFrozenContacts(contacts);
|
|
94
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
95
|
+
}
|
|
96
|
+
else if (typeof body.to === 'string')
|
|
97
|
+
snapshot.recipient_count = 1;
|
|
98
|
+
}
|
|
99
|
+
snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
100
|
+
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
101
|
+
return snapshot;
|
|
102
|
+
}
|
|
103
|
+
export function withoutApprovalSnapshot(params) {
|
|
104
|
+
const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
|
|
105
|
+
return rest;
|
|
106
|
+
}
|
|
@@ -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/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
1
2
|
import { runWithToolContext } from './db.js';
|
|
2
3
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
3
4
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
@@ -377,6 +378,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
377
378
|
'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
|
|
378
379
|
].join(' ');
|
|
379
380
|
export function registerAllTools(server, options) {
|
|
381
|
+
if (server.server)
|
|
382
|
+
installApprovalCapabilityProbe(server.server);
|
|
380
383
|
const rawScopes = options?.scopes ?? [];
|
|
381
384
|
if (rawScopes.includes('gateway')) {
|
|
382
385
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
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.105",
|
|
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",
|