@sendinel/mcp-server 1.0.207 → 1.0.208

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.
@@ -159,7 +159,7 @@ function broadcastSummary(d) {
159
159
  const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
160
160
  const when = d.send_at ? ` at ${d.send_at}` : '';
161
161
  const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
162
- return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}`;
162
+ return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}${d.one_send_summary ? ` — ${d.one_send_summary}` : ''}`;
163
163
  }
164
164
  /**
165
165
  * launch_campaign approvals carry the written emails in params.generated (#3669), so the summary
@@ -464,18 +464,15 @@ async function validateBranchTarget(campaignId, fromStepId, action, targetStepId
464
464
  }
465
465
  return null;
466
466
  }
467
- /**
468
- * Create, enroll and activate a one-email broadcast (#3669). Shared by broadcast_to_segment and
469
- * send_broadcast so both run the same steps. Enrolls exactly the given contact ids.
470
- */
471
- async function executeBroadcast(input) {
467
+ /** Read-only match shared by approval previews and execution. */
468
+ async function findBroadcastOneSend(input) {
472
469
  // #3749 One Send: the same email (site + subject + body) joins its existing active/completed
473
470
  // campaign from the last 90 days instead of creating another. Same rule as the dashboard helper.
474
471
  const contentKey = computeSendContentKey({ siteId: input.siteId, subject: input.subject, bodyHtml: input.bodyHtml });
475
472
  const since = new Date(Date.now() - ONE_SEND_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
476
473
  const { data: matches, error: matchErr } = await db()
477
- .from('campaigns')
478
- .select('id, status')
474
+ .schema('email').from('campaigns')
475
+ .select('id, name, status')
479
476
  .eq('project_id', input.projectId)
480
477
  .eq('metadata->>content_key', contentKey)
481
478
  .in('status', [...ONE_SEND_MATCH_STATUSES])
@@ -486,6 +483,17 @@ async function executeBroadcast(input) {
486
483
  return { ok: false, ...dbToolError(matchErr), code: 'ONE_SEND_LOOKUP_FAILED' };
487
484
  }
488
485
  const match = (matches ?? [])[0];
486
+ return { ok: true, contentKey, match };
487
+ }
488
+ /**
489
+ * Create, enroll and activate a one-email broadcast (#3669). Shared by broadcast_to_segment and
490
+ * send_broadcast so both run the same steps. Enrolls exactly the given contact ids.
491
+ */
492
+ async function executeBroadcast(input) {
493
+ const found = await findBroadcastOneSend(input);
494
+ if (!found.ok)
495
+ return found;
496
+ const { contentKey, match } = found;
489
497
  if (match)
490
498
  return attachBroadcastToExisting(input, match);
491
499
  const limit = await checkPlanLimit('active_campaigns');
@@ -564,15 +572,15 @@ async function executeBroadcast(input) {
564
572
  }
565
573
  return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
566
574
  }
567
- /** #3749: enroll into the matched campaign, skipping anyone enrolled or already sent to. */
568
- async function attachBroadcastToExisting(input, match) {
575
+ /** The same enrollment/log skip set is used before approval and immediately before writes. */
576
+ async function broadcastOneSendAudience(input, match) {
569
577
  const skip = new Set();
570
578
  const sentStatuses = new Set(ONE_SEND_SENT_STATUSES);
571
579
  const chunk = 200;
572
580
  for (let i = 0; i < input.contactIds.length; i += chunk) {
573
581
  const ids = input.contactIds.slice(i, i + chunk);
574
582
  const { data: enrolledRows, error: enrolledErr } = await db()
575
- .from('campaign_enrollments')
583
+ .schema('email').from('campaign_enrollments')
576
584
  .select('contact_id')
577
585
  .eq('campaign_id', match.id)
578
586
  .in('contact_id', ids)
@@ -584,7 +592,7 @@ async function attachBroadcastToExisting(input, match) {
584
592
  skip.add(row.contact_id);
585
593
  let logRows;
586
594
  try {
587
- logRows = await readAllRows(db().from('email_log')
595
+ logRows = await readAllRows(db().schema('email').from('email_log')
588
596
  .select('contact_id, status')
589
597
  .eq('project_id', input.projectId)
590
598
  .eq('campaign_id', match.id)
@@ -599,6 +607,37 @@ async function attachBroadcastToExisting(input, match) {
599
607
  }
600
608
  const fresh = input.contactIds.filter((id) => !skip.has(id));
601
609
  const alreadySent = input.contactIds.length - fresh.length;
610
+ return { ok: true, fresh, alreadySent };
611
+ }
612
+ async function previewOneSend(input) {
613
+ const found = await findBroadcastOneSend(input);
614
+ if (!found.ok)
615
+ return found;
616
+ if (!found.match)
617
+ return { ok: true, match: undefined, fresh: input.contactIds, alreadySent: 0 };
618
+ const audience = await broadcastOneSendAudience(input, found.match);
619
+ if (!audience.ok)
620
+ return audience;
621
+ return { ...audience, match: found.match };
622
+ }
623
+ function broadcastNoOp(executed) {
624
+ return {
625
+ campaign_id: executed.campaignId,
626
+ step_id: executed.stepId,
627
+ status: 'not_sent',
628
+ code: 'ALREADY_SENT_TO_AUDIENCE',
629
+ contacts_enrolled: 0,
630
+ grouped: true,
631
+ already_sent: executed.alreadySent ?? 0,
632
+ message: `Nothing was sent: all ${executed.alreadySent ?? 0} recipients already received this email. Changing the subject or body creates a new send.`,
633
+ };
634
+ }
635
+ /** #3749: enroll into the matched campaign, skipping anyone enrolled or already sent to. */
636
+ async function attachBroadcastToExisting(input, match) {
637
+ const audience = await broadcastOneSendAudience(input, match);
638
+ if (!audience.ok)
639
+ return audience;
640
+ const { fresh, alreadySent } = audience;
602
641
  const nextSendAt = input.sendAt ?? new Date().toISOString();
603
642
  let enrolled = 0;
604
643
  for (let i = 0; i < fresh.length; i += 500) {
@@ -642,7 +681,7 @@ async function attachBroadcastToExisting(input, match) {
642
681
  .order('step_order', { ascending: true })
643
682
  .limit(1);
644
683
  const stepId = (steps ?? [])[0]?.id ?? '';
645
- return { ok: true, campaignId: match.id, stepId, enrolled, nextSendAt, grouped: true, alreadySent };
684
+ return { ok: true, campaignId: match.id, stepId, enrolled, nextSendAt, grouped: true, alreadySent: alreadySent + fresh.length - enrolled };
646
685
  }
647
686
  /** #3714: a send nobody could receive leaves one audit row (counts by reason, no addresses). */
648
687
  function logRefusedSend(kind, siteId, sendClass, audience, extra = {}) {
@@ -1441,9 +1480,13 @@ export function registerCampaignTools(server) {
1441
1480
  site_id,
1442
1481
  segment_id,
1443
1482
  contacts_enrolled: executed.enrolled,
1483
+ ...(executed.grouped && executed.enrolled === 0 ? { outcome: 'no_op_already_sent', already_sent: executed.alreadySent ?? 0 } : {}),
1444
1484
  source: 'mcp',
1445
1485
  },
1446
1486
  }).catch(() => { });
1487
+ if (executed.grouped && executed.enrolled === 0)
1488
+ return j({ ...broadcastNoOp(executed),
1489
+ name: campaignName, segment: { id: segment.id, name: segment.name }, next_send_at: sendAt });
1447
1490
  fireWebhookEvent('campaign.activated', {
1448
1491
  campaign_id: campaign.id,
1449
1492
  campaign_name: campaignName,
@@ -1459,7 +1502,9 @@ export function registerCampaignTools(server) {
1459
1502
  segment: { id: segment.id, name: segment.name },
1460
1503
  contacts_enrolled: executed.enrolled,
1461
1504
  next_send_at: sendAt,
1462
- message: `Broadcast "${campaignName}" is active with ${executed.enrolled} contact(s) enrolled for immediate sending.`,
1505
+ message: executed.grouped && executed.alreadySent
1506
+ ? `${executed.enrolled} sent now; ${executed.alreadySent} skipped because they already received it.`
1507
+ : `Broadcast "${campaignName}" is active with ${executed.enrolled} contact(s) enrolled for immediate sending.`,
1463
1508
  ...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
1464
1509
  });
1465
1510
  });
@@ -1519,8 +1564,11 @@ export function registerCampaignTools(server) {
1519
1564
  action: 'campaign.send_broadcast',
1520
1565
  resourceType: 'campaign',
1521
1566
  resourceId: executed.campaignId,
1522
- details: { approval_id, decided_by: decidedBy ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1567
+ details: { approval_id, decided_by: decidedBy ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp',
1568
+ ...(executed.grouped && executed.enrolled === 0 ? { outcome: 'no_op_already_sent', already_sent: executed.alreadySent ?? 0 } : {}) },
1523
1569
  }).catch(() => { });
1570
+ if (executed.grouped && executed.enrolled === 0)
1571
+ return j({ ...broadcastNoOp(executed), send_at: payload.send_at ?? 'now' });
1524
1572
  fireWebhookEvent('campaign.activated', {
1525
1573
  campaign_id: executed.campaignId,
1526
1574
  campaign_name: payload.name,
@@ -1535,9 +1583,11 @@ export function registerCampaignTools(server) {
1535
1583
  contacts_enrolled: executed.enrolled,
1536
1584
  send_at: payload.send_at ?? 'now',
1537
1585
  ...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
1538
- message: payload.send_at
1539
- ? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
1540
- : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
1586
+ message: executed.grouped && executed.alreadySent
1587
+ ? `${executed.enrolled} ${payload.send_at ? `scheduled at ${payload.send_at}` : 'sent now'}; ${executed.alreadySent} skipped because they already received it.`
1588
+ : payload.send_at
1589
+ ? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
1590
+ : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
1541
1591
  });
1542
1592
  }
1543
1593
  // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
@@ -1666,6 +1716,34 @@ export function registerCampaignTools(server) {
1666
1716
  code: 'validation_failed', ...delivery,
1667
1717
  audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) } });
1668
1718
  }
1719
+ const preview = await previewOneSend({ projectId, siteId: site.id, subject: content.subject,
1720
+ bodyHtml: content.body_html, contactIds: resolved.contactIds });
1721
+ if (!preview.ok) {
1722
+ const { ok: _ok, ...failure } = preview;
1723
+ return j(failure);
1724
+ }
1725
+ const oneSendCounts = preview.match ? {
1726
+ already_sent_count: preview.alreadySent,
1727
+ will_send_count: preview.fresh.length,
1728
+ matches_campaign_id: preview.match.id,
1729
+ } : {};
1730
+ if (preview.match && preview.fresh.length === 0) {
1731
+ return j({ code: 'ALREADY_SENT_TO_AUDIENCE', ...oneSendCounts, recipient_count: 0,
1732
+ error: `Everyone in this audience already got this exact email from this sender (campaign ${preview.match.id}). Changing the subject or body creates a new send.` });
1733
+ }
1734
+ // Preserve sandbox policy counts for the full audience, and intersect that policy with the
1735
+ // fresh audience for the displayed count. Never narrow the frozen contact_ids.
1736
+ const recipientCount = preview.match
1737
+ ? (await sandboxAudience(projectId, preview.fresh)).recipient_count
1738
+ : delivery.recipient_count;
1739
+ if (recipientCount === 0) {
1740
+ return j({ error: 'No new recipients can be delivered under the sandbox policy. Choose an allowed audience before requesting approval.',
1741
+ code: 'validation_failed', ...delivery, recipient_count: 0, ...oneSendCounts,
1742
+ audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) } });
1743
+ }
1744
+ const oneSendSummary = preview.match
1745
+ ? `${preview.alreadySent} of ${resolved.contactIds.length} already received this email (campaign ${preview.match.name}); it will send to ${recipientCount ?? 'an unknown number of recipients'}.`
1746
+ : undefined;
1669
1747
  const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
1670
1748
  const payload = {
1671
1749
  site_id: site.id,
@@ -1679,8 +1757,9 @@ export function registerCampaignTools(server) {
1679
1757
  send_at: sendAt,
1680
1758
  send_class: sendClass,
1681
1759
  brand_asset_ids: brand_asset_ids ?? null,
1682
- // Frozen at approval time: the people approved are the people enrolled.
1760
+ // Frozen at approval time: execution checks One Send again for these same people.
1683
1761
  contact_ids: resolved.contactIds,
1762
+ ...oneSendCounts,
1684
1763
  };
1685
1764
  const approvalParams = { payload };
1686
1765
  const channelContext = currentChannelContext();
@@ -1693,7 +1772,8 @@ export function registerCampaignTools(server) {
1693
1772
  subject: content.subject,
1694
1773
  segment_name: segment_id ? segmentName : null,
1695
1774
  audience_label: segment_id ? null : `"${segmentName}"`,
1696
- recipient_count: delivery.recipient_count,
1775
+ recipient_count: recipientCount,
1776
+ one_send_summary: oneSendSummary,
1697
1777
  body_excerpt: approvalExcerpt(content.body_html),
1698
1778
  send_at: sendAt,
1699
1779
  });
@@ -1701,6 +1781,8 @@ export function registerCampaignTools(server) {
1701
1781
  ...approval,
1702
1782
  rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
1703
1783
  ...delivery,
1784
+ recipient_count: recipientCount,
1785
+ ...oneSendCounts,
1704
1786
  audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
1705
1787
  site: { id: site.id, name: site.name, from_email: site.from_email },
1706
1788
  send_at: sendAt ?? 'right after approval',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.207",
3
+ "version": "1.0.208",
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",