@neschadin/sendgrid-mcp 0.0.0-stage → 3.0.0

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.
@@ -0,0 +1,768 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import {
4
+ isSendGridApiError,
5
+ type SendGridClient,
6
+ type SendGridMailSendPayload,
7
+ } from '../client';
8
+ import { ensureSafeToolRegistration } from './tool_utils';
9
+ import {
10
+ PreflightOutputSchema,
11
+ SendWithPreflightOutputSchema,
12
+ } from './output_schemas';
13
+
14
+ const PROVIDER_FREE_FROM_DOMAINS = new Set([
15
+ 'gmail.com',
16
+ 'yahoo.com',
17
+ 'aol.com',
18
+ 'outlook.com',
19
+ 'hotmail.com',
20
+ 'icloud.com',
21
+ ]);
22
+ const MAX_PERSONALIZATIONS = 1000;
23
+ const MAX_CATEGORIES = 10;
24
+ const MAX_SEND_AT_SECONDS_AHEAD = 72 * 60 * 60;
25
+
26
+ const EmailAddressSchema = z.object({
27
+ email: z.email(),
28
+ name: z.string().optional(),
29
+ });
30
+
31
+ const AttachmentSchema = z.object({
32
+ content: z.string().min(1),
33
+ filename: z.string().min(1),
34
+ type: z.string().optional(),
35
+ disposition: z.enum(['attachment', 'inline']).optional(),
36
+ contentId: z.string().optional(),
37
+ });
38
+
39
+ const ContentSchema = z.object({
40
+ type: z.string().min(1),
41
+ value: z.string(),
42
+ });
43
+
44
+ const PersonalizationSchema = z.object({
45
+ to: z.array(EmailAddressSchema).min(1),
46
+ cc: z.array(EmailAddressSchema).optional(),
47
+ bcc: z.array(EmailAddressSchema).optional(),
48
+ subject: z.string().optional(),
49
+ dynamicTemplateData: z.record(z.string(), z.unknown()).optional(),
50
+ customArgs: z.record(z.string(), z.string()).optional(),
51
+ headers: z.record(z.string(), z.string()).optional(),
52
+ sendAt: z.number().int().optional(),
53
+ });
54
+
55
+ const AsmSchema = z.object({
56
+ groupId: z.number().int(),
57
+ groupsToDisplay: z.array(z.number().int()).optional(),
58
+ });
59
+
60
+ export const SendRequestSchema = z.object({
61
+ personalizations: z.array(PersonalizationSchema).min(1),
62
+ from: EmailAddressSchema,
63
+ replyTo: EmailAddressSchema.optional(),
64
+ subject: z.string().optional(),
65
+ content: z.array(ContentSchema).optional(),
66
+ attachments: z.array(AttachmentSchema).optional(),
67
+ templateId: z.string().optional(),
68
+ categories: z.array(z.string()).optional(),
69
+ customArgs: z.record(z.string(), z.string()).optional(),
70
+ headers: z.record(z.string(), z.string()).optional(),
71
+ sendAt: z.number().int().optional(),
72
+ batchId: z.string().optional(),
73
+ asm: AsmSchema.optional(),
74
+ ipPoolName: z.string().optional(),
75
+ mailSettings: z.record(z.string(), z.unknown()).optional(),
76
+ trackingSettings: z.record(z.string(), z.unknown()).optional(),
77
+ });
78
+
79
+ export type SendRequestInput = z.infer<typeof SendRequestSchema>;
80
+
81
+ type PreflightSeverity = 'blocker' | 'warning' | 'info';
82
+
83
+ interface PreflightIssue {
84
+ severity: PreflightSeverity;
85
+ code: string;
86
+ message: string;
87
+ }
88
+
89
+ interface PreflightOptions {
90
+ checkSenderIdentity: boolean;
91
+ partnerAccountId?: string;
92
+ }
93
+
94
+ export interface PreflightReport {
95
+ ok: boolean;
96
+ blockers: PreflightIssue[];
97
+ warnings: PreflightIssue[];
98
+ info: PreflightIssue[];
99
+ }
100
+
101
+ function normalizeEmail(email: string): string {
102
+ return email.trim().toLowerCase();
103
+ }
104
+
105
+ function extractDomain(email: string): string {
106
+ return normalizeEmail(email).split('@')[1] ?? '';
107
+ }
108
+
109
+ function isValidBase64(value: string): boolean {
110
+ const normalized = value.replace(/\s+/g, '');
111
+ if (normalized.length === 0 || normalized.length % 4 !== 0) return false;
112
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return false;
113
+
114
+ try {
115
+ const decoded = Uint8Array.from(atob(normalized), (char) =>
116
+ char.charCodeAt(0),
117
+ );
118
+ if (decoded.length === 0) return false;
119
+ let binary = '';
120
+ for (const byte of decoded) binary += String.fromCharCode(byte);
121
+ return btoa(binary).replace(/=+$/u, '') === normalized.replace(/=+$/u, '');
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function hasAnyPlainOrHtmlContent(request: SendRequestInput): boolean {
128
+ return request.content?.some((item) => item.value.trim().length > 0) === true;
129
+ }
130
+
131
+ function hasAnySubject(
132
+ request: SendRequestInput,
133
+ templateSubject?: string,
134
+ ): boolean {
135
+ if (request.subject?.trim()) return true;
136
+ if (templateSubject?.trim()) return true;
137
+
138
+ return request.personalizations.every(
139
+ (p) => typeof p.subject === 'string' && p.subject.trim().length > 0,
140
+ );
141
+ }
142
+
143
+ function pushIssue(
144
+ issues: PreflightIssue[],
145
+ severity: PreflightSeverity,
146
+ code: string,
147
+ message: string,
148
+ ) {
149
+ issues.push({ severity, code, message });
150
+ }
151
+
152
+ export function toMailSendPayload(
153
+ request: SendRequestInput,
154
+ ): SendGridMailSendPayload {
155
+ return {
156
+ personalizations: request.personalizations.map((p) => ({
157
+ to: p.to,
158
+ cc: p.cc,
159
+ bcc: p.bcc,
160
+ subject: p.subject,
161
+ dynamic_template_data: p.dynamicTemplateData,
162
+ custom_args: p.customArgs,
163
+ headers: p.headers,
164
+ send_at: p.sendAt,
165
+ })),
166
+ from: request.from,
167
+ reply_to: request.replyTo,
168
+ subject: request.subject,
169
+ content: request.content,
170
+ attachments: request.attachments?.map((attachment) => ({
171
+ content: attachment.content,
172
+ filename: attachment.filename,
173
+ type: attachment.type,
174
+ disposition: attachment.disposition,
175
+ content_id: attachment.contentId,
176
+ })),
177
+ template_id: request.templateId,
178
+ categories: request.categories,
179
+ custom_args: request.customArgs,
180
+ headers: request.headers,
181
+ send_at: request.sendAt,
182
+ batch_id: request.batchId,
183
+ asm: request.asm
184
+ ? {
185
+ group_id: request.asm.groupId,
186
+ groups_to_display: request.asm.groupsToDisplay,
187
+ }
188
+ : undefined,
189
+ ip_pool_name: request.ipPoolName,
190
+ mail_settings: request.mailSettings,
191
+ tracking_settings: request.trackingSettings,
192
+ };
193
+ }
194
+
195
+ function formatReport(report: PreflightReport): string {
196
+ const lines = [
197
+ `Preflight result: ${report.ok ? 'PASS' : 'FAIL'}`,
198
+ `Blockers: ${report.blockers.length}`,
199
+ `Warnings: ${report.warnings.length}`,
200
+ `Info: ${report.info.length}`,
201
+ '',
202
+ ];
203
+
204
+ for (const issue of report.blockers) {
205
+ lines.push(`[BLOCKER] ${issue.code}: ${issue.message}`);
206
+ }
207
+ for (const issue of report.warnings) {
208
+ lines.push(`[WARN] ${issue.code}: ${issue.message}`);
209
+ }
210
+ for (const issue of report.info) {
211
+ lines.push(`[INFO] ${issue.code}: ${issue.message}`);
212
+ }
213
+
214
+ if (
215
+ report.blockers.length === 0 &&
216
+ report.warnings.length === 0 &&
217
+ report.info.length === 0
218
+ ) {
219
+ lines.push('No issues detected.');
220
+ }
221
+
222
+ return lines.join('\n');
223
+ }
224
+
225
+ export async function runSendPreflight(
226
+ client: SendGridClient,
227
+ request: SendRequestInput,
228
+ options: PreflightOptions,
229
+ ): Promise<PreflightReport> {
230
+ const issues: PreflightIssue[] = [];
231
+ const now = Math.floor(Date.now() / 1000);
232
+
233
+ if (request.personalizations.length > MAX_PERSONALIZATIONS) {
234
+ pushIssue(
235
+ issues,
236
+ 'blocker',
237
+ 'TOO_MANY_PERSONALIZATIONS',
238
+ `SendGrid allows at most ${MAX_PERSONALIZATIONS} personalizations per /mail/send request.`,
239
+ );
240
+ }
241
+
242
+ if ((request.categories?.length ?? 0) > MAX_CATEGORIES) {
243
+ pushIssue(
244
+ issues,
245
+ 'blocker',
246
+ 'TOO_MANY_CATEGORIES',
247
+ `SendGrid allows at most ${MAX_CATEGORIES} categories per /mail/send request.`,
248
+ );
249
+ }
250
+
251
+ const sendAtValues = [
252
+ request.sendAt,
253
+ ...request.personalizations.map((p) => p.sendAt),
254
+ ].filter((value): value is number => typeof value === 'number');
255
+ for (const sendAt of sendAtValues) {
256
+ if (sendAt <= now) {
257
+ pushIssue(
258
+ issues,
259
+ 'blocker',
260
+ 'SEND_AT_NOT_IN_FUTURE',
261
+ `send_at must be in the future. Received ${sendAt}, current timestamp is ${now}.`,
262
+ );
263
+ } else if (sendAt > now + MAX_SEND_AT_SECONDS_AHEAD) {
264
+ pushIssue(
265
+ issues,
266
+ 'blocker',
267
+ 'SEND_AT_TOO_FAR_AHEAD',
268
+ 'SendGrid scheduled sends must be within 72 hours of submission.',
269
+ );
270
+ }
271
+ }
272
+
273
+ const bypassListManagement = request.mailSettings?.['bypass_list_management'];
274
+ if (
275
+ typeof bypassListManagement === 'object' &&
276
+ bypassListManagement !== null &&
277
+ (bypassListManagement as Record<string, unknown>)['enable'] === true
278
+ ) {
279
+ pushIssue(
280
+ issues,
281
+ 'warning',
282
+ 'BYPASS_LIST_MANAGEMENT_ENABLED',
283
+ 'mail_settings.bypass_list_management.enable=true can bypass unsubscribe/suppression safeguards.',
284
+ );
285
+ }
286
+
287
+ // Each personalization block must not repeat the same address across to/cc/bcc.
288
+ request.personalizations.forEach((personalization, index) => {
289
+ const seen = new Set<string>();
290
+ const all = [
291
+ ...(personalization.to ?? []),
292
+ ...(personalization.cc ?? []),
293
+ ...(personalization.bcc ?? []),
294
+ ];
295
+ for (const recipient of all) {
296
+ const email = normalizeEmail(recipient.email);
297
+ if (seen.has(email)) {
298
+ pushIssue(
299
+ issues,
300
+ 'blocker',
301
+ 'DUPLICATE_RECIPIENT',
302
+ `personalizations[${index}] contains duplicate recipient in to/cc/bcc: ${email}`,
303
+ );
304
+ }
305
+ seen.add(email);
306
+ }
307
+ });
308
+
309
+ if (!request.templateId) {
310
+ if (!hasAnyPlainOrHtmlContent(request)) {
311
+ pushIssue(
312
+ issues,
313
+ 'blocker',
314
+ 'MISSING_CONTENT',
315
+ 'Non-template send must include non-empty content.',
316
+ );
317
+ }
318
+
319
+ if (!hasAnySubject(request)) {
320
+ pushIssue(
321
+ issues,
322
+ 'blocker',
323
+ 'MISSING_SUBJECT',
324
+ 'Subject is required unless every personalization provides one.',
325
+ );
326
+ }
327
+ } else if (request.content && request.content.length > 0) {
328
+ pushIssue(
329
+ issues,
330
+ 'warning',
331
+ 'CONTENT_IGNORED_WITH_TEMPLATE',
332
+ 'content is usually ignored when templateId is provided.',
333
+ );
334
+ }
335
+
336
+ for (const attachment of request.attachments ?? []) {
337
+ if (!isValidBase64(attachment.content)) {
338
+ pushIssue(
339
+ issues,
340
+ 'blocker',
341
+ 'ATTACHMENT_NOT_BASE64',
342
+ `Attachment "${attachment.filename}" is not valid base64.`,
343
+ );
344
+ }
345
+
346
+ if (/\.(exe|js|vbs|cmd|bat|scr|jar)$/iu.test(attachment.filename)) {
347
+ pushIssue(
348
+ issues,
349
+ 'warning',
350
+ 'ATTACHMENT_HIGH_RISK_EXTENSION',
351
+ `Attachment "${attachment.filename}" may be rejected by mailbox providers.`,
352
+ );
353
+ }
354
+ }
355
+
356
+ const htmlBodies =
357
+ request.content
358
+ ?.filter((item) => item.type.toLowerCase() === 'text/html')
359
+ .map((item) => item.value) ?? [];
360
+ for (const html of htmlBodies) {
361
+ if (/<v:roundrect\b/iu.test(html)) {
362
+ pushIssue(
363
+ issues,
364
+ 'warning',
365
+ 'VML_ROUNDRECT_TRACKING',
366
+ 'VML roundrect links may break click tracking in Outlook Classic.',
367
+ );
368
+ }
369
+ if (/<script\b[^>]*data-cf/iu.test(html)) {
370
+ pushIssue(
371
+ issues,
372
+ 'warning',
373
+ 'CLOUDFLARE_SCRIPT_TAG_DETECTED',
374
+ 'Cloudflare bot-detection script tags may trigger Gmail attachment/security blocking.',
375
+ );
376
+ }
377
+ }
378
+
379
+ if (request.templateId) {
380
+ try {
381
+ const template = await client.getTemplate(request.templateId);
382
+ const activeVersion = template.versions.find(
383
+ (version) => version.active === 1,
384
+ );
385
+
386
+ if (!activeVersion) {
387
+ pushIssue(
388
+ issues,
389
+ 'blocker',
390
+ 'TEMPLATE_HAS_NO_ACTIVE_VERSION',
391
+ `Template ${request.templateId} exists but has no active version.`,
392
+ );
393
+ } else if (!hasAnySubject(request, activeVersion.subject)) {
394
+ pushIssue(
395
+ issues,
396
+ 'blocker',
397
+ 'MISSING_SUBJECT_WITH_TEMPLATE',
398
+ 'Template send has no subject in request and active template subject is empty.',
399
+ );
400
+ }
401
+ } catch (error) {
402
+ if (isSendGridApiError(error) && error.status === 404) {
403
+ pushIssue(
404
+ issues,
405
+ 'blocker',
406
+ 'INVALID_TEMPLATE_ID',
407
+ `Template ${request.templateId} was not found.`,
408
+ );
409
+ } else {
410
+ pushIssue(
411
+ issues,
412
+ 'blocker',
413
+ 'TEMPLATE_LOOKUP_FAILED',
414
+ `Could not validate template ${request.templateId}: ${String(error)}`,
415
+ );
416
+ }
417
+ }
418
+ }
419
+
420
+ const senderDomain = extractDomain(request.from.email);
421
+ let dmarcWarned = false;
422
+ try {
423
+ const warnList = await client.listDomainWarnList();
424
+ const listed = (domains: string[]) =>
425
+ domains.some((domain) => domain.toLowerCase() === senderDomain);
426
+ if (listed(warnList.hardFailures)) {
427
+ dmarcWarned = true;
428
+ pushIssue(
429
+ issues,
430
+ 'warning',
431
+ 'DMARC_HARD_FAIL',
432
+ `From domain "${senderDomain}" is on SendGrid's DMARC hard-fail warn list (GET /v3/verified_senders/domains).`,
433
+ );
434
+ } else if (listed(warnList.softFailures)) {
435
+ dmarcWarned = true;
436
+ pushIssue(
437
+ issues,
438
+ 'warning',
439
+ 'DMARC_SOFT_FAIL',
440
+ `From domain "${senderDomain}" is on SendGrid's DMARC soft-fail warn list (GET /v3/verified_senders/domains).`,
441
+ );
442
+ }
443
+ } catch {
444
+ dmarcWarned = false;
445
+ }
446
+ if (!dmarcWarned && PROVIDER_FREE_FROM_DOMAINS.has(senderDomain)) {
447
+ pushIssue(
448
+ issues,
449
+ 'warning',
450
+ 'FREE_MAILBOX_FROM_DOMAIN',
451
+ `From domain "${senderDomain}" is often DMARC-sensitive for API sends. The SendGrid domain warn list could not be loaded.`,
452
+ );
453
+ }
454
+
455
+ if (options.checkSenderIdentity) {
456
+ try {
457
+ const recipients = new Set<string>();
458
+ for (const personalization of request.personalizations) {
459
+ for (const recipient of [
460
+ ...(personalization.to ?? []),
461
+ ...(personalization.cc ?? []),
462
+ ...(personalization.bcc ?? []),
463
+ ]) {
464
+ recipients.add(normalizeEmail(recipient.email));
465
+ }
466
+ }
467
+
468
+ const [domains, senders, links] = await Promise.all([
469
+ client.listAuthenticatedDomains(),
470
+ client.listVerifiedSenders({ limit: 200 }),
471
+ client.listBrandedLinks(),
472
+ ]);
473
+
474
+ const senderEmail = normalizeEmail(request.from.email);
475
+ const domainAuthenticated = domains.some((domain) => {
476
+ if (!domain.domain || domain.valid === false) return false;
477
+ const root = domain.domain.toLowerCase();
478
+ return senderDomain === root || senderDomain.endsWith(`.${root}`);
479
+ });
480
+
481
+ const senderVerified = senders.some(
482
+ (sender) =>
483
+ sender.verified === true &&
484
+ typeof sender.from_email === 'string' &&
485
+ normalizeEmail(sender.from_email) === senderEmail,
486
+ );
487
+
488
+ if (!domainAuthenticated && !senderVerified) {
489
+ pushIssue(
490
+ issues,
491
+ 'blocker',
492
+ 'UNVERIFIED_SENDER_IDENTITY',
493
+ `From address ${request.from.email} is not matched by authenticated domains or verified senders.`,
494
+ );
495
+ } else if (!domainAuthenticated && senderVerified) {
496
+ pushIssue(
497
+ issues,
498
+ 'warning',
499
+ 'SINGLE_SENDER_ONLY',
500
+ `Sender ${request.from.email} is verified, but no authenticated domain match was found.`,
501
+ );
502
+ }
503
+
504
+ if (links.length === 0) {
505
+ pushIssue(
506
+ issues,
507
+ 'warning',
508
+ 'NO_LINK_BRANDING',
509
+ 'No link branding found; tracked links may use sendgrid.net.',
510
+ );
511
+ } else if (!links.some((link) => link.default === true)) {
512
+ pushIssue(
513
+ issues,
514
+ 'warning',
515
+ 'NO_DEFAULT_LINK_BRANDING',
516
+ 'No default link branding is configured.',
517
+ );
518
+ } else if (
519
+ !links.some((link) => {
520
+ const domain = link.domain?.toLowerCase();
521
+ if (!domain) return false;
522
+ return senderDomain === domain || senderDomain.endsWith(`.${domain}`);
523
+ })
524
+ ) {
525
+ pushIssue(
526
+ issues,
527
+ 'warning',
528
+ 'LINK_BRANDING_DOMAIN_MISMATCH',
529
+ `From domain ${senderDomain} is not aligned with current branded-link domains.`,
530
+ );
531
+ }
532
+
533
+ for (const recipient of recipients) {
534
+ const suppression = await client.checkSuppression(recipient);
535
+ if (suppression.bounced) {
536
+ pushIssue(
537
+ issues,
538
+ 'blocker',
539
+ 'RECIPIENT_BOUNCED',
540
+ `Recipient ${recipient} is on the bounce suppression list.`,
541
+ );
542
+ }
543
+ if (suppression.blocked) {
544
+ pushIssue(
545
+ issues,
546
+ 'blocker',
547
+ 'RECIPIENT_BLOCKED',
548
+ `Recipient ${recipient} is on the block suppression list.`,
549
+ );
550
+ }
551
+ if (suppression.invalidEmail) {
552
+ pushIssue(
553
+ issues,
554
+ 'blocker',
555
+ 'RECIPIENT_INVALID_EMAIL',
556
+ `Recipient ${recipient} is on the invalid email suppression list.`,
557
+ );
558
+ }
559
+ if (suppression.unsubscribed) {
560
+ pushIssue(
561
+ issues,
562
+ 'warning',
563
+ 'RECIPIENT_GLOBAL_UNSUBSCRIBE',
564
+ `Recipient ${recipient} is globally unsubscribed.`,
565
+ );
566
+ }
567
+ if (suppression.spamReported) {
568
+ pushIssue(
569
+ issues,
570
+ 'warning',
571
+ 'RECIPIENT_SPAM_REPORTED',
572
+ `Recipient ${recipient} has a spam-report suppression.`,
573
+ );
574
+ }
575
+ const suppressedGroups = (suppression.groupSuppressions ?? []).filter(
576
+ (group) => group.suppressed,
577
+ );
578
+ const requestedGroupId = request.asm?.groupId;
579
+ const matchedGroup = suppressedGroups.find(
580
+ (group) => group.id === requestedGroupId,
581
+ );
582
+ if (matchedGroup) {
583
+ pushIssue(
584
+ issues,
585
+ 'blocker',
586
+ 'RECIPIENT_GROUP_UNSUBSCRIBE',
587
+ `Recipient ${recipient} is unsubscribed from ASM group ${matchedGroup.id} (${matchedGroup.name}), which this send uses.`,
588
+ );
589
+ } else if (suppressedGroups.length > 0) {
590
+ pushIssue(
591
+ issues,
592
+ 'warning',
593
+ 'RECIPIENT_GROUP_UNSUBSCRIBE',
594
+ `Recipient ${recipient} is unsubscribed from ASM groups ${suppressedGroups.map((group) => `${group.id}:${group.name}`).join(', ')}. This send does not target those groups.`,
595
+ );
596
+ }
597
+ if (suppression.groupLookupError) {
598
+ pushIssue(
599
+ issues,
600
+ 'warning',
601
+ 'GROUP_SUPPRESSION_LOOKUP_FAILED',
602
+ `ASM group suppression lookup failed for ${recipient}: ${suppression.groupLookupError}`,
603
+ );
604
+ }
605
+ }
606
+ } catch (error) {
607
+ pushIssue(
608
+ issues,
609
+ 'warning',
610
+ 'SENDER_CHECK_FAILED',
611
+ `Sender identity checks could not be completed: ${String(error)}`,
612
+ );
613
+ }
614
+ }
615
+
616
+ if (options.partnerAccountId) {
617
+ try {
618
+ const state = await client.getPartnerAccountState(
619
+ options.partnerAccountId,
620
+ );
621
+ if (state.state !== 'activated') {
622
+ pushIssue(
623
+ issues,
624
+ 'blocker',
625
+ 'ACCOUNT_NOT_ACTIVATED',
626
+ `Partner account state is "${state.state}", expected "activated".`,
627
+ );
628
+ }
629
+ } catch (error) {
630
+ pushIssue(
631
+ issues,
632
+ 'warning',
633
+ 'ACCOUNT_STATE_UNAVAILABLE',
634
+ `Could not retrieve partner account state: ${String(error)}`,
635
+ );
636
+ }
637
+ }
638
+
639
+ const blockers = issues.filter((issue) => issue.severity === 'blocker');
640
+ const warnings = issues.filter((issue) => issue.severity === 'warning');
641
+ const info = issues.filter((issue) => issue.severity === 'info');
642
+
643
+ return {
644
+ ok: blockers.length === 0,
645
+ blockers,
646
+ warnings,
647
+ info,
648
+ };
649
+ }
650
+
651
+ export function registerPreflightTools(
652
+ server: McpServer,
653
+ client: SendGridClient,
654
+ ) {
655
+ ensureSafeToolRegistration(server);
656
+
657
+ server.registerTool(
658
+ 'validate_send_request',
659
+ {
660
+ description:
661
+ 'Run this before any send_* call. Validates /v3/mail/send payload shape, active dynamic template, sender identity (domain authentication or verified sender), link branding alignment, recipient suppressions, and scheduling limits (send_at must be in the future and within 72 hours per SendGrid Mail Send API). Returns blockers and warnings without sending mail.',
662
+ inputSchema: z.object({
663
+ request: SendRequestSchema,
664
+ partnerAccountId: z
665
+ .string()
666
+ .optional()
667
+ .describe(
668
+ 'Optional partner account ID for /partners/accounts/{id}/state check',
669
+ ),
670
+ checkSenderIdentity: z
671
+ .boolean()
672
+ .optional()
673
+ .describe(
674
+ 'Enable sender/domain/link-branding checks (default: true)',
675
+ ),
676
+ }),
677
+ outputSchema: PreflightOutputSchema,
678
+ },
679
+ async ({ request, partnerAccountId, checkSenderIdentity }) => {
680
+ const report = await runSendPreflight(client, request, {
681
+ partnerAccountId,
682
+ checkSenderIdentity: checkSenderIdentity ?? true,
683
+ });
684
+
685
+ return {
686
+ structuredContent: report,
687
+ content: [{ type: 'text', text: formatReport(report) }],
688
+ };
689
+ },
690
+ );
691
+
692
+ server.registerTool(
693
+ 'send_with_preflight',
694
+ {
695
+ description:
696
+ 'Preferred production send path: runs validate_send_request checks first, then POST /v3/mail/send only when no blockers (and optionally no warnings with abortOnWarnings). Use validate_send_request alone for a dry-run review before calling send_email_advanced or send_template_email_advanced.',
697
+ inputSchema: z.object({
698
+ request: SendRequestSchema,
699
+ partnerAccountId: z.string().optional(),
700
+ checkSenderIdentity: z.boolean().optional(),
701
+ abortOnWarnings: z
702
+ .boolean()
703
+ .optional()
704
+ .describe('If true, warnings also block sending (default: false)'),
705
+ }),
706
+ outputSchema: SendWithPreflightOutputSchema,
707
+ },
708
+ async ({
709
+ request,
710
+ partnerAccountId,
711
+ checkSenderIdentity,
712
+ abortOnWarnings,
713
+ }) => {
714
+ const report = await runSendPreflight(client, request, {
715
+ partnerAccountId,
716
+ checkSenderIdentity: checkSenderIdentity ?? true,
717
+ });
718
+
719
+ const shouldAbort =
720
+ report.blockers.length > 0 ||
721
+ (abortOnWarnings === true && report.warnings.length > 0);
722
+
723
+ if (shouldAbort) {
724
+ return {
725
+ structuredContent: {
726
+ sent: false,
727
+ report,
728
+ statusCode: null,
729
+ messageId: null,
730
+ },
731
+ content: [
732
+ {
733
+ type: 'text',
734
+ text: [
735
+ 'Send skipped due to preflight findings.',
736
+ '',
737
+ formatReport(report),
738
+ ].join('\n'),
739
+ },
740
+ ],
741
+ };
742
+ }
743
+
744
+ const sendResult = await client.sendMail(toMailSendPayload(request));
745
+
746
+ return {
747
+ structuredContent: {
748
+ sent: true,
749
+ report,
750
+ statusCode: sendResult.statusCode,
751
+ messageId: sendResult.messageId || null,
752
+ },
753
+ content: [
754
+ {
755
+ type: 'text',
756
+ text: [
757
+ 'Email accepted by SendGrid.',
758
+ `Status code: ${sendResult.statusCode}`,
759
+ `Message ID: ${sendResult.messageId || '(not returned)'}`,
760
+ '',
761
+ formatReport(report),
762
+ ].join('\n'),
763
+ },
764
+ ],
765
+ };
766
+ },
767
+ );
768
+ }