@sendinel/mcp-server 1.0.142 → 1.0.143
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.
|
@@ -50,6 +50,8 @@ export interface ApprovalResult {
|
|
|
50
50
|
summary: string;
|
|
51
51
|
expires_at: string;
|
|
52
52
|
approve_via: string;
|
|
53
|
+
decided_via?: 'sandbox';
|
|
54
|
+
decided_by?: null;
|
|
53
55
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
54
56
|
approve_url: string;
|
|
55
57
|
preview_text: string;
|
|
@@ -137,7 +137,7 @@ export function approvalExcerpt(html, max = 200) {
|
|
|
137
137
|
function broadcastSummary(d) {
|
|
138
138
|
if (!d.subject)
|
|
139
139
|
return null;
|
|
140
|
-
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : null;
|
|
140
|
+
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : d.recipient_count === null ? 'unknown recipients' : null;
|
|
141
141
|
const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
|
|
142
142
|
const when = d.send_at ? ` at ${d.send_at}` : '';
|
|
143
143
|
const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
|
|
@@ -323,11 +323,13 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
323
323
|
}
|
|
324
324
|
catch { /* Missing display data omits the project line. */ }
|
|
325
325
|
const approveUrl = approvalPageUrl(data.id);
|
|
326
|
-
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
|
|
326
|
+
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
|
|
327
327
|
if (sandboxApproved) {
|
|
328
328
|
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
329
|
-
approve_via: 'sandbox', approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
-
message:
|
|
329
|
+
approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
+
message: action === 'send_broadcast'
|
|
331
|
+
? 'Sandbox project: approved automatically (no person decided it). Call send_broadcast again with only approval_id to send.'
|
|
332
|
+
: 'Sandbox project: approved automatically\nCall this tool again with the same arguments plus approval_id to run it.' };
|
|
331
333
|
}
|
|
332
334
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
333
335
|
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then call the original tool again with approval_id.`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
2
|
+
export declare function sandboxAudience(projectId: string, contactIds: string[]): Promise<{
|
|
3
|
+
recipient_count: number;
|
|
4
|
+
audience_matched_count?: undefined;
|
|
5
|
+
sandbox?: undefined;
|
|
6
|
+
} | {
|
|
7
|
+
recipient_count: number;
|
|
8
|
+
audience_matched_count: number;
|
|
9
|
+
sandbox: {
|
|
10
|
+
active: true;
|
|
11
|
+
allowed_domains: string[] | null;
|
|
12
|
+
filtered_out: number;
|
|
13
|
+
count_unknown?: undefined;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
16
|
+
recipient_count: null;
|
|
17
|
+
audience_matched_count: number;
|
|
18
|
+
sandbox: {
|
|
19
|
+
active: boolean | null;
|
|
20
|
+
allowed_domains: string[] | null;
|
|
21
|
+
filtered_out: null;
|
|
22
|
+
count_unknown: boolean;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
export declare function broadcastApprovalNext(approval: {
|
|
26
|
+
approve_via?: string;
|
|
27
|
+
decision_status?: string;
|
|
28
|
+
}, sandbox?: {
|
|
29
|
+
allowed_domains: string[] | null;
|
|
30
|
+
}): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chunkIds, isSandboxDomain, matchSandboxDomains, readAllRows } from '@sendinel/domain';
|
|
2
|
+
import { adminDb, db } from '../db.js';
|
|
3
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
4
|
+
export async function sandboxAudience(projectId, contactIds) {
|
|
5
|
+
let active = null;
|
|
6
|
+
let allowedDomains = null;
|
|
7
|
+
try {
|
|
8
|
+
const { data, error } = await adminDb().schema('email').from('projects')
|
|
9
|
+
.select('sandbox_mode, sandbox_allowed_domains').eq('id', projectId).maybeSingle();
|
|
10
|
+
if (error || !data || typeof data.sandbox_mode !== 'boolean')
|
|
11
|
+
throw new Error('Sandbox policy unavailable');
|
|
12
|
+
active = data.sandbox_mode;
|
|
13
|
+
if (!active)
|
|
14
|
+
return { recipient_count: contactIds.length };
|
|
15
|
+
if (!Array.isArray(data.sandbox_allowed_domains) || !data.sandbox_allowed_domains.every(isSandboxDomain)) {
|
|
16
|
+
throw new Error('Sandbox domains unavailable');
|
|
17
|
+
}
|
|
18
|
+
allowedDomains = data.sandbox_allowed_domains;
|
|
19
|
+
const emails = new Map();
|
|
20
|
+
for (const chunk of chunkIds(contactIds)) {
|
|
21
|
+
const rows = await readAllRows(db().schema('email').from('subscribers').select('id, email').eq('project_id', projectId).in('id', chunk));
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
if (typeof row.email !== 'string')
|
|
24
|
+
throw new Error('Contact email unavailable');
|
|
25
|
+
emails.set(row.id, row.email);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (contactIds.some(id => !emails.has(id)))
|
|
29
|
+
throw new Error('Contact lookup incomplete');
|
|
30
|
+
const count = contactIds.filter(id => !matchSandboxDomains(allowedDomains, { to: emails.get(id) })).length;
|
|
31
|
+
return { recipient_count: count, audience_matched_count: contactIds.length,
|
|
32
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: contactIds.length - count } };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { recipient_count: null, audience_matched_count: contactIds.length,
|
|
36
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: null, count_unknown: true } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function broadcastApprovalNext(approval, sandbox) {
|
|
40
|
+
if (approval.approve_via === 'sandbox' && approval.decision_status === 'approved') {
|
|
41
|
+
const domains = sandbox?.allowed_domains;
|
|
42
|
+
return 'Sandbox project: this was approved automatically (no person decided it). Call send_broadcast again with only approval_id to send. ' +
|
|
43
|
+
(domains ? domains.length ? `Only recipients on ${domains.join(', ')} can be delivered, subject to send-time policy checks.` : 'No sandbox domains are allowed; no recipients can be delivered.'
|
|
44
|
+
: 'Sandbox domains could not be loaded; the deliverable recipient count is unknown.') +
|
|
45
|
+
' Do not say it was sent before that call succeeds.';
|
|
46
|
+
}
|
|
47
|
+
return 'Show the user the subject, recipient count and approve_url. After a person approves, call send_broadcast with only approval_id. Do not say it was sent before that call succeeds.';
|
|
48
|
+
}
|
package/build/tools/approvals.js
CHANGED
|
@@ -48,7 +48,7 @@ export function registerApprovalTools(server, options) {
|
|
|
48
48
|
}, async () => {
|
|
49
49
|
const { data, error } = await db()
|
|
50
50
|
.from('action_approvals')
|
|
51
|
-
.select('id, action, tier, channel, summary, status, requested_at, expires_at')
|
|
51
|
+
.select('id, action, tier, channel, summary, status, requested_at, expires_at, decided_via, decided_by')
|
|
52
52
|
.eq('project_id', getProjectId())
|
|
53
53
|
.eq('status', 'pending')
|
|
54
54
|
.gt('expires_at', new Date().toISOString())
|
|
@@ -56,6 +56,6 @@ export function registerApprovalTools(server, options) {
|
|
|
56
56
|
.limit(25);
|
|
57
57
|
if (error)
|
|
58
58
|
return toolError(dbToolError(error));
|
|
59
|
-
return j({ pending: data ?? [], count: data?.length ?? 0 });
|
|
59
|
+
return j({ pending: (data ?? []).map((row) => ({ ...row, ...(row.decided_via === 'sandbox' ? { decision_channel: 'sandbox' } : {}) })), count: data?.length ?? 0 });
|
|
60
60
|
});
|
|
61
61
|
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -18,6 +18,7 @@ import { checkPlanLimit } from '../plan-limits.js';
|
|
|
18
18
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
19
19
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
20
20
|
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
21
|
+
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
21
22
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
22
23
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
23
24
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
@@ -1578,6 +1579,7 @@ export function registerCampaignTools(server) {
|
|
|
1578
1579
|
else {
|
|
1579
1580
|
content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
|
|
1580
1581
|
}
|
|
1582
|
+
const delivery = await sandboxAudience(projectId, resolved.contactIds);
|
|
1581
1583
|
const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
|
|
1582
1584
|
const payload = {
|
|
1583
1585
|
site_id: site.id,
|
|
@@ -1603,18 +1605,18 @@ export function registerCampaignTools(server) {
|
|
|
1603
1605
|
subject: content.subject,
|
|
1604
1606
|
segment_name: segment_id ? segmentName : null,
|
|
1605
1607
|
audience_label: segment_id ? null : `"${segmentName}"`,
|
|
1606
|
-
recipient_count:
|
|
1608
|
+
recipient_count: delivery.recipient_count,
|
|
1607
1609
|
body_excerpt: approvalExcerpt(content.body_html),
|
|
1608
1610
|
send_at: sendAt,
|
|
1609
1611
|
});
|
|
1610
1612
|
return j({
|
|
1611
1613
|
...approval,
|
|
1612
1614
|
rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
|
|
1613
|
-
|
|
1615
|
+
...delivery,
|
|
1614
1616
|
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
|
|
1615
1617
|
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
1616
1618
|
send_at: sendAt ?? 'right after approval',
|
|
1617
|
-
next:
|
|
1619
|
+
next: broadcastApprovalNext(approval, delivery.sandbox),
|
|
1618
1620
|
});
|
|
1619
1621
|
});
|
|
1620
1622
|
server.registerTool('clone_campaign', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.143",
|
|
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",
|