@sendinel/mcp-server 1.0.215 → 1.0.217
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 +5 -2
- package/build/lib/action-approvals.js +13 -5
- package/build/lib/content-sources.d.ts +22 -2
- package/build/lib/content-sources.js +63 -6
- package/build/lib/how-to-recipes.js +12 -0
- package/build/lib/mcp-created-items.d.ts +3 -0
- package/build/lib/mcp-created-items.js +25 -16
- package/build/lib/sandbox-approvals.d.ts +8 -0
- package/build/lib/sandbox-approvals.js +35 -20
- package/build/tools/campaign-sets.js +12 -3
- package/build/tools/campaigns.js +20 -6
- package/build/tools/contacts.js +6 -3
- package/build/tools/gdpr.js +1 -1
- package/build/tools/tool-search.js +1 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DecisionEvidence } from './approval-elicitation-evidence.js';
|
|
2
|
+
import { type AutoApproveUnavailableReason } from './sandbox-approvals.js';
|
|
2
3
|
export { isApprovalGatedAction } from './approval-actions.js';
|
|
3
4
|
/**
|
|
4
5
|
* Action approval tiers:
|
|
@@ -35,8 +36,9 @@ export declare function assertChannelCanExecute(action: string, params: Record<s
|
|
|
35
36
|
/** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
|
|
36
37
|
export declare function currentChannelContext(): ChannelExecutionContext;
|
|
37
38
|
/**
|
|
38
|
-
* Display-only facts for the approval summary (segment name, count, subject, excerpt).
|
|
39
|
-
*
|
|
39
|
+
* Display-only facts for the approval summary (segment name, count, subject, excerpt).
|
|
40
|
+
* merge_variable_summary is frozen in __approval_snapshot.display; the other facts
|
|
41
|
+
* do not change the params an approval is checked against.
|
|
40
42
|
*/
|
|
41
43
|
export type ApprovalDisplay = Record<string, string | number | null | undefined>;
|
|
42
44
|
/** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
|
|
@@ -45,6 +47,7 @@ export declare function approvalExcerpt(html: unknown, max?: number): string;
|
|
|
45
47
|
export declare function buildApprovalSummary(action: string, params: Record<string, unknown>, display?: ApprovalDisplay): string;
|
|
46
48
|
export interface ApprovalResult {
|
|
47
49
|
requires_approval: true;
|
|
50
|
+
auto_approve_unavailable_reason?: AutoApproveUnavailableReason;
|
|
48
51
|
approval_id: string;
|
|
49
52
|
tier: ApprovalTier;
|
|
50
53
|
action: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
|
|
2
|
-
import { sandboxApprovalEligible,
|
|
2
|
+
import { sandboxApprovalEligible, trySandboxApprovalWithReason } from './sandbox-approvals.js';
|
|
3
3
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -252,8 +252,10 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
252
252
|
remove_suppression: (p) => `Remove ${p.email} from the suppression list — mail to this address will resume`,
|
|
253
253
|
};
|
|
254
254
|
const fn = summaries[action];
|
|
255
|
-
if (fn)
|
|
256
|
-
|
|
255
|
+
if (fn) {
|
|
256
|
+
const line = params.__approval_snapshot?.display?.merge_variable_summary;
|
|
257
|
+
return fn(params) + (line ? `\n${line}` : '');
|
|
258
|
+
}
|
|
257
259
|
const payload = JSON.stringify(normalizeApprovalParams(params));
|
|
258
260
|
return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
|
|
259
261
|
}
|
|
@@ -356,6 +358,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
356
358
|
const cleanParams = withoutApprovalSnapshot(params);
|
|
357
359
|
const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
|
|
358
360
|
...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
|
|
361
|
+
if (typeof display?.merge_variable_summary === 'string') {
|
|
362
|
+
snapshot.display = { ...snapshot.display, merge_variable_summary: display.merge_variable_summary };
|
|
363
|
+
}
|
|
359
364
|
const payload = cleanParams.payload;
|
|
360
365
|
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
361
366
|
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
@@ -392,7 +397,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
392
397
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
393
398
|
let projectName;
|
|
394
399
|
let sandboxDisplayRead = false;
|
|
395
|
-
const
|
|
400
|
+
const sandboxPolicyBlocked = effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')));
|
|
401
|
+
const sandboxAttempt = sandboxPolicyBlocked ? { approved: false, reason: 'approval_policy' } : await trySandboxApprovalWithReason(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
|
|
396
402
|
if (isTesterRequest(action))
|
|
397
403
|
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
398
404
|
fireWebhookEvent('approval.requested', {
|
|
@@ -414,16 +420,18 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
414
420
|
catch { /* Missing display data omits the project line. */ }
|
|
415
421
|
const approveUrl = approvalPageUrl(data.id);
|
|
416
422
|
const previewText = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
|
|
417
|
-
if (
|
|
423
|
+
if (sandboxAttempt.approved) {
|
|
418
424
|
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
419
425
|
approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
420
426
|
message: approvalNext(action, { approve_via: 'sandbox', decision_status: 'approved' }) };
|
|
421
427
|
}
|
|
428
|
+
const autoApproveUnavailableReason = sandboxAttempt.reason;
|
|
422
429
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
423
430
|
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then ${approvalRetryInstruction(action)}`
|
|
424
431
|
: '';
|
|
425
432
|
const result = await elicitApproval({
|
|
426
433
|
requires_approval: true,
|
|
434
|
+
auto_approve_unavailable_reason: autoApproveUnavailableReason,
|
|
427
435
|
...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
428
436
|
payload_hash: approvalPayloadHash(action, frozenParams) })),
|
|
429
437
|
approval_id: data.id,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ContentIssue } from '@sendinel/domain';
|
|
1
|
+
import { type MergeVariableWarning, type ContentIssue } from '@sendinel/domain';
|
|
2
2
|
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
3
3
|
export declare function eventKeysFromHistory(projectId: string, eventName: string): Promise<string[] | null>;
|
|
4
4
|
/**
|
|
@@ -32,7 +32,9 @@ export type CampaignContentFinding = ContentIssue & {
|
|
|
32
32
|
* every email of the given campaigns (#3853). Placeholder text and sending-domain links are
|
|
33
33
|
* blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
|
|
34
34
|
*/
|
|
35
|
-
export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[]
|
|
35
|
+
export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[], options?: {
|
|
36
|
+
mergeVariables?: boolean;
|
|
37
|
+
}): Promise<{
|
|
36
38
|
blocking: CampaignContentFinding[];
|
|
37
39
|
warnings: CampaignContentFinding[];
|
|
38
40
|
lookup_errors: string[];
|
|
@@ -59,4 +61,22 @@ export declare function summarizeContentWarnings(warnings: Array<{
|
|
|
59
61
|
email?: number;
|
|
60
62
|
warning: string;
|
|
61
63
|
}>): string;
|
|
64
|
+
export type CampaignMergeVariableWarning = MergeVariableWarning & {
|
|
65
|
+
step_order: number;
|
|
66
|
+
checked_at: string;
|
|
67
|
+
warning: string;
|
|
68
|
+
};
|
|
69
|
+
/** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
|
|
70
|
+
export declare function checkCampaignMergeVariables(projectId: string, campaign: {
|
|
71
|
+
trigger_event_name: string | null;
|
|
72
|
+
}, steps: Array<{
|
|
73
|
+
step_order: number;
|
|
74
|
+
subject?: string | null;
|
|
75
|
+
body_html?: string | null;
|
|
76
|
+
body_text?: string | null;
|
|
77
|
+
}>): Promise<CampaignMergeVariableWarning[]>;
|
|
78
|
+
/** One extra line on the approval, including explicit uncertainty for unknown sources. */
|
|
79
|
+
export declare function summarizeMergeVariableWarnings(warnings: CampaignMergeVariableWarning[]): string | undefined;
|
|
80
|
+
/** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
|
|
81
|
+
export declare function persistMergeVariableWarnings(projectId: string, campaignId: string, warnings: CampaignMergeVariableWarning[]): Promise<void>;
|
|
62
82
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf } from '@sendinel/domain';
|
|
1
|
+
import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf, findUnprovidedMergeTags } from '@sendinel/domain';
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
4
4
|
export async function eventKeysFromHistory(projectId, eventName) {
|
|
@@ -50,7 +50,7 @@ export async function contactPropertyKeys(projectId) {
|
|
|
50
50
|
* every email of the given campaigns (#3853). Placeholder text and sending-domain links are
|
|
51
51
|
* blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
|
|
52
52
|
*/
|
|
53
|
-
export async function checkCampaignContent(projectId, campaigns, steps) {
|
|
53
|
+
export async function checkCampaignContent(projectId, campaigns, steps, options = {}) {
|
|
54
54
|
const lookupErrors = [];
|
|
55
55
|
const sendingHostsBySite = new Map();
|
|
56
56
|
const allHosts = new Set();
|
|
@@ -63,11 +63,11 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
|
|
|
63
63
|
for (const host of hosts)
|
|
64
64
|
allHosts.add(host);
|
|
65
65
|
}
|
|
66
|
-
const contactProps = await contactPropertyKeys(projectId);
|
|
67
|
-
if (contactProps === null)
|
|
66
|
+
const contactProps = options.mergeVariables === false ? null : await contactPropertyKeys(projectId);
|
|
67
|
+
if (options.mergeVariables !== false && contactProps === null)
|
|
68
68
|
lookupErrors.push('Could not read your contacts, so merge tags that use contact properties were not confirmed.');
|
|
69
69
|
const eventKeys = new Map();
|
|
70
|
-
for (const eventName of new Set(campaigns.map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
|
|
70
|
+
for (const eventName of new Set((options.mergeVariables === false ? [] : campaigns).map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
|
|
71
71
|
const keys = await eventKeysFromHistory(projectId, eventName);
|
|
72
72
|
eventKeys.set(eventName, keys);
|
|
73
73
|
if (keys === null)
|
|
@@ -82,7 +82,7 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
|
|
|
82
82
|
: [...events, ...contactProps.keys.map((key) => `custom.${key}`)];
|
|
83
83
|
const hosts = (campaign.site_id ? sendingHostsBySite.get(campaign.site_id) : undefined) ?? [...allHosts];
|
|
84
84
|
for (const step of steps.filter((s) => s.campaign_id === campaign.id)) {
|
|
85
|
-
for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys })) {
|
|
85
|
+
for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys: options.mergeVariables === false ? undefined : sourcedKeys })) {
|
|
86
86
|
const finding = { ...issue, campaign: campaign.name, email: step.step_order + 1 };
|
|
87
87
|
(BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind) ? blocking : warnings).push(finding);
|
|
88
88
|
}
|
|
@@ -109,3 +109,60 @@ export function summarizeContentWarnings(warnings) {
|
|
|
109
109
|
const first = warnings.slice(0, 3).map((w) => (w.campaign ? `${w.campaign}, email ${w.email}: ${w.warning}` : w.warning));
|
|
110
110
|
return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}. ${first.join(' | ')}${warnings.length > 3 ? ` | and ${warnings.length - 3} more` : ''}`;
|
|
111
111
|
}
|
|
112
|
+
/** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
|
|
113
|
+
export async function checkCampaignMergeVariables(projectId, campaign, steps) {
|
|
114
|
+
const safe = async (load) => {
|
|
115
|
+
try {
|
|
116
|
+
return await load();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const contacts = await safe(() => contactPropertyKeys(projectId));
|
|
123
|
+
const events = campaign.trigger_event_name
|
|
124
|
+
? await safe(() => eventKeysFromHistory(projectId, campaign.trigger_event_name)) : [];
|
|
125
|
+
const knowledge = {
|
|
126
|
+
eventKeys: events === null ? null : new Set(events),
|
|
127
|
+
contactPropertyKeys: contacts === null ? null : { keys: new Set(contacts.keys), complete: contacts.complete },
|
|
128
|
+
};
|
|
129
|
+
const checked_at = new Date().toISOString();
|
|
130
|
+
return steps.flatMap(step => findUnprovidedMergeTags({ subject: step.subject, html: step.body_html, text: step.body_text }, knowledge).map(finding => ({ ...finding, step_order: step.step_order, checked_at,
|
|
131
|
+
warning: finding.kind === 'unknown_event'
|
|
132
|
+
? `Can't tell whether ${finding.tag} can be filled: event history is empty or couldn't be checked.`
|
|
133
|
+
: finding.kind === 'unknown_contacts'
|
|
134
|
+
? `Couldn't check whether ${finding.tag} can be filled: the contact scan failed or is incomplete.`
|
|
135
|
+
: `${finding.tag} may print blank: ${finding.kind === 'no_event_data' ? 'no observed trigger event data provides it' : 'no contact has this property'}.`,
|
|
136
|
+
})));
|
|
137
|
+
}
|
|
138
|
+
/** One extra line on the approval, including explicit uncertainty for unknown sources. */
|
|
139
|
+
export function summarizeMergeVariableWarnings(warnings) {
|
|
140
|
+
if (!warnings.length)
|
|
141
|
+
return undefined;
|
|
142
|
+
return `${warnings.length} merge variables may print blank: ${warnings.map(w => `${w.tag} (email ${w.step_order + 1}${w.kind.startsWith('unknown_') ? ": can't tell / couldn't check" : ''})`).join(', ')}`;
|
|
143
|
+
}
|
|
144
|
+
/** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
|
|
145
|
+
export async function persistMergeVariableWarnings(projectId, campaignId, warnings) {
|
|
146
|
+
try {
|
|
147
|
+
const source = db().schema('email');
|
|
148
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
149
|
+
const { data: current, error: readError } = await source.from('campaigns').select('metadata')
|
|
150
|
+
.eq('id', campaignId).eq('project_id', projectId).maybeSingle();
|
|
151
|
+
if (readError || !current)
|
|
152
|
+
break;
|
|
153
|
+
const metadata = current.metadata;
|
|
154
|
+
const next = { ...(metadata ?? {}), merge_variable_warnings: warnings
|
|
155
|
+
.filter(w => w.kind === 'no_event_data' || w.kind === 'no_contact_has_it')
|
|
156
|
+
.map(({ tag, step_order, kind, checked_at }) => ({ tag, step_order, kind, checked_at })) };
|
|
157
|
+
let update = source.from('campaigns').update({ metadata: next }).eq('id', campaignId).eq('project_id', projectId);
|
|
158
|
+
update = metadata == null ? update.is('metadata', null) : update.eq('metadata', JSON.stringify(metadata));
|
|
159
|
+
const { data, error } = await update.select('id');
|
|
160
|
+
if (error)
|
|
161
|
+
break;
|
|
162
|
+
if (data?.length)
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch { /* A failed note write must not prevent activation. */ }
|
|
167
|
+
console.error('[merge-variable-warnings] Could not save campaign warning note.');
|
|
168
|
+
}
|
|
@@ -8,6 +8,18 @@ const projectRule = 'On hosted /mcp, pass project_id on each call. It is require
|
|
|
8
8
|
const previewRule = 'preview_email uses the real send renderer with sample contact data. send_test_email with dry_run:true uses the real renderer without sending or needing approval, including in standard mode. Stdio requires SENDINEL_API_KEY.';
|
|
9
9
|
const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
|
|
10
10
|
export const HOW_TO_RECIPES = [
|
|
11
|
+
{
|
|
12
|
+
id: 'clean-up-test-objects', title: 'Clean up sandbox test contacts and objects',
|
|
13
|
+
when_to_use: 'Remove throwaway contacts or objects created by this credential after a sandbox test.',
|
|
14
|
+
synonyms: TOOL_ALIASES.delete_subscriber_data,
|
|
15
|
+
requires: { scopes: ['admin'], tester_ok: false },
|
|
16
|
+
steps: [
|
|
17
|
+
{ n: 1, tool: 'delete_subscriber_data', args_example: { contact_id: '11111111-1111-4111-8111-111111111111', reason: 'admin' }, approval, note: 'Replace contact_id with the saved ID of a test contact you created. This is irreversible GDPR erasure, including related data. In a sandbox project, same-credential provenance permits automatic approval; inspect decision_status and auto_approve_unavailable_reason. If pending, show approve_url and wait for human approval.' },
|
|
18
|
+
{ n: 2, tool: 'delete_subscriber_data', args_example: { approval_id: from(1, 'approval_id') }, approval, note: 'Only after decision_status is approved, re-call with ONLY approval_id to execute. Repeat for each intended test contact.' },
|
|
19
|
+
],
|
|
20
|
+
gotchas: [projectRule, 'is_test:true is only a contact classification, never delete authority. Non-sandbox projects keep human approval. Unknown or foreign provenance stays gated. Campaign cleanup uses delete_campaign with campaign_id; every cascading step must also belong to this credential. Templates and segments use delete_template with id and delete_segment with segment_id, with the same approval_id flow.'],
|
|
21
|
+
done_when: 'Each intended cleanup execution succeeds; automatic approval alone has not deleted anything.',
|
|
22
|
+
},
|
|
11
23
|
{
|
|
12
24
|
id: 'brand-kit-signature', title: 'Preview and save a brand-kit email signature',
|
|
13
25
|
when_to_use: 'Set up or edit a signature footer for future AI-generated drafts using stored brand details.',
|
|
@@ -25,4 +25,7 @@ export declare function withCreatedItems<T extends {
|
|
|
25
25
|
error: any;
|
|
26
26
|
}>(entityType: CreatedItemType, query: PromiseLike<T>): Promise<T>;
|
|
27
27
|
/** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
|
|
28
|
+
export type DeleteOwnershipUnavailableReason = 'not_created_by_this_credential' | 'unknown_provenance';
|
|
29
|
+
export declare function deleteOwnershipUnavailableReason(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<DeleteOwnershipUnavailableReason | null>;
|
|
30
|
+
/** Preserve the boolean authority API; diagnostics never supply another approval path. */
|
|
28
31
|
export declare function ownsDeleteTargets(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<boolean>;
|
|
@@ -54,11 +54,10 @@ export async function withCreatedItems(entityType, query) {
|
|
|
54
54
|
return result;
|
|
55
55
|
}
|
|
56
56
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
-
|
|
58
|
-
export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
57
|
+
export async function deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) {
|
|
59
58
|
const target = DELETE_ITEM_TARGETS[action];
|
|
60
59
|
if (!creatorKey || !target)
|
|
61
|
-
return
|
|
60
|
+
return 'unknown_provenance';
|
|
62
61
|
const [entityType, single, bulk] = target;
|
|
63
62
|
const source = adminDb().schema('email');
|
|
64
63
|
let ids = [];
|
|
@@ -66,36 +65,46 @@ export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
|
66
65
|
ids.push(params[single]);
|
|
67
66
|
if (params[bulk] !== undefined) {
|
|
68
67
|
if (!Array.isArray(params[bulk]) || !params[bulk].length)
|
|
69
|
-
return
|
|
68
|
+
return 'unknown_provenance';
|
|
70
69
|
ids.push(...params[bulk]);
|
|
71
70
|
}
|
|
72
71
|
// GDPR snapshots carry both ID and email. Verify the address names that same target.
|
|
73
72
|
if (action === 'delete_subscriber_data' && params.email !== undefined) {
|
|
74
73
|
if (typeof params.email !== 'string' || !params.email.trim())
|
|
75
|
-
return
|
|
74
|
+
return 'unknown_provenance';
|
|
76
75
|
const { data, error } = await source.from('subscribers').select('id').eq('project_id', projectId)
|
|
77
76
|
.eq('email', params.email.trim().toLowerCase()).maybeSingle();
|
|
78
77
|
if (error || !data || (ids.length && (ids.length !== 1 || ids[0] !== data.id)))
|
|
79
|
-
return
|
|
78
|
+
return 'unknown_provenance';
|
|
80
79
|
ids.push(data.id);
|
|
81
80
|
}
|
|
82
81
|
if (!ids.length || ids.some(id => typeof id !== 'string' || !uuid.test(id)))
|
|
83
|
-
return
|
|
82
|
+
return 'unknown_provenance';
|
|
84
83
|
ids = [...new Set(ids)];
|
|
85
84
|
const owns = async (type, targets) => {
|
|
86
|
-
const { data, error } = await source.from('mcp_created_items').select('entity_id')
|
|
87
|
-
.eq('project_id', projectId).eq('entity_type', type).
|
|
88
|
-
|
|
85
|
+
const { data, error } = await source.from('mcp_created_items').select('entity_id, creator_key')
|
|
86
|
+
.eq('project_id', projectId).eq('entity_type', type).in('entity_id', targets);
|
|
87
|
+
if (error || !Array.isArray(data))
|
|
88
|
+
return 'unknown_provenance';
|
|
89
|
+
if (targets.some(id => !data.some(row => row.entity_id === id && typeof row.creator_key === 'string')))
|
|
90
|
+
return 'unknown_provenance';
|
|
91
|
+
return targets.every(id => data.some(row => row.entity_id === id && row.creator_key === creatorKey))
|
|
92
|
+
? null : 'not_created_by_this_credential';
|
|
89
93
|
};
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
const reason = await owns(entityType, ids);
|
|
95
|
+
if (reason)
|
|
96
|
+
return reason;
|
|
92
97
|
// Deleting a campaign also deletes its steps; another client may have added one.
|
|
93
98
|
if (action === 'delete_campaign') {
|
|
94
99
|
const { data, error } = await source.from('campaign_steps').select('id').in('campaign_id', ids);
|
|
95
100
|
if (error || !Array.isArray(data))
|
|
96
|
-
return
|
|
97
|
-
if (data.length
|
|
98
|
-
return
|
|
101
|
+
return 'unknown_provenance';
|
|
102
|
+
if (data.length)
|
|
103
|
+
return await owns('campaign_steps', data.map(row => row.id));
|
|
99
104
|
}
|
|
100
|
-
return
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Preserve the boolean authority API; diagnostics never supply another approval path. */
|
|
108
|
+
export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
109
|
+
return await deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) === null;
|
|
101
110
|
}
|
|
@@ -26,6 +26,14 @@ export declare const SANDBOX_ADMIN_SCOPE: {
|
|
|
26
26
|
readonly set_team_member_role: 'org';
|
|
27
27
|
readonly tenant_export_full: 'project';
|
|
28
28
|
};
|
|
29
|
+
export type AutoApproveUnavailableReason = 'not_sandbox' | 'not_created_by_this_credential' | 'unknown_provenance' | 'action_not_eligible' | 'org_not_all_sandbox' | 'approval_policy' | 'auto_approval_failed';
|
|
29
30
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
31
|
+
export declare function sandboxAutoApproveUnavailableReason(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<AutoApproveUnavailableReason | null>;
|
|
32
|
+
/** Existing callers keep their boolean contract and the same fail-closed conditions. */
|
|
30
33
|
export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
34
|
+
/** Capture the refusal from the same attempt, without a second mutable authority read. */
|
|
35
|
+
export declare function trySandboxApprovalWithReason(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<{
|
|
36
|
+
approved: boolean;
|
|
37
|
+
reason?: AutoApproveUnavailableReason;
|
|
38
|
+
}>;
|
|
31
39
|
export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { adminDb } from '../db.js';
|
|
2
|
-
import { DELETE_ITEM_TARGETS,
|
|
2
|
+
import { DELETE_ITEM_TARGETS, deleteOwnershipUnavailableReason, requestingCreatorKey } from './mcp-created-items.js';
|
|
3
3
|
import { approvalClass } from '@sendinel/domain';
|
|
4
4
|
import { PROTECTED_ACTIONS } from './approval-actions.js';
|
|
5
5
|
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
@@ -16,48 +16,63 @@ export const SANDBOX_ADMIN_SCOPE = {
|
|
|
16
16
|
tenant_export_full: 'project',
|
|
17
17
|
};
|
|
18
18
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
19
|
-
export async function
|
|
19
|
+
export async function sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params = {}) {
|
|
20
20
|
const deletion = approvalClass(action) === 'D' || action.startsWith('delete_');
|
|
21
21
|
// No exception for shared org credentials, unknown delete actions or other protected effects.
|
|
22
|
-
if (deletion &&
|
|
23
|
-
return
|
|
22
|
+
if (deletion && !(action in DELETE_ITEM_TARGETS))
|
|
23
|
+
return 'action_not_eligible';
|
|
24
|
+
if (deletion && !requestingCreatorKey())
|
|
25
|
+
return 'unknown_provenance';
|
|
24
26
|
if (action === 'delete_provider_credential' || (!deletion && PROTECTED_ACTIONS.has(action)))
|
|
25
|
-
return
|
|
27
|
+
return 'action_not_eligible';
|
|
26
28
|
try {
|
|
27
29
|
const source = adminDb().schema('email');
|
|
28
30
|
const { data, error } = await source.from('projects').select('id, org_id, sandbox_mode, name')
|
|
29
31
|
.eq('id', projectId).maybeSingle();
|
|
30
32
|
onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
|
|
31
|
-
if (error || data?.id !== projectId
|
|
32
|
-
return
|
|
33
|
+
if (error || data?.id !== projectId)
|
|
34
|
+
return 'unknown_provenance';
|
|
35
|
+
if (data.sandbox_mode !== true)
|
|
36
|
+
return 'not_sandbox';
|
|
33
37
|
if (deletion)
|
|
34
|
-
return await
|
|
38
|
+
return await deleteOwnershipUnavailableReason(projectId, action, params, requestingCreatorKey());
|
|
35
39
|
if (!isHumanRequestableAdminTool(action))
|
|
36
|
-
return
|
|
40
|
+
return null;
|
|
37
41
|
const scope = SANDBOX_ADMIN_SCOPE[action];
|
|
38
42
|
if (scope === 'project')
|
|
39
|
-
return
|
|
43
|
+
return null;
|
|
40
44
|
if (scope !== 'org' || typeof data.org_id !== 'string')
|
|
41
|
-
return
|
|
45
|
+
return 'action_not_eligible';
|
|
42
46
|
const others = await source.from('projects').select('id').eq('org_id', data.org_id)
|
|
43
47
|
.eq('sandbox_mode', false).limit(1);
|
|
44
|
-
|
|
48
|
+
if (others.error || !Array.isArray(others.data))
|
|
49
|
+
return 'unknown_provenance';
|
|
50
|
+
return others.data.length === 0 ? null : 'org_not_all_sandbox';
|
|
45
51
|
}
|
|
46
52
|
catch {
|
|
47
|
-
return
|
|
53
|
+
return 'unknown_provenance';
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
/** Existing callers keep their boolean contract and the same fail-closed conditions. */
|
|
57
|
+
export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
|
|
58
|
+
return await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params) === null;
|
|
59
|
+
}
|
|
60
|
+
/** Capture the refusal from the same attempt, without a second mutable authority read. */
|
|
61
|
+
export async function trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params = {}) {
|
|
62
|
+
const reason = await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params);
|
|
63
|
+
if (reason)
|
|
64
|
+
return { approved: false, reason };
|
|
53
65
|
try {
|
|
54
66
|
// The RPC performs one conditional UPDATE + audit INSERT in one transaction.
|
|
55
67
|
const { data, error } = await adminDb().schema('email').rpc('approve_sandbox_action', {
|
|
56
68
|
p_approval_id: id, p_project_id: projectId, p_action: action, p_payload_hash: payloadHash,
|
|
57
69
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
catch {
|
|
61
|
-
return false;
|
|
70
|
+
if (!error && data === true)
|
|
71
|
+
return { approved: true };
|
|
62
72
|
}
|
|
73
|
+
catch { /* An RPC failure never grants authority. */ }
|
|
74
|
+
return { approved: false, reason: 'auto_approval_failed' };
|
|
75
|
+
}
|
|
76
|
+
export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
|
|
77
|
+
return (await trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params)).approved;
|
|
63
78
|
}
|
|
@@ -11,7 +11,7 @@ import { generateEmailFromBrief } from '../lib/email-generation.js';
|
|
|
11
11
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
12
12
|
import { logAudit } from '../audit.js';
|
|
13
13
|
import { applyCampaignTargetingFields, campaignTriggerKind } from './campaigns.js';
|
|
14
|
-
import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
14
|
+
import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
15
15
|
import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
|
|
16
16
|
import { dbToolError } from '../lib/db-error.js';
|
|
17
17
|
/**
|
|
@@ -291,9 +291,11 @@ export function registerCampaignSetTools(server) {
|
|
|
291
291
|
const projectId = getProjectId();
|
|
292
292
|
const { approval_id } = args;
|
|
293
293
|
async function applyCampaigns(set, ids, approval_id, decidedBy) {
|
|
294
|
+
const mergeVariableWarnings = [];
|
|
294
295
|
const activated = [];
|
|
295
296
|
const failed = [];
|
|
296
297
|
for (const c of set.campaigns) {
|
|
298
|
+
const warnings = await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id));
|
|
297
299
|
const { error } = await db()
|
|
298
300
|
.from('campaigns')
|
|
299
301
|
.update({ status: 'active', updated_at: new Date().toISOString() })
|
|
@@ -305,12 +307,15 @@ export function registerCampaignSetTools(server) {
|
|
|
305
307
|
continue;
|
|
306
308
|
}
|
|
307
309
|
activated.push(c.id);
|
|
310
|
+
await persistMergeVariableWarnings(projectId, c.id, warnings);
|
|
311
|
+
mergeVariableWarnings.push(...warnings.map(w => ({ ...w, campaign_id: c.id })));
|
|
308
312
|
fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
|
|
309
313
|
}
|
|
310
314
|
await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
|
|
311
315
|
return j({
|
|
312
316
|
activated,
|
|
313
317
|
failed,
|
|
318
|
+
merge_variable_warnings: mergeVariableWarnings,
|
|
314
319
|
...(set.campaigns.some(c => c.type === 'broadcast') ? {
|
|
315
320
|
campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
|
|
316
321
|
broadcast_activation: BROADCAST_ACTIVATION,
|
|
@@ -348,7 +353,7 @@ export function registerCampaignSetTools(server) {
|
|
|
348
353
|
return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed' });
|
|
349
354
|
}
|
|
350
355
|
// Same content gate as the request, so a bad email can't ride through on an old approval.
|
|
351
|
-
const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
|
|
356
|
+
const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps, { mergeVariables: false });
|
|
352
357
|
if (readiness.blocking.length)
|
|
353
358
|
return j(contentNotReadyResponse(readiness.blocking));
|
|
354
359
|
const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
|
|
@@ -396,10 +401,12 @@ export function registerCampaignSetTools(server) {
|
|
|
396
401
|
});
|
|
397
402
|
}
|
|
398
403
|
// Placeholder text and links to the sending domain never go out (#3853). Merge tags with no data source are warnings.
|
|
399
|
-
const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps);
|
|
404
|
+
const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps, { mergeVariables: false });
|
|
400
405
|
if (readiness.blocking.length)
|
|
401
406
|
return j(contentNotReadyResponse(readiness.blocking));
|
|
402
407
|
const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
|
|
408
|
+
const mergeVariableWarnings = (await Promise.all(set.campaigns.map(async (c) => (await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id)))
|
|
409
|
+
.map(w => ({ ...w, campaign_id: c.id }))))).flat();
|
|
403
410
|
const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
|
|
404
411
|
const channelContext = currentChannelContext();
|
|
405
412
|
const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
|
|
@@ -409,6 +416,7 @@ export function registerCampaignSetTools(server) {
|
|
|
409
416
|
return applyCampaigns(set, ids);
|
|
410
417
|
const names = set.campaigns.map((c) => c.name);
|
|
411
418
|
const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
|
|
419
|
+
merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
|
|
412
420
|
campaign_count: set.campaigns.length,
|
|
413
421
|
email_count: set.steps.length,
|
|
414
422
|
campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
|
|
@@ -417,6 +425,7 @@ export function registerCampaignSetTools(server) {
|
|
|
417
425
|
return j({
|
|
418
426
|
...approval,
|
|
419
427
|
campaigns: previewOf(set.campaigns, set.steps),
|
|
428
|
+
merge_variable_warnings: mergeVariableWarnings,
|
|
420
429
|
...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
|
|
421
430
|
next: approvalNext('activate_campaigns', approval),
|
|
422
431
|
});
|
package/build/tools/campaigns.js
CHANGED
|
@@ -32,7 +32,7 @@ import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } fro
|
|
|
32
32
|
import { sendEmailForSite } from '../lib/email-provider.js';
|
|
33
33
|
import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
34
34
|
import { logAudit } from '../audit.js';
|
|
35
|
-
import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
35
|
+
import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
36
36
|
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
37
37
|
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
38
38
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
@@ -1189,6 +1189,7 @@ export function registerCampaignTools(server) {
|
|
|
1189
1189
|
approval_id: z.string().uuid().optional().describe('Required when activating. Get this by calling once without it, then have a human approve.'),
|
|
1190
1190
|
},
|
|
1191
1191
|
}, async ({ campaign_id, status, approval_id }) => {
|
|
1192
|
+
let mergeVariableWarnings = [];
|
|
1192
1193
|
// Human approval required for activation
|
|
1193
1194
|
if (status === 'active') {
|
|
1194
1195
|
// An imported email whose wait wasn't in the source has no delay; it would never send (#3689).
|
|
@@ -1227,10 +1228,11 @@ export function registerCampaignTools(server) {
|
|
|
1227
1228
|
.eq('campaign_id', campaign_id);
|
|
1228
1229
|
if (contentErr)
|
|
1229
1230
|
return j({ error: `Could not check the campaign's emails: ${dbToolError(contentErr).error}`, code: 'STEP_LOOKUP_FAILED' });
|
|
1230
|
-
const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? []);
|
|
1231
|
+
const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? [], { mergeVariables: false });
|
|
1231
1232
|
if (readiness.blocking.length)
|
|
1232
1233
|
return j(contentNotReadyResponse(readiness.blocking));
|
|
1233
1234
|
contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
|
|
1235
|
+
mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), campRow, contentSteps ?? []);
|
|
1234
1236
|
}
|
|
1235
1237
|
const approvalParams = { campaign_id, status };
|
|
1236
1238
|
if (!approval_id) {
|
|
@@ -1246,12 +1248,13 @@ export function registerCampaignTools(server) {
|
|
|
1246
1248
|
.eq('project_id', getProjectId())
|
|
1247
1249
|
.maybeSingle();
|
|
1248
1250
|
const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext, {
|
|
1251
|
+
merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
|
|
1249
1252
|
campaign_name: camp?.name ?? null,
|
|
1250
1253
|
enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
|
|
1251
1254
|
trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
|
|
1252
1255
|
...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
|
|
1253
1256
|
});
|
|
1254
|
-
return j(contentWarnings.length ? {
|
|
1257
|
+
return j({ ...approval, merge_variable_warnings: mergeVariableWarnings, ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}) });
|
|
1255
1258
|
}
|
|
1256
1259
|
}
|
|
1257
1260
|
else {
|
|
@@ -1269,6 +1272,11 @@ export function registerCampaignTools(server) {
|
|
|
1269
1272
|
.single();
|
|
1270
1273
|
if (error)
|
|
1271
1274
|
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1275
|
+
if (status === 'active') {
|
|
1276
|
+
await persistMergeVariableWarnings(getProjectId(), campaign_id, mergeVariableWarnings);
|
|
1277
|
+
await logAudit({ action: 'campaign.status_changed', resourceType: 'campaign', resourceId: campaign_id,
|
|
1278
|
+
details: { status, merge_variable_warnings: mergeVariableWarnings } }).catch(() => { });
|
|
1279
|
+
}
|
|
1272
1280
|
// Fire webhook for status changes
|
|
1273
1281
|
const eventMap = { active: 'campaign.activated', completed: 'campaign.completed', paused: 'campaign.paused' };
|
|
1274
1282
|
if (eventMap[status]) {
|
|
@@ -1278,7 +1286,7 @@ export function registerCampaignTools(server) {
|
|
|
1278
1286
|
status,
|
|
1279
1287
|
}).catch(() => { });
|
|
1280
1288
|
}
|
|
1281
|
-
return { content: [{ type: 'text', text: JSON.stringify(campaignMetricsView(data), null, 2) }] };
|
|
1289
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...campaignMetricsView(data), ...(status === 'active' ? { merge_variable_warnings: mergeVariableWarnings } : {}) }, null, 2) }] };
|
|
1282
1290
|
});
|
|
1283
1291
|
server.registerTool('enroll_contact', {
|
|
1284
1292
|
title: 'Enroll Contact',
|
|
@@ -3411,6 +3419,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3411
3419
|
if (reentry.period_key !== undefined)
|
|
3412
3420
|
targeting.period_key = reentry.period_key;
|
|
3413
3421
|
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
3422
|
+
let mergeVariableWarnings = activate && !approval_id ? await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, steps.map((step, step_order) => ({ ...step, step_order }))) : [];
|
|
3414
3423
|
// Human-in-the-loop gate for activation
|
|
3415
3424
|
if (activate && confirmed) {
|
|
3416
3425
|
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
|
|
@@ -3421,8 +3430,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3421
3430
|
if (decision.tier !== 'autonomous') {
|
|
3422
3431
|
if (!approval_id) {
|
|
3423
3432
|
// No approval token — request one
|
|
3424
|
-
const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext);
|
|
3425
|
-
return j(approval);
|
|
3433
|
+
const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext, { merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings) });
|
|
3434
|
+
return j({ ...approval, merge_variable_warnings: mergeVariableWarnings });
|
|
3426
3435
|
}
|
|
3427
3436
|
// Have a token — verify it's approved
|
|
3428
3437
|
const status = await checkApproval(approval_id, { consume: true, action: 'create_campaign_with_content', params: approvalParams });
|
|
@@ -3459,6 +3468,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3459
3468
|
if (!confirmed) {
|
|
3460
3469
|
return j({
|
|
3461
3470
|
confirmation_required: true,
|
|
3471
|
+
...(activate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
|
|
3462
3472
|
campaign: { name, type: campaignType, site: site.name, send_class: sendClass, unsub_footer_template: footerTemplate, ...targeting },
|
|
3463
3473
|
steps: steps.map((s, i) => ({
|
|
3464
3474
|
step: i + 1,
|
|
@@ -3559,6 +3569,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3559
3569
|
// 4. Activate if requested
|
|
3560
3570
|
const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
|
|
3561
3571
|
if (willActivate) {
|
|
3572
|
+
mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, stepInserts);
|
|
3573
|
+
await persistMergeVariableWarnings(getProjectId(), campaign.id, mergeVariableWarnings);
|
|
3562
3574
|
await db()
|
|
3563
3575
|
.from('campaigns')
|
|
3564
3576
|
.update({ status: 'active' })
|
|
@@ -3586,6 +3598,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3586
3598
|
steps_created: steps.length,
|
|
3587
3599
|
contacts_enrolled: enrolled,
|
|
3588
3600
|
status: willActivate ? 'active' : 'draft',
|
|
3601
|
+
...(willActivate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
|
|
3589
3602
|
message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${willActivate ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
|
|
3590
3603
|
});
|
|
3591
3604
|
});
|
|
@@ -4089,6 +4102,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
4089
4102
|
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
4090
4103
|
return j({
|
|
4091
4104
|
...campaignMetricsView(campaign),
|
|
4105
|
+
merge_variable_warnings: campaign.metadata?.merge_variable_warnings ?? [],
|
|
4092
4106
|
reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
|
|
4093
4107
|
requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
|
|
4094
4108
|
...await campaignTriggerOutput(campaign),
|
package/build/tools/contacts.js
CHANGED
|
@@ -1315,6 +1315,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1315
1315
|
company_name: z.string().optional(),
|
|
1316
1316
|
company_domain: z.string().optional(),
|
|
1317
1317
|
tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
|
|
1318
|
+
is_test: z.boolean().optional().describe('Mark a NEW contact as a test contact: excluded from headline counts and default audiences. Ignored for existing contacts.'),
|
|
1318
1319
|
source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
|
|
1319
1320
|
utm_source: z.string().optional(),
|
|
1320
1321
|
utm_medium: z.string().optional(),
|
|
@@ -1322,7 +1323,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1322
1323
|
utm_content: z.string().optional(),
|
|
1323
1324
|
utm_term: z.string().optional(),
|
|
1324
1325
|
},
|
|
1325
|
-
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1326
|
+
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, is_test, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1326
1327
|
try {
|
|
1327
1328
|
const normalizedTags = normalizeTagInput(tags ?? []);
|
|
1328
1329
|
const warnings = tagInputWarnings(normalizedTags);
|
|
@@ -1370,6 +1371,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1370
1371
|
let contact;
|
|
1371
1372
|
if (!existingContact) {
|
|
1372
1373
|
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1374
|
+
if (is_test === true)
|
|
1375
|
+
row.is_test = true;
|
|
1373
1376
|
if (tags !== undefined)
|
|
1374
1377
|
row.tags = normalizedTags.tags;
|
|
1375
1378
|
if (customProperties)
|
|
@@ -1447,11 +1450,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1447
1450
|
if (needsConfirm) {
|
|
1448
1451
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1449
1452
|
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1450
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1453
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
|
|
1451
1454
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1452
1455
|
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1453
1456
|
}
|
|
1454
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1457
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
|
|
1455
1458
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1456
1459
|
if (!existing)
|
|
1457
1460
|
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
package/build/tools/gdpr.js
CHANGED
|
@@ -87,7 +87,7 @@ export function registerGdprTools(server) {
|
|
|
87
87
|
idempotentHint: false,
|
|
88
88
|
openWorldHint: false,
|
|
89
89
|
},
|
|
90
|
-
description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE.
|
|
90
|
+
description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE. Also use for test contact cleanup (reason: admin). In a sandbox project, contacts created by the same credential are erased without a human click via the approval_id retry flow. Other cases require human approval via dashboard.',
|
|
91
91
|
inputSchema: {
|
|
92
92
|
contact_id: z.string().uuid().describe('UUID of the subscriber to delete'),
|
|
93
93
|
reason: z.enum(['gdpr_request', 'user_request', 'admin']).optional().default('gdpr_request'),
|
|
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
|
|
|
12
12
|
* Only tools that exist in the registry belong here.
|
|
13
13
|
*/
|
|
14
14
|
export const TOOL_ALIASES = {
|
|
15
|
+
delete_subscriber_data: ['delete contact', 'delete contacts', 'remove subscriber', 'remove subscribers', 'clean up test contacts', 'clean up test objects', 'sandbox cleanup'],
|
|
15
16
|
list_custom_fields: ['custom fields', 'field definitions', 'contact schema', 'unused fields'],
|
|
16
17
|
update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
|
|
17
18
|
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.217",
|
|
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",
|