@sendinel/mcp-server 1.0.161 → 1.0.163
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/tools/campaigns.js +26 -4
- package/build/tools/content.js +6 -1
- package/build/tools/sites.js +20 -3
- package/build/tools/surveys.js +10 -0
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -12,6 +12,7 @@ import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendErr
|
|
|
12
12
|
// packages/domain/src/index.ts.
|
|
13
13
|
import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/domain/reply-actions';
|
|
14
14
|
import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
|
|
15
|
+
import { validTimezone } from '@sendinel/domain';
|
|
15
16
|
import { adminDb, db, requestContext } from '../db.js';
|
|
16
17
|
import { getProjectId } from '../project.js';
|
|
17
18
|
import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
|
|
@@ -172,6 +173,23 @@ export function campaignTriggerKind(campaign) {
|
|
|
172
173
|
return 'tag';
|
|
173
174
|
return 'manual';
|
|
174
175
|
}
|
|
176
|
+
const SEND_WINDOW_TIMEZONE_WARNING = 'This campaign has a send window but no timezone, so contacts without one are judged in UTC. Set a campaign or site timezone.';
|
|
177
|
+
async function campaignTimezoneWarnings(campaign) {
|
|
178
|
+
if (validTimezone(campaign.timezone))
|
|
179
|
+
return [];
|
|
180
|
+
if (!campaign.send_window && campaign.send_window_inherit === false)
|
|
181
|
+
return [];
|
|
182
|
+
let site = null;
|
|
183
|
+
if (campaign.site_id) {
|
|
184
|
+
const { data, error } = await db().from('sites').select('send_window, timezone')
|
|
185
|
+
.eq('id', campaign.site_id).eq('project_id', getProjectId()).maybeSingle();
|
|
186
|
+
if (error)
|
|
187
|
+
return ['Could not check the site send-window timezone. Read the site and verify its timezone.'];
|
|
188
|
+
site = data;
|
|
189
|
+
}
|
|
190
|
+
const sendWindow = campaign.send_window ?? (campaign.send_window_inherit !== false ? site?.send_window : null);
|
|
191
|
+
return sendWindow && !validTimezone(site?.timezone) ? [SEND_WINDOW_TIMEZONE_WARNING] : [];
|
|
192
|
+
}
|
|
175
193
|
async function campaignTriggerOutput(campaign) {
|
|
176
194
|
let seenEvents;
|
|
177
195
|
const config = normalizeTriggerConfig(campaign.trigger_config);
|
|
@@ -875,7 +893,8 @@ export function registerCampaignTools(server) {
|
|
|
875
893
|
.single();
|
|
876
894
|
if (error)
|
|
877
895
|
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
878
|
-
|
|
896
|
+
const warnings = await campaignTimezoneWarnings(data);
|
|
897
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data), ...(warnings.length ? { warnings } : {}) }, null, 2) }] };
|
|
879
898
|
});
|
|
880
899
|
const propertyConditions = z.array(z.union([
|
|
881
900
|
z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
|
|
@@ -3553,7 +3572,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3553
3572
|
server.registerTool('set_campaign_trigger', {
|
|
3554
3573
|
title: 'Set Campaign Trigger',
|
|
3555
3574
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3556
|
-
description: 'Send timing: the first step of an event-triggered product journey sends right away when it has no delay; later steps and delayed first steps wait for the site send window (contact zone, then campaign zone, then UTC). Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, derived trigger_kind, fires_on and trigger_diagnostics (including unseen-event warnings).',
|
|
3575
|
+
description: 'Send timing: the first step of an event-triggered product journey sends right away when it has no delay; later steps and delayed first steps wait for the site send window (contact zone, then campaign zone, then site zone, then UTC). Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, derived trigger_kind, fires_on and trigger_diagnostics (including unseen-event warnings).',
|
|
3557
3576
|
inputSchema: {
|
|
3558
3577
|
...reentryFields,
|
|
3559
3578
|
campaign_id: z.string().uuid().optional(),
|
|
@@ -3640,7 +3659,10 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3640
3659
|
}, async ({ approval_id, ...input }) => {
|
|
3641
3660
|
if (approval_id) {
|
|
3642
3661
|
const result = await resumeCampaignEdit('update_campaign', approval_id, input);
|
|
3643
|
-
|
|
3662
|
+
if (result.error)
|
|
3663
|
+
return j(result);
|
|
3664
|
+
const warnings = await campaignTimezoneWarnings(result);
|
|
3665
|
+
return j({ ...result, ...await campaignTriggerOutput(result), ...(warnings.length ? { warnings } : {}) });
|
|
3644
3666
|
}
|
|
3645
3667
|
const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
|
|
3646
3668
|
if (!campaign_id)
|
|
@@ -3759,7 +3781,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3759
3781
|
...data,
|
|
3760
3782
|
fires_on: buildCampaignFiresOn(data),
|
|
3761
3783
|
trigger_diagnostics: diagnostics,
|
|
3762
|
-
warnings: diagnostics,
|
|
3784
|
+
warnings: [...diagnostics, ...await campaignTimezoneWarnings(data)],
|
|
3763
3785
|
...(warning ? { warning } : {}),
|
|
3764
3786
|
});
|
|
3765
3787
|
});
|
package/build/tools/content.js
CHANGED
|
@@ -2,7 +2,7 @@ import { withApproval } from '../lib/action-approvals.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { adminDb, db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
|
-
import { stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
|
|
5
|
+
import { refuseActiveTextFields, stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
|
|
6
6
|
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
7
7
|
import { logAudit } from '../audit.js';
|
|
8
8
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
@@ -110,6 +110,11 @@ export function registerContentTools(server) {
|
|
|
110
110
|
gate_body: z.string().max(1000).optional(),
|
|
111
111
|
},
|
|
112
112
|
}, async ({ title, slug, asset_url, asset_type = 'page', site_id, source_attribution, gate_headline, gate_body }) => {
|
|
113
|
+
for (const [field, value] of Object.entries({ title, source_attribution, gate_headline, gate_body })) {
|
|
114
|
+
const error = refuseActiveTextFields(field, value);
|
|
115
|
+
if (error)
|
|
116
|
+
return j({ error, code: 'validation_failed' });
|
|
117
|
+
}
|
|
113
118
|
try {
|
|
114
119
|
assertHttpUrl(asset_url);
|
|
115
120
|
}
|
package/build/tools/sites.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { validateSiteUnsubscribeSettings } from '@sendinel/domain';
|
|
2
|
+
import { validTimezone, validateSiteUnsubscribeSettings } from '@sendinel/domain';
|
|
3
3
|
import { logAudit } from '../audit.js';
|
|
4
4
|
import { db } from '../db.js';
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
@@ -219,6 +219,7 @@ export function registerSiteTools(server) {
|
|
|
219
219
|
from_email: z.string().email().optional(),
|
|
220
220
|
from_name: z.string().optional(),
|
|
221
221
|
reply_to: z.string().email().nullable().optional().describe('Where replies to this site\'s email go; null clears it (replies then go to the From address). The previous value is returned as previous_reply_to.'),
|
|
222
|
+
timezone: z.string().refine(value => validTimezone(value) !== null, 'timezone must be a valid IANA timezone').nullable().optional().describe('Site send-window timezone; null clears it. Returns previous_timezone for undo.'),
|
|
222
223
|
active: z.boolean().optional(),
|
|
223
224
|
physical_address: z.string().trim().min(1).optional().describe('Physical mailing address; unchanged unless explicitly supplied.'),
|
|
224
225
|
unsub_link_enabled: z.boolean().optional().describe('Enable the unsubscribe link in the email footer.'),
|
|
@@ -229,7 +230,7 @@ export function registerSiteTools(server) {
|
|
|
229
230
|
unsub_brand_logo_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe page logo URL; null or blank clears it.'),
|
|
230
231
|
unsub_brand_color: z.string().nullable().optional().describe('Six-digit hex unsubscribe page color; null or blank clears it.'),
|
|
231
232
|
},
|
|
232
|
-
}, async ({ site_id, name, from_email, from_name, reply_to, active, physical_address, ...unsubscribeInput }) => {
|
|
233
|
+
}, async ({ site_id, name, from_email, from_name, reply_to, timezone, active, physical_address, ...unsubscribeInput }) => {
|
|
233
234
|
const updates = {};
|
|
234
235
|
if (name)
|
|
235
236
|
updates.name = name;
|
|
@@ -239,6 +240,8 @@ export function registerSiteTools(server) {
|
|
|
239
240
|
updates.from_name = from_name;
|
|
240
241
|
if (reply_to !== undefined)
|
|
241
242
|
updates.reply_to = reply_to;
|
|
243
|
+
if (timezone !== undefined)
|
|
244
|
+
updates.timezone = timezone;
|
|
242
245
|
if (active !== undefined)
|
|
243
246
|
updates.active = active;
|
|
244
247
|
if (physical_address !== undefined)
|
|
@@ -276,6 +279,15 @@ export function registerSiteTools(server) {
|
|
|
276
279
|
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
277
280
|
previousReplyTo = current.reply_to ?? null;
|
|
278
281
|
}
|
|
282
|
+
let previousTimezone;
|
|
283
|
+
if (timezone !== undefined) {
|
|
284
|
+
const { data: current, error } = await sb.from('sites').select('timezone').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
285
|
+
if (error)
|
|
286
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
287
|
+
if (!current)
|
|
288
|
+
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
289
|
+
previousTimezone = current.timezone ?? null;
|
|
290
|
+
}
|
|
279
291
|
updates.updated_at = new Date().toISOString();
|
|
280
292
|
let query = sb
|
|
281
293
|
.from('sites')
|
|
@@ -286,11 +298,15 @@ export function registerSiteTools(server) {
|
|
|
286
298
|
query = query.is('unsub_footer_template', null);
|
|
287
299
|
else if (previousTemplate !== undefined)
|
|
288
300
|
query = query.eq('unsub_footer_template', previousTemplate);
|
|
301
|
+
if (previousTimezone === null)
|
|
302
|
+
query = query.is('timezone', null);
|
|
303
|
+
else if (previousTimezone !== undefined)
|
|
304
|
+
query = query.eq('timezone', previousTimezone);
|
|
289
305
|
const { data, error } = await query.select().maybeSingle();
|
|
290
306
|
if (error)
|
|
291
307
|
return txt(`Error: ${dbToolError(error).error}`);
|
|
292
308
|
if (!data)
|
|
293
|
-
return j({ error: 'Site not found or
|
|
309
|
+
return j({ error: 'Site not found or settings changed during update. Read the site and retry.', code: 'SITE_CHANGED_OR_NOT_FOUND' });
|
|
294
310
|
await logAudit({
|
|
295
311
|
action: 'site.updated', resourceType: 'site', resourceId: site_id,
|
|
296
312
|
details: { fields: Object.keys(updates).filter((field) => field !== 'updated_at') },
|
|
@@ -298,6 +314,7 @@ export function registerSiteTools(server) {
|
|
|
298
314
|
return j({
|
|
299
315
|
...data,
|
|
300
316
|
...(previousReplyTo !== undefined ? { previous_reply_to: previousReplyTo } : {}),
|
|
317
|
+
...(previousTimezone !== undefined ? { previous_timezone: previousTimezone } : {}),
|
|
301
318
|
...(previousTemplate !== undefined && previousTemplate !== data.unsub_footer_template
|
|
302
319
|
? { previous_unsub_footer_template: previousTemplate } : {}),
|
|
303
320
|
});
|
package/build/tools/surveys.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { refuseActiveText, refuseActiveTextFields } from '@sendinel/domain';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -28,6 +29,9 @@ function normalizeQuestions(questions) {
|
|
|
28
29
|
});
|
|
29
30
|
}
|
|
30
31
|
function validateQuestions(questions) {
|
|
32
|
+
const activeTextError = refuseActiveTextFields('questions', questions);
|
|
33
|
+
if (activeTextError)
|
|
34
|
+
return activeTextError;
|
|
31
35
|
const ids = new Set();
|
|
32
36
|
for (const question of questions) {
|
|
33
37
|
if (!question.label.trim())
|
|
@@ -85,6 +89,9 @@ export function registerSurveyTools(server) {
|
|
|
85
89
|
status: z.enum(['draft', 'active', 'closed']).optional().default('draft'),
|
|
86
90
|
},
|
|
87
91
|
}, async ({ title, questions, site_id, status }) => {
|
|
92
|
+
const activeTextError = refuseActiveText('title', title) ?? refuseActiveTextFields('questions', questions);
|
|
93
|
+
if (activeTextError)
|
|
94
|
+
return j({ error: activeTextError, code: 'validation_failed' });
|
|
88
95
|
const normalized = normalizeQuestions(questions);
|
|
89
96
|
const validationError = validateQuestions(normalized);
|
|
90
97
|
if (validationError)
|
|
@@ -217,6 +224,9 @@ export function registerSurveyTools(server) {
|
|
|
217
224
|
},
|
|
218
225
|
}, async ({ survey_id, title, questions, status }) => {
|
|
219
226
|
const projectId = getProjectId();
|
|
227
|
+
const activeTextError = refuseActiveText('title', title) ?? refuseActiveTextFields('questions', questions);
|
|
228
|
+
if (activeTextError)
|
|
229
|
+
return j({ error: activeTextError, code: 'validation_failed' });
|
|
220
230
|
const updates = {};
|
|
221
231
|
if (title !== undefined)
|
|
222
232
|
updates.title = title;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.163",
|
|
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",
|