@sendinel/mcp-server 1.0.162 → 1.0.164

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/README.md CHANGED
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 348 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 349 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 348 tools (gateway mode exposes 4
27
27
  | `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
28
28
  | `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
29
29
  | `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
30
- | `approvals` | 5 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
30
+ | `approvals` | 6 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
31
31
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
32
32
  | `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
33
33
  | `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
@@ -31,7 +31,7 @@ export interface ExecutionDecision {
31
31
  * from getApprovalTier(), which only decides the tier itself. Call this before
32
32
  * requestApproval() at every gated/protected call site.
33
33
  */
34
- export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): ExecutionDecision;
34
+ export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): Promise<ExecutionDecision>;
35
35
  /** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
36
36
  export declare function currentChannelContext(): ChannelExecutionContext;
37
37
  /**
@@ -71,7 +71,8 @@ type AdminApprovalAuthority = {
71
71
  id?: string;
72
72
  action: string;
73
73
  projectId: string;
74
- phase: 'preview' | 'approved';
74
+ phase: 'preview' | 'approved' | 'level';
75
+ levelUserId?: string;
75
76
  decidedBy?: string;
76
77
  decisionChannel?: 'sandbox';
77
78
  };
@@ -105,6 +106,12 @@ type ToolResult = {
105
106
  text: string;
106
107
  }>;
107
108
  };
109
+ export declare function approvalSatisfied(action: string): boolean;
110
+ /** Catch preview/draft branches that historically returned before their hand-written gate.
111
+ * Existing gated execution paths retain their frozen-payload approval flow. */
112
+ export declare function withPreviewApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R): (args: A & {
113
+ approval_id?: string;
114
+ }, extra?: unknown) => Promise<R | ToolResult>;
108
115
  export declare function markNotExecuted<R extends object>(result: R): R;
109
116
  /** HTTP statuses that mean the request was refused before it changed anything. */
110
117
  export declare function isPreExecutionStatus(status: number | undefined): boolean;
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
1
2
  import { sandboxApprovalEligible, trySandboxApproval } from './sandbox-approvals.js';
2
3
  import { humanApprovalEligible } from './approval-human-eligibility.js';
3
4
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -92,8 +93,16 @@ const TIER2_CHANNELS = new Set([
92
93
  * from getApprovalTier(), which only decides the tier itself. Call this before
93
94
  * requestApproval() at every gated/protected call site.
94
95
  */
95
- export function assertChannelCanExecute(action, params, context) {
96
- const tier = getApprovalTier(action, params);
96
+ export async function assertChannelCanExecute(action, params, context) {
97
+ if (approvalSatisfied(action))
98
+ return { allowed: true, tier: 'autonomous' };
99
+ const effective = await effectiveApproval(action, params);
100
+ const originalTier = getApprovalTier(action, params);
101
+ const tier = effective.prompt ? (originalTier === 'autonomous' ? 'gated' : originalTier) : 'autonomous';
102
+ // An unverified caller never gains an exemption through a level.
103
+ if (effective.applied && !context.verified)
104
+ return { allowed: false, tier, reason: 'Verified channel required.' };
105
+ await auditApprovalLevel(action, effective);
97
106
  if (tier === 'autonomous')
98
107
  return { allowed: true, tier };
99
108
  if (!context.verified) {
@@ -267,20 +276,26 @@ function approvalParamsEqual(expected, actual) {
267
276
  * and returns instructions for the agent to wait.
268
277
  */
269
278
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
270
- const tier = getApprovalTier(action, params);
279
+ const effective = await effectiveApproval(action, params);
280
+ const originalTier = getApprovalTier(action, params);
281
+ const tier = originalTier === 'autonomous' ? 'gated' : originalTier;
271
282
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
272
283
  const { channel, verified } = channelContext ?? currentChannelContext();
273
284
  const actor = requestContext();
274
285
  // Ignore any model-supplied reserved field; freeze facts read by the server.
275
286
  const cleanParams = withoutApprovalSnapshot(params);
276
- const snapshot = await captureSnapshot(action, cleanParams);
287
+ const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
288
+ ...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
277
289
  const payload = cleanParams.payload;
278
290
  if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
279
291
  !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
280
292
  throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
281
293
  }
282
294
  const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
283
- const summary = buildApprovalSummary(action, frozenParams, display);
295
+ const detailSummary = buildApprovalSummary(action, frozenParams, display);
296
+ const preview = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary,
297
+ ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) });
298
+ const summary = `${preview.plain_summary.split('\n')[0]}\n${detailSummary}`;
284
299
  const { data, error } = await approvalStore()
285
300
  .from('action_approvals')
286
301
  .insert({
@@ -303,7 +318,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
303
318
  throw new Error(`Failed to create approval request: ${error.message}`);
304
319
  let projectName;
305
320
  let sandboxDisplayRead = false;
306
- const sandboxApproved = tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
321
+ const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || effective.class === 'F')) && tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
307
322
  if (isTesterRequest(action))
308
323
  await logTesterRequestAudit('tester_request', action, data.id, null);
309
324
  fireWebhookEvent('approval.requested', {
@@ -337,7 +352,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
337
352
  : '';
338
353
  const result = await elicitApproval({
339
354
  requires_approval: true,
340
- ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
355
+ ...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
341
356
  payload_hash: approvalPayloadHash(action, frozenParams) })),
342
357
  approval_id: data.id,
343
358
  tier,
@@ -347,8 +362,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
347
362
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
348
363
  approve_url: approveUrl,
349
364
  preview_text: previewText,
350
- message: `Tap to approve: ${approveUrl}\n${previewText}\nIt stays open for 24 hours. In a terminal with prompts you can also answer the on-screen Accept/Decline.${chatGuidance}`,
351
- }, { action, params: frozenParams, summary, projectName }, getProjectId());
365
+ message: `${previewText}\nTap to approve: ${approveUrl}\nIt stays open for 24 hours. ${effective.channelRule === 'human_only' ? 'Use the dashboard link or a typed chat reply.' : 'In a terminal with prompts you can also answer the on-screen Accept/Decline.'}${chatGuidance}`,
366
+ }, { action, params: frozenParams, summary, projectName, channelRule: effective.channelRule }, getProjectId());
352
367
  if (result.decision_status === 'approved') {
353
368
  return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
354
369
  }
@@ -444,6 +459,9 @@ async function checkApprovalState(approvalId, options = {}) {
444
459
  !await sandboxApprovalEligible(getProjectId(), data.action))) {
445
460
  return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
446
461
  }
462
+ if (data.status === 'approved' && data.params?.__approval_snapshot?.approval_channel_rule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
463
+ return { approved: false, status: 'human_decision_required' };
464
+ }
447
465
  if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
448
466
  (isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
449
467
  (!['mcp_app', 'mcp_elicitation', 'chat_confirm', 'mcp_person'].includes(data.decided_via) ||
@@ -494,6 +512,9 @@ async function checkApprovalState(approvalId, options = {}) {
494
512
  if (claimError || !claimed) {
495
513
  return { approved: false, status: 'consumed' };
496
514
  }
515
+ if ((await effectiveApproval(data.action, withoutApprovalSnapshot(data.params))).channelRule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
516
+ return { approved: false, status: 'human_decision_required' };
517
+ }
497
518
  return {
498
519
  approved: true,
499
520
  status: 'approved',
@@ -505,6 +526,30 @@ async function checkApprovalState(approvalId, options = {}) {
505
526
  function toolText(value) {
506
527
  return { content: [{ type: 'text', text: value }] };
507
528
  }
529
+ const satisfiedApproval = new AsyncLocalStorage();
530
+ export function approvalSatisfied(action) {
531
+ const authority = registeredApproval.getStore();
532
+ return satisfiedApproval.getStore() === action || (authority?.phase === 'level' && authority.action === action && authority.projectId === getProjectId());
533
+ }
534
+ /** Catch preview/draft branches that historically returned before their hand-written gate.
535
+ * Existing gated execution paths retain their frozen-payload approval flow. */
536
+ export function withPreviewApproval(action, handler) {
537
+ return async (args, extra) => {
538
+ const params = args;
539
+ const previewBranch = (['launch_campaign', 'create_campaign_with_content'].includes(action) && params.confirmed !== true) ||
540
+ ['update_campaign', 'set_campaign_trigger'].includes(action);
541
+ if (!previewBranch && getApprovalTier(action, params) !== 'autonomous')
542
+ return handler(args, extra);
543
+ const decision = await effectiveApproval(action, args);
544
+ if (!decision.applied || (previewBranch && !['launch_campaign', 'create_campaign_with_content'].includes(action) && decision.level !== 'ask_every_time'))
545
+ return handler(args, extra);
546
+ if (!decision.prompt) {
547
+ await auditApprovalLevel(action, decision);
548
+ return satisfiedApproval.run(action, () => handler(args, extra));
549
+ }
550
+ return withApproval(action, (params, innerExtra) => satisfiedApproval.run(action, () => handler(params, innerExtra)))(args, extra);
551
+ };
552
+ }
508
553
  /**
509
554
  * Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
510
555
  * (no approval_id) records the exact arguments and returns the approval request without running
@@ -530,13 +575,13 @@ export function withApproval(action, handler, options = {}) {
530
575
  return async (args, extra) => {
531
576
  const { approval_id, ...params } = args;
532
577
  const channelContext = currentChannelContext();
533
- const decision = assertChannelCanExecute(action, params, channelContext);
578
+ const decision = await assertChannelCanExecute(action, params, channelContext);
534
579
  if (!decision.allowed)
535
580
  return toolText(decision.reason ?? 'This channel cannot request this action.');
536
581
  let decidedBy;
537
582
  let decisionChannel;
538
583
  let consumedAt;
539
- if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
584
+ if (decision.tier !== 'autonomous') {
540
585
  if (!approval_id) {
541
586
  const display = options.display ? await options.display(params) : undefined;
542
587
  return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
@@ -550,7 +595,8 @@ export function withApproval(action, handler, options = {}) {
550
595
  }
551
596
  }
552
597
  if (options.registeredRequestOnly) {
553
- return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy, decisionChannel }, () => handler(args, extra));
598
+ return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: decision.tier === 'autonomous' ? 'level' : 'approved',
599
+ ...(decision.tier === 'autonomous' ? { levelUserId: requestContext()?.actorId ?? undefined } : {}), decidedBy, decisionChannel }, () => handler(args, extra));
554
600
  }
555
601
  const result = await handler(params, extra);
556
602
  // Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
@@ -0,0 +1,17 @@
1
+ import { type ApprovalClass, type ApprovalLevel } from '@sendinel/domain';
2
+ import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
3
+ export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
4
+ export type { ApprovalClass, ApprovalLevel } from '@sendinel/domain';
5
+ export type EffectiveApproval = {
6
+ prompt: boolean;
7
+ channelRule: 'any' | 'human_only';
8
+ class: ApprovalClass | undefined;
9
+ level: ApprovalLevel;
10
+ applied: boolean;
11
+ };
12
+ /** No transport-supplied role or preference grants authority: verify the live person grant. */
13
+ export declare function personApprovalLevel(ctx?: HumanDecisionIdentity | undefined, action?: string): Promise<ApprovalLevel | null>;
14
+ /** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
15
+ export declare function effectiveApproval(action: string, params?: Record<string, unknown>, ctx?: HumanDecisionIdentity | undefined): Promise<EffectiveApproval>;
16
+ /** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
17
+ export declare function auditApprovalLevel(action: string, decision: EffectiveApproval): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import { approvalClass, isApprovalLevel, matchSendAllowlist, readContactTestStatus } from '@sendinel/domain';
2
+ import { adminDb, requestContext } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { humanApprovalEligible } from './approval-human-eligibility.js';
5
+ import { getApprovalTier } from './action-approvals.js';
6
+ export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
7
+ /** No transport-supplied role or preference grants authority: verify the live person grant. */
8
+ export async function personApprovalLevel(ctx = requestContext(), action = 'get_approval_level') {
9
+ if (ctx?.scopes?.some(scope => scope === 'service' || scope.startsWith('service:')))
10
+ return null;
11
+ try {
12
+ if (!await humanApprovalEligible(action, getProjectId(), ctx, false, () => adminDb().schema('email')))
13
+ return null;
14
+ const { data, error } = await adminDb().schema('email').from('approval_preferences')
15
+ .select('level').eq('user_id', ctx.actorId).maybeSingle();
16
+ if (error || (data && !isApprovalLevel(data.level)))
17
+ return null;
18
+ return data?.level ?? 'default';
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** Explicit mailbox allowlists only: a NULL policy means unrestricted, never test-safe. */
25
+ async function safeAddresses(addresses, ctx) {
26
+ if (!addresses.length || !ctx.actorId)
27
+ return false;
28
+ const client = adminDb();
29
+ const { data, error } = await client.auth.admin.getUserById(ctx.actorId);
30
+ if (error || !data?.user?.email)
31
+ return false;
32
+ const remaining = addresses.filter(address => address.trim().toLowerCase() !== data.user.email.toLowerCase());
33
+ if (!remaining.length)
34
+ return true;
35
+ const project = await client.schema('email').from('projects')
36
+ .select('send_allowlist, org_id, org:organizations(send_allowlist)').eq('id', getProjectId()).single();
37
+ if (project.error || !project.data)
38
+ return false;
39
+ const lists = [project.data.send_allowlist, project.data.org?.send_allowlist].filter(Array.isArray);
40
+ return remaining.every(address => lists.some(list => !matchSendAllowlist(list, { to: address })));
41
+ }
42
+ async function exemptTargets(action, params, ctx) {
43
+ try {
44
+ if (action === 'draft_agent_reply')
45
+ return true;
46
+ if (action === 'send_test_email') {
47
+ // Include every copied recipient defensively; never infer recipients from display text.
48
+ const copied = [params.cc, params.bcc].filter(value => value !== undefined);
49
+ if (copied.some(value => !Array.isArray(value) || value.some(address => typeof address !== 'string')))
50
+ return false;
51
+ return typeof params.to_email === 'string' && await safeAddresses([params.to_email, ...copied.flat()], ctx);
52
+ }
53
+ // Filters and segments are open audiences, never an exemption based on a sample.
54
+ if (['enroll_segment', 'add_list_members_by_filter'].includes(action))
55
+ return false;
56
+ const ids = Array.isArray(params.contact_ids) ? params.contact_ids : params.contact_id ? [params.contact_id] : [];
57
+ if (ids.length) {
58
+ if (ids.some(id => typeof id !== 'string'))
59
+ return false;
60
+ const contacts = await readContactTestStatus(adminDb(), getProjectId(), ids);
61
+ return (await Promise.all(contacts.map(contact => contact.is_test === true ||
62
+ (typeof contact.email === 'string' && safeAddresses([contact.email], ctx))))).every(Boolean);
63
+ }
64
+ // Never trust a model-supplied is_test flag. Read the saved record by its address.
65
+ const address = params.email;
66
+ if (typeof address !== 'string')
67
+ return false;
68
+ if (await safeAddresses([address], ctx))
69
+ return true;
70
+ const { data, error } = await adminDb().schema('email').from('subscribers')
71
+ .select('is_test').eq('project_id', getProjectId()).eq('email', address.trim().toLowerCase()).maybeSingle();
72
+ return !error && data?.is_test === true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
79
+ export async function effectiveApproval(action, params = {}, ctx = requestContext()) {
80
+ const baseline = getApprovalTier(action, params) !== 'autonomous';
81
+ let kind = approvalClass(action);
82
+ const level = kind ? await personApprovalLevel(ctx, action) : null;
83
+ if (!level)
84
+ return { prompt: baseline, channelRule: 'any', class: kind, level: 'default', applied: false };
85
+ const exempt = (kind === 'T' || kind === 'E') && await exemptTargets(action, params, ctx);
86
+ if (action === 'send_test_email' && !exempt)
87
+ kind = 'S';
88
+ // Live trigger edits have an additional domain gate even under the legacy autonomous mode.
89
+ const triggerGate = ['update_campaign', 'set_campaign_trigger'].includes(action) && !!params.previous_campaign && !!params.updates;
90
+ const prompt = level === 'ask_every_time' ? true : level === 'auto' ? (kind === 'F' || kind === 'D') : (baseline || triggerGate) && !exempt;
91
+ return { prompt, class: kind, level, applied: true,
92
+ channelRule: level === 'ask_every_time' && (kind === 'S' || kind === 'F' || (kind === 'E' && !exempt)) ? 'human_only' : 'any' };
93
+ }
94
+ /** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
95
+ export async function auditApprovalLevel(action, decision) {
96
+ if (decision.prompt || !decision.applied || (decision.level === 'default' && decision.class !== 'T' && decision.class !== 'E'))
97
+ return;
98
+ const ctx = requestContext();
99
+ const { error } = await adminDb().schema('email').from('audit_log').insert({
100
+ project_id: getProjectId(), actor_type: 'user', actor_id: ctx.actorId,
101
+ action: 'approval.level_applied', resource_type: 'approval',
102
+ details: { action, class: decision.class, level: decision.level, user_id: ctx.actorId,
103
+ connection_id: `oauth:${ctx.oauthTokenId}`, reason: 'level' },
104
+ });
105
+ if (error)
106
+ throw new Error('Could not record your approval level. Nothing was run.');
107
+ }
@@ -57,6 +57,7 @@ export declare function elicitApproval<T extends ApprovalFallback>(result: T, fr
57
57
  params: unknown;
58
58
  summary: string;
59
59
  projectName?: string;
60
+ channelRule?: 'any' | 'human_only';
60
61
  }, projectId: string): Promise<T & {
61
62
  decision_status: ElicitationDecision;
62
63
  }>;
@@ -29,6 +29,8 @@ export async function elicitApproval(result, frozen, projectId) {
29
29
  const pending = { ...result, decision_status: 'decision_pending' };
30
30
  const warn = (reason) => console.warn('[approval_wait]', { stage: 'elicitation', reason });
31
31
  const exit = (reason) => { warn(reason); return pending; };
32
+ if (frozen.channelRule === 'human_only')
33
+ return exit('human_only');
32
34
  const context = requests.getStore();
33
35
  if (!context)
34
36
  return exit('no_transport_context');
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { humanApprovalEligible } from './approval-human-eligibility.js';
2
3
  import { requestContext } from '../db.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -21,11 +22,14 @@ export function createHumanApprovalCallback(binding) {
21
22
  const source = approvalStore();
22
23
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
23
24
  const { data: approval, error: loadError } = await client.from('action_approvals')
24
- .select('action').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
25
+ .select('action, params').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
25
26
  if (loadError || typeof approval?.action !== 'string' || !approval.action ||
26
27
  requestContext()?.actorId !== binding.userId ||
27
28
  !await humanApprovalEligible(approval.action, binding.projectId))
28
29
  return false;
30
+ if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' ||
31
+ (await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only')
32
+ return false;
29
33
  const now = new Date().toISOString();
30
34
  const { data, error } = await client.from('action_approvals')
31
35
  .update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
@@ -1,3 +1,4 @@
1
+ import { approvalStore } from './tester-requests-store.js';
1
2
  export type HumanDecisionIdentity = {
2
3
  projectId?: string;
3
4
  actorId?: string | null;
@@ -11,7 +12,7 @@ export type HumanDecisionIdentity = {
11
12
  * (for admin requests) the project's org membership on every prompt and every decision.
12
13
  * Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
13
14
  */
14
- export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean): Promise<boolean>;
15
+ export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean, store?: () => ReturnType<typeof approvalStore>): Promise<boolean>;
15
16
  /** Opt-in is a live grant fact as well as a transport snapshot; errors never enable it. */
16
17
  export declare function chatConfirmConnectionEnabled(identity?: {
17
18
  scopes?: readonly string[];
@@ -5,14 +5,14 @@ import { isHumanRequestableAdminTool } from './admin-tools.js';
5
5
  * (for admin requests) the project's org membership on every prompt and every decision.
6
6
  * Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
7
7
  */
8
- export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false) {
8
+ export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false, store) {
9
9
  if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
10
10
  (requireChatConfirm && identity.chatConfirmEnabled !== true) ||
11
11
  !identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
12
12
  identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
13
13
  return false;
14
14
  try {
15
- const source = approvalStore();
15
+ const source = store ? store() : approvalStore();
16
16
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
17
17
  const { data: grant, error } = await client.from('mcp_oauth_tokens')
18
18
  .select('id, user_id, connection_role, scopes, revoked_at, expires_at, chat_confirm_enabled')
@@ -23,6 +23,15 @@ export declare function captureApprovalDisplay(action: string, params: Params, p
23
23
  /** Exclude only optional display facts from revalidation, never from the payload hash. */
24
24
  export declare function withoutApprovalDisplay(snapshot: Params): Params;
25
25
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
26
- export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
26
+ export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<{
27
+ version: number;
28
+ invitation: Params;
29
+ recipient_count: number;
30
+ } | {
31
+ display?: {
32
+ target_name: string;
33
+ target_detail?: string | undefined;
34
+ } | undefined;
35
+ }>;
27
36
  export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
28
37
  export {};
@@ -1,6 +1,6 @@
1
1
  import { isTesterRequest } from './tester-policy.js';
2
2
  import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
3
- import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
3
+ import { campaignMatchesTrigger, normalizeTriggerList, readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
4
4
  import { callSendinelV1 } from './v1-client.js';
5
5
  import { adminDb, db } from '../db.js';
6
6
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
@@ -155,9 +155,45 @@ export async function captureApprovalDisplay(action, params, projectId) {
155
155
  }
156
156
  /** Exclude only optional display facts from revalidation, never from the payload hash. */
157
157
  export function withoutApprovalDisplay(snapshot) {
158
- const { display: _display, ...rest } = snapshot;
158
+ const { display: _display, approval_class: _class, approval_channel_rule: _rule, display_recipient_count: _count, ...rest } = snapshot;
159
159
  return rest;
160
160
  }
161
+ /** Potential sending campaigns, using the intake matchers. Unknown is never called no-send. */
162
+ async function captureEnrollmentConsequence(action, params, projectId) {
163
+ if (!['identify_subscriber', 'emit_contact_event', 'enroll_contact', 'enroll_segment'].includes(action))
164
+ return {};
165
+ const source = db();
166
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
167
+ if (action === 'enroll_contact' || action === 'enroll_segment') {
168
+ const { data, error } = await client.from('campaigns').select('id, name').eq('project_id', projectId).eq('id', params.campaign_id).maybeSingle();
169
+ if (error || !data)
170
+ throw new Error('Approval enrollment campaign unavailable');
171
+ let contact_email;
172
+ if (params.contact_id) {
173
+ const contact = await client.from('subscribers').select('email').eq('id', params.contact_id).eq('project_id', projectId).maybeSingle();
174
+ if (contact.error || !contact.data)
175
+ throw new Error('Approval contact unavailable');
176
+ contact_email = contact.data.email;
177
+ }
178
+ return { enrollment_campaigns: [data], ...(contact_email ? { contact_email } : {}) };
179
+ }
180
+ const campaigns = await readAllRows(client.from('campaigns')
181
+ .select('id, name, site_id, type, trigger_event_name, trigger_tag, trigger_config')
182
+ .eq('project_id', projectId).eq('status', 'active'));
183
+ let tags = Array.isArray(params.tags) ? params.tags : [];
184
+ if (action === 'emit_contact_event') {
185
+ const contact = await client.from('subscribers').select('tags').eq('project_id', projectId)
186
+ .eq('email', params.email).maybeSingle();
187
+ if (contact.error)
188
+ throw new Error('Approval event contact unavailable');
189
+ tags = normalizeTriggerList(contact.data?.tags ?? []);
190
+ }
191
+ const matches = campaigns.filter(campaign => action === 'identify_subscriber'
192
+ ? ((params.site_id && params.consent !== 'customer' && campaign.site_id === params.site_id && campaign.trigger_event_name === 'signup') ||
193
+ (campaign.type === 'triggered' && tags.some((tag) => campaignMatchesTrigger(campaign, { tags: [tag] }))))
194
+ : campaign.type === 'triggered' && campaignMatchesTrigger(campaign, { event: params.event, tags, data: params.data }));
195
+ return { enrollment_campaigns: matches.map(campaign => ({ id: campaign.id, name: campaign.name })).sort((a, b) => String(a.id).localeCompare(String(b.id))) };
196
+ }
161
197
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
162
198
  export async function captureApprovalSnapshot(action, params, projectId) {
163
199
  if (action === 'invite_team_member') {
@@ -169,7 +205,8 @@ export async function captureApprovalSnapshot(action, params, projectId) {
169
205
  return { version: 1, invitation: preview, recipient_count: 1 };
170
206
  }
171
207
  const display = await captureApprovalDisplay(action, params, projectId);
172
- const withDisplay = (value) => display ? { ...value, display } : value;
208
+ const enrollment = await captureEnrollmentConsequence(action, params, projectId).catch(() => ({}));
209
+ const withDisplay = (value) => ({ ...value, ...enrollment, ...(display ? { display } : {}) });
173
210
  const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
174
211
  const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
175
212
  if (!TARGET_ACTIONS.has(action))
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { requestContext } from '../db.js';
2
3
  import { getProjectId } from '../project.js';
3
4
  import { humanApprovalEligible } from './approval-human-eligibility.js';
@@ -81,6 +82,9 @@ export async function approveApproval(approvalId, transportClientName) {
81
82
  if (!await humanApprovalEligible(approval.action, projectId, actor)) {
82
83
  return refuse('approval_forbidden', 'This approval requires a person with the required project role.');
83
84
  }
85
+ if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' || (await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only') {
86
+ return refuse('human_decision_required', 'Use the dashboard link or confirm with your typed reply in chat.');
87
+ }
84
88
  const name = await clientName(transportClientName);
85
89
  const now = new Date().toISOString();
86
90
  const decision = client.from('action_approvals')
@@ -1,10 +1,11 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { isApplyingTesterRequest } from './tester-policy.js';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
4
5
  import { db } from '../db.js';
5
6
  import { getProjectId } from '../project.js';
6
7
  import { logAudit } from '../audit.js';
7
- import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
8
+ import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
8
9
  export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, period_key, updated_at';
9
10
  export const campaignEditError = (error, code) => ({ error, code });
10
11
  /** All writes include the revision used for the approval decision, including ungated drafts. */
@@ -23,13 +24,16 @@ export async function finishCampaignEdit(action, input, existing, updates, reque
23
24
  if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
24
25
  return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
25
26
  }
26
- if (isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
27
+ if (approvalSatisfied(action) || isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
27
28
  return writeEdit(input.campaign_id, existing, updates);
28
29
  const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
29
30
  const context = currentChannelContext();
30
- const decision = assertChannelCanExecute(action, params, context);
31
+ const decision = await assertChannelCanExecute(action, params, context);
31
32
  if (!decision.allowed)
32
33
  return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
34
+ const effective = await effectiveApproval(action, params);
35
+ if (effective.applied && effective.level === 'auto' && !effective.prompt)
36
+ return writeEdit(input.campaign_id, existing, updates);
33
37
  // This trigger policy requires a human decision even if the integration uses autonomous mode.
34
38
  return request(params);
35
39
  }
@@ -57,7 +61,7 @@ export async function resumeCampaignEdit(action, approvalId, input) {
57
61
  }
58
62
  if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
59
63
  return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
60
- const decision = assertChannelCanExecute(action, params, currentChannelContext());
64
+ const decision = await assertChannelCanExecute(action, params, currentChannelContext());
61
65
  if (!decision.allowed)
62
66
  return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
63
67
  const check = await checkApproval(approvalId, { consume: true, action, params });
@@ -94,6 +94,7 @@ export const API_USAGE_ERROR_CODE_MAP = {
94
94
  'validation_failed': 'validation_failed',
95
95
  'approval_not_cancellable': 'conflict',
96
96
  'approval_not_pending': 'conflict',
97
+ 'human_decision_required': 'forbidden',
97
98
  '23505': 'conflict',
98
99
  'conflict': 'conflict',
99
100
  'content_changed_since_approval': 'conflict',
package/build/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isApprovalGatedAction } from './lib/approval-actions.js';
1
2
  import { withToolErrors, toolError, installToolErrorBoundary } from './lib/db-error.js';
2
3
  import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
3
4
  import { installApprovalApp } from './lib/approval-app.js';
@@ -7,7 +8,7 @@ import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
7
8
  import { testerRequestTool } from './lib/tester-requests.js';
8
9
  export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
9
10
  import { ADMIN_TOOLS, isHumanRequestableAdminTool } from './lib/admin-tools.js';
10
- import { withApproval, isApprovalGatedAction } from './lib/action-approvals.js';
11
+ import { withPreviewApproval, withApproval } from './lib/action-approvals.js';
11
12
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
12
13
  import { registerAnalyticsTools } from './tools/analytics.js';
13
14
  import { registerCampaignTools } from './tools/campaigns.js';
@@ -318,6 +319,10 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
318
319
  if (wouldBlock)
319
320
  return; // omit from tool list entirely
320
321
  }
322
+ if (isApprovalGatedAction(name)) {
323
+ const originalHandler = handler;
324
+ handler = (args, extra) => withPreviewApproval(name, originalHandler)(args, extra);
325
+ }
321
326
  // ── Scope enforcement (only when scopes are provided) ─────────────
322
327
  if (scopes !== null) {
323
328
  const required = getRequiredScope(name);
@@ -1,3 +1,5 @@
1
+ import { personApprovalLevel } from '../lib/approval-classes.js';
2
+ import { APPROVAL_LEVEL_MEANING } from '@sendinel/domain';
1
3
  import { z } from 'zod';
2
4
  import { db } from '../db.js';
3
5
  import { getProjectId } from '../project.js';
@@ -9,6 +11,16 @@ import { approvalClientName } from '../lib/approval-capabilities.js';
9
11
  const txt = (text) => ({ content: [{ type: 'text', text }] });
10
12
  const j = (data) => txt(JSON.stringify(data, null, 2));
11
13
  export function registerApprovalTools(server, options) {
14
+ server.registerTool('get_approval_level', {
15
+ title: 'Get Approval Level',
16
+ description: 'Read your own approval setting. Only you can change it in dashboard settings; an AI cannot turn on Auto.',
17
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
18
+ inputSchema: {},
19
+ }, async () => {
20
+ const level = await personApprovalLevel();
21
+ return j({ level: level ?? 'default', applies_to_connection: level !== null,
22
+ meaning: level ? APPROVAL_LEVEL_MEANING[level] : 'Your connection keeps its usual approval checks.' });
23
+ });
12
24
  server.registerTool('cancel_approval', {
13
25
  title: 'Cancel Approval',
14
26
  description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected; cannot approve or undo cancellation.',
@@ -289,6 +289,38 @@ export function registerCampaignSetTools(server) {
289
289
  }, async (args) => {
290
290
  const projectId = getProjectId();
291
291
  const { approval_id } = args;
292
+ async function applyCampaigns(set, ids, approval_id, decidedBy) {
293
+ const activated = [];
294
+ const failed = [];
295
+ for (const c of set.campaigns) {
296
+ const { error } = await db()
297
+ .from('campaigns')
298
+ .update({ status: 'active', updated_at: new Date().toISOString() })
299
+ .eq('id', c.id)
300
+ .eq('project_id', projectId)
301
+ .in('status', ['draft', 'paused']);
302
+ if (error) {
303
+ failed.push({ campaign_id: c.id, ...dbToolError(error) });
304
+ continue;
305
+ }
306
+ activated.push(c.id);
307
+ fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
308
+ }
309
+ await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
310
+ return j({
311
+ activated,
312
+ failed,
313
+ ...(set.campaigns.some(c => c.type === 'broadcast') ? {
314
+ campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
315
+ broadcast_activation: BROADCAST_ACTIVATION,
316
+ } : {}),
317
+ message: failed.length
318
+ ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
319
+ : set.campaigns.some(c => c.type === 'broadcast')
320
+ ? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
321
+ : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
322
+ });
323
+ }
292
324
  if (approval_id) {
293
325
  const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
294
326
  if (extras.length)
@@ -318,36 +350,7 @@ export function registerCampaignSetTools(server) {
318
350
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
319
351
  if (!check.approved)
320
352
  return j({ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`, code: 'validation_failed', approval_status: check.status });
