@sendinel/mcp-server 1.0.7 → 1.0.8
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/composer-blocks.d.ts +116 -0
- package/build/lib/composer-blocks.js +187 -0
- package/build/tools/campaigns.js +90 -14
- package/build/tools/compound.js +33 -4
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export type ComposerBlockType = 'text' | 'heading' | 'button' | 'columns' | 'image' | 'social' | 'divider' | 'spacer' | 'unsubscribe';
|
|
2
|
+
export type ComposerBlock = {
|
|
3
|
+
id?: string;
|
|
4
|
+
type: ComposerBlockType;
|
|
5
|
+
content?: string;
|
|
6
|
+
align?: 'left' | 'center' | 'right';
|
|
7
|
+
level?: 1 | 2 | 3;
|
|
8
|
+
label?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
bg_color?: string;
|
|
11
|
+
text_color?: string;
|
|
12
|
+
border_radius?: number;
|
|
13
|
+
padding?: string;
|
|
14
|
+
left?: string;
|
|
15
|
+
right?: string;
|
|
16
|
+
split?: '50/50' | '60/40' | '40/60';
|
|
17
|
+
alt?: string;
|
|
18
|
+
width?: number | null;
|
|
19
|
+
brand_asset_id?: string | null;
|
|
20
|
+
links?: Array<{
|
|
21
|
+
platform: string;
|
|
22
|
+
url: string;
|
|
23
|
+
}>;
|
|
24
|
+
height?: number;
|
|
25
|
+
};
|
|
26
|
+
export declare const BLOCK_GENERATION_SCHEMA: {
|
|
27
|
+
readonly type: "array";
|
|
28
|
+
readonly minItems: 2;
|
|
29
|
+
readonly maxItems: 12;
|
|
30
|
+
readonly items: {
|
|
31
|
+
readonly type: "object";
|
|
32
|
+
readonly additionalProperties: false;
|
|
33
|
+
readonly properties: {
|
|
34
|
+
readonly type: {
|
|
35
|
+
readonly enum: readonly ["text", "heading", "button", "columns", "image", "social", "divider", "spacer", "unsubscribe"];
|
|
36
|
+
};
|
|
37
|
+
readonly content: {
|
|
38
|
+
readonly type: "string";
|
|
39
|
+
};
|
|
40
|
+
readonly align: {
|
|
41
|
+
readonly enum: readonly ["left", "center", "right"];
|
|
42
|
+
};
|
|
43
|
+
readonly level: {
|
|
44
|
+
readonly enum: readonly [1, 2, 3];
|
|
45
|
+
};
|
|
46
|
+
readonly label: {
|
|
47
|
+
readonly type: "string";
|
|
48
|
+
};
|
|
49
|
+
readonly url: {
|
|
50
|
+
readonly type: "string";
|
|
51
|
+
};
|
|
52
|
+
readonly bg_color: {
|
|
53
|
+
readonly type: "string";
|
|
54
|
+
};
|
|
55
|
+
readonly text_color: {
|
|
56
|
+
readonly type: "string";
|
|
57
|
+
};
|
|
58
|
+
readonly border_radius: {
|
|
59
|
+
readonly type: "number";
|
|
60
|
+
readonly minimum: 0;
|
|
61
|
+
readonly maximum: 24;
|
|
62
|
+
};
|
|
63
|
+
readonly padding: {
|
|
64
|
+
readonly type: "string";
|
|
65
|
+
};
|
|
66
|
+
readonly left: {
|
|
67
|
+
readonly type: "string";
|
|
68
|
+
};
|
|
69
|
+
readonly right: {
|
|
70
|
+
readonly type: "string";
|
|
71
|
+
};
|
|
72
|
+
readonly split: {
|
|
73
|
+
readonly enum: readonly ["50/50", "60/40", "40/60"];
|
|
74
|
+
};
|
|
75
|
+
readonly alt: {
|
|
76
|
+
readonly type: "string";
|
|
77
|
+
};
|
|
78
|
+
readonly width: {
|
|
79
|
+
readonly type: readonly ["number", "null"];
|
|
80
|
+
readonly minimum: 1;
|
|
81
|
+
readonly maximum: 1200;
|
|
82
|
+
};
|
|
83
|
+
readonly brand_asset_id: {
|
|
84
|
+
readonly type: readonly ["string", "null"];
|
|
85
|
+
};
|
|
86
|
+
readonly links: {
|
|
87
|
+
readonly type: "array";
|
|
88
|
+
readonly maxItems: 8;
|
|
89
|
+
readonly items: {
|
|
90
|
+
readonly type: "object";
|
|
91
|
+
readonly additionalProperties: false;
|
|
92
|
+
readonly properties: {
|
|
93
|
+
readonly platform: {
|
|
94
|
+
readonly type: "string";
|
|
95
|
+
};
|
|
96
|
+
readonly url: {
|
|
97
|
+
readonly type: "string";
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
readonly required: readonly ["platform", "url"];
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
readonly height: {
|
|
104
|
+
readonly type: "number";
|
|
105
|
+
readonly minimum: 4;
|
|
106
|
+
readonly maximum: 96;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
readonly required: readonly ["type"];
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
export declare function normalizeComposerBlocks(raw: unknown): ComposerBlock[];
|
|
113
|
+
export declare function blocksToHtml(blocks: ComposerBlock[], ctx?: {
|
|
114
|
+
preheader?: string | null;
|
|
115
|
+
}): string;
|
|
116
|
+
export declare function blocksToText(blocks: ComposerBlock[]): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
export const BLOCK_GENERATION_SCHEMA = {
|
|
2
|
+
type: 'array',
|
|
3
|
+
minItems: 2,
|
|
4
|
+
maxItems: 12,
|
|
5
|
+
items: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
additionalProperties: false,
|
|
8
|
+
properties: {
|
|
9
|
+
type: { enum: ['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe'] },
|
|
10
|
+
content: { type: 'string' },
|
|
11
|
+
align: { enum: ['left', 'center', 'right'] },
|
|
12
|
+
level: { enum: [1, 2, 3] },
|
|
13
|
+
label: { type: 'string' },
|
|
14
|
+
url: { type: 'string' },
|
|
15
|
+
bg_color: { type: 'string' },
|
|
16
|
+
text_color: { type: 'string' },
|
|
17
|
+
border_radius: { type: 'number', minimum: 0, maximum: 24 },
|
|
18
|
+
padding: { type: 'string' },
|
|
19
|
+
left: { type: 'string' },
|
|
20
|
+
right: { type: 'string' },
|
|
21
|
+
split: { enum: ['50/50', '60/40', '40/60'] },
|
|
22
|
+
alt: { type: 'string' },
|
|
23
|
+
width: { type: ['number', 'null'], minimum: 1, maximum: 1200 },
|
|
24
|
+
brand_asset_id: { type: ['string', 'null'] },
|
|
25
|
+
links: {
|
|
26
|
+
type: 'array',
|
|
27
|
+
maxItems: 8,
|
|
28
|
+
items: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
platform: { type: 'string' },
|
|
33
|
+
url: { type: 'string' },
|
|
34
|
+
},
|
|
35
|
+
required: ['platform', 'url'],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
height: { type: 'number', minimum: 4, maximum: 96 },
|
|
39
|
+
},
|
|
40
|
+
required: ['type'],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const BLOCK_TYPES = new Set(['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe']);
|
|
44
|
+
const HEADING_SIZES = { 1: 32, 2: 24, 3: 18 };
|
|
45
|
+
const SOCIAL_LABELS = { twitter: 'Twitter / X', x: 'Twitter / X', linkedin: 'LinkedIn', facebook: 'Facebook', instagram: 'Instagram', youtube: 'YouTube', website: 'Website' };
|
|
46
|
+
function cleanText(value, max = 2000) {
|
|
47
|
+
return typeof value === 'string' ? value.replace(/\u2014/g, ' - ').trim().slice(0, max) : '';
|
|
48
|
+
}
|
|
49
|
+
function escapeHtml(value) {
|
|
50
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
51
|
+
}
|
|
52
|
+
function escapeAttr(value) {
|
|
53
|
+
return escapeHtml(value).replace(/`/g, '`');
|
|
54
|
+
}
|
|
55
|
+
function cleanAlign(value) {
|
|
56
|
+
return value === 'center' || value === 'right' ? value : 'left';
|
|
57
|
+
}
|
|
58
|
+
function cleanUrl(value) {
|
|
59
|
+
const url = cleanText(value, 2000);
|
|
60
|
+
if (/^https?:\/\//i.test(url))
|
|
61
|
+
return url;
|
|
62
|
+
if (/^mailto:[^\s<>"]+@[^\s<>"]+$/i.test(url))
|
|
63
|
+
return url;
|
|
64
|
+
if (/^\{\{[A-Za-z0-9_.-]+\}\}$/.test(url))
|
|
65
|
+
return url;
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
function cleanImageUrl(value) {
|
|
69
|
+
const url = cleanText(value, 2000);
|
|
70
|
+
return /^https:\/\//i.test(url) ? url : '';
|
|
71
|
+
}
|
|
72
|
+
function cleanColor(value, fallback) {
|
|
73
|
+
const color = cleanText(value, 32);
|
|
74
|
+
return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(color) ? color : fallback;
|
|
75
|
+
}
|
|
76
|
+
function cleanPadding(value) {
|
|
77
|
+
const padding = cleanText(value, 80);
|
|
78
|
+
return /^([0-9]{1,2}px)(\s+[0-9]{1,2}px){0,3}$/.test(padding) ? padding : '12px 28px';
|
|
79
|
+
}
|
|
80
|
+
function cleanRichText(value, max = 2000) {
|
|
81
|
+
return cleanText(value, max)
|
|
82
|
+
.replace(/<\s*(script|style)[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
83
|
+
.replace(/<a\b[^>]*\bhref\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (tag, _quote, href, label) => cleanUrl(href) ? tag : String(label))
|
|
84
|
+
.replace(/<\s*\/?\s*([a-z0-9]+)([^>]*)>/gi, (tag, rawName, rawAttrs) => {
|
|
85
|
+
const name = String(rawName).toLowerCase();
|
|
86
|
+
if (['strong', 'b', 'em', 'i', 'u'].includes(name))
|
|
87
|
+
return tag.startsWith('</') ? `</${name}>` : `<${name}>`;
|
|
88
|
+
if (name === 'br')
|
|
89
|
+
return '<br>';
|
|
90
|
+
if (name === 'a') {
|
|
91
|
+
if (tag.startsWith('</'))
|
|
92
|
+
return '</a>';
|
|
93
|
+
const href = cleanUrl(String(rawAttrs).match(/\bhref\s*=\s*(["'])(.*?)\1/i)?.[2]);
|
|
94
|
+
return href ? `<a href="${escapeAttr(href)}" target="_blank" style="color:var(--color-editor-link)">` : '';
|
|
95
|
+
}
|
|
96
|
+
return escapeHtml(tag);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function splitWidths(split) {
|
|
100
|
+
if (split === '60/40')
|
|
101
|
+
return [60, 40];
|
|
102
|
+
if (split === '40/60')
|
|
103
|
+
return [40, 60];
|
|
104
|
+
return [50, 50];
|
|
105
|
+
}
|
|
106
|
+
export function normalizeComposerBlocks(raw) {
|
|
107
|
+
if (!Array.isArray(raw))
|
|
108
|
+
return [];
|
|
109
|
+
return raw.slice(0, 12).flatMap((item, index) => {
|
|
110
|
+
if (!item || typeof item !== 'object' || !BLOCK_TYPES.has(String(item.type)))
|
|
111
|
+
return [];
|
|
112
|
+
const block = item;
|
|
113
|
+
const type = String(block.type);
|
|
114
|
+
const base = { id: cleanText(block.id, 80) || `ai-block-${index + 1}`, type };
|
|
115
|
+
if (type === 'text')
|
|
116
|
+
return [{ ...base, content: cleanRichText(block.content), align: cleanAlign(block.align) }];
|
|
117
|
+
if (type === 'heading')
|
|
118
|
+
return [{ ...base, content: cleanText(block.content, 300), level: ([1, 2, 3].includes(Number(block.level)) ? Number(block.level) : 2), align: cleanAlign(block.align) }];
|
|
119
|
+
if (type === 'button')
|
|
120
|
+
return [{ ...base, label: cleanText(block.label, 120) || 'Learn more', url: cleanUrl(block.url) || '{{cta_url}}', align: cleanAlign(block.align) === 'left' ? 'center' : cleanAlign(block.align), bg_color: cleanColor(block.bg_color, '#00BFFF'), text_color: cleanColor(block.text_color, '#ffffff'), border_radius: Math.max(0, Math.min(24, Number(block.border_radius ?? 6))), padding: cleanPadding(block.padding) }];
|
|
121
|
+
if (type === 'columns')
|
|
122
|
+
return [{ ...base, left: cleanRichText(block.left), right: cleanRichText(block.right), split: block.split === '60/40' || block.split === '40/60' ? block.split : '50/50' }];
|
|
123
|
+
if (type === 'image') {
|
|
124
|
+
const url = cleanImageUrl(block.url);
|
|
125
|
+
return url ? [{ ...base, url, alt: cleanText(block.alt, 300), width: block.width == null ? null : Math.max(1, Math.min(1200, Number(block.width))), brand_asset_id: cleanText(block.brand_asset_id, 80) || null }] : [];
|
|
126
|
+
}
|
|
127
|
+
if (type === 'social') {
|
|
128
|
+
const links = Array.isArray(block.links)
|
|
129
|
+
? block.links.flatMap((link) => {
|
|
130
|
+
if (!link || typeof link !== 'object')
|
|
131
|
+
return [];
|
|
132
|
+
const platform = cleanText(link.platform, 80).toLowerCase();
|
|
133
|
+
const url = cleanUrl(link.url);
|
|
134
|
+
return platform && url ? [{ platform, url }] : [];
|
|
135
|
+
})
|
|
136
|
+
: [];
|
|
137
|
+
return [{ ...base, align: cleanAlign(block.align), links }];
|
|
138
|
+
}
|
|
139
|
+
if (type === 'spacer')
|
|
140
|
+
return [{ ...base, height: Math.max(4, Math.min(96, Number(block.height ?? 24))) }];
|
|
141
|
+
return [base];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export function blocksToHtml(blocks, ctx) {
|
|
145
|
+
const body = normalizeComposerBlocks(blocks).map((b) => {
|
|
146
|
+
if (b.type === 'text')
|
|
147
|
+
return String(b.content ?? '').split(/\n\n+/).filter((p) => p.trim()).map((p) => p.trim() === '---' ? '<hr style="border:none;border-top:1px solid #374151;margin:24px 0;" />' : `<p style="margin-bottom:16px;line-height:1.6;text-align:${cleanAlign(b.align)};">${p.trim()}</p>`).join('\n');
|
|
148
|
+
if (b.type === 'heading') {
|
|
149
|
+
const level = [1, 2, 3].includes(Number(b.level)) ? Number(b.level) : 2;
|
|
150
|
+
return `<h${level} style="font-family:Arial,sans-serif;font-size:${HEADING_SIZES[level]}px;font-weight:700;line-height:1.2;margin:0 0 16px;color:#111827;text-align:${cleanAlign(b.align)};">${escapeHtml(b.content || 'Your heading here')}</h${level}>`;
|
|
151
|
+
}
|
|
152
|
+
if (b.type === 'button')
|
|
153
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="${cleanAlign(b.align || 'center')}"><a href="${escapeAttr(b.url || '{{cta_url}}')}" target="_blank" style="display:inline-block;padding:${escapeAttr(b.padding || '12px 28px')};background:${escapeAttr(b.bg_color || '#00BFFF')};color:${escapeAttr(b.text_color || '#ffffff')};font-weight:600;text-decoration:none;border-radius:${Math.max(0, Math.min(24, Number(b.border_radius ?? 6)))}px;font-family:Arial,sans-serif;font-size:15px;line-height:1;">${escapeHtml(b.label || 'Get Started')}</a></td></tr></table>`;
|
|
154
|
+
if (b.type === 'columns') {
|
|
155
|
+
const [left, right] = splitWidths(b.split);
|
|
156
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="${left}%" valign="top" style="padding-right:12px;">${b.left || ''}</td><td width="${right}%" valign="top" style="padding-left:12px;">${b.right || ''}</td></tr></table>`;
|
|
157
|
+
}
|
|
158
|
+
if (b.type === 'social') {
|
|
159
|
+
const items = (b.links ?? []).map((link) => `<a href="${escapeAttr(link.url)}" target="_blank" style="display:inline-block;margin:0 6px;color:#6b7280;font-size:12px;text-decoration:none;font-family:Arial,sans-serif;">${escapeHtml(SOCIAL_LABELS[link.platform] || link.platform)}</a>`).join('');
|
|
160
|
+
return items ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="${cleanAlign(b.align || 'center')}" style="padding:16px 0;">${items}</td></tr></table>` : '';
|
|
161
|
+
}
|
|
162
|
+
if (b.type === 'divider')
|
|
163
|
+
return '<hr style="border:none;border-top:1px solid #374151;margin:24px 0;" />';
|
|
164
|
+
if (b.type === 'spacer')
|
|
165
|
+
return `<div style="height:${Number(b.height || 20)}px;"></div>`;
|
|
166
|
+
if (b.type === 'image' && b.url)
|
|
167
|
+
return `<img src="${escapeAttr(b.url)}" alt="${escapeAttr(b.alt || '')}"${b.brand_asset_id ? ` data-brand-asset-id="${escapeAttr(b.brand_asset_id)}"` : ''} style="max-width:100%;height:auto;display:block;${b.width ? `width:${Number(b.width)}px;` : ''}margin-bottom:16px;" />`;
|
|
168
|
+
if (b.type === 'unsubscribe')
|
|
169
|
+
return '<p style="margin-bottom:0;font-size:12px;color:#6b7280;text-align:center;"><a href="{{unsubscribe_url}}" style="color:#6b7280;">Unsubscribe</a></p>';
|
|
170
|
+
return '';
|
|
171
|
+
}).join('\n');
|
|
172
|
+
const preheader = ctx?.preheader ? `<span style="display:none!important;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(ctx.preheader)}</span>` : '';
|
|
173
|
+
return `${preheader}${body}`;
|
|
174
|
+
}
|
|
175
|
+
export function blocksToText(blocks) {
|
|
176
|
+
return normalizeComposerBlocks(blocks).map((block) => {
|
|
177
|
+
if (block.type === 'heading' || block.type === 'text')
|
|
178
|
+
return block.content;
|
|
179
|
+
if (block.type === 'button')
|
|
180
|
+
return [block.label, block.url].filter(Boolean).join(': ');
|
|
181
|
+
if (block.type === 'columns')
|
|
182
|
+
return [block.left, block.right].filter(Boolean).join('\n\n');
|
|
183
|
+
if (block.type === 'image')
|
|
184
|
+
return block.alt ? `[Image: ${block.alt}]` : '';
|
|
185
|
+
return '';
|
|
186
|
+
}).filter(Boolean).join('\n\n').replace(/<[^>]+>/g, ' ').replace(/\s+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
|
|
187
|
+
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sendEmailForSite } from '../lib/email-provider.js';
|
|
|
12
12
|
import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
13
13
|
import { logAudit } from '../audit.js';
|
|
14
14
|
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
15
|
+
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
15
16
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
16
17
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
17
18
|
function normalizeTriggerValue(value) {
|
|
@@ -444,13 +445,14 @@ export function registerCampaignTools(server) {
|
|
|
444
445
|
subject: z.string().min(1),
|
|
445
446
|
body_html: z.string().optional(),
|
|
446
447
|
body_text: z.string().optional(),
|
|
448
|
+
blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
|
|
447
449
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
448
450
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
449
451
|
variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
|
|
450
452
|
survey_id: z.string().uuid().optional(),
|
|
451
453
|
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
|
|
452
454
|
},
|
|
453
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
|
|
455
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
|
|
454
456
|
const renderVars = {};
|
|
455
457
|
if (survey_id) {
|
|
456
458
|
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
@@ -459,11 +461,13 @@ export function registerCampaignTools(server) {
|
|
|
459
461
|
renderVars.survey_link = surveyLink;
|
|
460
462
|
}
|
|
461
463
|
const renderedSubject = survey_id ? renderTemplateString(subject, renderVars) : subject;
|
|
462
|
-
const
|
|
464
|
+
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
465
|
+
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
466
|
+
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
463
467
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateString(body_text, renderVars) : body_text;
|
|
464
468
|
const { data, error } = await db()
|
|
465
469
|
.from('campaign_steps')
|
|
466
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
|
|
470
|
+
.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' })
|
|
467
471
|
.select()
|
|
468
472
|
.single();
|
|
469
473
|
if (error)
|
|
@@ -956,6 +960,7 @@ export function registerCampaignTools(server) {
|
|
|
956
960
|
subject: s.subject,
|
|
957
961
|
body_html: s.body_html,
|
|
958
962
|
body_text: s.body_text,
|
|
963
|
+
blocks: s.blocks ?? null,
|
|
959
964
|
delay_days: s.delay_days,
|
|
960
965
|
delay_hours: s.delay_hours,
|
|
961
966
|
}));
|
|
@@ -973,14 +978,15 @@ export function registerCampaignTools(server) {
|
|
|
973
978
|
idempotentHint: true,
|
|
974
979
|
openWorldHint: true,
|
|
975
980
|
},
|
|
976
|
-
description: 'Generate email content
|
|
981
|
+
description: 'Generate email content as editable Composer blocks plus rendered HTML/plain text from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
|
|
977
982
|
inputSchema: {
|
|
978
983
|
site_id: z.string().uuid(),
|
|
979
984
|
brief: z.string().min(1).describe('Description of the email to generate'),
|
|
980
985
|
format: z.enum(['both', 'html', 'text']).optional().describe('Output format, default both'),
|
|
981
986
|
contact_id: z.string().uuid().optional().describe('Optional contact UUID — when provided, AI adapts tone based on their engagement level and history'),
|
|
987
|
+
content_recommendation: z.unknown().optional().describe('Optional topic evidence from get_campaign_content_recommendations or another recommendation source.'),
|
|
982
988
|
},
|
|
983
|
-
}, async ({ site_id, brief, format = 'both', contact_id }) => {
|
|
989
|
+
}, async ({ site_id, brief, format = 'both', contact_id, content_recommendation }) => {
|
|
984
990
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
985
991
|
if (!apiKey)
|
|
986
992
|
return txt('Error: ANTHROPIC_API_KEY not configured');
|
|
@@ -1012,7 +1018,26 @@ export function registerCampaignTools(server) {
|
|
|
1012
1018
|
? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name} <${site.from_email}>`
|
|
1013
1019
|
: 'No brand info available';
|
|
1014
1020
|
const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
|
|
1015
|
-
let systemPrompt = buildEmailSystemPrompt(brandContext, voicePrompt)
|
|
1021
|
+
let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt)}
|
|
1022
|
+
|
|
1023
|
+
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1024
|
+
{
|
|
1025
|
+
"execution_trace": object,
|
|
1026
|
+
"conclusion": {
|
|
1027
|
+
"subject": string,
|
|
1028
|
+
"preview_text": string,
|
|
1029
|
+
"blocks": ComposerBlock[],
|
|
1030
|
+
"body_text": string,
|
|
1031
|
+
"cta_text": string,
|
|
1032
|
+
"reasoning_log": string,
|
|
1033
|
+
"brand_voice_compliance": "PASS" | string
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
ComposerBlock[] must conform to this JSON Schema:
|
|
1038
|
+
${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
|
|
1039
|
+
|
|
1040
|
+
Do not include body_html in your response; Sendinel renders body_html from blocks.`;
|
|
1016
1041
|
// Inject per-contact context when available
|
|
1017
1042
|
if (contact) {
|
|
1018
1043
|
const lastActivity = contact.last_opened_at
|
|
@@ -1035,14 +1060,28 @@ Adapt your tone accordingly:
|
|
|
1035
1060
|
const anthropic = new Anthropic({ apiKey });
|
|
1036
1061
|
const genResponse = await anthropic.messages.create({
|
|
1037
1062
|
model: getAnthropicModelId(),
|
|
1038
|
-
max_tokens:
|
|
1063
|
+
max_tokens: 3000,
|
|
1039
1064
|
system: systemPrompt,
|
|
1040
|
-
messages: [{ role: 'user', content: brief }],
|
|
1065
|
+
messages: [{ role: 'user', content: content_recommendation ? `${brief}\n\nContent recommendation evidence:\n${typeof content_recommendation === 'string' ? content_recommendation : JSON.stringify(content_recommendation, null, 2)}` : brief }],
|
|
1041
1066
|
});
|
|
1042
1067
|
const textBlock = genResponse.content.find((b) => b.type === 'text');
|
|
1043
1068
|
const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
|
|
1044
1069
|
try {
|
|
1045
|
-
const
|
|
1070
|
+
const parsed = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
|
|
1071
|
+
const conclusion = parsed.conclusion ?? parsed;
|
|
1072
|
+
const blocks = normalizeComposerBlocks(conclusion.blocks);
|
|
1073
|
+
if (!blocks.length)
|
|
1074
|
+
return txt(`Error: Generated email did not include Composer blocks. AI returned: ${raw.slice(0, 200)}`);
|
|
1075
|
+
const result = {
|
|
1076
|
+
subject: conclusion.subject,
|
|
1077
|
+
preview_text: conclusion.preview_text,
|
|
1078
|
+
body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
|
|
1079
|
+
body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
|
|
1080
|
+
blocks,
|
|
1081
|
+
cta_text: conclusion.cta_text,
|
|
1082
|
+
reasoning_log: conclusion.reasoning_log,
|
|
1083
|
+
brand_voice_compliance: conclusion.brand_voice_compliance,
|
|
1084
|
+
};
|
|
1046
1085
|
if (format === 'html')
|
|
1047
1086
|
delete result.body_text;
|
|
1048
1087
|
if (format === 'text')
|
|
@@ -1283,13 +1322,32 @@ Adapt your tone accordingly:
|
|
|
1283
1322
|
.maybeSingle();
|
|
1284
1323
|
const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
|
|
1285
1324
|
const voicePrompt = buildVoicePrompt(site?.brand_voice);
|
|
1286
|
-
const systemPrompt = buildEmailSystemPrompt(brandCtx, voicePrompt)
|
|
1325
|
+
const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt)}
|
|
1326
|
+
|
|
1327
|
+
Generate structured Composer blocks first. Return ONLY valid JSON with:
|
|
1328
|
+
{
|
|
1329
|
+
"execution_trace": object,
|
|
1330
|
+
"conclusion": {
|
|
1331
|
+
"subject": string,
|
|
1332
|
+
"preview_text": string,
|
|
1333
|
+
"blocks": ComposerBlock[],
|
|
1334
|
+
"body_text": string,
|
|
1335
|
+
"cta_text": string,
|
|
1336
|
+
"reasoning_log": string,
|
|
1337
|
+
"brand_voice_compliance": "PASS" | string
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
ComposerBlock[] must conform to this JSON Schema:
|
|
1342
|
+
${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
|
|
1343
|
+
|
|
1344
|
+
Do not include body_html in your response; Sendinel renders body_html from blocks.`;
|
|
1287
1345
|
const steps = [];
|
|
1288
1346
|
for (let i = 0; i < emails.length; i++) {
|
|
1289
1347
|
const email = emails[i];
|
|
1290
1348
|
const genResp = await anthropic.messages.create({
|
|
1291
1349
|
model: getAnthropicModelId(),
|
|
1292
|
-
max_tokens:
|
|
1350
|
+
max_tokens: 3000,
|
|
1293
1351
|
system: systemPrompt,
|
|
1294
1352
|
messages: [{ role: 'user', content: email.brief }],
|
|
1295
1353
|
});
|
|
@@ -1297,7 +1355,17 @@ Adapt your tone accordingly:
|
|
|
1297
1355
|
const rawEmail = gtb && 'text' in gtb ? gtb.text : '{}';
|
|
1298
1356
|
let emailContent;
|
|
1299
1357
|
try {
|
|
1300
|
-
|
|
1358
|
+
const parsed = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
|
|
1359
|
+
const conclusion = parsed.conclusion ?? parsed;
|
|
1360
|
+
const blocks = normalizeComposerBlocks(conclusion.blocks);
|
|
1361
|
+
emailContent = blocks.length
|
|
1362
|
+
? {
|
|
1363
|
+
subject: conclusion.subject,
|
|
1364
|
+
body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
|
|
1365
|
+
body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
|
|
1366
|
+
blocks,
|
|
1367
|
+
}
|
|
1368
|
+
: parsed;
|
|
1301
1369
|
}
|
|
1302
1370
|
catch {
|
|
1303
1371
|
emailContent = { subject: `Step ${i + 1}`, body_html: '<p>' + email.brief + '</p>', body_text: email.brief };
|
|
@@ -1306,7 +1374,8 @@ Adapt your tone accordingly:
|
|
|
1306
1374
|
.from('campaign_steps')
|
|
1307
1375
|
.insert({
|
|
1308
1376
|
campaign_id: campaign.id, step_order: i,
|
|
1309
|
-
subject: emailContent.subject, body_html: emailContent.body_html, body_text: emailContent.body_text,
|
|
1377
|
+
subject: String(emailContent.subject ?? `Step ${i + 1}`), body_html: emailContent.body_html, body_text: emailContent.body_text,
|
|
1378
|
+
blocks: Array.isArray(emailContent.blocks) ? emailContent.blocks : null,
|
|
1310
1379
|
delay_days: email.delay_days ?? 0, delay_hours: email.delay_hours ?? 0,
|
|
1311
1380
|
})
|
|
1312
1381
|
.select('id, step_order, subject')
|
|
@@ -1716,6 +1785,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
1716
1785
|
subject: z.string().min(1).describe('Email subject line'),
|
|
1717
1786
|
body_html: z.string().min(1).describe('Full HTML email body'),
|
|
1718
1787
|
body_text: z.string().optional().describe('Plain text fallback'),
|
|
1788
|
+
blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
|
|
1719
1789
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
1720
1790
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
1721
1791
|
})).min(1).describe('Email steps with pre-built content'),
|
|
@@ -1813,6 +1883,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
1813
1883
|
subject: s.subject,
|
|
1814
1884
|
body_html: s.body_html,
|
|
1815
1885
|
body_text: s.body_text ?? null,
|
|
1886
|
+
blocks: s.blocks ? normalizeComposerBlocks(s.blocks) : null,
|
|
1816
1887
|
delay_days: s.delay_days ?? 0,
|
|
1817
1888
|
delay_hours: s.delay_hours ?? 0,
|
|
1818
1889
|
}));
|
|
@@ -1906,7 +1977,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
1906
1977
|
return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
1907
1978
|
const { data, error } = await db()
|
|
1908
1979
|
.from('campaign_steps')
|
|
1909
|
-
.select('id, step_order, subject, body_html, body_text, delay_days, delay_hours, created_at, updated_at')
|
|
1980
|
+
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, created_at, updated_at')
|
|
1910
1981
|
.eq('campaign_id', campaign_id)
|
|
1911
1982
|
.order('step_order', { ascending: true });
|
|
1912
1983
|
if (error)
|
|
@@ -1927,6 +1998,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
1927
1998
|
subject: z.string().optional(),
|
|
1928
1999
|
body_html: z.string().optional(),
|
|
1929
2000
|
body_text: z.string().optional(),
|
|
2001
|
+
blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
|
|
1930
2002
|
delay_days: z.number().int().min(0).optional(),
|
|
1931
2003
|
delay_hours: z.number().int().min(0).optional(),
|
|
1932
2004
|
step_order: z.number().int().min(0).optional(),
|
|
@@ -1957,8 +2029,12 @@ Return ONLY valid JSON with this exact shape:
|
|
|
1957
2029
|
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
1958
2030
|
renderVars.survey_link = surveyLink;
|
|
1959
2031
|
}
|
|
2032
|
+
const normalizedBlocks = Array.isArray(stepUpdates.blocks) ? normalizeComposerBlocks(stepUpdates.blocks) : undefined;
|
|
1960
2033
|
const renderedUpdates = {
|
|
1961
2034
|
...stepUpdates,
|
|
2035
|
+
...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
|
|
2036
|
+
...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
|
|
2037
|
+
...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
|
|
1962
2038
|
...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateString(stepUpdates.subject, renderVars) } : {}),
|
|
1963
2039
|
...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateString(stepUpdates.body_html, renderVars) } : {}),
|
|
1964
2040
|
...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateString(stepUpdates.body_text, renderVars) } : {}),
|
package/build/tools/compound.js
CHANGED
|
@@ -4,6 +4,7 @@ import { db } from '../db.js';
|
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { buildVoicePrompt } from '../lib/voice.js';
|
|
6
6
|
import { getAnthropicFastModelId } from '../anthropic-model.js';
|
|
7
|
+
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
7
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
8
9
|
function getAnthropicClient() {
|
|
9
10
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
@@ -65,15 +66,30 @@ BRAND VOICE: ${voiceContext}
|
|
|
65
66
|
|
|
66
67
|
Generate exactly ${num_steps} emails. For each email, provide:
|
|
67
68
|
- subject
|
|
68
|
-
-
|
|
69
|
+
- blocks (ComposerBlock[])
|
|
69
70
|
- body_text
|
|
70
71
|
- delay_hours_from_previous (0 for the first email, ${step_delay_hours} for subsequent ones)
|
|
71
72
|
|
|
73
|
+
ComposerBlock[] must conform to this JSON Schema:
|
|
74
|
+
${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
|
|
75
|
+
|
|
76
|
+
Do not include body_html. Sendinel renders body_html from blocks.
|
|
72
77
|
Respond with a JSON array.`,
|
|
73
78
|
}],
|
|
74
79
|
});
|
|
75
80
|
const raw = msg.content[0].text;
|
|
76
|
-
steps = extractJsonArray(raw)
|
|
81
|
+
steps = extractJsonArray(raw).map((step, index) => {
|
|
82
|
+
const blocks = normalizeComposerBlocks(step.blocks);
|
|
83
|
+
return {
|
|
84
|
+
subject: typeof step.subject === 'string' ? step.subject : `Step ${index + 1}`,
|
|
85
|
+
body_html: blocks.length ? blocksToHtml(blocks) : '',
|
|
86
|
+
body_text: typeof step.body_text === 'string' && step.body_text.trim() ? step.body_text : blocksToText(blocks),
|
|
87
|
+
blocks,
|
|
88
|
+
delay_hours_from_previous: Number(step.delay_hours_from_previous ?? (index === 0 ? 0 : step_delay_hours)),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
if (steps.some((step) => !step.blocks?.length))
|
|
92
|
+
throw new Error('AI response did not include editable Composer blocks for every step');
|
|
77
93
|
}
|
|
78
94
|
catch (error) {
|
|
79
95
|
return j({ error: `AI generation failed: ${error instanceof Error ? error.message : String(error)}`, code: 'GENERATION_FAILED' });
|
|
@@ -108,6 +124,7 @@ Respond with a JSON array.`,
|
|
|
108
124
|
subject: step.subject,
|
|
109
125
|
body_html: step.body_html,
|
|
110
126
|
body_text: step.body_text,
|
|
127
|
+
blocks: step.blocks ?? null,
|
|
111
128
|
delay_days: 0,
|
|
112
129
|
delay_hours: step.delay_hours_from_previous,
|
|
113
130
|
template_id: String(template.id),
|
|
@@ -158,11 +175,23 @@ Respond with a JSON array.`,
|
|
|
158
175
|
content: `Write an email for "${site?.name ?? 'our product'}".
|
|
159
176
|
BRIEF: ${brief}
|
|
160
177
|
BRAND VOICE: ${buildVoicePrompt(site?.brand_voice)}
|
|
161
|
-
|
|
178
|
+
Generate structured Composer blocks first. ComposerBlock[] must conform to this JSON Schema:
|
|
179
|
+
${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
|
|
180
|
+
Do not include body_html. Sendinel renders body_html from blocks.
|
|
181
|
+
Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
|
|
162
182
|
}],
|
|
163
183
|
});
|
|
164
184
|
const raw = msg.content[0].text;
|
|
165
|
-
|
|
185
|
+
const parsed = extractJsonObject(raw);
|
|
186
|
+
const blocks = normalizeComposerBlocks(parsed.blocks);
|
|
187
|
+
if (!blocks.length)
|
|
188
|
+
throw new Error('AI response did not include editable Composer blocks');
|
|
189
|
+
generated = {
|
|
190
|
+
subject: parsed.subject,
|
|
191
|
+
body_html: blocksToHtml(blocks),
|
|
192
|
+
body_text: parsed.body_text || blocksToText(blocks),
|
|
193
|
+
blocks,
|
|
194
|
+
};
|
|
166
195
|
}
|
|
167
196
|
catch (error) {
|
|
168
197
|
return j({ error: `Generation failed: ${error instanceof Error ? error.message : String(error)}` });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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",
|