@sendinel/mcp-server 1.0.162 → 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.
@@ -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
- return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) }, null, 2) }] };
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
- return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
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
  });
@@ -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 footer changed during update. Read the site and retry.', code: 'SITE_CHANGED_OR_NOT_FOUND' });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.162",
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",