@sendinel/mcp-server 1.0.165 → 1.0.167
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.d.ts +13 -1
- package/build/lib/action-approvals.js +92 -13
- package/build/lib/approval-chat-confirm.js +2 -1
- package/build/lib/approval-tool-decisions.js +4 -2
- package/build/lib/domain-health.d.ts +18 -0
- package/build/lib/domain-health.js +34 -0
- package/build/lib/how-to-recipes.js +8 -8
- package/build/lib/sandbox-audience.js +3 -2
- package/build/server.js +11 -1
- package/build/tools/ab-testing.js +1 -1
- package/build/tools/agent-mailboxes.js +2 -2
- package/build/tools/analytics.js +26 -105
- package/build/tools/approvals.js +6 -5
- package/build/tools/automations.js +2 -2
- package/build/tools/brand-kit.js +1 -1
- package/build/tools/campaign-sets.d.ts +1 -0
- package/build/tools/campaign-sets.js +11 -4
- package/build/tools/campaigns.js +70 -49
- package/build/tools/contacts.js +9 -9
- package/build/tools/content.js +2 -2
- package/build/tools/data.js +4 -4
- package/build/tools/delivery-ops.js +20 -20
- package/build/tools/landing-pages.js +2 -2
- package/build/tools/lists.js +1 -1
- package/build/tools/newsletters.js +3 -3
- package/build/tools/org.js +4 -4
- package/build/tools/segments.js +1 -1
- package/build/tools/signatures.js +1 -1
- package/build/tools/sms.js +4 -4
- package/build/tools/social-posts.js +2 -2
- package/build/tools/social-replies.js +2 -2
- package/build/tools/templates.js +1 -1
- package/build/tools/webhooks.js +3 -3
- package/package.json +1 -1
package/build/tools/analytics.js
CHANGED
|
@@ -6,9 +6,9 @@ import { z } from 'zod';
|
|
|
6
6
|
import { db, adminDb, requestContext } from '../db.js';
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
8
8
|
import { resolveProjectClient } from '../byod-client.js';
|
|
9
|
-
import { decrypt } from '../crypto.js';
|
|
10
9
|
import { logAudit } from '../audit.js';
|
|
11
10
|
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
11
|
+
import { storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
|
|
12
12
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
13
13
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
14
14
|
const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
|
|
@@ -579,21 +579,22 @@ export function registerAnalyticsTools(server) {
|
|
|
579
579
|
idempotentHint: true,
|
|
580
580
|
openWorldHint: false,
|
|
581
581
|
},
|
|
582
|
-
description: 'SPF/DKIM/DMARC
|
|
582
|
+
description: 'Stored domain_health SPF/DKIM/DMARC history and warmup progress for a site. Each row adds a DNS summary with verification_source and checked_at; null means unchecked, never verified.',
|
|
583
583
|
inputSchema: {
|
|
584
584
|
site_id: z.string().uuid().describe('Site UUID'),
|
|
585
585
|
days: z.number().int().min(1).max(90).optional().describe('Recent days to return, default 7'),
|
|
586
586
|
},
|
|
587
587
|
}, async ({ site_id, days = 7 }) => {
|
|
588
|
-
const { data, error } = await db()
|
|
588
|
+
const { data, error } = await db().schema('email')
|
|
589
589
|
.from('domain_health')
|
|
590
|
-
.select('
|
|
590
|
+
.select('*, sites!inner(project_id)')
|
|
591
591
|
.eq('site_id', site_id)
|
|
592
|
-
.
|
|
592
|
+
.eq('sites.project_id', getProjectId())
|
|
593
|
+
.order('check_date', { ascending: false }).order('created_at', { ascending: false }).order('id', { ascending: false })
|
|
593
594
|
.limit(days);
|
|
594
595
|
if (error)
|
|
595
|
-
return
|
|
596
|
-
return
|
|
596
|
+
return toolError(dbToolError(error, 'Unable to load domain health'));
|
|
597
|
+
return j((data ?? []).map(({ sites: _sites, ...row }) => ({ ...row, dns: storedDomainDns(row) })));
|
|
597
598
|
});
|
|
598
599
|
server.registerTool('get_engagement_insights', {
|
|
599
600
|
title: 'Get Engagement Insights',
|
|
@@ -700,9 +701,9 @@ export function registerAnalyticsTools(server) {
|
|
|
700
701
|
readOnlyHint: true,
|
|
701
702
|
destructiveHint: false,
|
|
702
703
|
idempotentHint: true,
|
|
703
|
-
openWorldHint:
|
|
704
|
+
openWorldHint: false,
|
|
704
705
|
},
|
|
705
|
-
description: '
|
|
706
|
+
description: 'Read stored domain_health DNS observations (SPF/DKIM/DMARC), warmup status, bounce/complaint rates and risk assessment. Uses the same DNS source as refresh_domain_dns, get_domain_health and list_domains; null means unchecked. No live provider or DNS calls.',
|
|
706
707
|
inputSchema: {
|
|
707
708
|
site_id: z.string().uuid(),
|
|
708
709
|
},
|
|
@@ -717,95 +718,25 @@ export function registerAnalyticsTools(server) {
|
|
|
717
718
|
if (siteErr || !site)
|
|
718
719
|
return txt(`Error: Site not found — ${(siteErr ? dbToolError(siteErr).error : undefined) ?? 'not found'}`);
|
|
719
720
|
const domain = site.sending_domain;
|
|
720
|
-
const
|
|
721
|
-
let verificationSource = 'dns_lookup';
|
|
722
|
-
let resendDomainStatus = null;
|
|
723
|
-
// Try Resend domain verification API first — avoids false negatives from
|
|
724
|
-
// raw DNS lookups (Resend uses varying DKIM selectors per domain vintage).
|
|
725
|
-
try {
|
|
726
|
-
// Resolve Resend API key: site key → project key → env
|
|
727
|
-
let apiKey = null;
|
|
728
|
-
const { data: siteRow } = await adminDb()
|
|
729
|
-
.from('sites')
|
|
730
|
-
.select('provider_api_key_enc')
|
|
731
|
-
.eq('id', site_id)
|
|
732
|
-
.eq('project_id', projectId)
|
|
733
|
-
.maybeSingle();
|
|
734
|
-
if (siteRow?.provider_api_key_enc) {
|
|
735
|
-
apiKey = decrypt(siteRow.provider_api_key_enc);
|
|
736
|
-
}
|
|
737
|
-
else {
|
|
738
|
-
const { data: projRow } = await adminDb()
|
|
739
|
-
.from('projects')
|
|
740
|
-
.select('provider_api_key_enc')
|
|
741
|
-
.eq('id', projectId)
|
|
742
|
-
.maybeSingle();
|
|
743
|
-
if (projRow?.provider_api_key_enc) {
|
|
744
|
-
apiKey = decrypt(projRow.provider_api_key_enc);
|
|
745
|
-
}
|
|
746
|
-
else if (process.env.RESEND_API_KEY) {
|
|
747
|
-
apiKey = process.env.RESEND_API_KEY;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
if (apiKey) {
|
|
751
|
-
const { Resend } = await import('resend');
|
|
752
|
-
const resend = new Resend(apiKey);
|
|
753
|
-
const { data: domainList } = await resend.domains.list();
|
|
754
|
-
const domains = domainList?.data ?? [];
|
|
755
|
-
const match = domains.find((d) => d.name === domain);
|
|
756
|
-
if (match) {
|
|
757
|
-
resendDomainStatus = match.status;
|
|
758
|
-
if (match.status === 'verified') {
|
|
759
|
-
verificationSource = 'resend_api';
|
|
760
|
-
dnsResults.spf = { found: true, verified_by: 'resend_api', note: 'Resend handles SPF authentication at infrastructure level' };
|
|
761
|
-
dnsResults.dkim = { found: true, verified_by: 'resend_api', note: 'DKIM signing managed by Resend (selector may vary)' };
|
|
762
|
-
dnsResults.dmarc = { found: true, verified_by: 'resend_api', note: 'Domain verified by Resend — DMARC alignment confirmed' };
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
catch {
|
|
768
|
-
// Resend API unavailable or key missing — fall back to DNS lookup
|
|
769
|
-
}
|
|
770
|
-
// Fall back to raw DNS checks if Resend API didn't confirm verification
|
|
771
|
-
if (verificationSource === 'dns_lookup') {
|
|
772
|
-
try {
|
|
773
|
-
const dns = await import('dns/promises');
|
|
774
|
-
try {
|
|
775
|
-
const txtRecords = await dns.resolveTxt(domain);
|
|
776
|
-
const spfRecord = txtRecords.flat().find((r) => r.startsWith('v=spf1'));
|
|
777
|
-
dnsResults.spf = { found: !!spfRecord, record: spfRecord ?? null, includes_resend: spfRecord?.includes('include:amazonses.com') || spfRecord?.includes('include:_spf.resend.com') || false };
|
|
778
|
-
}
|
|
779
|
-
catch {
|
|
780
|
-
dnsResults.spf = { found: false, error: 'No SPF record found' };
|
|
781
|
-
}
|
|
782
|
-
try {
|
|
783
|
-
const dmarcRecords = await dns.resolveTxt(`_dmarc.${domain}`);
|
|
784
|
-
const dmarcRecord = dmarcRecords.flat().find((r) => r.startsWith('v=DMARC1'));
|
|
785
|
-
const policy = dmarcRecord?.match(/p=(\w+)/)?.[1] ?? 'unknown';
|
|
786
|
-
dnsResults.dmarc = { found: !!dmarcRecord, record: dmarcRecord ?? null, policy, strict: policy === 'quarantine' || policy === 'reject' };
|
|
787
|
-
}
|
|
788
|
-
catch {
|
|
789
|
-
dnsResults.dmarc = { found: false, error: 'No DMARC record found' };
|
|
790
|
-
}
|
|
791
|
-
try {
|
|
792
|
-
const cnames = await dns.resolveCname(`resend._domainkey.${domain}`);
|
|
793
|
-
dnsResults.dkim = { found: cnames.length > 0, cname: cnames[0] ?? null };
|
|
794
|
-
}
|
|
795
|
-
catch {
|
|
796
|
-
dnsResults.dkim = { found: false, error: 'No DKIM CNAME found for resend._domainkey' };
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
catch { /* dns module unavailable */ }
|
|
800
|
-
}
|
|
801
|
-
const { data: healthRows } = await db()
|
|
721
|
+
const { data: healthRows, error: healthError } = await db().schema('email')
|
|
802
722
|
.from('domain_health')
|
|
803
|
-
.select('
|
|
723
|
+
.select('*, sites!inner(project_id)')
|
|
804
724
|
.eq('site_id', site_id)
|
|
725
|
+
.eq('sites.project_id', projectId)
|
|
805
726
|
.order('check_date', { ascending: false })
|
|
727
|
+
.order('created_at', { ascending: false }).order('id', { ascending: false })
|
|
806
728
|
.limit(14);
|
|
729
|
+
if (healthError)
|
|
730
|
+
return toolError(dbToolError(healthError, 'Unable to load domain health'));
|
|
807
731
|
const health = healthRows ?? [];
|
|
808
732
|
const latest = health[0];
|
|
733
|
+
const storedDns = storedDomainDns(latest);
|
|
734
|
+
const verificationSource = storedDns.verification_source;
|
|
735
|
+
const dnsResults = {
|
|
736
|
+
spf: { found: storedDns.spf, verified_by: storedDns.spf === true ? verificationSource : null, record: null, includes_resend: null, error: null, note: 'Stored SPF observation; raw DNS was not queried.' },
|
|
737
|
+
dkim: { found: storedDns.dkim, verified_by: storedDns.dkim === true ? verificationSource : null, cname: null, error: null, note: 'Stored DKIM observation; raw DNS was not queried.' },
|
|
738
|
+
dmarc: { found: storedDns.dmarc, verified_by: storedDns.dmarc === true ? verificationSource : null, record: null, policy: null, strict: null, error: null, note: 'Stored DMARC observation; raw DNS was not queried.' },
|
|
739
|
+
};
|
|
809
740
|
const warmupDay = latest?.warmup_day ?? null;
|
|
810
741
|
const warmupLimit = latest?.warmup_daily_limit ?? null;
|
|
811
742
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
@@ -821,18 +752,7 @@ export function registerAnalyticsTools(server) {
|
|
|
821
752
|
const complained = logs.filter(isComplainedRow).length;
|
|
822
753
|
const bounceRate = percentRate(bounced, totalSent, 8) ?? 0;
|
|
823
754
|
const complaintRate = percentRate(complained, totalSent, 8) ?? 0;
|
|
824
|
-
const risks =
|
|
825
|
-
if (verificationSource === 'dns_lookup') {
|
|
826
|
-
if (!dnsResults.spf?.found)
|
|
827
|
-
risks.push('SPF record missing');
|
|
828
|
-
if (!dnsResults.dkim?.found)
|
|
829
|
-
risks.push('DKIM not configured');
|
|
830
|
-
if (!dnsResults.dmarc?.found)
|
|
831
|
-
risks.push('DMARC record missing');
|
|
832
|
-
}
|
|
833
|
-
if (resendDomainStatus && resendDomainStatus !== 'verified') {
|
|
834
|
-
risks.push(`Resend domain status is '${resendDomainStatus}' — domain verification incomplete`);
|
|
835
|
-
}
|
|
755
|
+
const risks = storedDnsReasons(storedDns);
|
|
836
756
|
if (bounceRate > 5)
|
|
837
757
|
risks.push(`HIGH bounce rate: ${bounceRate.toFixed(1)}%`);
|
|
838
758
|
else if (bounceRate > 2)
|
|
@@ -846,7 +766,8 @@ export function registerAnalyticsTools(server) {
|
|
|
846
766
|
return j({
|
|
847
767
|
site: { slug: site.slug, domain, from_email: site.from_email },
|
|
848
768
|
verification_source: verificationSource,
|
|
849
|
-
resend_domain_status:
|
|
769
|
+
resend_domain_status: null, // Retained for compatibility; no live Resend query is made.
|
|
770
|
+
checked_at: storedDns.checked_at,
|
|
850
771
|
dns: dnsResults,
|
|
851
772
|
warmup: { day: warmupDay, daily_limit: warmupLimit, completed: warmupDay ? warmupDay >= 43 : null },
|
|
852
773
|
recent_7d: { total_sent: totalSent, bounced, complained, bounce_rate: `${bounceRate.toFixed(2)}%`, complaint_rate: `${complaintRate.toFixed(3)}%` },
|
package/build/tools/approvals.js
CHANGED
|
@@ -23,7 +23,7 @@ export function registerApprovalTools(server, options) {
|
|
|
23
23
|
});
|
|
24
24
|
server.registerTool('cancel_approval', {
|
|
25
25
|
title: 'Cancel Approval',
|
|
26
|
-
description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected;
|
|
26
|
+
description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
|
|
27
27
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
28
28
|
inputSchema: { approval_id: z.string().uuid() },
|
|
29
29
|
}, async ({ approval_id }) => {
|
|
@@ -77,14 +77,15 @@ export function registerApprovalTools(server, options) {
|
|
|
77
77
|
idempotentHint: true,
|
|
78
78
|
openWorldHint: false,
|
|
79
79
|
},
|
|
80
|
-
description: 'List unexpired
|
|
81
|
-
inputSchema: {},
|
|
82
|
-
}, async () => {
|
|
80
|
+
description: 'List unexpired, unused approval requests for this project. Defaults to pending; pass status: approved to find approved-but-unused requests, including sandbox auto-approvals, to execute or cancel. Expired and used rows are excluded.',
|
|
81
|
+
inputSchema: { status: z.enum(['pending', 'approved']).optional().describe('Default pending. Use approved to find unused approvals, including sandbox auto-approvals.') },
|
|
82
|
+
}, async ({ status }) => {
|
|
83
83
|
const { data, error } = await db()
|
|
84
84
|
.from('action_approvals')
|
|
85
85
|
.select('id, action, tier, channel, summary, status, requested_at, expires_at, decided_via, decided_by')
|
|
86
86
|
.eq('project_id', getProjectId())
|
|
87
|
-
.eq('status', 'pending')
|
|
87
|
+
.eq('status', status ?? 'pending')
|
|
88
|
+
.is('used_at', null)
|
|
88
89
|
.gt('expires_at', new Date().toISOString())
|
|
89
90
|
.order('requested_at', { ascending: false })
|
|
90
91
|
.limit(25);
|
|
@@ -135,7 +135,7 @@ export function registerAutomationTools(server) {
|
|
|
135
135
|
},
|
|
136
136
|
description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
|
|
137
137
|
inputSchema: {
|
|
138
|
-
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with
|
|
138
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
|
|
139
139
|
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
140
140
|
campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
|
|
141
141
|
site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
|
|
@@ -217,7 +217,7 @@ export function registerAutomationTools(server) {
|
|
|
217
217
|
},
|
|
218
218
|
description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
|
|
219
219
|
inputSchema: {
|
|
220
|
-
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with
|
|
220
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
|
|
221
221
|
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
222
222
|
campaign_id: z.string().uuid().describe('Triggered campaign UUID with ladder steps'),
|
|
223
223
|
next_send_at: z.string().datetime().optional().describe('First eligible send time. Defaults to now.'),
|
package/build/tools/brand-kit.js
CHANGED
|
@@ -39,7 +39,7 @@ export function registerBrandKitTools(server) {
|
|
|
39
39
|
},
|
|
40
40
|
description: 'Import raw Brand Core / Brand Brain / Content Library / Brand Kit markdown text into the current project.',
|
|
41
41
|
inputSchema: {
|
|
42
|
-
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with
|
|
42
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
|
|
43
43
|
text: z.string().min(1).describe('Raw markdown text from the Brand Core / Brand Kit companion output.'),
|
|
44
44
|
},
|
|
45
45
|
}, withApproval('import_brand_kit_text', async ({ text }) => {
|
|
@@ -5,11 +5,11 @@ import { db } from '../db.js';
|
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
7
7
|
import { validateTemplate } from '../lib/voice.js';
|
|
8
|
-
import { approvalExcerpt, assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
8
|
+
import { approvalNext, approvalExcerpt, assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
9
9
|
import { generateEmailFromBrief } from '../lib/email-generation.js';
|
|
10
10
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
11
11
|
import { logAudit } from '../audit.js';
|
|
12
|
-
import { applyCampaignTargetingFields } from './campaigns.js';
|
|
12
|
+
import { applyCampaignTargetingFields, campaignTriggerKind } from './campaigns.js';
|
|
13
13
|
import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
14
14
|
import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
|
|
15
15
|
import { dbToolError } from '../lib/db-error.js';
|
|
@@ -94,7 +94,7 @@ export function previewOf(campaigns, steps) {
|
|
|
94
94
|
export async function loadSet(projectId, campaignIds) {
|
|
95
95
|
const { data: campaigns, error } = await db()
|
|
96
96
|
.from('campaigns')
|
|
97
|
-
.select('id, name, type, segment_id, status, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
|
|
97
|
+
.select('id, name, type, segment_id, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, site_id')
|
|
98
98
|
.eq('project_id', projectId)
|
|
99
99
|
.in('id', campaignIds);
|
|
100
100
|
if (error)
|
|
@@ -343,6 +343,9 @@ export function registerCampaignSetTools(server) {
|
|
|
343
343
|
if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
|
|
344
344
|
return j({ error: 'A campaign or email changed (or was deleted) after approval. Nothing was activated. Request a new approval.', code: 'CONTENT_CHANGED_SINCE_APPROVAL' });
|
|
345
345
|
}
|
|
346
|
+
if (current.campaigns.some(c => c.type === 'triggered' && campaignTriggerKind(c) === 'manual' && !c.segment_id)) {
|
|
347
|
+
return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed' });
|
|
348
|
+
}
|
|
346
349
|
// Same content gate as the request, so a bad email can't ride through on an old approval.
|
|
347
350
|
const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
|
|
348
351
|
if (readiness.blocking.length)
|
|
@@ -375,6 +378,10 @@ export function registerCampaignSetTools(server) {
|
|
|
375
378
|
const notReady = set.campaigns.filter((c) => !['draft', 'paused'].includes(c.status));
|
|
376
379
|
if (notReady.length)
|
|
377
380
|
return j({ error: 'Only draft or paused campaigns can be activated here.', code: 'NOT_ACTIVATABLE', campaigns: notReady.map((c) => ({ id: c.id, name: c.name, status: c.status })) });
|
|
381
|
+
const triggerless = set.campaigns.filter(c => c.type === 'triggered' && campaignTriggerKind(c) === 'manual' && !c.segment_id);
|
|
382
|
+
if (triggerless.length)
|
|
383
|
+
return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed',
|
|
384
|
+
campaigns: triggerless.map(c => ({ id: c.id, name: c.name })) });
|
|
378
385
|
const empty = set.campaigns.filter((c) => !set.steps.some((s) => s.campaign_id === c.id));
|
|
379
386
|
if (empty.length)
|
|
380
387
|
return j({ error: 'Some campaigns have no emails yet.', code: 'NO_EMAILS', campaigns: empty.map((c) => ({ id: c.id, name: c.name })) });
|
|
@@ -410,7 +417,7 @@ export function registerCampaignSetTools(server) {
|
|
|
410
417
|
...approval,
|
|
411
418
|
campaigns: previewOf(set.campaigns, set.steps),
|
|
412
419
|
...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
|
|
413
|
-
next: '
|
|
420
|
+
next: approvalNext('activate_campaigns', approval),
|
|
414
421
|
});
|
|
415
422
|
});
|
|
416
423
|
}
|