@sendinel/mcp-server 1.0.74 → 1.0.75

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.
@@ -2288,7 +2288,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2288
2288
  idempotentHint: false,
2289
2289
  openWorldHint: true,
2290
2290
  },
2291
- 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. Logged as test, not counted in campaign metrics.',
2291
+ 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; suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
2292
2292
  inputSchema: {
2293
2293
  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 the same arguments plus this approval_id to run the action.'),
2294
2294
  to_email: z.string().email().describe('Email address to send the test to'),
@@ -2298,91 +2298,34 @@ Do not include body_html in your response; Sendinel renders body_html from block
2298
2298
  subject: z.string().optional(),
2299
2299
  body_html: z.string().optional(),
2300
2300
  body_text: z.string().optional(),
2301
- },
2302
- }, withApproval('send_test_email', async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
2303
- const projectId = getProjectId();
2304
- let subj = subject ?? '';
2305
- let html = body_html ?? '';
2306
- let text = body_text ?? '';
2307
- if (!subj && !html) {
2308
- if (step_id) {
2309
- const { data: step } = await db().from('campaign_steps').select('subject, body_html, body_text').eq('id', step_id).maybeSingle();
2310
- if (!step)
2311
- return txt('Error: Step not found');
2312
- subj = step.subject;
2313
- html = step.body_html ?? '';
2314
- text = step.body_text ?? '';
2315
- }
2316
- else if (draft_id) {
2317
- const { data: draft } = await db().from('ai_drafts').select('subject, body_html, body_text').eq('id', draft_id).eq('project_id', projectId).maybeSingle();
2318
- if (!draft)
2319
- return txt('Error: Draft not found');
2320
- subj = draft.subject;
2321
- html = draft.body_html ?? '';
2322
- text = draft.body_text ?? '';
2323
- }
2324
- else {
2301
+ dry_run: z.boolean().optional().describe('Render a preview through the real send renderer without sending or writing email_log.'),
2302
+ },
2303
+ }, async (args, extra) => {
2304
+ // Fail before the approval flow can touch the DB when stdio is not configured.
2305
+ if (!requestContext()?.v1Caller && !process.env.SENDINEL_API_KEY) {
2306
+ const result = await callSendinelV1('/api/v1/campaigns/test-send');
2307
+ if (!result.ok)
2308
+ return j({ error: result.error, status: result.status });
2309
+ }
2310
+ return withApproval('send_test_email', async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text, dry_run }) => {
2311
+ // Same precedence the tool always had, so callers passing several sources keep
2312
+ // working: raw subject/body_html wins, then step_id, then draft_id. The route
2313
+ // takes exactly one source.
2314
+ const source = subject || body_html ? { subject, body_html, body_text }
2315
+ : step_id ? { step_id }
2316
+ : draft_id ? { draft_id }
2317
+ : body_text ? { body_text }
2318
+ : null;
2319
+ if (!source)
2325
2320
  return txt('Provide content via subject/body_html, step_id, or draft_id.');
2326
- }
2327
- }
2328
- const resolve = (t) => t
2329
- .replace(/\{\{first_name\}\}/g, 'Test')
2330
- .replace(/\{\{email\}\}/g, to_email)
2331
- .replace(/\{\{unsubscribe_url\}\}/g, '#test-unsubscribe');
2332
- // Suppression check — don't send test emails to bounced/complained addresses
2333
- const { data: suppressed } = await db()
2334
- .from('suppressions')
2335
- .select('reason')
2336
- .eq('project_id', projectId)
2337
- .eq('email', to_email.toLowerCase())
2338
- .maybeSingle();
2339
- if (suppressed)
2340
- return txt(`Error: Recipient suppressed (${suppressed.reason}). Test emails to suppressed addresses risk domain reputation.`);
2341
- const { data: site } = await db()
2342
- .from('sites')
2343
- .select('from_email, from_name')
2344
- .eq('id', site_id)
2345
- .eq('project_id', projectId)
2346
- .maybeSingle();
2347
- if (!site)
2348
- return txt('Error: Site not found');
2349
- const resolvedHtml = resolve(html);
2350
- const resolvedText = resolve(text);
2351
- const payload = {
2352
- from: `${site.from_name} <${site.from_email}>`,
2353
- to: to_email,
2354
- subject: `[TEST] ${resolve(subj)}`,
2355
- html: resolvedHtml || undefined,
2356
- text: resolvedText || undefined,
2357
- };
2358
- let sendResult;
2359
- try {
2360
- sendResult = await sendEmailForSite(projectId, site_id, payload);
2361
- }
2362
- catch (error) {
2363
- return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
2364
- }
2365
- await db().from('email_log').insert({
2366
- project_id: projectId,
2367
- site_id,
2368
- contact_id: null,
2369
- to_email,
2370
- from_email: site.from_email,
2371
- subject: resolve(subj),
2372
- status: 'sent',
2373
- category: 'test',
2374
- sent_at: new Date().toISOString(),
2375
- provider_message_id: sendResult.messageId ?? null,
2376
- body_html: resolvedHtml ?? null,
2377
- });
2378
- return j({
2379
- sent: true,
2380
- to: to_email,
2381
- provider: sendResult.provider,
2382
- provider_message_id: sendResult.messageId,
2383
- resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
2384
- });
2385
- }));
2321
+ // All sources use the dashboard's shared renderer. In stdio mode callSendinelV1
2322
+ // requires SENDINEL_API_KEY; never fall back to a separate local renderer.
2323
+ const result = await callSendinelV1('/api/v1/campaigns/test-send', {
2324
+ body: { to_email, site_id, ...source, dry_run },
2325
+ });
2326
+ return result.ok ? j(result.data) : j({ error: result.error, status: result.status });
2327
+ })(args, extra);
2328
+ });
2386
2329
  server.registerTool('send_transactional', {
2387
2330
  title: 'Send Transactional',
2388
2331
  annotations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.74",
3
+ "version": "1.0.75",
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",