@sendinel/mcp-server 1.0.50 → 1.0.52
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 +3 -2
- package/build/lib/content-sources.d.ts +62 -0
- package/build/lib/content-sources.js +111 -0
- package/build/tools/campaign-sets.js +12 -0
- package/build/tools/campaigns.js +69 -11
- package/build/tools/contacts.js +44 -7
- package/build/tools/convert-imported.js +1 -45
- package/package.json +1 -1
|
@@ -192,10 +192,11 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
192
192
|
const count = typeof display.campaign_count === 'number' ? display.campaign_count : p.campaign_ids?.length ?? 0;
|
|
193
193
|
const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
|
|
194
194
|
const names = display.campaign_names ? `: ${display.campaign_names}` : '';
|
|
195
|
-
|
|
195
|
+
const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
|
|
196
|
+
return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
|
|
196
197
|
},
|
|
197
198
|
update_campaign_status: (p) => (p.status === 'active'
|
|
198
|
-
? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}`
|
|
199
|
+
? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}${display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : ''}`
|
|
199
200
|
: `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
|
|
200
201
|
rotate_custom_webhook_secret: (p) => `Rotate custom webhook ${p.connection_id} signing secret — immediately invalidates the previous secret with no grace period`,
|
|
201
202
|
create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ContentIssue } from '@sendinel/domain';
|
|
2
|
+
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
3
|
+
export declare function eventKeysFromHistory(projectId: string, eventName: string): Promise<string[] | null>;
|
|
4
|
+
/**
|
|
5
|
+
* Contact property keys the project's contacts actually have, from the most recent contacts.
|
|
6
|
+
* complete is true only when every contact was read. Null when the lookup failed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function contactPropertyKeys(projectId: string): Promise<{
|
|
9
|
+
keys: string[];
|
|
10
|
+
complete: boolean;
|
|
11
|
+
checked: number;
|
|
12
|
+
} | null>;
|
|
13
|
+
type ContentStep = {
|
|
14
|
+
campaign_id: string;
|
|
15
|
+
step_order: number;
|
|
16
|
+
subject: string | null;
|
|
17
|
+
body_html: string | null;
|
|
18
|
+
body_text: string | null;
|
|
19
|
+
};
|
|
20
|
+
type ContentCampaign = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
site_id: string | null;
|
|
24
|
+
trigger_event_name: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type CampaignContentFinding = ContentIssue & {
|
|
27
|
+
campaign: string;
|
|
28
|
+
email: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Looks for placeholder text, links to the sending domain and merge tags with no data source in
|
|
32
|
+
* every email of the given campaigns (#3853). Placeholder text and sending-domain links are
|
|
33
|
+
* blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
|
|
34
|
+
*/
|
|
35
|
+
export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[]): Promise<{
|
|
36
|
+
blocking: CampaignContentFinding[];
|
|
37
|
+
warnings: CampaignContentFinding[];
|
|
38
|
+
lookup_errors: string[];
|
|
39
|
+
}>;
|
|
40
|
+
/** The refusal for emails with placeholder text or a link to the sending domain (#3853). */
|
|
41
|
+
export declare function contentNotReadyResponse(blocking: CampaignContentFinding[]): {
|
|
42
|
+
error: string;
|
|
43
|
+
code: string;
|
|
44
|
+
emails: {
|
|
45
|
+
campaign: string;
|
|
46
|
+
email: number;
|
|
47
|
+
kind: import("@sendinel/domain").ContentIssueKind;
|
|
48
|
+
problem: string;
|
|
49
|
+
excerpt: string;
|
|
50
|
+
}[];
|
|
51
|
+
};
|
|
52
|
+
export declare function contentWarningView(warnings: CampaignContentFinding[], lookupErrors: string[]): {
|
|
53
|
+
kind: string;
|
|
54
|
+
warning: string;
|
|
55
|
+
}[];
|
|
56
|
+
/** One line for the approval card: how many warnings and the first few. */
|
|
57
|
+
export declare function summarizeContentWarnings(warnings: Array<{
|
|
58
|
+
campaign?: string;
|
|
59
|
+
email?: number;
|
|
60
|
+
warning: string;
|
|
61
|
+
}>): string;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf } from '@sendinel/domain';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
4
|
+
export async function eventKeysFromHistory(projectId, eventName) {
|
|
5
|
+
const { data, error } = await db()
|
|
6
|
+
.from('contact_events')
|
|
7
|
+
.select('metadata')
|
|
8
|
+
.eq('project_id', projectId)
|
|
9
|
+
.eq('title', eventName)
|
|
10
|
+
.order('created_at', { ascending: false })
|
|
11
|
+
.limit(200);
|
|
12
|
+
if (error)
|
|
13
|
+
return null;
|
|
14
|
+
const keys = new Set();
|
|
15
|
+
for (const row of (data ?? [])) {
|
|
16
|
+
if (row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata))
|
|
17
|
+
for (const key of Object.keys(row.metadata))
|
|
18
|
+
keys.add(key);
|
|
19
|
+
}
|
|
20
|
+
return Array.from(keys);
|
|
21
|
+
}
|
|
22
|
+
const CONTACT_SAMPLE = 1000;
|
|
23
|
+
/**
|
|
24
|
+
* Contact property keys the project's contacts actually have, from the most recent contacts.
|
|
25
|
+
* complete is true only when every contact was read. Null when the lookup failed.
|
|
26
|
+
*/
|
|
27
|
+
export async function contactPropertyKeys(projectId) {
|
|
28
|
+
const { data, count, error } = await db()
|
|
29
|
+
.from('subscribers')
|
|
30
|
+
.select('custom_properties', { count: 'exact' })
|
|
31
|
+
.eq('project_id', projectId)
|
|
32
|
+
.order('created_at', { ascending: false })
|
|
33
|
+
.limit(CONTACT_SAMPLE);
|
|
34
|
+
if (error)
|
|
35
|
+
return null;
|
|
36
|
+
const keys = new Set();
|
|
37
|
+
for (const row of (data ?? [])) {
|
|
38
|
+
const props = row.custom_properties;
|
|
39
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
40
|
+
for (const [key, value] of Object.entries(props))
|
|
41
|
+
if (value !== null && value !== undefined && value !== '')
|
|
42
|
+
keys.add(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const checked = (data ?? []).length;
|
|
46
|
+
return { keys: Array.from(keys), complete: typeof count === 'number' && count <= checked, checked };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Looks for placeholder text, links to the sending domain and merge tags with no data source in
|
|
50
|
+
* every email of the given campaigns (#3853). Placeholder text and sending-domain links are
|
|
51
|
+
* blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
|
|
52
|
+
*/
|
|
53
|
+
export async function checkCampaignContent(projectId, campaigns, steps) {
|
|
54
|
+
const lookupErrors = [];
|
|
55
|
+
const sendingHostsBySite = new Map();
|
|
56
|
+
const allHosts = new Set();
|
|
57
|
+
const { data: sites, error: sitesErr } = await db().from('sites').select('id, sending_domain, from_email').eq('project_id', projectId);
|
|
58
|
+
if (sitesErr)
|
|
59
|
+
lookupErrors.push(`Could not read your sending domains, so links were not checked against them: ${sitesErr.message}`);
|
|
60
|
+
for (const site of (sites ?? [])) {
|
|
61
|
+
const hosts = [sendingHostOf(site.sending_domain), sendingHostOf(site.from_email)].filter((h) => Boolean(h));
|
|
62
|
+
sendingHostsBySite.set(site.id, hosts);
|
|
63
|
+
for (const host of hosts)
|
|
64
|
+
allHosts.add(host);
|
|
65
|
+
}
|
|
66
|
+
const contactProps = await contactPropertyKeys(projectId);
|
|
67
|
+
if (contactProps === null)
|
|
68
|
+
lookupErrors.push('Could not read your contacts, so merge tags that use contact properties were not confirmed.');
|
|
69
|
+
const eventKeys = new Map();
|
|
70
|
+
for (const eventName of new Set(campaigns.map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
|
|
71
|
+
const keys = await eventKeysFromHistory(projectId, eventName);
|
|
72
|
+
eventKeys.set(eventName, keys);
|
|
73
|
+
if (keys === null)
|
|
74
|
+
lookupErrors.push(`Could not read the event history for "${eventName}", so its merge tags were not confirmed.`);
|
|
75
|
+
}
|
|
76
|
+
const blocking = [];
|
|
77
|
+
const warnings = [];
|
|
78
|
+
for (const campaign of campaigns) {
|
|
79
|
+
const events = campaign.trigger_event_name ? eventKeys.get(campaign.trigger_event_name) : [];
|
|
80
|
+
const sourcedKeys = contactProps === null || events === null || events === undefined
|
|
81
|
+
? null
|
|
82
|
+
: [...events, ...contactProps.keys.map((key) => `custom.${key}`)];
|
|
83
|
+
const hosts = (campaign.site_id ? sendingHostsBySite.get(campaign.site_id) : undefined) ?? [...allHosts];
|
|
84
|
+
for (const step of steps.filter((s) => s.campaign_id === campaign.id)) {
|
|
85
|
+
for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys })) {
|
|
86
|
+
const finding = { ...issue, campaign: campaign.name, email: step.step_order + 1 };
|
|
87
|
+
(BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind) ? blocking : warnings).push(finding);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { blocking, warnings, lookup_errors: lookupErrors };
|
|
92
|
+
}
|
|
93
|
+
/** The refusal for emails with placeholder text or a link to the sending domain (#3853). */
|
|
94
|
+
export function contentNotReadyResponse(blocking) {
|
|
95
|
+
return {
|
|
96
|
+
error: 'Some emails are not ready to send. Fix the text or links below, then try again.',
|
|
97
|
+
code: 'CONTENT_NOT_READY',
|
|
98
|
+
emails: blocking.map((f) => ({ campaign: f.campaign, email: f.email, kind: f.kind, problem: f.message, excerpt: f.excerpt })),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function contentWarningView(warnings, lookupErrors) {
|
|
102
|
+
return [
|
|
103
|
+
...warnings.map((f) => ({ campaign: f.campaign, email: f.email, kind: f.kind, warning: f.message })),
|
|
104
|
+
...lookupErrors.map((message) => ({ kind: 'could_not_check', warning: message })),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
/** One line for the approval card: how many warnings and the first few. */
|
|
108
|
+
export function summarizeContentWarnings(warnings) {
|
|
109
|
+
const first = warnings.slice(0, 3).map((w) => (w.campaign ? `${w.campaign}, email ${w.email}: ${w.warning}` : w.warning));
|
|
110
|
+
return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}. ${first.join(' | ')}${warnings.length > 3 ? ` | and ${warnings.length - 3} more` : ''}`;
|
|
111
|
+
}
|
|
@@ -10,6 +10,7 @@ import { generateEmailFromBrief } from '../lib/email-generation.js';
|
|
|
10
10
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
11
11
|
import { logAudit } from '../audit.js';
|
|
12
12
|
import { applyCampaignTargetingFields } from './campaigns.js';
|
|
13
|
+
import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
13
14
|
/**
|
|
14
15
|
* Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
|
|
15
16
|
* activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
|
|
@@ -286,6 +287,10 @@ export function registerCampaignSetTools(server) {
|
|
|
286
287
|
if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
|
|
287
288
|
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' });
|
|
288
289
|
}
|
|
290
|
+
// Same content gate as the request, so a bad email can't ride through on an old approval.
|
|
291
|
+
const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
|
|
292
|
+
if (readiness.blocking.length)
|
|
293
|
+
return j(contentNotReadyResponse(readiness.blocking));
|
|
289
294
|
const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
|
|
290
295
|
if (!check.approved)
|
|
291
296
|
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
@@ -349,6 +354,11 @@ export function registerCampaignSetTools(server) {
|
|
|
349
354
|
emails: waitNotSet.map((s) => ({ campaign: set.campaigns.find((c) => c.id === s.campaign_id)?.name ?? s.campaign_id, email: s.step_order + 1, subject: s.subject })),
|
|
350
355
|
});
|
|
351
356
|
}
|
|
357
|
+
// Placeholder text and links to the sending domain never go out (#3853). Merge tags with no data source are warnings.
|
|
358
|
+
const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps);
|
|
359
|
+
if (readiness.blocking.length)
|
|
360
|
+
return j(contentNotReadyResponse(readiness.blocking));
|
|
361
|
+
const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
|
|
352
362
|
const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
|
|
353
363
|
const channelContext = currentChannelContext();
|
|
354
364
|
const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
|
|
@@ -359,10 +369,12 @@ export function registerCampaignSetTools(server) {
|
|
|
359
369
|
campaign_count: set.campaigns.length,
|
|
360
370
|
email_count: set.steps.length,
|
|
361
371
|
campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
|
|
372
|
+
...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
|
|
362
373
|
});
|
|
363
374
|
return j({
|
|
364
375
|
...approval,
|
|
365
376
|
campaigns: previewOf(set.campaigns, set.steps),
|
|
377
|
+
...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
|
|
366
378
|
next: 'Show the person the campaigns above and approve_url. After approval, call activate_campaigns with only approval_id.',
|
|
367
379
|
});
|
|
368
380
|
});
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
-
import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, readAllRows, } from '@sendinel/domain';
|
|
3
|
+
import { 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, readAllRows, } from '@sendinel/domain';
|
|
4
4
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
5
5
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
6
6
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -20,6 +20,7 @@ import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } fro
|
|
|
20
20
|
import { sendEmailForSite } from '../lib/email-provider.js';
|
|
21
21
|
import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
22
22
|
import { logAudit } from '../audit.js';
|
|
23
|
+
import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
|
|
23
24
|
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
24
25
|
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
25
26
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
@@ -714,8 +715,9 @@ export function registerCampaignTools(server) {
|
|
|
714
715
|
variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
|
|
715
716
|
survey_id: z.string().uuid().optional(),
|
|
716
717
|
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
|
|
718
|
+
skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
|
|
717
719
|
},
|
|
718
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
|
|
720
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event }) => {
|
|
719
721
|
const renderVars = {};
|
|
720
722
|
if (survey_id) {
|
|
721
723
|
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
@@ -730,7 +732,7 @@ export function registerCampaignTools(server) {
|
|
|
730
732
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
731
733
|
const { data, error } = await db()
|
|
732
734
|
.from('campaign_steps')
|
|
733
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
|
|
735
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null })
|
|
734
736
|
.select()
|
|
735
737
|
.single();
|
|
736
738
|
if (error)
|
|
@@ -780,6 +782,28 @@ export function registerCampaignTools(server) {
|
|
|
780
782
|
emails: unsetSteps.map((s) => ({ email: s.step_order + 1, subject: s.subject })),
|
|
781
783
|
});
|
|
782
784
|
}
|
|
785
|
+
// Placeholder text and links to the sending domain never go out (#3853).
|
|
786
|
+
const { data: campRow, error: campErr } = await db()
|
|
787
|
+
.from('campaigns')
|
|
788
|
+
.select('id, name, site_id, trigger_event_name')
|
|
789
|
+
.eq('id', campaign_id)
|
|
790
|
+
.eq('project_id', getProjectId())
|
|
791
|
+
.maybeSingle();
|
|
792
|
+
if (campErr)
|
|
793
|
+
return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
|
|
794
|
+
let contentWarnings = [];
|
|
795
|
+
if (campRow) {
|
|
796
|
+
const { data: contentSteps, error: contentErr } = await db()
|
|
797
|
+
.from('campaign_steps')
|
|
798
|
+
.select('campaign_id, step_order, subject, body_html, body_text')
|
|
799
|
+
.eq('campaign_id', campaign_id);
|
|
800
|
+
if (contentErr)
|
|
801
|
+
return j({ error: `Could not check the campaign's emails: ${contentErr.message}`, code: 'STEP_LOOKUP_FAILED' });
|
|
802
|
+
const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? []);
|
|
803
|
+
if (readiness.blocking.length)
|
|
804
|
+
return j(contentNotReadyResponse(readiness.blocking));
|
|
805
|
+
contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
|
|
806
|
+
}
|
|
783
807
|
const approvalParams = { campaign_id, status };
|
|
784
808
|
if (!approval_id) {
|
|
785
809
|
const channelContext = currentChannelContext();
|
|
@@ -797,8 +821,9 @@ export function registerCampaignTools(server) {
|
|
|
797
821
|
campaign_name: camp?.name ?? null,
|
|
798
822
|
enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
|
|
799
823
|
trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
|
|
824
|
+
...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
|
|
800
825
|
});
|
|
801
|
-
return j(approval);
|
|
826
|
+
return j(contentWarnings.length ? { ...approval, content_warnings: contentWarnings } : approval);
|
|
802
827
|
}
|
|
803
828
|
}
|
|
804
829
|
else {
|
|
@@ -2733,25 +2758,57 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2733
2758
|
idempotentHint: true,
|
|
2734
2759
|
openWorldHint: false,
|
|
2735
2760
|
},
|
|
2736
|
-
description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, stock phrases, excessive CTAs, missing sign-off
|
|
2761
|
+
description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
|
|
2737
2762
|
inputSchema: {
|
|
2738
2763
|
html: z.string().describe('HTML email content to validate'),
|
|
2739
2764
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
|
2740
2765
|
custom_variables: z.array(z.string()).optional().describe('Variable names (without braces) this template legitimately uses beyond the fixed known set.'),
|
|
2766
|
+
subject: z.string().optional().describe('Subject line, checked for placeholder text and merge tags.'),
|
|
2767
|
+
body_text: z.string().optional().describe('Plain text version, checked for placeholder text and links.'),
|
|
2768
|
+
trigger_event_name: z.string().min(1).optional().describe('Event that starts the campaign. Its past data keys and your contact properties are used to check merge tags.'),
|
|
2769
|
+
event_keys: z.array(z.string()).optional().describe('Data keys the trigger event sends. Merge tags not in this list, your contact properties or the built-in set are flagged.'),
|
|
2741
2770
|
},
|
|
2742
|
-
}, async ({ html, site_id, custom_variables }) => {
|
|
2771
|
+
}, async ({ html, site_id, custom_variables, subject, body_text, trigger_event_name, event_keys }) => {
|
|
2772
|
+
const projectId = getProjectId();
|
|
2743
2773
|
let voiceConfig = null;
|
|
2744
2774
|
if (site_id) {
|
|
2745
2775
|
const { data: site } = await db()
|
|
2746
2776
|
.from('sites')
|
|
2747
2777
|
.select('brand_voice')
|
|
2748
2778
|
.eq('id', site_id)
|
|
2749
|
-
.eq('project_id',
|
|
2779
|
+
.eq('project_id', projectId)
|
|
2750
2780
|
.maybeSingle();
|
|
2751
2781
|
voiceConfig = site?.brand_voice ?? null;
|
|
2752
2782
|
}
|
|
2753
|
-
|
|
2754
|
-
|
|
2783
|
+
// The brand kit's sign-off name applies to every site in the project (#3854).
|
|
2784
|
+
const { data: projectRow } = await db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
|
|
2785
|
+
// #3853: placeholder text, links to the sending domain, merge tags with no data source.
|
|
2786
|
+
const { data: siteRows } = await db().from('sites').select('id, sending_domain, from_email').eq('project_id', projectId);
|
|
2787
|
+
const sendingHosts = (siteRows ?? [])
|
|
2788
|
+
.filter((row) => !site_id || row.id === site_id)
|
|
2789
|
+
.flatMap((row) => [sendingHostOf(row.sending_domain), sendingHostOf(row.from_email)])
|
|
2790
|
+
.filter((host) => Boolean(host));
|
|
2791
|
+
let sourcedKeys;
|
|
2792
|
+
if (trigger_event_name || event_keys) {
|
|
2793
|
+
const fromHistory = trigger_event_name ? await eventKeysFromHistory(projectId, trigger_event_name) : [];
|
|
2794
|
+
const contacts = await contactPropertyKeys(projectId);
|
|
2795
|
+
sourcedKeys = fromHistory === null || contacts === null
|
|
2796
|
+
? null
|
|
2797
|
+
: [...(event_keys ?? []), ...fromHistory, ...contacts.keys.map((key) => `custom.${key}`)];
|
|
2798
|
+
}
|
|
2799
|
+
const result = validateTemplate(html, voiceConfig, {
|
|
2800
|
+
customVariables: [...(custom_variables ?? []), ...(sourcedKeys ?? [])],
|
|
2801
|
+
subject,
|
|
2802
|
+
bodyText: body_text,
|
|
2803
|
+
brandSettings: projectRow?.brand_settings ?? null,
|
|
2804
|
+
});
|
|
2805
|
+
for (const issue of findContentIssues({ subject, body_html: html, body_text }, { sendingHosts, sourcedKeys })) {
|
|
2806
|
+
if (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind))
|
|
2807
|
+
result.errors.push(`${issue.message} (${issue.excerpt})`);
|
|
2808
|
+
else if (!result.errors.some((error) => error.includes(issue.excerpt)))
|
|
2809
|
+
result.warnings.push(issue.message);
|
|
2810
|
+
}
|
|
2811
|
+
return j({ ...result, valid: result.errors.length === 0 });
|
|
2755
2812
|
});
|
|
2756
2813
|
server.registerTool('check_content_consistency', {
|
|
2757
2814
|
title: 'Check Content Consistency',
|
|
@@ -3039,7 +3096,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3039
3096
|
return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
3040
3097
|
const { data, error } = await db()
|
|
3041
3098
|
.from('campaign_steps')
|
|
3042
|
-
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, created_at, updated_at')
|
|
3099
|
+
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, created_at, updated_at')
|
|
3043
3100
|
.eq('campaign_id', campaign_id)
|
|
3044
3101
|
.order('step_order', { ascending: true });
|
|
3045
3102
|
if (error)
|
|
@@ -3065,6 +3122,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3065
3122
|
delay_hours: z.number().int().min(0).optional(),
|
|
3066
3123
|
step_order: z.number().int().min(0).optional(),
|
|
3067
3124
|
survey_id: z.string().uuid().optional(),
|
|
3125
|
+
skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
|
|
3068
3126
|
},
|
|
3069
3127
|
}, async ({ step_id, ...updates }) => {
|
|
3070
3128
|
const projectId = getProjectId();
|
|
@@ -3105,7 +3163,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3105
3163
|
.from('campaign_steps')
|
|
3106
3164
|
.update({ ...renderedUpdates, updated_at: new Date().toISOString() })
|
|
3107
3165
|
.eq('id', step_id)
|
|
3108
|
-
.select('id, step_order, subject, delay_days, delay_hours, updated_at')
|
|
3166
|
+
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, updated_at')
|
|
3109
3167
|
.single();
|
|
3110
3168
|
if (error)
|
|
3111
3169
|
return j({ error: error.message });
|
package/build/tools/contacts.js
CHANGED
|
@@ -1359,30 +1359,67 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1359
1359
|
idempotentHint: true,
|
|
1360
1360
|
openWorldHint: false,
|
|
1361
1361
|
},
|
|
1362
|
-
description: '
|
|
1362
|
+
description: 'See sent emails and their delivery status: the email history for your project, one row per message with recipient, subject, send time, status (sent, delivered, opened, clicked, bounced, complained, failed) and where it came from (source: campaign or transactional). Filter by recipient email, subject words, campaign, site, status, and a sent-between date range (sent_after / sent_before, ISO dates; a date alone covers the whole day, UTC). Newest first. Page past 200 with offset: when a filter or offset is used the answer is { emails, has_more, next_offset }; with no filters it is the plain list.',
|
|
1363
1363
|
inputSchema: {
|
|
1364
1364
|
site_id: z.string().uuid().optional(),
|
|
1365
|
-
contact_email: z.string().optional(),
|
|
1366
|
-
status: z.enum(['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed']).optional(),
|
|
1365
|
+
contact_email: z.string().optional().describe('Exact recipient email address.'),
|
|
1366
|
+
status: z.enum(['queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed', 'failed']).optional(),
|
|
1367
|
+
sent_after: z.string().optional().describe('Only emails sent at or after this time. ISO 8601; a bare date like 2026-10-15 starts at 00:00 UTC.'),
|
|
1368
|
+
sent_before: z.string().optional().describe('Only emails sent at or before this time. ISO 8601; a bare date like 2026-10-15 covers that whole day (UTC).'),
|
|
1369
|
+
campaign_id: z.string().uuid().optional().describe('Only emails sent by this campaign.'),
|
|
1370
|
+
subject_contains: z.string().min(1).max(200).optional().describe('Only emails whose subject contains these words (not case sensitive).'),
|
|
1371
|
+
offset: z.number().int().min(0).max(100000).optional().describe('How many rows to skip, to page past the first 200. Use next_offset from the last answer.'),
|
|
1367
1372
|
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
1368
1373
|
},
|
|
1369
|
-
}, async ({ site_id, contact_email, status, limit }) => {
|
|
1374
|
+
}, async ({ site_id, contact_email, status, sent_after, sent_before, campaign_id, subject_contains, offset, limit }) => {
|
|
1375
|
+
const parseBound = (raw, edge) => {
|
|
1376
|
+
const value = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}${edge === 'start' ? 'T00:00:00.000Z' : 'T23:59:59.999Z'}` : raw;
|
|
1377
|
+
const date = new Date(value);
|
|
1378
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
1379
|
+
};
|
|
1380
|
+
const after = sent_after === undefined ? undefined : parseBound(sent_after, 'start');
|
|
1381
|
+
const before = sent_before === undefined ? undefined : parseBound(sent_before, 'end');
|
|
1382
|
+
if (after === null)
|
|
1383
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: `sent_after is not a date: ${sent_after}. Use ISO 8601, e.g. 2026-10-15.`, code: 'BAD_REQUEST' }, null, 2) }] };
|
|
1384
|
+
if (before === null)
|
|
1385
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: `sent_before is not a date: ${sent_before}. Use ISO 8601, e.g. 2026-10-15.`, code: 'BAD_REQUEST' }, null, 2) }] };
|
|
1386
|
+
const pageSize = limit ?? 50;
|
|
1387
|
+
const start = offset ?? 0;
|
|
1388
|
+
const paged = offset !== undefined || after !== undefined || before !== undefined || campaign_id !== undefined || subject_contains !== undefined;
|
|
1389
|
+
// Fetch one extra row to know whether another page exists.
|
|
1370
1390
|
let q = db()
|
|
1371
1391
|
.from('email_log')
|
|
1372
|
-
.select('id, to_email, subject, status, sent_at, opened_at, clicked_at, bounced_at, site_id, campaign_id')
|
|
1392
|
+
.select('id, to_email, from_email, subject, status, category, sent_at, delivered_at, opened_at, clicked_at, bounced_at, bounce_type, complained_at, error_message, site_id, campaign_id, created_at')
|
|
1373
1393
|
.eq('project_id', getProjectId())
|
|
1374
1394
|
.order('sent_at', { ascending: false })
|
|
1375
|
-
.
|
|
1395
|
+
.order('id', { ascending: false })
|
|
1396
|
+
.range(start, start + pageSize);
|
|
1376
1397
|
if (site_id)
|
|
1377
1398
|
q = q.eq('site_id', site_id);
|
|
1378
1399
|
if (status)
|
|
1379
1400
|
q = q.eq('status', status);
|
|
1380
1401
|
if (contact_email)
|
|
1381
1402
|
q = q.eq('to_email', contact_email);
|
|
1403
|
+
if (campaign_id)
|
|
1404
|
+
q = q.eq('campaign_id', campaign_id);
|
|
1405
|
+
if (after)
|
|
1406
|
+
q = q.gte('sent_at', after);
|
|
1407
|
+
if (before)
|
|
1408
|
+
q = q.lte('sent_at', before);
|
|
1409
|
+
if (subject_contains)
|
|
1410
|
+
q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
|
|
1382
1411
|
const { data, error } = await q;
|
|
1383
1412
|
if (error)
|
|
1384
1413
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
1385
|
-
|
|
1414
|
+
const fetched = (data ?? []);
|
|
1415
|
+
const hasMore = fetched.length > pageSize;
|
|
1416
|
+
// source: where the email came from. A campaign id wins; otherwise the stored category (transactional, test, marketing).
|
|
1417
|
+
const rows = fetched.slice(0, pageSize).map((row) => ({
|
|
1418
|
+
...row,
|
|
1419
|
+
source: row.campaign_id ? 'campaign' : (row.category === 'transactional' ? 'transactional' : (row.category ?? 'other')),
|
|
1420
|
+
}));
|
|
1421
|
+
const body = paged ? { emails: rows, has_more: hasMore, next_offset: hasMore ? start + pageSize : null } : rows;
|
|
1422
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
|
|
1386
1423
|
});
|
|
1387
1424
|
server.registerTool('list_suppressions', {
|
|
1388
1425
|
title: 'List Suppressions',
|
|
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
|
|
|
5
5
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
6
6
|
import { validateTemplate } from '../lib/voice.js';
|
|
7
7
|
import { logAudit } from '../audit.js';
|
|
8
|
+
import { eventKeysFromHistory, contactPropertyKeys } from '../lib/content-sources.js';
|
|
8
9
|
import { loadSet, previewOf, writeCampaignDrafts } from './campaign-sets.js';
|
|
9
10
|
/**
|
|
10
11
|
* convert_imported_campaigns (#3689): turns campaigns imported from another platform (Customer.io
|
|
@@ -30,51 +31,6 @@ const Conversion = z.object({
|
|
|
30
31
|
event_keys: z.array(z.string()).optional().describe('Data keys the trigger event sends. Used to catch placeholders that would come out blank.'),
|
|
31
32
|
send_class: z.enum(['broadcast', 'product']).optional().describe('product for account, trial and onboarding email; broadcast for marketing.'),
|
|
32
33
|
});
|
|
33
|
-
/** Data keys seen on an event in the project's history, or null when the lookup failed. */
|
|
34
|
-
async function eventKeysFromHistory(projectId, eventName) {
|
|
35
|
-
const { data, error } = await db()
|
|
36
|
-
.from('contact_events')
|
|
37
|
-
.select('metadata')
|
|
38
|
-
.eq('project_id', projectId)
|
|
39
|
-
.eq('title', eventName)
|
|
40
|
-
.order('created_at', { ascending: false })
|
|
41
|
-
.limit(200);
|
|
42
|
-
if (error)
|
|
43
|
-
return null;
|
|
44
|
-
const keys = new Set();
|
|
45
|
-
for (const row of (data ?? [])) {
|
|
46
|
-
if (row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata))
|
|
47
|
-
for (const key of Object.keys(row.metadata))
|
|
48
|
-
keys.add(key);
|
|
49
|
-
}
|
|
50
|
-
return Array.from(keys);
|
|
51
|
-
}
|
|
52
|
-
const CONTACT_SAMPLE = 1000;
|
|
53
|
-
/**
|
|
54
|
-
* Contact property keys the project's contacts actually have, from the most recent contacts.
|
|
55
|
-
* complete is true only when every contact was read. Null when the lookup failed.
|
|
56
|
-
*/
|
|
57
|
-
async function contactPropertyKeys(projectId) {
|
|
58
|
-
const { data, count, error } = await db()
|
|
59
|
-
.from('subscribers')
|
|
60
|
-
.select('custom_properties', { count: 'exact' })
|
|
61
|
-
.eq('project_id', projectId)
|
|
62
|
-
.order('created_at', { ascending: false })
|
|
63
|
-
.limit(CONTACT_SAMPLE);
|
|
64
|
-
if (error)
|
|
65
|
-
return null;
|
|
66
|
-
const keys = new Set();
|
|
67
|
-
for (const row of (data ?? [])) {
|
|
68
|
-
const props = row.custom_properties;
|
|
69
|
-
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
70
|
-
for (const [key, value] of Object.entries(props))
|
|
71
|
-
if (value !== null && value !== undefined && value !== '')
|
|
72
|
-
keys.add(key);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const checked = (data ?? []).length;
|
|
76
|
-
return { keys: Array.from(keys), complete: typeof count === 'number' && count <= checked, checked };
|
|
77
|
-
}
|
|
78
34
|
function reviewView(plan) {
|
|
79
35
|
return {
|
|
80
36
|
source: { campaign_id: plan.source_campaign_id, name: plan.source_name },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.52",
|
|
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",
|