@sendinel/mcp-server 1.0.230 → 1.0.232

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.
@@ -198,6 +198,27 @@ function buildTrueConversionAnalytics(params) {
198
198
  .sort((a, b) => b.count - a.count || b.revenue - a.revenue),
199
199
  };
200
200
  }
201
+ /**
202
+ * #4838: send allowlist visibility without exposing addresses. NULL = no restriction (inactive);
203
+ * an array (even empty) is active and recipients must match an exact address on it. Anything else
204
+ * (row or column not loaded) is unknown, reported as null, never as "inactive": the send gate fails
205
+ * closed on an unreadable list, so this must not claim there is none.
206
+ */
207
+ function sendAllowlistSummary(list) {
208
+ if (Array.isArray(list))
209
+ return { active: true, count: list.length };
210
+ return list === null ? { active: false, count: null } : { active: null, count: null };
211
+ }
212
+ function sendAllowlistFields(projectList, orgList) {
213
+ const project = sendAllowlistSummary(projectList);
214
+ const org = sendAllowlistSummary(orgList);
215
+ return {
216
+ send_allowlist_active: project.active,
217
+ send_allowlist_count: project.count,
218
+ org_send_allowlist_active: org.active,
219
+ org_send_allowlist_count: org.count,
220
+ };
221
+ }
201
222
  export function registerAnalyticsTools(server) {
202
223
  server.registerTool('list_event_log', {
203
224
  title: 'List Event Log',
@@ -273,7 +294,7 @@ export function registerAnalyticsTools(server) {
273
294
  idempotentHint: true,
274
295
  openWorldHint: false,
275
296
  },
276
- description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account.',
297
+ description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account. Each project also shows its send limits: sandbox_allowed_domains (recipient domains allowed while sandbox_mode is on), send_allowlist_active/send_allowlist_count (project list of exact addresses) and org_send_allowlist_active/org_send_allowlist_count (organization list of exact addresses). A recipient must pass every active list; addresses are not shown. An active value of null means that list could not be read.',
277
298
  inputSchema: {},
278
299
  }, async () => {
279
300
  const ctx = requestContext();
@@ -297,10 +318,10 @@ export function registerAnalyticsTools(server) {
297
318
  const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
298
319
  const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
299
320
  directProjectIds.length
300
- ? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('id', directProjectIds)
321
+ ? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('id', directProjectIds)
301
322
  : Promise.resolve({ data: [], error: null }),
302
323
  ownedOrgIds.length
303
- ? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('org_id', ownedOrgIds)
324
+ ? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('org_id', ownedOrgIds)
304
325
  : Promise.resolve({ data: [], error: null }),
305
326
  ]);
306
327
  if (directProjectsError)
@@ -317,11 +338,12 @@ export function registerAnalyticsTools(server) {
317
338
  }
318
339
  const orgIds = Array.from(new Set(Array.from(byId.values()).map((project) => project.org_id).filter((id) => Boolean(id))));
319
340
  const { data: organizations, error: organizationsError } = orgIds.length
320
- ? await adminDb().from('organizations').select('id, name').in('id', orgIds)
341
+ ? await adminDb().from('organizations').select('id, name, send_allowlist').in('id', orgIds)
321
342
  : { data: [], error: null };
322
343
  if (organizationsError)
323
344
  return txt(`Error: ${dbToolError(organizationsError).error}`);
324
345
  const orgNames = new Map((organizations ?? []).map((org) => [org.id, org.name]));
346
+ const orgAllowlists = new Map((organizations ?? []).map((org) => [org.id, org.send_allowlist]));
325
347
  return j({
326
348
  projects: Array.from(byId.values())
327
349
  .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
@@ -332,6 +354,7 @@ export function registerAnalyticsTools(server) {
332
354
  org_name: project.org_id ? orgNames.get(project.org_id) ?? null : null,
333
355
  sandbox_mode: project.sandbox_mode,
334
356
  sandbox_allowed_domains: project.sandbox_allowed_domains,
357
+ ...sendAllowlistFields(project.send_allowlist, project.org_id ? orgAllowlists.get(project.org_id) : null),
335
358
  active: project.id === ctx.projectId,
336
359
  })),
337
360
  });
