@sendinel/mcp-server 1.0.206 → 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.
- package/build/lib/action-approvals.js +1 -1
- package/build/tools/campaigns.js +101 -19
- package/build/tools/warmup.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
package/build/tools/campaigns.js
CHANGED
|
@@ -464,18 +464,15 @@ async function validateBranchTarget(campaignId, fromStepId, action, targetStepId
|
|
|
464
464
|
}
|
|
465
465
|
return null;
|
|
466
466
|
}
|
|
467
|
-
/**
|
|
468
|
-
|
|
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
|
-
/**
|
|
568
|
-
async function
|
|
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:
|
|
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:
|
|
1539
|
-
?
|
|
1540
|
-
:
|
|
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:
|
|
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:
|
|
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/build/tools/warmup.js
CHANGED
|
@@ -52,6 +52,8 @@ export function registerWarmupTools(server) {
|
|
|
52
52
|
progress_pct: Math.min(100, Math.round((day / 42) * 100)),
|
|
53
53
|
next_milestone: day >= 42 ? null : `Day ${Math.min(42, day + 1)}`,
|
|
54
54
|
// Additive. started_at is the latest health row date, not a start date; kept for compatibility.
|
|
55
|
+
started_at_basis: 'latest_health_check',
|
|
56
|
+
progress_basis: 'recorded_sending_day',
|
|
55
57
|
last_send_date: lastSendDate,
|
|
56
58
|
idle: data ? lastSendDate === null || lastSendDate < yesterday : false,
|
|
57
59
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
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",
|