@sendinel/mcp-server 1.0.29 → 1.0.31

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.
@@ -458,8 +458,9 @@ export function registerCampaignTools(server) {
458
458
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('For trigger_type=date_field: { field, days_before?, recurring? }. Otherwise: { events?: string[], tags?: string[] }.'),
459
459
  segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
460
460
  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. The campaign must still be activated via update_campaign_status.'),
461
+ 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)."),
461
462
  },
462
- }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at }) => {
463
+ }, 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 }) => {
463
464
  const limit = await checkPlanLimit('active_campaigns');
464
465
  if (!limit.allowed) {
465
466
  return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
@@ -473,6 +474,7 @@ export function registerCampaignTools(server) {
473
474
  description,
474
475
  sends_per_hour: sends_per_hour ?? null,
475
476
  requires_approval,
477
+ ...(send_class ? { send_class } : {}),
476
478
  };
477
479
  const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
478
480
  if (scheduleError)
@@ -2691,7 +2693,7 @@ Return ONLY valid JSON with this exact shape:
2691
2693
  idempotentHint: true,
2692
2694
  openWorldHint: false,
2693
2695
  },
2694
- description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
2696
+ description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
2695
2697
  inputSchema: {
2696
2698
  campaign_id: z.string().uuid(),
2697
2699
  name: z.string().min(1).max(200).optional(),
@@ -2705,8 +2707,9 @@ Return ONLY valid JSON with this exact shape:
2705
2707
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
2706
2708
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
2707
2709
  send_in_contact_timezone: z.boolean().optional(),
2710
+ 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)."),
2708
2711
  },
2709
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone }) => {
2712
+ }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone, send_class }) => {
2710
2713
  const projectId = getProjectId();
2711
2714
  let campaignType;
2712
2715
  if (send_at !== undefined && send_at !== null) {
@@ -2736,6 +2739,8 @@ Return ONLY valid JSON with this exact shape:
2736
2739
  updates.sends_per_hour = sends_per_hour;
2737
2740
  if (send_in_contact_timezone !== undefined)
2738
2741
  updates.send_in_contact_timezone = send_in_contact_timezone;
2742
+ if (send_class !== undefined)
2743
+ updates.send_class = send_class;
2739
2744
  const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
2740
2745
  if (targetingError)
2741
2746
  return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
@@ -1921,15 +1921,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1921
1921
  ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
1922
1922
  }
1923
1923
  if (params.site_id) {
1924
- const { data: subs } = await db()
1925
- .from('site_subscriptions')
1926
- .select('contact_id')
1927
- .eq('site_id', params.site_id)
1928
- .eq('subscribed', true);
1929
- const ids = (subs ?? []).map((s) => s.contact_id);
1930
- if (!ids.length)
1924
+ // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
1925
+ const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId);
1926
+ if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
1931
1927
  return j({ total: 0, sample: [] });
1932
- q = q.in('id', ids);
1928
+ q = siteFiltered.query;
1933
1929
  }
1934
1930
  if (params.company_domain) {
1935
1931
  const { data: companies } = await db()
@@ -1976,6 +1972,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1976
1972
  if (params.custom_property) {
1977
1973
  ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
1978
1974
  }
1975
+ // The total must count the same site the sample came from (#3674).
1976
+ if (params.site_id)
1977
+ ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId));
1979
1978
  const { count } = await countQ;
1980
1979
  return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
1981
1980
  });
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, } from '@sendinel/domain';
2
+ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { logAudit } from '../audit.js';
@@ -34,17 +34,13 @@ async function getSegmentContactIds(rules) {
34
34
  const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
35
35
  if (siteContactIds && siteContactIds.length === 0)
36
36
  return [];
37
- const { data, error } = await filtered;
38
- if (error || !data)
39
- return [];
37
+ // Paged and loud (#3674): an unpaged read stopped at 1,000 rows, and an error read as "no one".
38
+ const data = await readAllRows(filtered);
40
39
  const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
41
40
  let ids = contacts.map((contact) => String(contact.id));
42
41
  if (tree.exclude_suppressed !== false && ids.length > 0) {
43
- const { data: suppressed } = await db()
44
- .from('suppressions')
45
- .select('email')
46
- .eq('project_id', projectId);
47
- if (suppressed?.length) {
42
+ const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
43
+ if (suppressed.length) {
48
44
  const suppressedEmails = new Set(suppressed.map((row) => row.email));
49
45
  ids = contacts
50
46
  .filter((contact) => !suppressedEmails.has(String(contact.email)))
@@ -3,7 +3,7 @@ import Anthropic from '@anthropic-ai/sdk';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { getAnthropicModelId } from '../anthropic-model.js';
6
- import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, } from '@sendinel/domain';
6
+ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
7
7
  async function loadFieldTypes(projectId) {
8
8
  const client = db();
9
9
  const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
@@ -34,18 +34,14 @@ async function getSegmentContactIds(rules) {
34
34
  const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
35
35
  if (siteContactIds && siteContactIds.length === 0)
36
36
  return [];
37
- const { data, error } = await filtered;
38
- if (error || !data)
39
- return [];
37
+ // Paged and loud (#3674): an unpaged read stopped at 1,000 rows, and an error read as "no one".
38
+ const data = await readAllRows(filtered);
40
39
  const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
41
40
  let ids = contacts.map((c) => String(c.id));
42
41
  if (tree.exclude_suppressed !== false && ids.length > 0) {
43
- const { data: suppressed } = await db()
44
- .from('suppressions')
45
- .select('email')
46
- .eq('project_id', projectId);
47
- if (suppressed?.length) {
48
- const suppressedEmails = new Set(suppressed.map((s) => s.email));
42
+ const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
43
+ if (suppressed.length) {
44
+ const suppressedEmails = new Set(suppressed.map((row) => row.email));
49
45
  ids = contacts
50
46
  .filter((c) => !suppressedEmails.has(String(c.email)))
51
47
  .map((c) => String(c.id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
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",