@sendinel/mcp-server 1.0.132 → 1.0.134
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.
|
@@ -438,9 +438,10 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
438
438
|
}
|
|
439
439
|
if (expiry <= Date.now())
|
|
440
440
|
return { approved: false, status: 'expired' };
|
|
441
|
+
const consumedAt = new Date().toISOString();
|
|
441
442
|
const claimQuery = approvalStore()
|
|
442
443
|
.from('action_approvals')
|
|
443
|
-
.update({ status: 'used', used_at:
|
|
444
|
+
.update({ status: 'used', used_at: consumedAt })
|
|
444
445
|
.eq('id', approvalId)
|
|
445
446
|
.eq('project_id', getProjectId())
|
|
446
447
|
.eq('status', 'approved')
|
|
@@ -461,6 +462,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
461
462
|
approved: true,
|
|
462
463
|
status: 'approved',
|
|
463
464
|
decided_by: claimed.decided_by,
|
|
465
|
+
...(options.action === 'send_test_email' ? { consumed_at: consumedAt } : {}),
|
|
464
466
|
...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
|
|
465
467
|
};
|
|
466
468
|
}
|
|
@@ -484,6 +486,7 @@ export function withApproval(action, handler, options = {}) {
|
|
|
484
486
|
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
485
487
|
let decidedBy;
|
|
486
488
|
let decisionChannel;
|
|
489
|
+
let consumedAt;
|
|
487
490
|
if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
|
|
488
491
|
if (!approval_id) {
|
|
489
492
|
const display = options.display ? await options.display(params) : undefined;
|
|
@@ -491,6 +494,7 @@ export function withApproval(action, handler, options = {}) {
|
|
|
491
494
|
}
|
|
492
495
|
const check = await checkApproval(approval_id, { consume: true, action, params });
|
|
493
496
|
decidedBy = check.decided_by;
|
|
497
|
+
consumedAt = check.consumed_at;
|
|
494
498
|
decisionChannel = check.decision_channel;
|
|
495
499
|
if (!check.approved) {
|
|
496
500
|
return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
@@ -499,6 +503,34 @@ export function withApproval(action, handler, options = {}) {
|
|
|
499
503
|
if (options.registeredRequestOnly) {
|
|
500
504
|
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy, decisionChannel }, () => handler(args, extra));
|
|
501
505
|
}
|
|
502
|
-
|
|
506
|
+
const result = await handler(params, extra);
|
|
507
|
+
// Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
|
|
508
|
+
// failure may release it; transport errors and unknown outcomes can already have sent.
|
|
509
|
+
if (action === 'send_test_email' && approval_id && consumedAt && testSendNotAccepted(result)) {
|
|
510
|
+
const { data: restored, error } = await approvalStore().from('action_approvals')
|
|
511
|
+
.update({ status: 'approved', used_at: null })
|
|
512
|
+
.eq('id', approval_id).eq('project_id', getProjectId()).eq('action', 'send_test_email')
|
|
513
|
+
.eq('status', 'used').eq('used_at', consumedAt)
|
|
514
|
+
.select('id').maybeSingle();
|
|
515
|
+
if (error || !restored) {
|
|
516
|
+
console.error('Test-send approval could not be restored after a pre-acceptance failure.');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return result;
|
|
503
520
|
};
|
|
504
521
|
}
|
|
522
|
+
function testSendNotAccepted(result) {
|
|
523
|
+
const [block] = result.content;
|
|
524
|
+
if (result.content.length !== 1 || block.type !== 'text')
|
|
525
|
+
return false;
|
|
526
|
+
try {
|
|
527
|
+
const outcome = JSON.parse(block.text);
|
|
528
|
+
const code = outcome?.error?.error?.code;
|
|
529
|
+
return (outcome.status === 500 && ['provider_unconfigured', 'provider_refused'].includes(code))
|
|
530
|
+
|| (outcome.status === 422 && ['sandbox_refused', 'send_allowlist_refused'].includes(code))
|
|
531
|
+
|| (outcome.status === 503 && code === 'send_allowlist_unavailable');
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkProjectSendAllowlist } from "@sendinel/domain";
|
|
1
|
+
import { checkProjectSendAllowlist, stripActiveEmailContent } from "@sendinel/domain";
|
|
2
2
|
import { Resend } from "resend";
|
|
3
3
|
import { adminDb, db } from "../db.js";
|
|
4
4
|
import { decrypt } from "../crypto.js";
|
|
@@ -251,6 +251,7 @@ export async function sendEmailForSite(projectId, siteId, args) {
|
|
|
251
251
|
const refusal = await checkProjectSendAllowlist(adminDb(), projectId, args, "mcp");
|
|
252
252
|
if (refusal)
|
|
253
253
|
return refusal;
|
|
254
|
+
args = { ...args, ...(args.html !== undefined ? { html: stripActiveEmailContent(args.html).html } : {}) };
|
|
254
255
|
const config = await resolveProviderConfig(projectId, siteId);
|
|
255
256
|
switch (config.name) {
|
|
256
257
|
case "resend":
|
package/build/tools/campaigns.js
CHANGED
|
@@ -2,7 +2,7 @@ import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } fro
|
|
|
2
2
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
-
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
|
|
5
|
+
import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
|
|
6
6
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
7
7
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
8
8
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -1510,7 +1510,7 @@ export function registerCampaignTools(server) {
|
|
|
1510
1510
|
});
|
|
1511
1511
|
return j({
|
|
1512
1512
|
...approval,
|
|
1513
|
-
rendered: { subject: content.subject, body_html: content.body_html, body_text: content.body_text },
|
|
1513
|
+
rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
|
|
1514
1514
|
recipient_count: resolved.contactIds.length,
|
|
1515
1515
|
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
|
|
1516
1516
|
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
@@ -2461,6 +2461,8 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2461
2461
|
}
|
|
2462
2462
|
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
|
|
2463
2463
|
}
|
|
2464
|
+
if (resolvedHtml !== undefined)
|
|
2465
|
+
resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
|
|
2464
2466
|
if (!resolvedSubject?.trim())
|
|
2465
2467
|
return txt('Error: subject is required');
|
|
2466
2468
|
const payload = {
|
|
@@ -2653,6 +2655,8 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2653
2655
|
}
|
|
2654
2656
|
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
|
|
2655
2657
|
}
|
|
2658
|
+
if (resolvedHtml !== undefined)
|
|
2659
|
+
resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
|
|
2656
2660
|
if (!resolvedSubject?.trim())
|
|
2657
2661
|
return txt('Error: subject is required');
|
|
2658
2662
|
const { data: projectContext, error: projectContextError } = await adminDb()
|
|
@@ -2871,6 +2875,17 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2871
2875
|
bodyText: body_text,
|
|
2872
2876
|
brandSettings: projectRow?.brand_settings ?? null,
|
|
2873
2877
|
});
|
|
2878
|
+
const active = stripActiveEmailContent(html).removed;
|
|
2879
|
+
if (active.includes('script'))
|
|
2880
|
+
result.warnings.push('The script tag is removed at send time.');
|
|
2881
|
+
if (active.includes('event-handler attribute'))
|
|
2882
|
+
result.warnings.push('Each on* event-handler attribute is removed at send time.');
|
|
2883
|
+
const schemes = active.filter(finding => finding.endsWith(' URL'));
|
|
2884
|
+
if (schemes.length)
|
|
2885
|
+
result.warnings.push(`Active links (${schemes.join(', ')}) are removed at send time.`);
|
|
2886
|
+
const tags = active.filter(finding => finding !== 'script' && finding !== 'event-handler attribute' && !finding.endsWith(' URL'));
|
|
2887
|
+
if (tags.length)
|
|
2888
|
+
result.warnings.push(`Other active tags (${tags.join(', ')}) are removed at send time.`);
|
|
2874
2889
|
for (const issue of findContentIssues({ subject, body_html: html, body_text }, { sendingHosts, sourcedKeys })) {
|
|
2875
2890
|
if (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind))
|
|
2876
2891
|
result.errors.push(`${issue.message} (${issue.excerpt})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.134",
|
|
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",
|