@@ -341,19 +364,19 @@ export function registerAnalyticsTools(server) {
341
364
  // Look up the org for this project, then list all projects in that org.
342
365
  const { data: project, error: projErr } = await adminDb()
343
366
  .from('projects')
344
- .select('id, name, org_id, sandbox_mode, sandbox_allowed_domains')
367
+ .select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist')
345
368
  .eq('id', projectId)
346
369
  .maybeSingle();
347
370
  if (projErr || !project)
348
371
  return txt(`Error: could not find project ${projectId}`);
349
372
  if (!project.org_id) {
350
373
  // No org — return just this project
351
- return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, current: true }] });
374
+ return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, ...sendAllowlistFields(project.send_allowlist, null), current: true }] });
352
375
  }
353
376
  // List all projects in the same organization
354
377
  const { data: projects, error: listErr } = await adminDb()
355
378
  .from('projects')
356
- .select('id, name, sandbox_mode, sandbox_allowed_domains')
379
+ .select('id, name, sandbox_mode, sandbox_allowed_domains, send_allowlist')
357
380
  .eq('org_id', project.org_id)
358
381
  .order('name');
359
382
  if (listErr)
@@ -361,7 +384,7 @@ export function registerAnalyticsTools(server) {
361
384
  // Get org info
362
385
  const { data: org } = await adminDb()
363
386
  .from('organizations')
364
- .select('id, name, plan')
387
+ .select('id, name, plan, send_allowlist')
365
388
  .eq('id', project.org_id)
366
389
  .maybeSingle();
367
390
  return j({
@@ -371,6 +394,7 @@ export function registerAnalyticsTools(server) {
371
394
  name: p.name,
372
395
  sandbox_mode: p.sandbox_mode,
373
396
  sandbox_allowed_domains: p.sandbox_allowed_domains,
397
+ ...sendAllowlistFields(p.send_allowlist, org?.send_allowlist),
374
398
  current: p.id === projectId,
375
399
  })),
376
400
  });
@@ -957,7 +957,7 @@ export function registerCampaignTools(server) {
957
957
  idempotentHint: false,
958
958
  openWorldHint: false,
959
959
  },
960
- description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval.',
960
+ description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval. Optional timezone (IANA name, e.g. America/New_York) sets the campaign time zone at create time; when omitted the database column default applies, unchanged from before.',
961
961
  inputSchema: {
962
962
  ...reentryFields,
963
963
  site_id: z.string().uuid(),
@@ -973,8 +973,12 @@ export function registerCampaignTools(server) {
973
973
  segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
974
974
  send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. Activation via activate_campaigns requires human approval and enrolls nobody. To schedule a one-off segment broadcast use send_broadcast with segment_id and send_at.'),
975
975
  send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
976
+ timezone: z.string().optional().describe('Optional IANA time zone for this campaign, e.g. America/New_York. Must be a valid IANA name. When omitted, the database column default applies (no change from earlier behavior).'),
976
977
  },
977
- }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key }) => {
978
+ }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key, timezone }) => {
979
+ if (timezone !== undefined && !validTimezone(timezone)) {
980
+ return j({ error: `timezone must be a valid IANA time zone name (for example America/New_York); got "${timezone}"`, code: 'validation_failed' });
981
+ }
978
982
  const limit = await checkPlanLimit('active_campaigns');
979
983
  if (!limit.allowed) {
980
984
  return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
@@ -989,6 +993,8 @@ export function registerCampaignTools(server) {
989
993
  sends_per_hour: sends_per_hour ?? null,
990
994
  requires_approval,
991
995
  ...(send_class ? { send_class } : {}),
996
+ // Omitted: no key at all, so the column default applies exactly as before (#4838 item 9).
997
+ ...(timezone !== undefined ? { timezone } : {}),
992
998
  };
993
999
  const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
994
1000
  if (!reentry.ok)
@@ -2723,7 +2729,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2723
2729
  idempotentHint: false,
2724
2730
  openWorldHint: true,
2725
2731
  },
2726
- description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
2732
+ description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics. The recipient must pass every active send limit: while sandbox_mode is on, its domain must be in sandbox_allowed_domains; if the project send allowlist is active, the exact address must be on it; if the organization send allowlist is active, the exact address must be on that too. Being on sandbox_allowed_domains alone is not enough when an exact-address list is active. list_projects shows which lists are active (send_allowlist_active, org_send_allowlist_active). A refusal names the list that refused (send_allowlist_refused or sandbox_refused).',
2727
2733
  inputSchema: {
2728
2734
  approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
2729
2735
  to_email: z.string().email().describe('Email address to send the test to'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.230",
3
+ "version": "1.0.232",
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",