@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 instanceof Error ? error.message : String(error) });
120
+ audience_error: safe.error, code: safe.code });
119
121
  }
120
122
  }
121
123
  return { campaigns: loaded, steps: (steps ?? []) };
@@ -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 txt(`Error: Failed to mint reply action — ${errMsg}`);
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,
@@ -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
- return j({ error: `Generation failed: ${error instanceof Error ? error.message : String(error)}` });
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({
@@ -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
- logAudit({ action: 'byod_writeback_failed', resourceType: 'contact', resourceId: contact?.id, details: { email: contact?.email, error: error instanceof Error ? error.message : String(error) } });
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.150",
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",