@sendinel/mcp-server 1.0.150 → 1.0.152
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.
|
@@ -13,6 +13,7 @@ type CampaignSetRow = {
|
|
|
13
13
|
recipient_count?: number | null;
|
|
14
14
|
audience?: Record<string, unknown> | null;
|
|
15
15
|
audience_error?: string;
|
|
16
|
+
code?: string;
|
|
16
17
|
};
|
|
17
18
|
type StepRow = {
|
|
18
19
|
campaign_id: string;
|
|
@@ -41,6 +42,7 @@ export declare function previewOf(campaigns: CampaignSetRow[], steps: StepRow[])
|
|
|
41
42
|
recipient_count?: number | null | undefined;
|
|
42
43
|
audience?: Record<string, unknown> | null | undefined;
|
|
43
44
|
audience_error?: string | undefined;
|
|
45
|
+
code?: string | undefined;
|
|
44
46
|
activation?: string | undefined;
|
|
45
47
|
emails: {
|
|
46
48
|
step_order: number;
|
|
@@ -77,7 +77,7 @@ export function previewOf(campaigns, steps) {
|
|
|
77
77
|
...(c.type === 'broadcast' ? {
|
|
78
78
|
recipient_count: c.recipient_count ?? null,
|
|
79
79
|
audience: c.audience ?? null,
|
|
80
|
-
...(c.audience_error ? { audience_error: c.audience_error } : {}),
|
|
80
|
+
...(c.audience_error ? { audience_error: c.audience_error, code: c.code } : {}),
|
|
81
81
|
activation: BROADCAST_ACTIVATION,
|
|
82
82
|
} : {}),
|
|
83
83
|
emails: steps
|
|
@@ -114,8 +114,10 @@ export async function loadSet(projectId, campaignIds) {
|
|
|
114
114
|
Object.assign(campaign, { recipient_count, audience });
|
|
115
115
|
}
|
|
116
116
|
catch (error) {
|
|
117
|
+
console.error("[campaign-sets] Audience lookup failed:", error);
|
|
118
|
+
const safe = dbToolError(error, "Unable to load campaign audience");
|
|
117
119
|
Object.assign(campaign, { recipient_count: null, audience: null,
|
|
118
|
-
audience_error: error
|
|
120
|
+
audience_error: safe.error, code: safe.code });
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
return { campaigns: loaded, steps: (steps ?? []) };
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateStepMultiEventRules, stepMultiEventRulesView } from "@sendinel/domain";
|
|
1
2
|
import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
|
|
2
3
|
import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
3
4
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
@@ -40,6 +41,12 @@ const reentryFields = {
|
|
|
40
41
|
reentry_mode: z.enum(['once_ever', 'per_event_period']).optional().describe(REENTRY_DESCRIPTION),
|
|
41
42
|
period_key: z.string().min(1).max(64).nullable().optional().describe('Flat event-data key naming the period (starts with a letter; letters, digits and underscores only). Key alone selects per_event_period; null clears to once ever. ' + REENTRY_DESCRIPTION),
|
|
42
43
|
};
|
|
44
|
+
// Schema rejects empty/oversized writes; shared validation trims and deduplicates before storage.
|
|
45
|
+
const multiEventFields = {
|
|
46
|
+
skip_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Skip if ANY listed event has occurred. ANDed with legacy and required gates. null clears.'),
|
|
47
|
+
send_only_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Required events at send time, ALL by default; null clears. ANDed with legacy gates.'),
|
|
48
|
+
send_only_if_events_mode: z.enum(['all', 'any']).nullable().optional().describe('Required-event list mode; omitted/null defaults to all on new steps. Omission preserves the mode on updates.'),
|
|
49
|
+
};
|
|
43
50
|
function stripHtml(value) {
|
|
44
51
|
return value
|
|
45
52
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
@@ -891,11 +898,15 @@ export function registerCampaignTools(server) {
|
|
|
891
898
|
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
|
|
892
899
|
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
893
900
|
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
901
|
+
...multiEventFields,
|
|
894
902
|
skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
|
|
895
903
|
send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
|
|
896
904
|
},
|
|
897
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
|
|
905
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property }) => {
|
|
898
906
|
try {
|
|
907
|
+
const events = validateStepMultiEventRules({ skip_if_events, send_only_if_events, send_only_if_events_mode });
|
|
908
|
+
if (!events.ok)
|
|
909
|
+
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
899
910
|
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
900
911
|
if (!properties.ok)
|
|
901
912
|
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
@@ -932,7 +943,7 @@ export function registerCampaignTools(server) {
|
|
|
932
943
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
933
944
|
const { data, error } = await db()
|
|
934
945
|
.from('campaign_steps')
|
|
935
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
946
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...properties.rules })
|
|
936
947
|
.select()
|
|
937
948
|
.single();
|
|
938
949
|
if (error)
|
|
@@ -2831,11 +2842,12 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2831
2842
|
}
|
|
2832
2843
|
catch (err) {
|
|
2833
2844
|
await db().from('email_log').update({ status: 'failed' }).eq('id', logRow.id);
|
|
2845
|
+
console.error("[campaigns] Reply action mint failed:", err);
|
|
2834
2846
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2835
2847
|
if (errMsg.includes('23505') || errMsg.includes('uidx_reply_actions_pending_correlation') || errMsg.includes('already pending')) {
|
|
2836
2848
|
return txt('An awaiting-reply send for this correlation_id is already pending.');
|
|
2837
2849
|
}
|
|
2838
|
-
return
|
|
2850
|
+
return toolError(dbToolError(err, "Failed to mint reply action"));
|
|
2839
2851
|
}
|
|
2840
2852
|
const payload = {
|
|
2841
2853
|
from: `${site.from_name} <${site.from_email}>`,
|
|
@@ -3112,6 +3124,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3112
3124
|
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
3113
3125
|
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
|
|
3114
3126
|
steps: z.array(z.object({
|
|
3127
|
+
...multiEventFields,
|
|
3128
|
+
skip_if_event: z.string().trim().min(1).nullable().optional(),
|
|
3129
|
+
send_only_if_event: z.string().trim().min(1).nullable().optional(),
|
|
3115
3130
|
subject: z.string().min(1).describe('Email subject line'),
|
|
3116
3131
|
body_html: z.string().min(1).describe('Full HTML email body'),
|
|
3117
3132
|
body_text: z.string().optional().describe('Plain text fallback'),
|
|
@@ -3126,6 +3141,12 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3126
3141
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
|
|
3127
3142
|
},
|
|
3128
3143
|
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template, reentry_mode, period_key }) => {
|
|
3144
|
+
for (const step of steps) {
|
|
3145
|
+
const events = validateStepMultiEventRules(step);
|
|
3146
|
+
if (!events.ok)
|
|
3147
|
+
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
3148
|
+
Object.assign(step, events.rules);
|
|
3149
|
+
}
|
|
3129
3150
|
const footerTemplate = unsub_footer_template?.trim() ?? null;
|
|
3130
3151
|
if (footerTemplate !== null) {
|
|
3131
3152
|
const footerError = validateUnsubFooterTemplate(footerTemplate);
|
|
@@ -3202,6 +3223,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3202
3223
|
delay: s.delay_days ? `${s.delay_days}d ${s.delay_hours ?? 0}h` : `${s.delay_hours ?? 0}h`,
|
|
3203
3224
|
has_html: !!s.body_html,
|
|
3204
3225
|
has_text: !!s.body_text,
|
|
3226
|
+
skip_if_event: s.skip_if_event ?? null,
|
|
3227
|
+
send_only_if_event: s.send_only_if_event ?? null,
|
|
3228
|
+
...stepMultiEventRulesView(s),
|
|
3205
3229
|
})),
|
|
3206
3230
|
segment_id: segment_id ?? null,
|
|
3207
3231
|
sends_per_hour: sends_per_hour ?? null,
|
|
@@ -3240,6 +3264,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3240
3264
|
blocks: s.blocks ? normalizeComposerBlocks(s.blocks) : null,
|
|
3241
3265
|
delay_days: s.delay_days ?? 0,
|
|
3242
3266
|
delay_hours: s.delay_hours ?? 0,
|
|
3267
|
+
skip_if_event: s.skip_if_event ?? null,
|
|
3268
|
+
send_only_if_event: s.send_only_if_event ?? null,
|
|
3269
|
+
...stepMultiEventRulesView(s),
|
|
3243
3270
|
}));
|
|
3244
3271
|
const { error: stepErr } = await db()
|
|
3245
3272
|
.from('campaign_steps')
|
|
@@ -3337,7 +3364,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3337
3364
|
return j({ error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
3338
3365
|
const { data, error } = await db()
|
|
3339
3366
|
.from('campaign_steps')
|
|
3340
|
-
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, created_at, updated_at')
|
|
3367
|
+
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, created_at, updated_at')
|
|
3341
3368
|
.eq('campaign_id', campaign_id)
|
|
3342
3369
|
.order('step_order', { ascending: true });
|
|
3343
3370
|
if (error)
|
|
@@ -3365,6 +3392,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3365
3392
|
survey_id: z.string().uuid().optional(),
|
|
3366
3393
|
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
3367
3394
|
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
3395
|
+
...multiEventFields,
|
|
3368
3396
|
skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
|
|
3369
3397
|
send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
|
|
3370
3398
|
link_actions: z.array(z.discriminatedUnion('action', [
|
|
@@ -3381,6 +3409,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3381
3409
|
}, async ({ step_id, ...updates }) => {
|
|
3382
3410
|
const projectId = getProjectId();
|
|
3383
3411
|
const { survey_id, ...stepUpdates } = updates;
|
|
3412
|
+
const events = validateStepMultiEventRules(stepUpdates);
|
|
3413
|
+
if (!events.ok)
|
|
3414
|
+
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
3384
3415
|
const properties = validateStepPropertyRules(stepUpdates);
|
|
3385
3416
|
if (!properties.ok)
|
|
3386
3417
|
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
@@ -3409,6 +3440,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3409
3440
|
const normalizedBlocks = Array.isArray(stepUpdates.blocks) ? normalizeComposerBlocks(stepUpdates.blocks) : undefined;
|
|
3410
3441
|
const renderedUpdates = {
|
|
3411
3442
|
...stepUpdates,
|
|
3443
|
+
...events.rules,
|
|
3412
3444
|
...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
|
|
3413
3445
|
...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
|
|
3414
3446
|
...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
|
|
@@ -3420,7 +3452,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3420
3452
|
.from('campaign_steps')
|
|
3421
3453
|
.update({ ...renderedUpdates, updated_at: new Date().toISOString() })
|
|
3422
3454
|
.eq('id', step_id)
|
|
3423
|
-
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, updated_at')
|
|
3455
|
+
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, updated_at')
|
|
3424
3456
|
.single();
|
|
3425
3457
|
if (error)
|
|
3426
3458
|
return j(dbToolError(error));
|
|
@@ -3798,7 +3830,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3798
3830
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3799
3831
|
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3800
3832
|
...campaignPeriodFields(periods, period),
|
|
3801
|
-
steps: (steps ?? []).map((step) => ({ ...step, total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
|
|
3833
|
+
steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
|
|
3802
3834
|
active_enrollments: enrollCount ?? 0,
|
|
3803
3835
|
send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
|
|
3804
3836
|
queued_email_count: queuedCount,
|
package/build/tools/compound.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getProjectId } from '../project.js';
|
|
|
6
6
|
import { buildVoicePrompt, deriveTemplateVariables } from '../lib/voice.js';
|
|
7
7
|
import { parseModelJson, getAnthropicFastModelId } from '../anthropic-model.js';
|
|
8
8
|
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
9
|
-
import { dbToolError } from '../lib/db-error.js';
|
|
9
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
10
10
|
// Haiku 5.5 (fast role) thinks before answering; thinking counts toward max_tokens (#4193).
|
|
11
11
|
const FAST_MIN_MAX_TOKENS = 8000;
|
|
12
12
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
@@ -221,7 +221,8 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
|
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
223
|
catch (error) {
|
|
224
|
-
|
|
224
|
+
console.error("[compound] Generation failed:", error);
|
|
225
|
+
return toolError(dbToolError(error, "Generation failed: unable to generate template"));
|
|
225
226
|
}
|
|
226
227
|
const { data: template, error } = await db().from('campaign_templates')
|
|
227
228
|
.insert({
|
package/build/tools/contacts.js
CHANGED
|
@@ -182,7 +182,8 @@ async function writeBackMcpContact(projectId, contact, patch) {
|
|
|
182
182
|
await customer.schema('email').from('subscribers').update(update).eq('id', target.rowId);
|
|
183
183
|
}
|
|
184
184
|
catch (error) {
|
|
185
|
-
|
|
185
|
+
console.error('[contacts] BYOD writeback failed:', error);
|
|
186
|
+
logAudit({ action: 'byod_writeback_failed', resourceType: 'contact', resourceId: contact?.id, details: { email: contact?.email, ...dbToolError(error, 'Contact writeback failed') } });
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
function daysAgo(days) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.152",
|
|
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",
|