@sendinel/mcp-server 1.0.42 → 1.0.43

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.
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { NAME_FALLBACKS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
3
+ import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
4
4
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
5
5
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
6
6
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -229,6 +229,25 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
229
229
  * send_broadcast so both run the same steps. Enrolls exactly the given contact ids.
230
230
  */
231
231
  async function executeBroadcast(input) {
232
+ // #3749 One Send: the same email (site + subject + body) joins its existing active/completed
233
+ // campaign from the last 90 days instead of creating another. Same rule as the dashboard helper.
234
+ const contentKey = computeSendContentKey({ siteId: input.siteId, subject: input.subject, bodyHtml: input.bodyHtml });
235
+ const since = new Date(Date.now() - ONE_SEND_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
236
+ const { data: matches, error: matchErr } = await db()
237
+ .from('campaigns')
238
+ .select('id, status')
239
+ .eq('project_id', input.projectId)
240
+ .eq('metadata->>content_key', contentKey)
241
+ .in('status', [...ONE_SEND_MATCH_STATUSES])
242
+ .gte('created_at', since)
243
+ .order('created_at', { ascending: false })
244
+ .limit(1);
245
+ if (matchErr) {
246
+ return { ok: false, error: matchErr.message, code: 'ONE_SEND_LOOKUP_FAILED' };
247
+ }
248
+ const match = (matches ?? [])[0];
249
+ if (match)
250
+ return attachBroadcastToExisting(input, match);
232
251
  const limit = await checkPlanLimit('active_campaigns');
233
252
  if (!limit.allowed) {
234
253
  return { ok: false, error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' };
@@ -246,7 +265,7 @@ async function executeBroadcast(input) {
246
265
  send_class: input.sendClass,
247
266
  sends_per_hour: input.sendsPerHour ?? null,
248
267
  requires_approval: false,
249
- metadata: { source: input.source, ...(input.metadata ?? {}) },
268
+ metadata: { source: input.source, ...(input.metadata ?? {}), content_key: contentKey },
250
269
  })
251
270
  .select('id')
252
271
  .single();
@@ -305,6 +324,83 @@ async function executeBroadcast(input) {
305
324
  }
306
325
  return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
307
326
  }
327
+ /** #3749: enroll into the matched campaign, skipping anyone enrolled or already sent to. */
328
+ async function attachBroadcastToExisting(input, match) {
329
+ const skip = new Set();
330
+ const sentStatuses = new Set(ONE_SEND_SENT_STATUSES);
331
+ const chunk = 200;
332
+ for (let i = 0; i < input.contactIds.length; i += chunk) {
333
+ const ids = input.contactIds.slice(i, i + chunk);
334
+ const { data: enrolledRows, error: enrolledErr } = await db()
335
+ .from('campaign_enrollments')
336
+ .select('contact_id')
337
+ .eq('campaign_id', match.id)
338
+ .in('contact_id', ids)
339
+ .limit(10000);
340
+ if (enrolledErr)
341
+ return { ok: false, error: enrolledErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
342
+ for (const row of enrolledRows ?? [])
343
+ skip.add(row.contact_id);
344
+ const { data: logRows, error: logErr } = await db()
345
+ .from('email_log')
346
+ .select('contact_id, status')
347
+ .eq('project_id', input.projectId)
348
+ .eq('campaign_id', match.id)
349
+ .in('contact_id', ids)
350
+ .limit(10000);
351
+ if (logErr)
352
+ return { ok: false, error: logErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
353
+ for (const row of logRows ?? [])
354
+ if (sentStatuses.has(row.status))
355
+ skip.add(row.contact_id);
356
+ }
357
+ const fresh = input.contactIds.filter((id) => !skip.has(id));
358
+ const alreadySent = input.contactIds.length - fresh.length;
359
+ const nextSendAt = input.sendAt ?? new Date().toISOString();
360
+ let enrolled = 0;
361
+ for (let i = 0; i < fresh.length; i += 500) {
362
+ const batch = fresh.slice(i, i + 500).map((contact_id) => ({
363
+ campaign_id: match.id,
364
+ contact_id,
365
+ next_send_at: nextSendAt,
366
+ }));
367
+ const { data: inserted, error: enrollErr } = await db()
368
+ .from('campaign_enrollments')
369
+ .upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true })
370
+ .select('contact_id');
371
+ if (enrollErr) {
372
+ return { ok: false, error: enrollErr.message, code: 'ENROLLMENT_FAILED', campaign_id: match.id, enrolled_before_failure: enrolled };
373
+ }
374
+ enrolled += (inserted ?? []).length;
375
+ }
376
+ if (enrolled > 0) {
377
+ const { count } = await db()
378
+ .from('campaign_enrollments')
379
+ .select('id', { count: 'exact', head: true })
380
+ .eq('campaign_id', match.id);
381
+ const update = { updated_at: new Date().toISOString() };
382
+ if (typeof count === 'number')
383
+ update.total_enrolled = count;
384
+ if (match.status === 'completed')
385
+ update.status = 'active';
386
+ const { error: updateErr } = await db()
387
+ .from('campaigns')
388
+ .update(update)
389
+ .eq('id', match.id)
390
+ .eq('project_id', input.projectId);
391
+ if (updateErr) {
392
+ return { ok: false, error: updateErr.message, code: 'ACTIVATION_FAILED', campaign_id: match.id, enrolled };
393
+ }
394
+ }
395
+ const { data: steps } = await db()
396
+ .from('campaign_steps')
397
+ .select('id')
398
+ .eq('campaign_id', match.id)
399
+ .order('step_order', { ascending: true })
400
+ .limit(1);
401
+ const stepId = (steps ?? [])[0]?.id ?? '';
402
+ return { ok: true, campaignId: match.id, stepId, enrolled, nextSendAt, grouped: true, alreadySent };
403
+ }
308
404
  /** #3714: a send nobody could receive leaves one audit row (counts by reason, no addresses). */
309
405
  function logRefusedSend(kind, siteId, sendClass, audience, extra = {}) {
310
406
  const reasons = {};
@@ -993,7 +1089,7 @@ export function registerCampaignTools(server) {
993
1089
  details: {
994
1090
  site_id,
995
1091
  segment_id,
996
- contacts_enrolled: contactIds.length,
1092
+ contacts_enrolled: executed.enrolled,
997
1093
  source: 'mcp',
998
1094
  },
999
1095
  }).catch(() => { });
@@ -1001,7 +1097,7 @@ export function registerCampaignTools(server) {
1001
1097
  campaign_id: campaign.id,
1002
1098
  campaign_name: campaignName,
1003
1099
  type: 'broadcast',
1004
- contacts_enrolled: contactIds.length,
1100
+ contacts_enrolled: executed.enrolled,
1005
1101
  steps: 1,
1006
1102
  }).catch(() => { });
1007
1103
  return j({
@@ -1010,9 +1106,10 @@ export function registerCampaignTools(server) {
1010
1106
  name: campaignName,
1011
1107
  status: 'active',
1012
1108
  segment: { id: segment.id, name: segment.name },
1013
- contacts_enrolled: contactIds.length,
1109
+ contacts_enrolled: executed.enrolled,
1014
1110
  next_send_at: sendAt,
1015
- message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
1111
+ message: `Broadcast "${campaignName}" is active with ${executed.enrolled} contact(s) enrolled for immediate sending.`,
1112
+ ...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
1016
1113
  });
1017
1114
  });
1018
1115
  server.registerTool('send_broadcast', {
@@ -1108,6 +1205,7 @@ export function registerCampaignTools(server) {
1108
1205
  status: 'active',
1109
1206
  contacts_enrolled: executed.enrolled,
1110
1207
  send_at: payload.send_at ?? 'now',
1208
+ ...(executed.grouped ? { grouped: true, already_sent: executed.alreadySent ?? 0 } : {}),
1111
1209
  message: payload.send_at
1112
1210
  ? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
1113
1211
  : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.42",
3
+ "version": "1.0.43",
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",