@sendinel/mcp-server 1.0.51 → 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 +63 -7
- 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';
|
|
@@ -781,6 +782,28 @@ export function registerCampaignTools(server) {
|
|
|
781
782
|
emails: unsetSteps.map((s) => ({ email: s.step_order + 1, subject: s.subject })),
|
|
782
783
|
});
|
|
783
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
|
+
}
|
|
784
807
|
const approvalParams = { campaign_id, status };
|
|
785
808
|
if (!approval_id) {
|
|
786
809
|
const channelContext = currentChannelContext();
|
|
@@ -798,8 +821,9 @@ export function registerCampaignTools(server) {
|
|
|
798
821
|
campaign_name: camp?.name ?? null,
|
|
799
822
|
enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
|
|
800
823
|
trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
|
|
824
|
+
...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
|
|
801
825
|
});
|
|
802
|
-
return j(approval);
|
|
826
|
+
return j(contentWarnings.length ? { ...approval, content_warnings: contentWarnings } : approval);
|
|
803
827
|
}
|
|
804
828
|
}
|
|
805
829
|
else {
|
|
@@ -2734,25 +2758,57 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2734
2758
|
idempotentHint: true,
|
|
2735
2759
|
openWorldHint: false,
|
|
2736
2760
|
},
|
|
2737
|
-
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.',
|
|
2738
2762
|
inputSchema: {
|
|
2739
2763
|
html: z.string().describe('HTML email content to validate'),
|
|
2740
2764
|
site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
|
|
2741
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.'),
|
|
2742
2770
|
},
|
|
2743
|
-
}, 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();
|
|
2744
2773
|
let voiceConfig = null;
|
|
2745
2774
|
if (site_id) {
|
|
2746
2775
|
const { data: site } = await db()
|
|
2747
2776
|
.from('sites')
|
|
2748
2777
|
.select('brand_voice')
|
|
2749
2778
|
.eq('id', site_id)
|
|
2750
|
-
.eq('project_id',
|
|
2779
|
+
.eq('project_id', projectId)
|
|
2751
2780
|
.maybeSingle();
|
|
2752
2781
|
voiceConfig = site?.brand_voice ?? null;
|
|
2753
2782
|
}
|
|
2754
|
-
|
|
2755
|
-
|
|
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 });
|
|
2756
2812
|
});
|
|
2757
2813
|
server.registerTool('check_content_consistency', {
|
|
2758
2814
|
title: 'Check Content Consistency',
|
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",
|