321
- const activated = [];
322
- const failed = [];
323
- for (const c of current.campaigns) {
324
- const { error } = await db()
325
- .from('campaigns')
326
- .update({ status: 'active', updated_at: new Date().toISOString() })
327
- .eq('id', c.id)
328
- .eq('project_id', projectId)
329
- .in('status', ['draft', 'paused']);
330
- if (error) {
331
- failed.push({ campaign_id: c.id, ...dbToolError(error) });
332
- continue;
333
- }
334
- activated.push(c.id);
335
- fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
336
- }
337
- await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: check.decided_by ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
338
- return j({
339
- activated,
340
- failed,
341
- ...(current.campaigns.some(c => c.type === 'broadcast') ? {
342
- campaigns: previewOf(current.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), current.steps),
343
- broadcast_activation: BROADCAST_ACTIVATION,
344
- } : {}),
345
- message: failed.length
346
- ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
347
- : current.campaigns.some(c => c.type === 'broadcast')
348
- ? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
349
- : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
350
- });
353
+ return applyCampaigns(current, ids, approval_id, check.decided_by);
351
354
  }
352
355
  // Call 1: build one approval for the exact set.
353
356
  let ids = args.campaign_ids ?? [];
@@ -391,9 +394,11 @@ export function registerCampaignSetTools(server) {
391
394
  const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
392
395
  const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
393
396
  const channelContext = currentChannelContext();
394
- const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
397
+ const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
395
398
  if (!decision.allowed)
396
399
  return j({ error: decision.reason ?? 'This channel cannot request this action.', code: 'forbidden' });
400
+ if (decision.tier === 'autonomous')
401
+ return applyCampaigns(set, ids);
397
402
  const names = set.campaigns.map((c) => c.name);
398
403
  const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
399
404
  campaign_count: set.campaigns.length,
@@ -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(),
@@ -1036,7 +1055,7 @@ export function registerCampaignTools(server) {
1036
1055
  const approvalParams = { campaign_id, status };
1037
1056
  if (!approval_id) {
1038
1057
  const channelContext = currentChannelContext();
1039
- const decision = assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
1058
+ const decision = await assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
1040
1059
  if (!decision.allowed)
1041
1060
  return txt(decision.reason ?? 'This channel cannot request this action.');
1042
1061
  if (decision.tier !== 'autonomous') {
@@ -1347,7 +1366,7 @@ export function registerCampaignTools(server) {
1347
1366
  });
1348
1367
  }
1349
1368
  const channelContext = currentChannelContext();
1350
- const decision = assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
1369
+ const decision = await assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
1351
1370
  if (!decision.allowed)
1352
1371
  return txt(decision.reason ?? 'This channel cannot request this action.');
1353
1372
  if (decision.tier !== 'autonomous') {
@@ -1443,29 +1462,7 @@ export function registerCampaignTools(server) {
1443
1462
  }, async (args) => {
1444
1463
  const projectId = getProjectId();
1445
1464
  const { approval_id } = args;
1446
- // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
1447
- if (approval_id) {
1448
- const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
1449
- if (extras.length) {
1450
- return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
1451
- }
1452
- const { data: row, error: rowErr } = await db()
1453
- .from('action_approvals')
1454
- .select('params')
1455
- .eq('id', approval_id)
1456
- .eq('project_id', projectId)
1457
- .eq('action', 'send_broadcast')
1458
- .maybeSingle();
1459
- if (rowErr)
1460
- return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
1461
- if (!row)
1462
- return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1463
- const stored = (row.params ?? {});
1464
- const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
1465
- if (!check.approved) {
1466
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1467
- }
1468
- const payload = (stored.payload ?? {});
1465
+ async function applyBroadcast(payload, approval_id, decidedBy) {
1469
1466
  const contactIds = Array.isArray(payload.contact_ids) ? payload.contact_ids.map(String) : [];
1470
1467
  if (payload.send_at && new Date(payload.send_at).getTime() <= Date.now()) {
1471
1468
  // An approved future time that has now passed: send right away rather than silently skip.
@@ -1495,7 +1492,7 @@ export function registerCampaignTools(server) {
1495
1492
  action: 'campaign.send_broadcast',
1496
1493
  resourceType: 'campaign',
1497
1494
  resourceId: executed.campaignId,
1498
- details: { approval_id, decided_by: check.decided_by ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1495
+ details: { approval_id, decided_by: decidedBy ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1499
1496
  }).catch(() => { });
1500
1497
  fireWebhookEvent('campaign.activated', {
1501
1498
  campaign_id: executed.campaignId,
@@ -1516,6 +1513,30 @@ export function registerCampaignTools(server) {
1516
1513
  : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
1517
1514
  });
1518
1515
  }
1516
+ // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
1517
+ if (approval_id) {
1518
+ const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
1519
+ if (extras.length) {
1520
+ return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
1521
+ }
1522
+ const { data: row, error: rowErr } = await db()
1523
+ .from('action_approvals')
1524
+ .select('params')
1525
+ .eq('id', approval_id)
1526
+ .eq('project_id', projectId)
1527
+ .eq('action', 'send_broadcast')
1528
+ .maybeSingle();
1529
+ if (rowErr)
1530
+ return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
1531
+ if (!row)
1532
+ return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1533
+ const stored = (row.params ?? {});
1534
+ const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
1535
+ if (!check.approved) {
1536
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1537
+ }
1538
+ return applyBroadcast((stored.payload ?? {}), approval_id, check.decided_by);
1539
+ }
1519
1540
  // ── Call 1: resolve, render, count, request approval. Sends nothing. ──
1520
1541
  const { audience, segment_id, brief, html, subject, body_text, site_id, send_at, name, send_class, brand_asset_ids } = args;
1521
1542
  if (Boolean(audience) === Boolean(segment_id))
@@ -1622,9 +1643,11 @@ export function registerCampaignTools(server) {
1622
1643
  };
1623
1644
  const approvalParams = { payload };
1624
1645
  const channelContext = currentChannelContext();
1625
- const decision = assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
1646
+ const decision = await assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
1626
1647
  if (!decision.allowed)
1627
1648
  return txt(decision.reason ?? 'This channel cannot request this action.');
1649
+ if (decision.tier === 'autonomous')
1650
+ return applyBroadcast(payload);
1628
1651
  const approval = await requestApproval('send_broadcast', approvalParams, undefined, channelContext, {
1629
1652
  subject: content.subject,
1630
1653
  segment_name: segment_id ? segmentName : null,
@@ -2092,7 +2115,7 @@ Adapt your tone accordingly:
2092
2115
  const channelContext = currentChannelContext();
2093
2116
  let needsApproval = false;
2094
2117
  if (confirmed) {
2095
- const decision = assertChannelCanExecute('launch_campaign', requestParams, channelContext);
2118
+ const decision = await assertChannelCanExecute('launch_campaign', requestParams, channelContext);
2096
2119
  if (!decision.allowed)
2097
2120
  return txt(decision.reason ?? 'This channel cannot request this action.');
2098
2121
  needsApproval = decision.tier !== 'autonomous';
@@ -3186,7 +3209,7 @@ Return ONLY valid JSON with this exact shape:
3186
3209
  if (activate && confirmed) {
3187
3210
  const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
3188
3211
  const channelContext = currentChannelContext();
3189
- const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
3212
+ const decision = await assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
3190
3213
  if (!decision.allowed)
3191
3214
  return txt(decision.reason ?? 'This channel cannot request this action.');
3192
3215
  if (decision.tier !== 'autonomous') {
@@ -3553,7 +3576,7 @@ Return ONLY valid JSON with this exact shape:
3553
3576
  server.registerTool('set_campaign_trigger', {
3554
3577
  title: 'Set Campaign Trigger',
3555
3578
  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).',
3579
+ 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
3580
  inputSchema: {
3558
3581
  ...reentryFields,
3559
3582
  campaign_id: z.string().uuid().optional(),
@@ -3640,7 +3663,10 @@ Return ONLY valid JSON with this exact shape:
3640
3663
  }, async ({ approval_id, ...input }) => {
3641
3664
  if (approval_id) {
3642
3665
  const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3643
- return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3666
+ if (result.error)
3667
+ return j(result);
3668
+ const warnings = await campaignTimezoneWarnings(result);
3669
+ return j({ ...result, ...await campaignTriggerOutput(result), ...(warnings.length ? { warnings } : {}) });
3644
3670
  }
3645
3671
  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
3672
  if (!campaign_id)
@@ -3759,7 +3785,7 @@ Return ONLY valid JSON with this exact shape:
3759
3785
  ...data,
3760
3786
  fires_on: buildCampaignFiresOn(data),
3761
3787
  trigger_diagnostics: diagnostics,
3762
- warnings: diagnostics,
3788
+ warnings: [...diagnostics, ...await campaignTimezoneWarnings(data)],
3763
3789
  ...(warning ? { warning } : {}),
3764
3790
  });
3765
3791
  });
@@ -97,7 +97,7 @@ async function authorizeConnectorMutation(action, confirmed, approvalId, approva
97
97
  });
98
98
  }
99
99
  const channelContext = currentChannelContext();
100
- const decision = assertChannelCanExecute(action, approvalParams, channelContext);
100
+ const decision = await assertChannelCanExecute(action, approvalParams, channelContext);
101
101
  if (!decision.allowed)
102
102
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
103
103
  if (decision.tier === 'autonomous')
@@ -2471,7 +2471,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2471
2471
  const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
2472
2472
  if (!dry_run && inactive.length > 0) {
2473
2473
  const channelContext = currentChannelContext();
2474
- const decision = assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
2474
+ const decision = await assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
2475
2475
  if (!decision.allowed)
2476
2476
  return txt(decision.reason ?? 'This channel cannot request this action.');
2477
2477
  if (decision.tier !== 'autonomous') {
@@ -2686,7 +2686,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2686
2686
  };
2687
2687
  if (!approval_id) {
2688
2688
  const channelContext = currentChannelContext();
2689
- const decision = assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
2689
+ const decision = await assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
2690
2690
  if (!decision.allowed)
2691
2691
  return txt(decision.reason ?? 'This channel cannot request this action.');
2692
2692
  const approval = await requestApproval('merge_subscribers', approvalParams, undefined, channelContext);
@@ -421,7 +421,7 @@ export function registerDataTools(server) {
421
421
  });
422
422
  }
423
423
  const channelContext = currentChannelContext();
424
- const decision = assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
424
+ const decision = await assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
425
425
  if (!decision.allowed)
426
426
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
427
427
  if (decision.tier !== 'autonomous') {
@@ -868,7 +868,7 @@ export function registerDataTools(server) {
868
868
  }
869
869
  const approvalParams = { connection_id };
870
870
  const channelContext = currentChannelContext();
871
- const decision = assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
871
+ const decision = await assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
872
872
  if (!decision.allowed)
873
873
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
874
874
  if (decision.tier !== 'autonomous') {
@@ -1034,7 +1034,7 @@ export function registerDeliveryOpsTools(server) {
1034
1034
  });
1035
1035
  }
1036
1036
  const channelContext = currentChannelContext();
1037
- const decision = assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
1037
+ const decision = await assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
1038
1038
  if (!decision.allowed)
1039
1039
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
1040
1040
  if (decision.tier !== 'autonomous') {
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { logAudit } from '../audit.js';
5
- import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
5
+ import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
6
6
  import { dbToolError } from '../lib/db-error.js';
7
7
  const txt = (text) => ({ content: [{ type: 'text', text }] });
8
8
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -35,7 +35,7 @@ export function registerGdprTools(server) {
35
35
  // Human approval required (always, even in autonomous mode — protected tier)
36
36
  if (!approval_id) {
37
37
  const channelContext = currentChannelContext();
38
- const decision = assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
38
+ const decision = await assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
39
39
  if (!decision.allowed)
40
40
  return txt(decision.reason ?? 'This channel cannot request this action.');
41
41
  const approval = await requestApproval('delete_subscriber_data', approvalParams, undefined, channelContext);
@@ -182,20 +182,22 @@ export function registerGdprTools(server) {
182
182
  request_type: request.request_type,
183
183
  email: request.email,
184
184
  };
185
- if (!approval_id) {
186
- const channelContext = currentChannelContext();
187
- const decision = assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
188
- if (!decision.allowed)
189
- return txt(decision.reason ?? 'This channel cannot request this action.');
190
- return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
191
- }
192
- const check = await checkApproval(approval_id, {
193
- consume: true,
194
- action: 'action_ccpa_request',
195
- params: approvalParams,
196
- });
197
- if (!check.approved) {
198
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
185
+ if (!approvalSatisfied('action_ccpa_request')) {
186
+ if (!approval_id) {
187
+ const channelContext = currentChannelContext();
188
+ const decision = await assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
189
+ if (!decision.allowed)
190
+ return txt(decision.reason ?? 'This channel cannot request this action.');
191
+ return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
192
+ }
193
+ const check = await checkApproval(approval_id, {
194
+ consume: true,
195
+ action: 'action_ccpa_request',
196
+ params: approvalParams,
197
+ });
198
+ if (!check.approved) {
199
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
200
+ }
199
201
  }
200
202
  const { data: contact, error: contactErr } = await db()
201
203
  .from('subscribers')
@@ -242,7 +242,7 @@ export function registerListTools(server) {
242
242
  });
243
243
  }
244
244
  const channelContext = currentChannelContext();
245
- const decision = assertChannelCanExecute('add_list_members', approvalParams, channelContext);
245
+ const decision = await assertChannelCanExecute('add_list_members', approvalParams, channelContext);
246
246
  if (!decision.allowed)
247
247
  return txt(decision.reason ?? 'This channel cannot request this action.');
248
248
  if (decision.tier !== 'autonomous') {
@@ -318,7 +318,7 @@ export function registerListTools(server) {
318
318
  });
319
319
  }
320
320
  const channelContext = currentChannelContext();
321
- const decision = assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
321
+ const decision = await assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
322
322
  if (!decision.allowed)
323
323
  return txt(decision.reason ?? 'This channel cannot request this action.');
324
324
  if (decision.tier !== 'autonomous') {
@@ -400,7 +400,7 @@ export function registerListTools(server) {
400
400
  });
401
401
  }
402
402
  const channelContext = currentChannelContext();
403
- const decision = assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
403
+ const decision = await assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
404
404
  if (!decision.allowed)
405
405
  return txt(decision.reason ?? 'This channel cannot request this action.');
406
406
  if (decision.tier !== 'autonomous') {
@@ -239,7 +239,7 @@ export function registerOrgTools(server) {
239
239
  return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
240
240
  }
241
241
  const channelContext = currentChannelContext();
242
- const decision = assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
242
+ const decision = await assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
243
243
  if (!decision.allowed)
244
244
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
245
245
  if (decision.tier !== 'autonomous') {
@@ -308,7 +308,7 @@ export function registerOrgTools(server) {
308
308
  });
309
309
  }
310
310
  const channelContext = currentChannelContext();
311
- const decision = assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
311
+ const decision = await assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
312
312
  if (!decision.allowed)
313
313
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
314
314
  if (decision.tier !== 'autonomous') {
@@ -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.164",
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",