@sendinel/mcp-server 1.0.36 → 1.0.38
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 +17 -1
- package/build/tools/campaigns.js +170 -72
- package/build/tools/delivery-ops.js +37 -12
- package/package.json +1 -1
|
@@ -152,10 +152,26 @@ function broadcastSummary(d) {
|
|
|
152
152
|
const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
|
|
153
153
|
return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}`;
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* launch_campaign approvals carry the written emails in params.generated (#3669), so the summary
|
|
157
|
+
* can name what goes out. Null for an older approval that has only the briefs.
|
|
158
|
+
*/
|
|
159
|
+
function launchSummary(p, d) {
|
|
160
|
+
const generated = (p.generated ?? {});
|
|
161
|
+
const steps = Array.isArray(generated.steps) ? generated.steps : [];
|
|
162
|
+
if (!steps.length)
|
|
163
|
+
return null;
|
|
164
|
+
const subjects = steps.map((step, i) => `${i + 1}. "${approvalExcerpt(step?.subject, 120)}"`).join(' ');
|
|
165
|
+
const count = Array.isArray(generated.contact_ids) ? generated.contact_ids.length : null;
|
|
166
|
+
const people = count === null ? 'real contacts' : `${count.toLocaleString('en-US')} contact${count === 1 ? '' : 's'}`;
|
|
167
|
+
const audience = d.segment_name ? ` in segment "${d.segment_name}"` : (d.audience_label ? ` matching ${d.audience_label}` : '');
|
|
168
|
+
const excerpt = approvalExcerpt(steps[0]?.body_html);
|
|
169
|
+
return `Launch campaign "${p.name || 'unknown'}": ${steps.length} email${steps.length === 1 ? '' : 's'} to ${people}${audience} — ${subjects}${excerpt ? ` — first email: "${excerpt}"` : ''}`;
|
|
170
|
+
}
|
|
155
171
|
/** Build a human-readable summary of the action for the approval screen */
|
|
156
172
|
export function buildApprovalSummary(action, params, display = {}) {
|
|
157
173
|
const summaries = {
|
|
158
|
-
launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
174
|
+
launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
159
175
|
install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
|
|
160
176
|
install_connector_view: (p) => `Reinstall the customer-side contact view for project ${p.project_id ?? 'current'}`,
|
|
161
177
|
set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1525,7 +1525,7 @@ Adapt your tone accordingly:
|
|
|
1525
1525
|
idempotentHint: false,
|
|
1526
1526
|
openWorldHint: true,
|
|
1527
1527
|
},
|
|
1528
|
-
description: 'Full campaign lifecycle
|
|
1528
|
+
description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with the same arguments plus approval_id to launch them. Nothing is created or sent before approval.',
|
|
1529
1529
|
inputSchema: {
|
|
1530
1530
|
name: z.string().min(1).describe('Campaign name'),
|
|
1531
1531
|
site_id: z.string().uuid(),
|
|
@@ -1544,12 +1544,10 @@ Adapt your tone accordingly:
|
|
|
1544
1544
|
}, async ({ name, site_id, type, segment_description, segment_id, emails, sends_per_hour, confirmed, approval_id }) => {
|
|
1545
1545
|
if (!segment_description && !segment_id)
|
|
1546
1546
|
return txt('Provide segment_description or segment_id.');
|
|
1547
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1548
|
-
if (!apiKey)
|
|
1549
|
-
return txt('Error: ANTHROPIC_API_KEY not configured');
|
|
1550
|
-
const anthropic = new Anthropic({ apiKey });
|
|
1551
1547
|
const projectId = getProjectId();
|
|
1552
|
-
|
|
1548
|
+
// What the caller asked for. An approval is checked against these, so the approved call must
|
|
1549
|
+
// repeat them unchanged.
|
|
1550
|
+
const requestParams = {
|
|
1553
1551
|
name,
|
|
1554
1552
|
site_id,
|
|
1555
1553
|
type: type ?? 'sequence',
|
|
@@ -1558,22 +1556,125 @@ Adapt your tone accordingly:
|
|
|
1558
1556
|
emails,
|
|
1559
1557
|
sends_per_hour,
|
|
1560
1558
|
};
|
|
1559
|
+
function readLaunchPlan(value) {
|
|
1560
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1561
|
+
return null;
|
|
1562
|
+
const plan = value;
|
|
1563
|
+
if (!plan.rules || typeof plan.rules !== 'object' || Array.isArray(plan.rules))
|
|
1564
|
+
return null;
|
|
1565
|
+
if (!Array.isArray(plan.contact_ids) || !Array.isArray(plan.steps) || plan.steps.length === 0)
|
|
1566
|
+
return null;
|
|
1567
|
+
const steps = [];
|
|
1568
|
+
for (const raw of plan.steps) {
|
|
1569
|
+
if (!raw || typeof raw.subject !== 'string' || !raw.subject.trim() || typeof raw.body_html !== 'string' || !raw.body_html.trim())
|
|
1570
|
+
return null;
|
|
1571
|
+
steps.push({
|
|
1572
|
+
subject: raw.subject,
|
|
1573
|
+
body_html: raw.body_html,
|
|
1574
|
+
body_text: typeof raw.body_text === 'string' ? raw.body_text : null,
|
|
1575
|
+
blocks: Array.isArray(raw.blocks) ? raw.blocks : null,
|
|
1576
|
+
delay_days: typeof raw.delay_days === 'number' ? raw.delay_days : 0,
|
|
1577
|
+
delay_hours: typeof raw.delay_hours === 'number' ? raw.delay_hours : 0,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
rules: plan.rules,
|
|
1582
|
+
segment_name: typeof plan.segment_name === 'string' ? plan.segment_name : `${name} — segment`,
|
|
1583
|
+
contact_ids: plan.contact_ids.map(String),
|
|
1584
|
+
steps,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
// Creates the segment (when the audience was described in words), the campaign and its steps,
|
|
1588
|
+
// enrolls the given people and activates. Uses only the plan: no model call happens here.
|
|
1589
|
+
async function launchFromPlan(plan) {
|
|
1590
|
+
let segId = segment_id;
|
|
1591
|
+
if (segment_description) {
|
|
1592
|
+
const { data: seg, error: segErr } = await db()
|
|
1593
|
+
.from('segments')
|
|
1594
|
+
.insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
|
|
1595
|
+
.select('id')
|
|
1596
|
+
.single();
|
|
1597
|
+
if (segErr)
|
|
1598
|
+
return txt(`Error creating segment: ${segErr.message}`);
|
|
1599
|
+
segId = seg.id;
|
|
1600
|
+
}
|
|
1601
|
+
const campLimit = await checkPlanLimit('active_campaigns');
|
|
1602
|
+
if (!campLimit.allowed)
|
|
1603
|
+
return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
|
|
1604
|
+
const { data: campaign, error: campErr } = await db()
|
|
1605
|
+
.from('campaigns')
|
|
1606
|
+
.insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
|
|
1607
|
+
.select('id')
|
|
1608
|
+
.single();
|
|
1609
|
+
if (campErr)
|
|
1610
|
+
return txt(`Error creating campaign: ${campErr.message}`);
|
|
1611
|
+
const steps = [];
|
|
1612
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
1613
|
+
const planned = plan.steps[i];
|
|
1614
|
+
const { data: step, error: stepErr } = await db()
|
|
1615
|
+
.from('campaign_steps')
|
|
1616
|
+
.insert({
|
|
1617
|
+
campaign_id: campaign.id, step_order: i,
|
|
1618
|
+
subject: planned.subject, body_html: planned.body_html, body_text: planned.body_text,
|
|
1619
|
+
blocks: planned.blocks,
|
|
1620
|
+
delay_days: planned.delay_days, delay_hours: planned.delay_hours,
|
|
1621
|
+
})
|
|
1622
|
+
.select('id, step_order, subject')
|
|
1623
|
+
.single();
|
|
1624
|
+
if (stepErr)
|
|
1625
|
+
return txt(`Error adding step ${i}: ${stepErr.message}`);
|
|
1626
|
+
steps.push(step);
|
|
1627
|
+
}
|
|
1628
|
+
const enrollRows = plan.contact_ids.map((id) => ({ campaign_id: campaign.id, contact_id: id, next_send_at: new Date().toISOString() }));
|
|
1629
|
+
if (enrollRows.length) {
|
|
1630
|
+
await db().from('campaign_enrollments').upsert(enrollRows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
|
|
1631
|
+
}
|
|
1632
|
+
await db().from('campaigns').update({ status: 'active', total_enrolled: enrollRows.length, updated_at: new Date().toISOString() }).eq('id', campaign.id);
|
|
1633
|
+
return j({
|
|
1634
|
+
campaign_id: campaign.id, campaign_name: name, status: 'active',
|
|
1635
|
+
segment: { id: segId, name: plan.segment_name, contacts: plan.contact_ids.length },
|
|
1636
|
+
enrolled: enrollRows.length,
|
|
1637
|
+
steps: steps.map(s => ({ step: s.step_order, subject: s.subject })),
|
|
1638
|
+
message: `Campaign "${name}" is live! ${enrollRows.length} contacts enrolled across ${steps.length} email(s).`,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
const channelContext = currentChannelContext();
|
|
1642
|
+
let needsApproval = false;
|
|
1561
1643
|
if (confirmed) {
|
|
1562
|
-
const
|
|
1563
|
-
const decision = assertChannelCanExecute('launch_campaign', approvalParams, channelContext);
|
|
1644
|
+
const decision = assertChannelCanExecute('launch_campaign', requestParams, channelContext);
|
|
1564
1645
|
if (!decision.allowed)
|
|
1565
1646
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1647
|
+
needsApproval = decision.tier !== 'autonomous';
|
|
1648
|
+
}
|
|
1649
|
+
// Approved call: launch exactly what the person approved.
|
|
1650
|
+
if (confirmed && needsApproval && approval_id) {
|
|
1651
|
+
const { data: row, error: rowErr } = await db()
|
|
1652
|
+
.from('action_approvals')
|
|
1653
|
+
.select('params')
|
|
1654
|
+
.eq('id', approval_id)
|
|
1655
|
+
.eq('project_id', projectId)
|
|
1656
|
+
.eq('action', 'launch_campaign')
|
|
1657
|
+
.maybeSingle();
|
|
1658
|
+
if (rowErr)
|
|
1659
|
+
return txt(`Error reading approval ${approval_id}: ${rowErr.message}`);
|
|
1660
|
+
if (!row)
|
|
1661
|
+
return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
|
|
1662
|
+
const stored = (row.params ?? {});
|
|
1663
|
+
const plan = readLaunchPlan(stored.generated);
|
|
1664
|
+
// Checked before the approval is used up: an approval made before the emails were shown
|
|
1665
|
+
// for approval covers only the briefs, so it is refused and left as it is.
|
|
1666
|
+
if (!plan)
|
|
1667
|
+
return txt(`Approval ${approval_id} does not include the written emails, so it cannot launch anything. Request a new approval.`);
|
|
1668
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'launch_campaign', params: { ...requestParams, generated: stored.generated } });
|
|
1669
|
+
if (!check.approved) {
|
|
1670
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1575
1671
|
}
|
|
1672
|
+
return launchFromPlan(plan);
|
|
1576
1673
|
}
|
|
1674
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1675
|
+
if (!apiKey)
|
|
1676
|
+
return txt('Error: ANTHROPIC_API_KEY not configured');
|
|
1677
|
+
const anthropic = new Anthropic({ apiKey });
|
|
1577
1678
|
function applyPaymentRules(query, payment) {
|
|
1578
1679
|
if (!payment)
|
|
1579
1680
|
return query;
|
|
@@ -1654,9 +1755,10 @@ Adapt your tone accordingly:
|
|
|
1654
1755
|
}
|
|
1655
1756
|
return ids;
|
|
1656
1757
|
};
|
|
1657
|
-
|
|
1758
|
+
const segId = segment_id;
|
|
1658
1759
|
let segmentName;
|
|
1659
1760
|
let contactCount = 0;
|
|
1761
|
+
let contactIds = [];
|
|
1660
1762
|
let parsedRules;
|
|
1661
1763
|
if (segment_description) {
|
|
1662
1764
|
const parseResp = await anthropic.messages.create({
|
|
@@ -1673,8 +1775,8 @@ Adapt your tone accordingly:
|
|
|
1673
1775
|
catch {
|
|
1674
1776
|
return txt(`Error: Failed to parse segment from "${segment_description}"`);
|
|
1675
1777
|
}
|
|
1676
|
-
|
|
1677
|
-
contactCount =
|
|
1778
|
+
contactIds = await getContactIds(parsedRules);
|
|
1779
|
+
contactCount = contactIds.length;
|
|
1678
1780
|
segmentName = `${name} — segment`;
|
|
1679
1781
|
}
|
|
1680
1782
|
else {
|
|
@@ -1688,8 +1790,8 @@ Adapt your tone accordingly:
|
|
|
1688
1790
|
return txt('Error: segment not found');
|
|
1689
1791
|
segmentName = seg.name;
|
|
1690
1792
|
parsedRules = seg.rules;
|
|
1691
|
-
|
|
1692
|
-
contactCount =
|
|
1793
|
+
contactIds = await getContactIds(parsedRules);
|
|
1794
|
+
contactCount = contactIds.length;
|
|
1693
1795
|
}
|
|
1694
1796
|
if (!confirmed) {
|
|
1695
1797
|
const subjectPreviews = [];
|
|
@@ -1718,27 +1820,10 @@ Adapt your tone accordingly:
|
|
|
1718
1820
|
}
|
|
1719
1821
|
if (contactCount === 0)
|
|
1720
1822
|
return txt('No contacts match the segment. Adjust targeting criteria.');
|
|
1721
|
-
//
|
|
1722
|
-
if (segment_description) {
|
|
1723
|
-
const { data: seg, error: segErr } = await db()
|
|
1724
|
-
.from('segments')
|
|
1725
|
-
.insert({ project_id: projectId, name: segmentName, description: segment_description, rules: parsedRules })
|
|
1726
|
-
.select('id')
|
|
1727
|
-
.single();
|
|
1728
|
-
if (segErr)
|
|
1729
|
-
return txt(`Error creating segment: ${segErr.message}`);
|
|
1730
|
-
segId = seg.id;
|
|
1731
|
-
}
|
|
1823
|
+
// Checked before anything is written or an approval is requested.
|
|
1732
1824
|
const campLimit = await checkPlanLimit('active_campaigns');
|
|
1733
1825
|
if (!campLimit.allowed)
|
|
1734
1826
|
return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
|
|
1735
|
-
const { data: campaign, error: campErr } = await db()
|
|
1736
|
-
.from('campaigns')
|
|
1737
|
-
.insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
|
|
1738
|
-
.select('id')
|
|
1739
|
-
.single();
|
|
1740
|
-
if (campErr)
|
|
1741
|
-
return txt(`Error creating campaign: ${campErr.message}`);
|
|
1742
1827
|
const [{ data: site }, { data: project }] = await Promise.all([
|
|
1743
1828
|
db()
|
|
1744
1829
|
.from('sites')
|
|
@@ -1752,6 +1837,8 @@ Adapt your tone accordingly:
|
|
|
1752
1837
|
.eq('id', projectId)
|
|
1753
1838
|
.maybeSingle(),
|
|
1754
1839
|
]);
|
|
1840
|
+
if (!site)
|
|
1841
|
+
return txt('Error: site not found');
|
|
1755
1842
|
const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
|
|
1756
1843
|
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1757
1844
|
const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt, project?.brand_settings ?? null)}
|
|
@@ -1774,7 +1861,9 @@ ComposerBlock[] must conform to this JSON Schema:
|
|
|
1774
1861
|
${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
|
|
1775
1862
|
|
|
1776
1863
|
Do not include body_html in your response; Sendinel renders body_html from blocks.`;
|
|
1777
|
-
|
|
1864
|
+
// Write every email first. If one cannot be written, stop: nothing is created and no
|
|
1865
|
+
// approval is requested, so a brief can never go out in place of an email.
|
|
1866
|
+
const plannedSteps = [];
|
|
1778
1867
|
for (let i = 0; i < emails.length; i++) {
|
|
1779
1868
|
const email = emails[i];
|
|
1780
1869
|
const genResp = await anthropic.messages.create({
|
|
@@ -1785,52 +1874,61 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
1785
1874
|
});
|
|
1786
1875
|
const gtb = genResp.content.find((b) => b.type === 'text');
|
|
1787
1876
|
const rawEmail = gtb && 'text' in gtb ? gtb.text : '{}';
|
|
1788
|
-
let
|
|
1877
|
+
let written = null;
|
|
1789
1878
|
try {
|
|
1790
1879
|
const parsed = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
|
|
1791
1880
|
const conclusion = parsed.conclusion ?? parsed;
|
|
1792
1881
|
const blocks = normalizeComposerBlocks(conclusion.blocks);
|
|
1793
|
-
|
|
1882
|
+
written = blocks.length
|
|
1794
1883
|
? {
|
|
1795
1884
|
subject: conclusion.subject,
|
|
1796
1885
|
body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
|
|
1797
1886
|
body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
|
|
1798
1887
|
blocks,
|
|
1799
1888
|
}
|
|
1800
|
-
:
|
|
1889
|
+
: { subject: conclusion.subject, body_html: conclusion.body_html, body_text: conclusion.body_text, blocks: null };
|
|
1801
1890
|
}
|
|
1802
1891
|
catch {
|
|
1803
|
-
|
|
1892
|
+
written = null;
|
|
1804
1893
|
}
|
|
1805
|
-
|
|
1806
|
-
.from
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
subject:
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
.
|
|
1814
|
-
.
|
|
1815
|
-
|
|
1816
|
-
return txt(`Error adding step ${i}: ${stepErr.message}`);
|
|
1817
|
-
steps.push(step);
|
|
1894
|
+
if (!written || typeof written.subject !== 'string' || !written.subject.trim() || typeof written.body_html !== 'string' || !written.body_html.trim()) {
|
|
1895
|
+
return txt(`Error: email ${i + 1} of ${emails.length} could not be written from its brief. Nothing was created and no approval was requested. Try again, or reword that brief.`);
|
|
1896
|
+
}
|
|
1897
|
+
plannedSteps.push({
|
|
1898
|
+
subject: written.subject.trim(),
|
|
1899
|
+
body_html: written.body_html,
|
|
1900
|
+
body_text: typeof written.body_text === 'string' && written.body_text.trim() ? written.body_text : null,
|
|
1901
|
+
blocks: written.blocks,
|
|
1902
|
+
delay_days: email.delay_days ?? 0,
|
|
1903
|
+
delay_hours: email.delay_hours ?? 0,
|
|
1904
|
+
});
|
|
1818
1905
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1906
|
+
const plan = {
|
|
1907
|
+
rules: parsedRules,
|
|
1908
|
+
segment_name: segmentName ?? `${name} — segment`,
|
|
1909
|
+
// Frozen here: the people a person approves are the people enrolled.
|
|
1910
|
+
contact_ids: contactIds,
|
|
1911
|
+
steps: plannedSteps,
|
|
1912
|
+
};
|
|
1913
|
+
if (needsApproval) {
|
|
1914
|
+
const approval = await requestApproval('launch_campaign', { ...requestParams, generated: plan }, undefined, channelContext, {
|
|
1915
|
+
segment_name: segment_id ? plan.segment_name : null,
|
|
1916
|
+
audience_label: segment_description ? `"${segment_description}"` : null,
|
|
1917
|
+
});
|
|
1918
|
+
return j({
|
|
1919
|
+
...approval,
|
|
1920
|
+
campaign_name: name,
|
|
1921
|
+
recipient_count: plan.contact_ids.length,
|
|
1922
|
+
segment: segment_description
|
|
1923
|
+
? { description: segment_description, parsed_rules: plan.rules, matching_contacts: plan.contact_ids.length }
|
|
1924
|
+
: { id: segId, name: plan.segment_name, matching_contacts: plan.contact_ids.length },
|
|
1925
|
+
emails: plan.steps.map((step, i) => ({
|
|
1926
|
+
step: i, subject: step.subject, body_html: step.body_html, body_text: step.body_text, delay_days: step.delay_days, delay_hours: step.delay_hours,
|
|
1927
|
+
})),
|
|
1928
|
+
next: 'Show the user each subject, the recipient count and approve_url. After a person approves, call launch_campaign again with the same arguments plus approval_id. These exact emails are sent; nothing is rewritten. Do not say it launched before that call succeeds.',
|
|
1929
|
+
});
|
|
1824
1930
|
}
|
|
1825
|
-
|
|
1826
|
-
await db().from('campaigns').update({ status: 'active', total_enrolled: enrollIds.length, updated_at: new Date().toISOString() }).eq('id', campaign.id);
|
|
1827
|
-
return j({
|
|
1828
|
-
campaign_id: campaign.id, campaign_name: name, status: 'active',
|
|
1829
|
-
segment: { id: segId, name: segmentName, contacts: contactCount },
|
|
1830
|
-
enrolled: enrollIds.length,
|
|
1831
|
-
steps: steps.map(s => ({ step: s.step_order, subject: s.subject })),
|
|
1832
|
-
message: `Campaign "${name}" is live! ${enrollIds.length} contacts enrolled across ${steps.length} email(s).`,
|
|
1833
|
-
});
|
|
1931
|
+
return launchFromPlan(plan);
|
|
1834
1932
|
});
|
|
1835
1933
|
server.registerTool('preview_email', {
|
|
1836
1934
|
title: 'Preview Email',
|
|
@@ -703,14 +703,15 @@ export function registerDeliveryOpsTools(server) {
|
|
|
703
703
|
idempotentHint: true,
|
|
704
704
|
openWorldHint: false,
|
|
705
705
|
},
|
|
706
|
-
description: 'Export contacts, email log, campaign, or suppression data as JSON.',
|
|
706
|
+
description: 'Export contacts, email log, campaign, or suppression data as JSON, newest first. Returns up to `limit` rows per call plus `total` (all matching rows). When `truncated` is true, call again with `offset` set to `next_offset` until it is false to get every row.',
|
|
707
707
|
inputSchema: {
|
|
708
708
|
resource: z.enum(['contacts', 'email_log', 'campaigns', 'suppressions']),
|
|
709
709
|
site_id: z.string().uuid().optional(),
|
|
710
710
|
since: z.string().datetime().optional(),
|
|
711
711
|
limit: z.number().int().min(1).max(5000).default(1000).optional(),
|
|
712
|
+
offset: z.number().int().min(0).default(0).optional().describe('Rows to skip. Use the next_offset from the previous call to continue.'),
|
|
712
713
|
},
|
|
713
|
-
}, async ({ resource, site_id, since, limit = 1000 }) => {
|
|
714
|
+
}, async ({ resource, site_id, since, limit = 1000, offset = 0 }) => {
|
|
714
715
|
const projectId = getProjectId();
|
|
715
716
|
const tableMap = {
|
|
716
717
|
contacts: 'subscribers',
|
|
@@ -718,16 +719,40 @@ export function registerDeliveryOpsTools(server) {
|
|
|
718
719
|
campaigns: 'campaigns',
|
|
719
720
|
suppressions: 'suppressions',
|
|
720
721
|
};
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
722
|
+
// #3715: one request returns at most 1,000 rows however large `limit` is, with no error.
|
|
723
|
+
// Read `limit` rows in pages, and report the real total so the caller can tell when it has
|
|
724
|
+
// everything. id breaks ties so pages do not overlap.
|
|
725
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
726
|
+
const filtered = (query) => {
|
|
727
|
+
let q = query.eq('project_id', projectId);
|
|
728
|
+
if (site_id && (resource === 'email_log' || resource === 'campaigns'))
|
|
729
|
+
q = q.eq('site_id', site_id);
|
|
730
|
+
if (since)
|
|
731
|
+
q = q.gte('created_at', since);
|
|
732
|
+
return q;
|
|
733
|
+
};
|
|
734
|
+
const PAGE = 1000;
|
|
735
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
736
|
+
const rows = [];
|
|
737
|
+
while (rows.length < limit) {
|
|
738
|
+
const size = Math.min(PAGE, limit - rows.length);
|
|
739
|
+
const from = offset + rows.length;
|
|
740
|
+
const { data, error } = await filtered(db().from(tableMap[resource]).select('*'))
|
|
741
|
+
.order('created_at', { ascending: false })
|
|
742
|
+
.order('id', { ascending: true })
|
|
743
|
+
.range(from, from + size - 1);
|
|
744
|
+
if (error)
|
|
745
|
+
return j({ error: error.message });
|
|
746
|
+
rows.push(...(data ?? []));
|
|
747
|
+
if ((data?.length ?? 0) < size)
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
const { count, error: countError } = await filtered(db().from(tableMap[resource]).select('id', { count: 'exact', head: true }));
|
|
751
|
+
// An unknown total is reported as null, never as a number.
|
|
752
|
+
const total = countError || typeof count !== 'number' ? null : count;
|
|
753
|
+
const nextOffset = offset + rows.length;
|
|
754
|
+
const truncated = total === null ? rows.length === limit : nextOffset < total;
|
|
755
|
+
return j({ resource, count: rows.length, total, offset, next_offset: truncated ? nextOffset : null, truncated, data: rows });
|
|
731
756
|
});
|
|
732
757
|
server.registerTool('list_domains', {
|
|
733
758
|
title: 'List Domains',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.38",
|
|
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",
|