@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.
- package/LICENSE +21 -0
- package/MCP_TOOLS.md +457 -0
- package/README.md +175 -2
- package/bin/sendgrid-mcp +2 -0
- package/mcp.json.example +9 -0
- package/package.json +63 -4
- package/src/client.ts +1585 -0
- package/src/config.ts +197 -0
- package/src/http.ts +132 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +43 -0
- package/src/redact.ts +91 -0
- package/src/tool_signal.ts +50 -0
- package/src/tools/account.ts +566 -0
- package/src/tools/classify_error.ts +133 -0
- package/src/tools/console_settings.ts +329 -0
- package/src/tools/delivery_trace.ts +291 -0
- package/src/tools/diagnostics.ts +1557 -0
- package/src/tools/email.ts +486 -0
- package/src/tools/output_schemas.ts +488 -0
- package/src/tools/preflight.ts +768 -0
- package/src/tools/sync.ts +139 -0
- package/src/tools/templates.ts +602 -0
- package/src/tools/tool_utils.ts +341 -0
- package/src/webhook_receiver.ts +467 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isSendGridApiError, type SendGridClient } from '../client';
|
|
4
|
+
import {
|
|
5
|
+
ensureSafeToolRegistration,
|
|
6
|
+
formatToolError,
|
|
7
|
+
ListPagingInputFields,
|
|
8
|
+
ReadInputFields,
|
|
9
|
+
ResponseFormatSchema,
|
|
10
|
+
} from './tool_utils';
|
|
11
|
+
import {
|
|
12
|
+
AnalyzeEngagementOutputSchema,
|
|
13
|
+
AsmGroupListOutputSchema,
|
|
14
|
+
AsmGroupSchema,
|
|
15
|
+
AsmGroupSuppressionSchema,
|
|
16
|
+
CategoryListOutputSchema,
|
|
17
|
+
ClassifyErrorOutputSchema,
|
|
18
|
+
DeleteSuppressionOutputSchema,
|
|
19
|
+
EmailLogsOutputSchema,
|
|
20
|
+
EmailStatsOutputSchema,
|
|
21
|
+
EventWebhookListOutputSchema,
|
|
22
|
+
EventWebhookSchema,
|
|
23
|
+
ManageEventWebhookOutputSchema,
|
|
24
|
+
ListSuppressionsOutputSchema,
|
|
25
|
+
MessageActivitySchema,
|
|
26
|
+
ReceivedWebhookEventsOutputSchema,
|
|
27
|
+
SearchMessageActivityOutputSchema,
|
|
28
|
+
StatsDimensionSchema,
|
|
29
|
+
TriageDeliveryOutputSchema,
|
|
30
|
+
WebhookReceiverStatusOutputSchema,
|
|
31
|
+
buildPaginationMeta,
|
|
32
|
+
jsonReadResult,
|
|
33
|
+
paginateArray,
|
|
34
|
+
} from './output_schemas';
|
|
35
|
+
import {
|
|
36
|
+
activityListMeta,
|
|
37
|
+
compileActivitySearch,
|
|
38
|
+
rejectActivityOffset,
|
|
39
|
+
searchActivityOrLogs,
|
|
40
|
+
summarizeActivityMessage,
|
|
41
|
+
traceMessage,
|
|
42
|
+
} from './delivery_trace';
|
|
43
|
+
import {
|
|
44
|
+
clearStoredWebhookEvents,
|
|
45
|
+
getStoredWebhookEvents,
|
|
46
|
+
getWebhookReceiverStatus,
|
|
47
|
+
} from '../webhook_receiver';
|
|
48
|
+
import { classifySendGridError } from './classify_error';
|
|
49
|
+
|
|
50
|
+
type EngagementEvent = {
|
|
51
|
+
event: string;
|
|
52
|
+
email?: string;
|
|
53
|
+
timestamp?: number;
|
|
54
|
+
ip?: string;
|
|
55
|
+
useragent?: string;
|
|
56
|
+
sg_machine_open?: boolean;
|
|
57
|
+
sg_message_id?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ConfirmTokenSchema = z
|
|
61
|
+
.literal('CONFIRM')
|
|
62
|
+
.describe('Required confirmation token');
|
|
63
|
+
|
|
64
|
+
const SHORT_STATS_WINDOW = new Set(['browser', 'device', 'client']);
|
|
65
|
+
|
|
66
|
+
function collectStatTotals(payload: unknown): {
|
|
67
|
+
totals: { requests: number; delivered: number; bounces: number; opens: number };
|
|
68
|
+
lines: string[];
|
|
69
|
+
} {
|
|
70
|
+
const totals = { requests: 0, delivered: 0, bounces: 0, opens: 0 };
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
const visit = (value: unknown, prefix: string) => {
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
for (const item of value) visit(item, prefix);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!value || typeof value !== 'object') return;
|
|
78
|
+
const record = value as Record<string, unknown>;
|
|
79
|
+
const date = typeof record['date'] === 'string' ? record['date'] : prefix;
|
|
80
|
+
const name = typeof record['name'] === 'string' ? record['name'] : undefined;
|
|
81
|
+
const metrics = record['metrics'];
|
|
82
|
+
if (metrics && typeof metrics === 'object') {
|
|
83
|
+
const metric = metrics as Record<string, unknown>;
|
|
84
|
+
if (typeof metric['requests'] === 'number') {
|
|
85
|
+
const delivered =
|
|
86
|
+
typeof metric['delivered'] === 'number' ? metric['delivered'] : 0;
|
|
87
|
+
const bounces =
|
|
88
|
+
typeof metric['bounces'] === 'number' ? metric['bounces'] : 0;
|
|
89
|
+
const opens = typeof metric['opens'] === 'number' ? metric['opens'] : 0;
|
|
90
|
+
totals.requests += metric['requests'];
|
|
91
|
+
totals.delivered += delivered;
|
|
92
|
+
totals.bounces += bounces;
|
|
93
|
+
totals.opens += opens;
|
|
94
|
+
const label = [date, name].filter((part) => part && part.length > 0).join(' ');
|
|
95
|
+
lines.push(
|
|
96
|
+
`${label}: requests=${metric['requests']} delivered=${delivered} bounces=${bounces} opens=${opens}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(record['stats'])) visit(record['stats'], date);
|
|
101
|
+
};
|
|
102
|
+
visit(payload, '');
|
|
103
|
+
return { totals, lines };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asStatsSeries(payload: unknown): Record<string, unknown>[] | undefined {
|
|
107
|
+
if (!Array.isArray(payload)) return undefined;
|
|
108
|
+
const series = payload.filter(
|
|
109
|
+
(item): item is Record<string, unknown> =>
|
|
110
|
+
!!item && typeof item === 'object' && !Array.isArray(item),
|
|
111
|
+
);
|
|
112
|
+
return series.length > 0 ? series : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DateSchema = z
|
|
116
|
+
.string()
|
|
117
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD')
|
|
118
|
+
.refine((value) => {
|
|
119
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
120
|
+
return (
|
|
121
|
+
!Number.isNaN(date.getTime()) && date.toISOString().startsWith(value)
|
|
122
|
+
);
|
|
123
|
+
}, 'Invalid calendar date');
|
|
124
|
+
|
|
125
|
+
function domainFromEmail(email?: string): string {
|
|
126
|
+
if (!email) return '';
|
|
127
|
+
return email.trim().toLowerCase().split('@')[1] ?? '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalize(text: string): string {
|
|
131
|
+
return text.trim().toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function summarizeRates(requests: number, delivered: number): string {
|
|
135
|
+
if (requests === 0) return '0%';
|
|
136
|
+
return `${((delivered / requests) * 100).toFixed(1)}%`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function findTopCount(
|
|
140
|
+
values: string[],
|
|
141
|
+
): { value: string; count: number } | undefined {
|
|
142
|
+
if (values.length === 0) return undefined;
|
|
143
|
+
const map = new Map<string, number>();
|
|
144
|
+
for (const value of values) {
|
|
145
|
+
map.set(value, (map.get(value) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let top: { value: string; count: number } | undefined;
|
|
149
|
+
for (const [value, count] of map.entries()) {
|
|
150
|
+
if (!top || count > top.count) top = { value, count };
|
|
151
|
+
}
|
|
152
|
+
return top;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function registerDiagnosticsTools(
|
|
156
|
+
server: McpServer,
|
|
157
|
+
client: SendGridClient,
|
|
158
|
+
) {
|
|
159
|
+
ensureSafeToolRegistration(server);
|
|
160
|
+
server.registerTool(
|
|
161
|
+
'search_message_activity',
|
|
162
|
+
{
|
|
163
|
+
description:
|
|
164
|
+
'Search SendGrid Email Activity (GET /v3/messages). Pass xMessageId for the Mail Send x-message-id header; it is compiled to msg_id LIKE. offset is not supported by this API. On 403/404, or an empty EU Activity result, falls back to POST /v3/logs.',
|
|
165
|
+
inputSchema: z
|
|
166
|
+
.object({
|
|
167
|
+
query: z
|
|
168
|
+
.string()
|
|
169
|
+
.min(1)
|
|
170
|
+
.optional()
|
|
171
|
+
.describe(
|
|
172
|
+
'SendGrid Email Activity query, e.g. from_email="ops@acme.com" AND to_email="user@acme.com"',
|
|
173
|
+
),
|
|
174
|
+
xMessageId: z
|
|
175
|
+
.string()
|
|
176
|
+
.min(1)
|
|
177
|
+
.optional()
|
|
178
|
+
.describe(
|
|
179
|
+
'x-message-id from the POST /v3/mail/send response header. Compiled to msg_id LIKE \'<id>%\'. This is not the full msg_id.',
|
|
180
|
+
),
|
|
181
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
182
|
+
offset: z
|
|
183
|
+
.number()
|
|
184
|
+
.int()
|
|
185
|
+
.min(0)
|
|
186
|
+
.optional()
|
|
187
|
+
.describe(
|
|
188
|
+
'Not supported by GET /v3/messages. Values above 0 return an error. Narrow the query instead.',
|
|
189
|
+
),
|
|
190
|
+
response_format: ResponseFormatSchema.optional(),
|
|
191
|
+
})
|
|
192
|
+
.superRefine((value, ctx) => {
|
|
193
|
+
if (!value.query && !value.xMessageId) {
|
|
194
|
+
ctx.addIssue({
|
|
195
|
+
code: 'custom',
|
|
196
|
+
message: 'Provide query or xMessageId',
|
|
197
|
+
path: ['query'],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
outputSchema: SearchMessageActivityOutputSchema,
|
|
202
|
+
},
|
|
203
|
+
async ({ query, xMessageId, limit, offset, response_format }) => {
|
|
204
|
+
try {
|
|
205
|
+
rejectActivityOffset(offset);
|
|
206
|
+
const pageLimit = limit ?? 25;
|
|
207
|
+
const compiled = compileActivitySearch({ query, xMessageId });
|
|
208
|
+
const found = await searchActivityOrLogs(client, compiled, pageLimit);
|
|
209
|
+
const messages = found.messages;
|
|
210
|
+
const pagination = activityListMeta(messages.length, pageLimit);
|
|
211
|
+
const notes = [found.note, pagination.note].filter(
|
|
212
|
+
(item): item is string => typeof item === 'string',
|
|
213
|
+
);
|
|
214
|
+
const rows = messages.map((message) => summarizeActivityMessage(message));
|
|
215
|
+
const body =
|
|
216
|
+
rows.length === 0
|
|
217
|
+
? `No messages matched query: ${compiled}`
|
|
218
|
+
: [
|
|
219
|
+
`Matched messages: ${rows.length}`,
|
|
220
|
+
`Source: ${found.source}`,
|
|
221
|
+
'',
|
|
222
|
+
...rows.map((row) => `- ${row}`),
|
|
223
|
+
].join('\n');
|
|
224
|
+
|
|
225
|
+
return jsonReadResult(
|
|
226
|
+
{
|
|
227
|
+
total_count: pagination.total_count,
|
|
228
|
+
count: pagination.count,
|
|
229
|
+
offset: pagination.offset,
|
|
230
|
+
has_more: pagination.has_more,
|
|
231
|
+
next_offset: pagination.next_offset,
|
|
232
|
+
messages,
|
|
233
|
+
source: found.source,
|
|
234
|
+
...(notes.length > 0 ? { note: notes.join('\n') } : {}),
|
|
235
|
+
},
|
|
236
|
+
[...notes, ...(notes.length > 0 ? [''] : []), body].join('\n'),
|
|
237
|
+
response_format,
|
|
238
|
+
);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (isSendGridApiError(error)) {
|
|
241
|
+
const addonHint =
|
|
242
|
+
error.status === 403 || error.status === 404
|
|
243
|
+
? '\nHint: Email Activity API may require the Email Activity add-on.'
|
|
244
|
+
: '';
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Failed to query Email Activity API.${addonHint}\n${formatToolError(error)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
server.registerTool(
|
|
255
|
+
'get_message_activity',
|
|
256
|
+
{
|
|
257
|
+
description:
|
|
258
|
+
'Get Email Activity for one message. msgId may be a full msg_id or the Mail Send x-message-id (resolved with msg_id LIKE). On Activity 403/404 for a full id, falls back to POST /v3/logs.',
|
|
259
|
+
inputSchema: z.object({
|
|
260
|
+
msgId: z.string().min(1),
|
|
261
|
+
...ReadInputFields,
|
|
262
|
+
}),
|
|
263
|
+
outputSchema: MessageActivitySchema,
|
|
264
|
+
},
|
|
265
|
+
async ({ msgId, response_format }) => {
|
|
266
|
+
try {
|
|
267
|
+
const traced = await traceMessage(client, msgId);
|
|
268
|
+
const summary = summarizeActivityMessage(traced.message);
|
|
269
|
+
return jsonReadResult(
|
|
270
|
+
traced.message,
|
|
271
|
+
[summary, ...(traced.note ? ['', traced.note] : []), '', JSON.stringify(traced.message, null, 2)].join('\n'),
|
|
272
|
+
response_format,
|
|
273
|
+
);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (isSendGridApiError(error)) {
|
|
276
|
+
const addonHint =
|
|
277
|
+
error.status === 403 || error.status === 404
|
|
278
|
+
? '\nHint: Email Activity API access may be unavailable without add-on.'
|
|
279
|
+
: '';
|
|
280
|
+
throw new Error(
|
|
281
|
+
`Failed to load message activity.${addonHint}\n${formatToolError(error)}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
server.registerTool(
|
|
290
|
+
'list_event_webhooks',
|
|
291
|
+
{
|
|
292
|
+
description:
|
|
293
|
+
'List all Event Webhook configurations directly from SendGrid.',
|
|
294
|
+
inputSchema: z.object({
|
|
295
|
+
includeAccountStatusChange: z.boolean().optional(),
|
|
296
|
+
...ListPagingInputFields,
|
|
297
|
+
}),
|
|
298
|
+
outputSchema: EventWebhookListOutputSchema,
|
|
299
|
+
},
|
|
300
|
+
async ({ includeAccountStatusChange, limit, offset, response_format }) => {
|
|
301
|
+
const response = await client.getAllEventWebhooks(
|
|
302
|
+
includeAccountStatusChange ?? false,
|
|
303
|
+
);
|
|
304
|
+
const webhooks = response.webhooks ?? [];
|
|
305
|
+
const { items, pagination } = paginateArray(webhooks, limit, offset);
|
|
306
|
+
const rows = items.map((webhook) => {
|
|
307
|
+
return [
|
|
308
|
+
`- id=${webhook.id ?? 'n/a'}`,
|
|
309
|
+
`enabled=${String(webhook.enabled ?? false)}`,
|
|
310
|
+
`url=${String(webhook.url ?? 'n/a')}`,
|
|
311
|
+
`delivered=${String(webhook.delivered ?? false)}`,
|
|
312
|
+
`bounce=${String(webhook.bounce ?? false)}`,
|
|
313
|
+
`deferred=${String(webhook.deferred ?? false)}`,
|
|
314
|
+
`dropped=${String(webhook.dropped ?? false)}`,
|
|
315
|
+
`open=${String(webhook.open ?? false)}`,
|
|
316
|
+
`click=${String(webhook.click ?? false)}`,
|
|
317
|
+
`public_key=${webhook.public_key ? 'set' : 'not-set'}`,
|
|
318
|
+
].join(' | ');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return jsonReadResult(
|
|
322
|
+
{
|
|
323
|
+
...pagination,
|
|
324
|
+
maxAllowed: response.max_allowed ?? null,
|
|
325
|
+
webhooks: items,
|
|
326
|
+
},
|
|
327
|
+
rows.length === 0
|
|
328
|
+
? 'No event webhooks found.'
|
|
329
|
+
: [
|
|
330
|
+
`Max allowed webhooks: ${response.max_allowed ?? 'n/a'}`,
|
|
331
|
+
`Configured webhooks: ${pagination.total_count}`,
|
|
332
|
+
'',
|
|
333
|
+
...rows,
|
|
334
|
+
].join('\n'),
|
|
335
|
+
response_format,
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
server.registerTool(
|
|
341
|
+
'get_event_webhook',
|
|
342
|
+
{
|
|
343
|
+
description: 'Get one Event Webhook config by ID from SendGrid.',
|
|
344
|
+
inputSchema: z.object({
|
|
345
|
+
id: z.string().min(1),
|
|
346
|
+
includeAccountStatusChange: z.boolean().optional(),
|
|
347
|
+
...ReadInputFields,
|
|
348
|
+
}),
|
|
349
|
+
outputSchema: EventWebhookSchema,
|
|
350
|
+
},
|
|
351
|
+
async ({ id, includeAccountStatusChange, response_format }) => {
|
|
352
|
+
const webhook = await client.getEventWebhook(
|
|
353
|
+
id,
|
|
354
|
+
includeAccountStatusChange ?? false,
|
|
355
|
+
);
|
|
356
|
+
return jsonReadResult(webhook, JSON.stringify(webhook, null, 2), response_format);
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
server.registerTool(
|
|
361
|
+
'update_event_webhook',
|
|
362
|
+
{
|
|
363
|
+
description:
|
|
364
|
+
'Update Event Webhook settings in SendGrid (URL, enabled flag, and event toggles).',
|
|
365
|
+
inputSchema: z.object({
|
|
366
|
+
confirmToken: z
|
|
367
|
+
.literal('CONFIRM')
|
|
368
|
+
.describe('Safety token required for mutating webhook settings'),
|
|
369
|
+
id: z.string().min(1),
|
|
370
|
+
includeAccountStatusChange: z.boolean().optional(),
|
|
371
|
+
enabled: z.boolean().optional(),
|
|
372
|
+
url: z.url().optional(),
|
|
373
|
+
accountStatusChange: z.boolean().optional(),
|
|
374
|
+
groupResubscribe: z.boolean().optional(),
|
|
375
|
+
delivered: z.boolean().optional(),
|
|
376
|
+
groupUnsubscribe: z.boolean().optional(),
|
|
377
|
+
spamReport: z.boolean().optional(),
|
|
378
|
+
bounce: z.boolean().optional(),
|
|
379
|
+
deferred: z.boolean().optional(),
|
|
380
|
+
unsubscribe: z.boolean().optional(),
|
|
381
|
+
processed: z.boolean().optional(),
|
|
382
|
+
open: z.boolean().optional(),
|
|
383
|
+
click: z.boolean().optional(),
|
|
384
|
+
dropped: z.boolean().optional(),
|
|
385
|
+
friendlyName: z.string().nullable().optional(),
|
|
386
|
+
oauthClientId: z.string().nullable().optional(),
|
|
387
|
+
oauthClientSecret: z.string().nullable().optional(),
|
|
388
|
+
oauthTokenUrl: z.string().nullable().optional(),
|
|
389
|
+
}),
|
|
390
|
+
},
|
|
391
|
+
async ({
|
|
392
|
+
id,
|
|
393
|
+
includeAccountStatusChange,
|
|
394
|
+
enabled,
|
|
395
|
+
url,
|
|
396
|
+
accountStatusChange,
|
|
397
|
+
groupResubscribe,
|
|
398
|
+
delivered,
|
|
399
|
+
groupUnsubscribe,
|
|
400
|
+
spamReport,
|
|
401
|
+
bounce,
|
|
402
|
+
deferred,
|
|
403
|
+
unsubscribe,
|
|
404
|
+
processed,
|
|
405
|
+
open,
|
|
406
|
+
click,
|
|
407
|
+
dropped,
|
|
408
|
+
friendlyName,
|
|
409
|
+
oauthClientId,
|
|
410
|
+
oauthClientSecret,
|
|
411
|
+
oauthTokenUrl,
|
|
412
|
+
}) => {
|
|
413
|
+
const updated = await client.updateEventWebhook(
|
|
414
|
+
id,
|
|
415
|
+
{
|
|
416
|
+
enabled,
|
|
417
|
+
url,
|
|
418
|
+
account_status_change: accountStatusChange,
|
|
419
|
+
group_resubscribe: groupResubscribe,
|
|
420
|
+
delivered,
|
|
421
|
+
group_unsubscribe: groupUnsubscribe,
|
|
422
|
+
spam_report: spamReport,
|
|
423
|
+
bounce,
|
|
424
|
+
deferred,
|
|
425
|
+
unsubscribe,
|
|
426
|
+
processed,
|
|
427
|
+
open,
|
|
428
|
+
click,
|
|
429
|
+
dropped,
|
|
430
|
+
friendly_name: friendlyName,
|
|
431
|
+
oauth_client_id: oauthClientId ?? undefined,
|
|
432
|
+
oauth_client_secret: oauthClientSecret ?? undefined,
|
|
433
|
+
oauth_token_url: oauthTokenUrl ?? undefined,
|
|
434
|
+
},
|
|
435
|
+
includeAccountStatusChange ?? false,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }],
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
server.registerTool(
|
|
445
|
+
'toggle_event_webhook_signature',
|
|
446
|
+
{
|
|
447
|
+
description:
|
|
448
|
+
'Enable or disable SendGrid signature verification for a specific Event Webhook.',
|
|
449
|
+
inputSchema: z.object({
|
|
450
|
+
confirmToken: z
|
|
451
|
+
.literal('CONFIRM')
|
|
452
|
+
.describe('Safety token required for mutating webhook settings'),
|
|
453
|
+
id: z.string().min(1),
|
|
454
|
+
enabled: z.boolean(),
|
|
455
|
+
}),
|
|
456
|
+
},
|
|
457
|
+
async ({ id, enabled }) => {
|
|
458
|
+
const response = await client.toggleEventWebhookSignatureVerification(
|
|
459
|
+
id,
|
|
460
|
+
enabled,
|
|
461
|
+
);
|
|
462
|
+
return {
|
|
463
|
+
content: [
|
|
464
|
+
{
|
|
465
|
+
type: 'text',
|
|
466
|
+
text: [
|
|
467
|
+
`Webhook ID: ${response.id}`,
|
|
468
|
+
`Signature verification: ${enabled ? 'enabled' : 'disabled'}`,
|
|
469
|
+
`Public key: ${response.public_key ? 'present' : 'not present'}`,
|
|
470
|
+
].join('\n'),
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const WebhookEventFields = {
|
|
478
|
+
enabled: z.boolean().optional(),
|
|
479
|
+
friendlyName: z.string().nullable().optional(),
|
|
480
|
+
bounce: z.boolean().optional(),
|
|
481
|
+
click: z.boolean().optional(),
|
|
482
|
+
deferred: z.boolean().optional(),
|
|
483
|
+
delivered: z.boolean().optional(),
|
|
484
|
+
dropped: z.boolean().optional(),
|
|
485
|
+
groupResubscribe: z.boolean().optional(),
|
|
486
|
+
groupUnsubscribe: z.boolean().optional(),
|
|
487
|
+
open: z.boolean().optional(),
|
|
488
|
+
processed: z.boolean().optional(),
|
|
489
|
+
spamReport: z.boolean().optional(),
|
|
490
|
+
unsubscribe: z.boolean().optional(),
|
|
491
|
+
oauthClientId: z.string().nullable().optional(),
|
|
492
|
+
oauthClientSecret: z.string().nullable().optional(),
|
|
493
|
+
oauthTokenUrl: z.string().nullable().optional(),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
server.registerTool(
|
|
497
|
+
'manage_event_webhook',
|
|
498
|
+
{
|
|
499
|
+
description:
|
|
500
|
+
'Create, delete, or test a SendGrid Event Webhook. create requires url. delete requires id. test sends a sample event to url, or to the saved webhook url when only id is set.',
|
|
501
|
+
inputSchema: z
|
|
502
|
+
.object({
|
|
503
|
+
confirmToken: ConfirmTokenSchema,
|
|
504
|
+
action: z.enum(['create', 'delete', 'test']),
|
|
505
|
+
id: z.string().min(1).optional(),
|
|
506
|
+
url: z.url().optional(),
|
|
507
|
+
...WebhookEventFields,
|
|
508
|
+
})
|
|
509
|
+
.superRefine((value, ctx) => {
|
|
510
|
+
if (value.action === 'create' && !value.url) {
|
|
511
|
+
ctx.addIssue({
|
|
512
|
+
code: 'custom',
|
|
513
|
+
message: 'url is required when action is create',
|
|
514
|
+
path: ['url'],
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (value.action === 'delete' && !value.id) {
|
|
518
|
+
ctx.addIssue({
|
|
519
|
+
code: 'custom',
|
|
520
|
+
message: 'id is required when action is delete',
|
|
521
|
+
path: ['id'],
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (value.action === 'test' && !value.url && !value.id) {
|
|
525
|
+
ctx.addIssue({
|
|
526
|
+
code: 'custom',
|
|
527
|
+
message: 'test requires url or id',
|
|
528
|
+
path: ['url'],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}),
|
|
532
|
+
outputSchema: ManageEventWebhookOutputSchema,
|
|
533
|
+
},
|
|
534
|
+
async ({
|
|
535
|
+
action,
|
|
536
|
+
id,
|
|
537
|
+
url,
|
|
538
|
+
enabled,
|
|
539
|
+
friendlyName,
|
|
540
|
+
bounce,
|
|
541
|
+
click,
|
|
542
|
+
deferred,
|
|
543
|
+
delivered,
|
|
544
|
+
dropped,
|
|
545
|
+
groupResubscribe,
|
|
546
|
+
groupUnsubscribe,
|
|
547
|
+
open,
|
|
548
|
+
processed,
|
|
549
|
+
spamReport,
|
|
550
|
+
unsubscribe,
|
|
551
|
+
oauthClientId,
|
|
552
|
+
oauthClientSecret,
|
|
553
|
+
oauthTokenUrl,
|
|
554
|
+
}) => {
|
|
555
|
+
const eventPayload = {
|
|
556
|
+
enabled,
|
|
557
|
+
friendly_name: friendlyName,
|
|
558
|
+
bounce,
|
|
559
|
+
click,
|
|
560
|
+
deferred,
|
|
561
|
+
delivered,
|
|
562
|
+
dropped,
|
|
563
|
+
group_resubscribe: groupResubscribe,
|
|
564
|
+
group_unsubscribe: groupUnsubscribe,
|
|
565
|
+
open,
|
|
566
|
+
processed,
|
|
567
|
+
spam_report: spamReport,
|
|
568
|
+
unsubscribe,
|
|
569
|
+
oauth_client_id: oauthClientId ?? undefined,
|
|
570
|
+
oauth_client_secret: oauthClientSecret ?? undefined,
|
|
571
|
+
oauth_token_url: oauthTokenUrl ?? undefined,
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
if (action === 'delete') {
|
|
575
|
+
await client.deleteEventWebhook(id!);
|
|
576
|
+
return jsonReadResult(
|
|
577
|
+
{ action, id: id ?? null, url: null },
|
|
578
|
+
`Deleted Event Webhook ${id}.`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let targetUrl = url;
|
|
583
|
+
if (action === 'test' && !targetUrl && id) {
|
|
584
|
+
const existing = await client.getEventWebhook(id);
|
|
585
|
+
targetUrl = existing.url;
|
|
586
|
+
}
|
|
587
|
+
if (!targetUrl) {
|
|
588
|
+
throw new Error('Event Webhook url is missing.');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (action === 'create') {
|
|
592
|
+
const webhook = await client.createEventWebhook({
|
|
593
|
+
...eventPayload,
|
|
594
|
+
url: targetUrl,
|
|
595
|
+
});
|
|
596
|
+
return jsonReadResult(
|
|
597
|
+
{ action, id: webhook.id ?? null, url: webhook.url ?? targetUrl, webhook },
|
|
598
|
+
`Created Event Webhook ${webhook.id} -> ${webhook.url ?? targetUrl}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
await client.testEventWebhook({ url: targetUrl, id });
|
|
603
|
+
return jsonReadResult(
|
|
604
|
+
{ action, id: id ?? null, url: targetUrl },
|
|
605
|
+
`Sent Event Webhook test notification to ${targetUrl}.`,
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
server.registerTool(
|
|
611
|
+
'get_webhook_receiver_status',
|
|
612
|
+
{
|
|
613
|
+
description:
|
|
614
|
+
'Show local webhook receiver status for incoming SendGrid Event Webhook posts.',
|
|
615
|
+
inputSchema: z.object({ ...ReadInputFields }),
|
|
616
|
+
outputSchema: WebhookReceiverStatusOutputSchema,
|
|
617
|
+
},
|
|
618
|
+
async ({ response_format }) => {
|
|
619
|
+
const status = getWebhookReceiverStatus();
|
|
620
|
+
return jsonReadResult(status, JSON.stringify(status, null, 2), response_format);
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
server.registerTool(
|
|
625
|
+
'get_received_webhook_events',
|
|
626
|
+
{
|
|
627
|
+
description:
|
|
628
|
+
'Read recent Event Webhook payloads captured by this server (for SendGrid-side diagnostics).',
|
|
629
|
+
inputSchema: z.object({
|
|
630
|
+
limit: z.number().int().min(1).max(5000).optional(),
|
|
631
|
+
eventType: z.string().optional(),
|
|
632
|
+
email: z.email().optional(),
|
|
633
|
+
messageId: z.string().optional(),
|
|
634
|
+
onlyVerified: z.boolean().optional(),
|
|
635
|
+
offset: z.number().int().min(0).optional(),
|
|
636
|
+
response_format: ResponseFormatSchema.optional(),
|
|
637
|
+
}),
|
|
638
|
+
outputSchema: ReceivedWebhookEventsOutputSchema,
|
|
639
|
+
},
|
|
640
|
+
async ({
|
|
641
|
+
limit,
|
|
642
|
+
offset,
|
|
643
|
+
eventType,
|
|
644
|
+
email,
|
|
645
|
+
messageId,
|
|
646
|
+
onlyVerified,
|
|
647
|
+
response_format,
|
|
648
|
+
}) => {
|
|
649
|
+
const events = getStoredWebhookEvents({
|
|
650
|
+
limit,
|
|
651
|
+
eventType,
|
|
652
|
+
email,
|
|
653
|
+
messageId,
|
|
654
|
+
onlyVerified,
|
|
655
|
+
});
|
|
656
|
+
const pageOffset = offset ?? 0;
|
|
657
|
+
const pagination = buildPaginationMeta({
|
|
658
|
+
totalCount: events.length + pageOffset,
|
|
659
|
+
count: events.length,
|
|
660
|
+
offset: pageOffset,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return jsonReadResult(
|
|
664
|
+
{ ...pagination, events },
|
|
665
|
+
events.length === 0
|
|
666
|
+
? 'No matching webhook events captured.'
|
|
667
|
+
: JSON.stringify(events, null, 2),
|
|
668
|
+
response_format,
|
|
669
|
+
);
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
server.registerTool(
|
|
674
|
+
'clear_received_webhook_events',
|
|
675
|
+
{
|
|
676
|
+
description: 'Clear locally captured SendGrid Event Webhook payloads.',
|
|
677
|
+
inputSchema: z.object({
|
|
678
|
+
confirm: z
|
|
679
|
+
.boolean()
|
|
680
|
+
.describe('Safety switch: must be true to clear buffered events'),
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
async ({ confirm }) => {
|
|
684
|
+
if (!confirm) {
|
|
685
|
+
return {
|
|
686
|
+
content: [
|
|
687
|
+
{
|
|
688
|
+
type: 'text',
|
|
689
|
+
text: 'Skipped. Set confirm=true to clear captured events.',
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
clearStoredWebhookEvents();
|
|
695
|
+
return {
|
|
696
|
+
content: [{ type: 'text', text: 'Captured webhook events cleared.' }],
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
server.registerTool(
|
|
702
|
+
'classify_sendgrid_error',
|
|
703
|
+
{
|
|
704
|
+
description:
|
|
705
|
+
'Classify SendGrid API errors and return likely causes with targeted remediation steps.',
|
|
706
|
+
inputSchema: z
|
|
707
|
+
.object({
|
|
708
|
+
statusCode: z.number().int().optional(),
|
|
709
|
+
errorMessage: z.string().optional(),
|
|
710
|
+
rawBody: z.string().optional(),
|
|
711
|
+
...ReadInputFields,
|
|
712
|
+
})
|
|
713
|
+
.refine(
|
|
714
|
+
(value) =>
|
|
715
|
+
value.statusCode !== undefined ||
|
|
716
|
+
(value.errorMessage?.trim().length ?? 0) > 0 ||
|
|
717
|
+
(value.rawBody?.trim().length ?? 0) > 0,
|
|
718
|
+
'Provide at least one of statusCode, errorMessage, or rawBody.',
|
|
719
|
+
),
|
|
720
|
+
outputSchema: ClassifyErrorOutputSchema,
|
|
721
|
+
},
|
|
722
|
+
async ({ statusCode, errorMessage, rawBody, response_format }) => {
|
|
723
|
+
const text = normalize(`${errorMessage ?? ''}\n${rawBody ?? ''}`);
|
|
724
|
+
const classified = classifySendGridError(statusCode, text);
|
|
725
|
+
const structured = {
|
|
726
|
+
category: classified.category,
|
|
727
|
+
statusCode: statusCode ?? null,
|
|
728
|
+
probableCauses: classified.probableCauses,
|
|
729
|
+
actions: classified.actions,
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
return jsonReadResult(
|
|
733
|
+
structured,
|
|
734
|
+
[
|
|
735
|
+
`Category: ${classified.category}`,
|
|
736
|
+
`Status code: ${statusCode ?? 'unknown'}`,
|
|
737
|
+
'',
|
|
738
|
+
'Probable causes:',
|
|
739
|
+
...classified.probableCauses.map((cause) => `- ${cause}`),
|
|
740
|
+
'',
|
|
741
|
+
'Recommended actions:',
|
|
742
|
+
...classified.actions.map((action) => `- ${action}`),
|
|
743
|
+
].join('\n'),
|
|
744
|
+
response_format,
|
|
745
|
+
);
|
|
746
|
+
},
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
server.registerTool(
|
|
750
|
+
'triage_delivery_issue',
|
|
751
|
+
{
|
|
752
|
+
description:
|
|
753
|
+
'Run targeted delivery triage for common incidents (202 accepted no inbox, processing, sender identity, DMARC/auth, template drops, deferrals, unsubscribe spikes).',
|
|
754
|
+
inputSchema: z.object({
|
|
755
|
+
scenario: z.enum([
|
|
756
|
+
'accepted_not_delivered',
|
|
757
|
+
'processing_stuck',
|
|
758
|
+
'invalid_template_drop',
|
|
759
|
+
'sender_identity_error',
|
|
760
|
+
'dmarc_or_auth_block',
|
|
761
|
+
'high_unsubscribes_or_spam',
|
|
762
|
+
'deferrals_or_throttling',
|
|
763
|
+
]),
|
|
764
|
+
recipientEmail: z.email().optional(),
|
|
765
|
+
fromEmail: z.email().optional(),
|
|
766
|
+
templateId: z.string().optional(),
|
|
767
|
+
messageId: z.string().optional(),
|
|
768
|
+
activityQuery: z.string().optional(),
|
|
769
|
+
activityLimit: z.number().int().min(1).max(1000).optional(),
|
|
770
|
+
provider: z.string().optional(),
|
|
771
|
+
sinceDate: DateSchema.optional().describe(
|
|
772
|
+
'YYYY-MM-DD for aggregate stats pull',
|
|
773
|
+
),
|
|
774
|
+
partnerAccountId: z
|
|
775
|
+
.string()
|
|
776
|
+
.optional()
|
|
777
|
+
.describe('Optional /partners/accounts/{id}/state check'),
|
|
778
|
+
...ReadInputFields,
|
|
779
|
+
}),
|
|
780
|
+
outputSchema: TriageDeliveryOutputSchema,
|
|
781
|
+
},
|
|
782
|
+
async ({
|
|
783
|
+
scenario,
|
|
784
|
+
recipientEmail,
|
|
785
|
+
fromEmail,
|
|
786
|
+
templateId,
|
|
787
|
+
messageId,
|
|
788
|
+
activityQuery,
|
|
789
|
+
activityLimit,
|
|
790
|
+
provider,
|
|
791
|
+
sinceDate,
|
|
792
|
+
partnerAccountId,
|
|
793
|
+
response_format,
|
|
794
|
+
}) => {
|
|
795
|
+
const findings: string[] = [];
|
|
796
|
+
const actions: string[] = [];
|
|
797
|
+
|
|
798
|
+
if (messageId) {
|
|
799
|
+
try {
|
|
800
|
+
const traced = await traceMessage(client, messageId);
|
|
801
|
+
findings.push(
|
|
802
|
+
`Message activity for ${messageId}: ${summarizeActivityMessage(traced.message)}${traced.note ? `. ${traced.note}` : ''}`,
|
|
803
|
+
);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
findings.push(
|
|
806
|
+
`Message activity lookup failed.\n${formatToolError(error)}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (activityQuery) {
|
|
812
|
+
try {
|
|
813
|
+
const activity = await searchActivityOrLogs(
|
|
814
|
+
client,
|
|
815
|
+
activityQuery,
|
|
816
|
+
activityLimit ?? 10,
|
|
817
|
+
);
|
|
818
|
+
findings.push(
|
|
819
|
+
`Email Activity search matched ${activity.messages.length} messages via ${activity.source} for query: ${activityQuery}${activity.note ? `. ${activity.note}` : ''}`,
|
|
820
|
+
);
|
|
821
|
+
const first = activity.messages[0];
|
|
822
|
+
if (first) findings.push(summarizeActivityMessage(first));
|
|
823
|
+
} catch (error) {
|
|
824
|
+
findings.push(
|
|
825
|
+
`Email Activity search failed.\n${formatToolError(error)}`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (recipientEmail) {
|
|
831
|
+
try {
|
|
832
|
+
const suppression = await client.checkSuppression(recipientEmail);
|
|
833
|
+
const suppressedGroups = suppression.groupSuppressions
|
|
834
|
+
.filter((group) => group.suppressed)
|
|
835
|
+
.map((group) => `${group.id}:${group.name}`)
|
|
836
|
+
.join(', ');
|
|
837
|
+
findings.push(
|
|
838
|
+
`Suppression status for ${recipientEmail}: bounced=${suppression.bounced}, blocked=${suppression.blocked}, unsubscribed=${suppression.unsubscribed}, spamReported=${suppression.spamReported}, invalidEmail=${suppression.invalidEmail}, groupUnsubscribed=${suppression.groupUnsubscribed}${suppressedGroups ? ` groups=${suppressedGroups}` : ''}`,
|
|
839
|
+
);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
findings.push(`Suppression check failed.\n${formatToolError(error)}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (templateId) {
|
|
846
|
+
try {
|
|
847
|
+
const template = await client.getTemplate(templateId);
|
|
848
|
+
const active = template.versions.find(
|
|
849
|
+
(version) => version.active === 1,
|
|
850
|
+
);
|
|
851
|
+
findings.push(
|
|
852
|
+
active
|
|
853
|
+
? `Template ${templateId} active version: ${active.id} (subject: "${active.subject}")`
|
|
854
|
+
: `Template ${templateId} has no active version.`,
|
|
855
|
+
);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
findings.push(
|
|
858
|
+
`Template lookup failed for ${templateId}.\n${formatToolError(error)}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (fromEmail) {
|
|
864
|
+
const senderDomain = domainFromEmail(fromEmail);
|
|
865
|
+
try {
|
|
866
|
+
const [domains, verifiedSenders] = await Promise.all([
|
|
867
|
+
client.listAuthenticatedDomains(),
|
|
868
|
+
client.listVerifiedSenders({ limit: 200 }),
|
|
869
|
+
]);
|
|
870
|
+
|
|
871
|
+
const domainAuthenticated = domains.some((domain) => {
|
|
872
|
+
const root = domain.domain?.toLowerCase();
|
|
873
|
+
if (!root || domain.valid === false) return false;
|
|
874
|
+
return senderDomain === root || senderDomain.endsWith(`.${root}`);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const senderVerified = verifiedSenders.some(
|
|
878
|
+
(sender) =>
|
|
879
|
+
sender.verified === true &&
|
|
880
|
+
typeof sender.from_email === 'string' &&
|
|
881
|
+
normalize(sender.from_email) === normalize(fromEmail),
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
findings.push(
|
|
885
|
+
`Sender checks for ${fromEmail}: domainAuthenticated=${domainAuthenticated}, senderVerified=${senderVerified}`,
|
|
886
|
+
);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
findings.push(
|
|
889
|
+
`Sender-auth checks failed.\n${formatToolError(error)}`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (partnerAccountId) {
|
|
895
|
+
try {
|
|
896
|
+
const state = await client.getPartnerAccountState(partnerAccountId);
|
|
897
|
+
findings.push(`Partner account state: ${state.state}`);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
findings.push(
|
|
900
|
+
`Partner account-state check failed.\n${formatToolError(error)}`,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (sinceDate) {
|
|
906
|
+
try {
|
|
907
|
+
const stats = await client.getStats(sinceDate);
|
|
908
|
+
const total = stats.reduce(
|
|
909
|
+
(acc, day) => {
|
|
910
|
+
const metrics = day.stats[0]?.metrics;
|
|
911
|
+
if (!metrics) return acc;
|
|
912
|
+
acc.requests += metrics.requests;
|
|
913
|
+
acc.delivered += metrics.delivered;
|
|
914
|
+
acc.bounces += metrics.bounces;
|
|
915
|
+
acc.opens += metrics.opens;
|
|
916
|
+
acc.clicks += metrics.clicks;
|
|
917
|
+
acc.unsubscribes += metrics.unsubscribes;
|
|
918
|
+
acc.spamReports += metrics.spam_reports;
|
|
919
|
+
return acc;
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
requests: 0,
|
|
923
|
+
delivered: 0,
|
|
924
|
+
bounces: 0,
|
|
925
|
+
opens: 0,
|
|
926
|
+
clicks: 0,
|
|
927
|
+
unsubscribes: 0,
|
|
928
|
+
spamReports: 0,
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
findings.push(
|
|
932
|
+
`Stats since ${sinceDate}: requests=${total.requests}, delivered=${total.delivered} (${summarizeRates(total.requests, total.delivered)}), bounces=${total.bounces}, opens=${total.opens}, clicks=${total.clicks}, unsubscribes=${total.unsubscribes}, spam_reports=${total.spamReports}`,
|
|
933
|
+
);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
findings.push(`Stats lookup failed.\n${formatToolError(error)}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
switch (scenario) {
|
|
940
|
+
case 'accepted_not_delivered':
|
|
941
|
+
actions.push(
|
|
942
|
+
'Treat 202 as queue acceptance, not inbox delivery confirmation.',
|
|
943
|
+
'Check template validity/active version and render inputs.',
|
|
944
|
+
'Check account state/billing and suppressions for affected recipients.',
|
|
945
|
+
'Use Event Webhook or Activity Feed for final event outcome correlation.',
|
|
946
|
+
);
|
|
947
|
+
break;
|
|
948
|
+
case 'processing_stuck':
|
|
949
|
+
actions.push(
|
|
950
|
+
'Check account billing/frozen state first.',
|
|
951
|
+
'Re-trigger sends from integration after account reactivation; old processing items may never complete.',
|
|
952
|
+
'Track delivery outcomes via webhook, not request-time status only.',
|
|
953
|
+
);
|
|
954
|
+
break;
|
|
955
|
+
case 'invalid_template_drop':
|
|
956
|
+
actions.push(
|
|
957
|
+
'Verify template ID exists and active version is set.',
|
|
958
|
+
'Ensure payload data keys match template handlebars expectations.',
|
|
959
|
+
'Re-send only after template activation/repair.',
|
|
960
|
+
);
|
|
961
|
+
break;
|
|
962
|
+
case 'sender_identity_error':
|
|
963
|
+
actions.push(
|
|
964
|
+
'Use a From domain that is authenticated in SendGrid.',
|
|
965
|
+
'Do not rely on free mailbox From domains for API traffic.',
|
|
966
|
+
'Keep Reply-To for user-facing mailbox while From stays authenticated.',
|
|
967
|
+
);
|
|
968
|
+
break;
|
|
969
|
+
case 'dmarc_or_auth_block':
|
|
970
|
+
actions.push(
|
|
971
|
+
'Align From domain with authenticated domain/SPF+DKIM configuration.',
|
|
972
|
+
'Avoid protected mailbox-provider From domains (gmail/yahoo/aol) in API sends.',
|
|
973
|
+
'Use dedicated sending domain/subdomain and verify DMARC alignment policy.',
|
|
974
|
+
);
|
|
975
|
+
break;
|
|
976
|
+
case 'high_unsubscribes_or_spam':
|
|
977
|
+
actions.push(
|
|
978
|
+
'Inspect event stream for non-human opens/clicks triggering accidental unsubscribes.',
|
|
979
|
+
'Use group unsubscribes/ASM to force deliberate unsubscribe flow.',
|
|
980
|
+
'Reduce sends to low-engagement users and apply sunsetting policy.',
|
|
981
|
+
);
|
|
982
|
+
break;
|
|
983
|
+
case 'deferrals_or_throttling':
|
|
984
|
+
actions.push(
|
|
985
|
+
'Throttle per provider domain and spread sends over time.',
|
|
986
|
+
'Use scheduled sends with batching instead of burst sends.',
|
|
987
|
+
'For Yahoo-related domains, use conservative hourly pacing and monitor deferrals.',
|
|
988
|
+
);
|
|
989
|
+
if (provider && normalize(provider).includes('yahoo')) {
|
|
990
|
+
actions.push(
|
|
991
|
+
'Yahoo-specific: keep hourly sends per IP low and avoid peak-time bursts.',
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return jsonReadResult(
|
|
998
|
+
{ scenario, findings, actions },
|
|
999
|
+
[
|
|
1000
|
+
`Scenario: ${scenario}`,
|
|
1001
|
+
'',
|
|
1002
|
+
'Findings:',
|
|
1003
|
+
...(findings.length > 0
|
|
1004
|
+
? findings.map((finding) => `- ${finding}`)
|
|
1005
|
+
: ['- No runtime findings were gathered.']),
|
|
1006
|
+
'',
|
|
1007
|
+
'Recommended actions:',
|
|
1008
|
+
...actions.map((action) => `- ${action}`),
|
|
1009
|
+
].join('\n'),
|
|
1010
|
+
response_format,
|
|
1011
|
+
);
|
|
1012
|
+
},
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
server.registerTool(
|
|
1016
|
+
'analyze_engagement_anomalies',
|
|
1017
|
+
{
|
|
1018
|
+
description:
|
|
1019
|
+
'Analyze Event Webhook events for non-human engagement patterns, machine opens, and unique-open approximation.',
|
|
1020
|
+
inputSchema: z.object({
|
|
1021
|
+
events: z
|
|
1022
|
+
.array(
|
|
1023
|
+
z
|
|
1024
|
+
.object({
|
|
1025
|
+
event: z.string(),
|
|
1026
|
+
email: z.string().optional(),
|
|
1027
|
+
timestamp: z.number().int().optional(),
|
|
1028
|
+
ip: z.string().optional(),
|
|
1029
|
+
useragent: z.string().optional(),
|
|
1030
|
+
sg_machine_open: z.boolean().optional(),
|
|
1031
|
+
sg_message_id: z.string().optional(),
|
|
1032
|
+
})
|
|
1033
|
+
.passthrough(),
|
|
1034
|
+
)
|
|
1035
|
+
.min(1),
|
|
1036
|
+
nearDeliveryWindowSec: z
|
|
1037
|
+
.number()
|
|
1038
|
+
.int()
|
|
1039
|
+
.optional()
|
|
1040
|
+
.describe(
|
|
1041
|
+
'Delta window to mark suspicious immediate clicks (default 5s)',
|
|
1042
|
+
),
|
|
1043
|
+
...ReadInputFields,
|
|
1044
|
+
}),
|
|
1045
|
+
outputSchema: AnalyzeEngagementOutputSchema,
|
|
1046
|
+
},
|
|
1047
|
+
async ({ events, nearDeliveryWindowSec, response_format }) => {
|
|
1048
|
+
const webhookEvents = events as EngagementEvent[];
|
|
1049
|
+
const clickEvents = webhookEvents.filter(
|
|
1050
|
+
(event) => event.event === 'click',
|
|
1051
|
+
);
|
|
1052
|
+
const openEvents = webhookEvents.filter(
|
|
1053
|
+
(event) => event.event === 'open',
|
|
1054
|
+
);
|
|
1055
|
+
const deliveredEvents = webhookEvents.filter(
|
|
1056
|
+
(event) => event.event === 'delivered',
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
const machineOpens = openEvents.filter(
|
|
1060
|
+
(event) => event.sg_machine_open === true,
|
|
1061
|
+
).length;
|
|
1062
|
+
const gmailPrefetchOpens = openEvents.filter((event) => {
|
|
1063
|
+
const ua = normalize(event.useragent ?? '');
|
|
1064
|
+
return ua.includes('googleimageproxy') || ua.includes('ggpht.com');
|
|
1065
|
+
}).length;
|
|
1066
|
+
|
|
1067
|
+
const openOrClickIps = webhookEvents
|
|
1068
|
+
.filter((event) => event.event === 'open' || event.event === 'click')
|
|
1069
|
+
.map((event) => event.ip ?? '')
|
|
1070
|
+
.filter(Boolean);
|
|
1071
|
+
const topIp = findTopCount(openOrClickIps);
|
|
1072
|
+
const topIpShare =
|
|
1073
|
+
topIp && openOrClickIps.length > 0
|
|
1074
|
+
? (topIp.count / openOrClickIps.length) * 100
|
|
1075
|
+
: 0;
|
|
1076
|
+
|
|
1077
|
+
const openOrClickUa = webhookEvents
|
|
1078
|
+
.filter((event) => event.event === 'open' || event.event === 'click')
|
|
1079
|
+
.map((event) => normalize(event.useragent ?? ''))
|
|
1080
|
+
.filter(Boolean);
|
|
1081
|
+
const topUa = findTopCount(openOrClickUa);
|
|
1082
|
+
|
|
1083
|
+
const deliveredAtByKey = new Map<string, number>();
|
|
1084
|
+
for (const event of deliveredEvents) {
|
|
1085
|
+
const key = event.sg_message_id ?? event.email ?? '';
|
|
1086
|
+
if (!key || typeof event.timestamp !== 'number') continue;
|
|
1087
|
+
const existing = deliveredAtByKey.get(key);
|
|
1088
|
+
if (existing === undefined || event.timestamp < existing) {
|
|
1089
|
+
deliveredAtByKey.set(key, event.timestamp);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const suspiciousImmediateClicks = clickEvents.filter((event) => {
|
|
1094
|
+
const key = event.sg_message_id ?? event.email ?? '';
|
|
1095
|
+
const deliveredAt = deliveredAtByKey.get(key);
|
|
1096
|
+
if (
|
|
1097
|
+
!key ||
|
|
1098
|
+
deliveredAt === undefined ||
|
|
1099
|
+
event.timestamp === undefined
|
|
1100
|
+
) {
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
return (
|
|
1104
|
+
Math.abs(event.timestamp - deliveredAt) <=
|
|
1105
|
+
(nearDeliveryWindowSec ?? 5)
|
|
1106
|
+
);
|
|
1107
|
+
}).length;
|
|
1108
|
+
|
|
1109
|
+
const uniqueOpenApproxByKey = new Set<string>();
|
|
1110
|
+
for (const event of openEvents) {
|
|
1111
|
+
if (event.sg_machine_open === true) continue;
|
|
1112
|
+
const key = event.sg_message_id ?? event.email;
|
|
1113
|
+
if (!key) continue;
|
|
1114
|
+
uniqueOpenApproxByKey.add(key);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const findings: string[] = [];
|
|
1118
|
+
if (machineOpens > 0) {
|
|
1119
|
+
findings.push(
|
|
1120
|
+
`Machine opens detected (sg_machine_open=true): ${machineOpens}.`,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
if (gmailPrefetchOpens > 0) {
|
|
1124
|
+
findings.push(`Likely Gmail prefetch opens: ${gmailPrefetchOpens}.`);
|
|
1125
|
+
}
|
|
1126
|
+
if (topIp && topIpShare >= 60 && openOrClickIps.length >= 10) {
|
|
1127
|
+
findings.push(
|
|
1128
|
+
`High single-IP concentration: ${topIp.value} accounts for ${topIpShare.toFixed(1)}% of open/click events.`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
if (topUa && topUa.count >= 10) {
|
|
1132
|
+
findings.push(
|
|
1133
|
+
`Repeated user-agent pattern detected (${topUa.count} events): ${topUa.value}`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
if (suspiciousImmediateClicks > 0) {
|
|
1137
|
+
findings.push(
|
|
1138
|
+
`Clicks within ${nearDeliveryWindowSec ?? 5}s of delivery: ${suspiciousImmediateClicks}.`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const suggestions = [
|
|
1143
|
+
'Exclude sg_machine_open=true from user-open KPIs.',
|
|
1144
|
+
'De-duplicate opens by message ID for unique-open metrics.',
|
|
1145
|
+
'Down-rank click/open bursts with same IP + same user-agent near delivery time.',
|
|
1146
|
+
];
|
|
1147
|
+
|
|
1148
|
+
const structured = {
|
|
1149
|
+
totals: {
|
|
1150
|
+
events: webhookEvents.length,
|
|
1151
|
+
opens: openEvents.length,
|
|
1152
|
+
clicks: clickEvents.length,
|
|
1153
|
+
delivered: deliveredEvents.length,
|
|
1154
|
+
uniqueOpenApprox: uniqueOpenApproxByKey.size,
|
|
1155
|
+
},
|
|
1156
|
+
findings,
|
|
1157
|
+
suggestions,
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
return jsonReadResult(
|
|
1161
|
+
structured,
|
|
1162
|
+
[
|
|
1163
|
+
'Engagement analysis:',
|
|
1164
|
+
`- Total events: ${webhookEvents.length}`,
|
|
1165
|
+
`- Opens: ${openEvents.length}`,
|
|
1166
|
+
`- Clicks: ${clickEvents.length}`,
|
|
1167
|
+
`- Delivered: ${deliveredEvents.length}`,
|
|
1168
|
+
`- Unique-open approximation (first non-machine open per message/email): ${uniqueOpenApproxByKey.size}`,
|
|
1169
|
+
'',
|
|
1170
|
+
'Anomaly findings:',
|
|
1171
|
+
...(findings.length > 0
|
|
1172
|
+
? findings.map((finding) => `- ${finding}`)
|
|
1173
|
+
: ['- No strong anomaly pattern detected.']),
|
|
1174
|
+
'',
|
|
1175
|
+
'Suggested handling:',
|
|
1176
|
+
...suggestions.map((item) => `- ${item}`),
|
|
1177
|
+
].join('\n'),
|
|
1178
|
+
response_format,
|
|
1179
|
+
);
|
|
1180
|
+
},
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
server.registerTool(
|
|
1184
|
+
'list_asm_groups',
|
|
1185
|
+
{
|
|
1186
|
+
description:
|
|
1187
|
+
'List ASM unsubscribe groups (GET /v3/asm/groups). Use the id as asm.groupId when sending. This is not a marketing contact list.',
|
|
1188
|
+
inputSchema: z.object({ ...ReadInputFields }),
|
|
1189
|
+
outputSchema: AsmGroupListOutputSchema,
|
|
1190
|
+
},
|
|
1191
|
+
async ({ response_format }) => {
|
|
1192
|
+
const groups = await client.listAsmGroups();
|
|
1193
|
+
return jsonReadResult(
|
|
1194
|
+
{ count: groups.length, groups },
|
|
1195
|
+
groups.length === 0
|
|
1196
|
+
? 'No ASM unsubscribe groups.'
|
|
1197
|
+
: groups
|
|
1198
|
+
.map(
|
|
1199
|
+
(group) =>
|
|
1200
|
+
`- ${group.id} ${group.name} default=${String(group.is_default ?? false)} unsubscribes=${group.unsubscribes ?? 'n/a'}`,
|
|
1201
|
+
)
|
|
1202
|
+
.join('\n'),
|
|
1203
|
+
response_format,
|
|
1204
|
+
);
|
|
1205
|
+
},
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
server.registerTool(
|
|
1209
|
+
'create_asm_group',
|
|
1210
|
+
{
|
|
1211
|
+
description:
|
|
1212
|
+
'Create an ASM unsubscribe group (POST /v3/asm/groups) for transactional mail. Does not create a marketing list.',
|
|
1213
|
+
inputSchema: z.object({
|
|
1214
|
+
confirmToken: ConfirmTokenSchema,
|
|
1215
|
+
name: z.string().min(1).max(100),
|
|
1216
|
+
description: z.string().min(1).max(255),
|
|
1217
|
+
isDefault: z.boolean().optional(),
|
|
1218
|
+
}),
|
|
1219
|
+
outputSchema: AsmGroupSchema,
|
|
1220
|
+
},
|
|
1221
|
+
async ({ name, description, isDefault }) => {
|
|
1222
|
+
const group = await client.createAsmGroup({
|
|
1223
|
+
name,
|
|
1224
|
+
description,
|
|
1225
|
+
is_default: isDefault,
|
|
1226
|
+
});
|
|
1227
|
+
return jsonReadResult(
|
|
1228
|
+
group,
|
|
1229
|
+
`Created ASM group ${group.id} ${group.name}.`,
|
|
1230
|
+
);
|
|
1231
|
+
},
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
server.registerTool(
|
|
1235
|
+
'list_suppressions',
|
|
1236
|
+
{
|
|
1237
|
+
description:
|
|
1238
|
+
'List SendGrid suppressions by type (bounces, blocks, unsubscribes, spam reports, invalid emails, global unsubscribes).',
|
|
1239
|
+
inputSchema: z.object({
|
|
1240
|
+
type: z.enum([
|
|
1241
|
+
'bounces',
|
|
1242
|
+
'blocks',
|
|
1243
|
+
'unsubscribes',
|
|
1244
|
+
'spam_reports',
|
|
1245
|
+
'invalid_emails',
|
|
1246
|
+
'global_unsubscribes',
|
|
1247
|
+
]),
|
|
1248
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
1249
|
+
offset: z.number().int().min(0).optional(),
|
|
1250
|
+
startTime: z.number().int().optional(),
|
|
1251
|
+
endTime: z.number().int().optional(),
|
|
1252
|
+
email: z.email().optional(),
|
|
1253
|
+
response_format: ResponseFormatSchema.optional(),
|
|
1254
|
+
}),
|
|
1255
|
+
outputSchema: ListSuppressionsOutputSchema,
|
|
1256
|
+
},
|
|
1257
|
+
async ({ type, limit, offset, startTime, endTime, email, response_format }) => {
|
|
1258
|
+
const pageLimit = limit ?? 50;
|
|
1259
|
+
const pageOffset = offset ?? 0;
|
|
1260
|
+
const entries = await client.listSuppressions(type, {
|
|
1261
|
+
limit: pageLimit,
|
|
1262
|
+
offset: pageOffset,
|
|
1263
|
+
startTime,
|
|
1264
|
+
endTime,
|
|
1265
|
+
email,
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
const rows = entries.map((entry) => {
|
|
1269
|
+
return `- email=${entry.email} | created=${entry.created} | reason=${entry.reason ?? 'n/a'} | status=${entry.status ?? 'n/a'}`;
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
const pagination = buildPaginationMeta({
|
|
1273
|
+
totalCount: pageOffset + entries.length,
|
|
1274
|
+
count: entries.length,
|
|
1275
|
+
offset: pageOffset,
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
return jsonReadResult(
|
|
1279
|
+
{
|
|
1280
|
+
...pagination,
|
|
1281
|
+
has_more: entries.length >= pageLimit,
|
|
1282
|
+
next_offset:
|
|
1283
|
+
entries.length >= pageLimit ? pageOffset + entries.length : null,
|
|
1284
|
+
type,
|
|
1285
|
+
entries,
|
|
1286
|
+
},
|
|
1287
|
+
rows.length === 0
|
|
1288
|
+
? `No entries in ${type}.`
|
|
1289
|
+
: [`Type: ${type}`, `Entries: ${rows.length}`, '', ...rows].join('\n'),
|
|
1290
|
+
response_format,
|
|
1291
|
+
);
|
|
1292
|
+
},
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
server.registerTool(
|
|
1296
|
+
'check_suppression',
|
|
1297
|
+
{
|
|
1298
|
+
description:
|
|
1299
|
+
'Check bounce, block, global unsubscribe, spam report, invalid email, and ASM group suppressions (GET /v3/asm/suppressions/{email}) for one recipient.',
|
|
1300
|
+
inputSchema: z.object({
|
|
1301
|
+
email: z.email().describe('Email address to check'),
|
|
1302
|
+
...ReadInputFields,
|
|
1303
|
+
}),
|
|
1304
|
+
outputSchema: z.object({
|
|
1305
|
+
email: z.email(),
|
|
1306
|
+
bounced: z.boolean(),
|
|
1307
|
+
blocked: z.boolean(),
|
|
1308
|
+
unsubscribed: z.boolean(),
|
|
1309
|
+
spamReported: z.boolean(),
|
|
1310
|
+
invalidEmail: z.boolean(),
|
|
1311
|
+
groupUnsubscribed: z.boolean(),
|
|
1312
|
+
groupSuppressions: z.array(AsmGroupSuppressionSchema),
|
|
1313
|
+
groupLookupError: z.string().optional(),
|
|
1314
|
+
}),
|
|
1315
|
+
},
|
|
1316
|
+
async ({ email, response_format }) => {
|
|
1317
|
+
const result = await client.checkSuppression(email);
|
|
1318
|
+
|
|
1319
|
+
const flags = [
|
|
1320
|
+
result.bounced && 'BOUNCED',
|
|
1321
|
+
result.blocked && 'BLOCKED',
|
|
1322
|
+
result.unsubscribed && 'GLOBAL UNSUBSCRIBE',
|
|
1323
|
+
result.spamReported && 'SPAM REPORTED',
|
|
1324
|
+
result.invalidEmail && 'INVALID EMAIL',
|
|
1325
|
+
result.groupUnsubscribed && 'GROUP UNSUBSCRIBE',
|
|
1326
|
+
].filter(Boolean);
|
|
1327
|
+
|
|
1328
|
+
const status =
|
|
1329
|
+
flags.length === 0 ? 'Clean - not suppressed' : flags.join(' | ');
|
|
1330
|
+
|
|
1331
|
+
const lines = [`Email: ${email}`, `Status: ${status}`, ``];
|
|
1332
|
+
|
|
1333
|
+
const details = result.details as Record<string, unknown[]>;
|
|
1334
|
+
for (const [key, entries] of Object.entries(details)) {
|
|
1335
|
+
if (Array.isArray(entries) && entries.length > 0) {
|
|
1336
|
+
lines.push(`${key}: ${JSON.stringify(entries, null, 2)}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (result.groupLookupError) {
|
|
1341
|
+
lines.push(`ASM group lookup failed: ${result.groupLookupError}`);
|
|
1342
|
+
}
|
|
1343
|
+
const suppressedGroups = result.groupSuppressions.filter(
|
|
1344
|
+
(group) => group.suppressed,
|
|
1345
|
+
);
|
|
1346
|
+
if (suppressedGroups.length > 0) {
|
|
1347
|
+
lines.push(
|
|
1348
|
+
`ASM groups: ${suppressedGroups.map((group) => `${group.id} ${group.name}`).join(', ')}`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return jsonReadResult(
|
|
1353
|
+
{
|
|
1354
|
+
email,
|
|
1355
|
+
bounced: result.bounced,
|
|
1356
|
+
blocked: result.blocked,
|
|
1357
|
+
unsubscribed: result.unsubscribed,
|
|
1358
|
+
spamReported: result.spamReported,
|
|
1359
|
+
invalidEmail: result.invalidEmail,
|
|
1360
|
+
groupUnsubscribed: result.groupUnsubscribed,
|
|
1361
|
+
groupSuppressions: result.groupSuppressions.map((group) => ({
|
|
1362
|
+
id: group.id,
|
|
1363
|
+
name: group.name || `group ${group.id}`,
|
|
1364
|
+
suppressed: group.suppressed,
|
|
1365
|
+
})),
|
|
1366
|
+
...(result.groupLookupError
|
|
1367
|
+
? { groupLookupError: result.groupLookupError }
|
|
1368
|
+
: {}),
|
|
1369
|
+
},
|
|
1370
|
+
lines.join('\n'),
|
|
1371
|
+
response_format,
|
|
1372
|
+
);
|
|
1373
|
+
},
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
server.registerTool(
|
|
1377
|
+
'get_email_stats',
|
|
1378
|
+
{
|
|
1379
|
+
description:
|
|
1380
|
+
'Get email statistics. dimension=global is the account daily rollup. category requires categories. mailbox_provider, geo, browser, device, and client are the other SendGrid stats cuts. Browser, device, and client stats retain about 7 days.',
|
|
1381
|
+
inputSchema: z.object({
|
|
1382
|
+
startDate: DateSchema.describe('Start date YYYY-MM-DD'),
|
|
1383
|
+
endDate: DateSchema.optional().describe(
|
|
1384
|
+
'End date YYYY-MM-DD (defaults to today)',
|
|
1385
|
+
),
|
|
1386
|
+
dimension: StatsDimensionSchema.optional().describe(
|
|
1387
|
+
'Default global. category requires categories. browser, device, and client only retain about 7 days.',
|
|
1388
|
+
),
|
|
1389
|
+
aggregatedBy: z.enum(['day', 'week', 'month']).optional(),
|
|
1390
|
+
categories: z.array(z.string().min(1)).max(25).optional(),
|
|
1391
|
+
mailboxProviders: z.array(z.string().min(1)).max(10).optional(),
|
|
1392
|
+
country: z.string().length(2).optional().describe('ISO country code for dimension=geo'),
|
|
1393
|
+
browsers: z.array(z.string().min(1)).max(10).optional(),
|
|
1394
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1395
|
+
...ReadInputFields,
|
|
1396
|
+
}),
|
|
1397
|
+
outputSchema: EmailStatsOutputSchema,
|
|
1398
|
+
},
|
|
1399
|
+
async ({
|
|
1400
|
+
startDate,
|
|
1401
|
+
endDate,
|
|
1402
|
+
dimension,
|
|
1403
|
+
aggregatedBy,
|
|
1404
|
+
categories,
|
|
1405
|
+
mailboxProviders,
|
|
1406
|
+
country,
|
|
1407
|
+
browsers,
|
|
1408
|
+
limit,
|
|
1409
|
+
response_format,
|
|
1410
|
+
}) => {
|
|
1411
|
+
const selected = dimension ?? 'global';
|
|
1412
|
+
const payload = await client.getStats({
|
|
1413
|
+
startDate,
|
|
1414
|
+
endDate,
|
|
1415
|
+
dimension: selected,
|
|
1416
|
+
aggregatedBy,
|
|
1417
|
+
categories,
|
|
1418
|
+
mailboxProviders,
|
|
1419
|
+
country,
|
|
1420
|
+
browsers,
|
|
1421
|
+
limit,
|
|
1422
|
+
});
|
|
1423
|
+
const { totals, lines } = collectStatTotals(payload);
|
|
1424
|
+
const note = SHORT_STATS_WINDOW.has(selected)
|
|
1425
|
+
? 'Browser, device, and client statistics retain about 7 days.'
|
|
1426
|
+
: undefined;
|
|
1427
|
+
const series = selected === 'global' ? undefined : asStatsSeries(payload);
|
|
1428
|
+
|
|
1429
|
+
return jsonReadResult(
|
|
1430
|
+
{
|
|
1431
|
+
startDate,
|
|
1432
|
+
endDate: endDate ?? null,
|
|
1433
|
+
dimension: selected,
|
|
1434
|
+
aggregatedBy: aggregatedBy ?? 'day',
|
|
1435
|
+
totals,
|
|
1436
|
+
...(note ? { note } : {}),
|
|
1437
|
+
...(series ? { series } : {}),
|
|
1438
|
+
},
|
|
1439
|
+
[
|
|
1440
|
+
`Stats (${selected}): ${startDate} → ${endDate ?? 'today'}`,
|
|
1441
|
+
...(note ? [note] : []),
|
|
1442
|
+
`Total: ${totals.requests} requests, ${totals.delivered} delivered, ${totals.bounces} bounces, ${totals.opens} opens`,
|
|
1443
|
+
'',
|
|
1444
|
+
...(lines.length > 0 ? lines : ['No stats for the given range.']),
|
|
1445
|
+
].join('\n'),
|
|
1446
|
+
response_format,
|
|
1447
|
+
);
|
|
1448
|
+
},
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
server.registerTool(
|
|
1452
|
+
'list_categories',
|
|
1453
|
+
{
|
|
1454
|
+
description:
|
|
1455
|
+
'List category names (GET /v3/categories) so get_email_stats dimension=category can be given a real categories array.',
|
|
1456
|
+
inputSchema: z.object({
|
|
1457
|
+
category: z.string().min(1).optional().describe('Filter by category name prefix or exact name, per SendGrid'),
|
|
1458
|
+
...ListPagingInputFields,
|
|
1459
|
+
}),
|
|
1460
|
+
outputSchema: CategoryListOutputSchema,
|
|
1461
|
+
},
|
|
1462
|
+
async ({ category, limit, offset, response_format }) => {
|
|
1463
|
+
const categories = await client.listCategories({
|
|
1464
|
+
category,
|
|
1465
|
+
limit: limit ?? 50,
|
|
1466
|
+
offset: offset ?? 0,
|
|
1467
|
+
});
|
|
1468
|
+
return jsonReadResult(
|
|
1469
|
+
{ count: categories.length, categories },
|
|
1470
|
+
categories.length === 0
|
|
1471
|
+
? 'No categories.'
|
|
1472
|
+
: categories.map((name) => `- ${name}`).join('\n'),
|
|
1473
|
+
response_format,
|
|
1474
|
+
);
|
|
1475
|
+
},
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
server.registerTool(
|
|
1479
|
+
'search_email_logs',
|
|
1480
|
+
{
|
|
1481
|
+
description:
|
|
1482
|
+
'Search Email Logs (POST /v3/logs). Use when Email Activity is unavailable, especially for EU regional subusers. Query fields: sg_message_id =, subject =, to_email =, status IN, reason =, categories IN, sg_message_id_created_at comparisons. Combine with AND. No nesting. Parent accounts pass exactly one subuser.',
|
|
1483
|
+
inputSchema: z.object({
|
|
1484
|
+
query: z.string().min(1).optional(),
|
|
1485
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
1486
|
+
subusers: z.array(z.string().min(1)).max(1).optional(),
|
|
1487
|
+
...ReadInputFields,
|
|
1488
|
+
}),
|
|
1489
|
+
outputSchema: EmailLogsOutputSchema,
|
|
1490
|
+
},
|
|
1491
|
+
async ({ query, limit, subusers, response_format }) => {
|
|
1492
|
+
const result = await client.searchEmailLogs({ query, limit, subusers });
|
|
1493
|
+
const messages = result.messages ?? [];
|
|
1494
|
+
return jsonReadResult(
|
|
1495
|
+
{ count: messages.length, messages },
|
|
1496
|
+
messages.length === 0
|
|
1497
|
+
? 'No Email Logs rows matched.'
|
|
1498
|
+
: messages
|
|
1499
|
+
.map(
|
|
1500
|
+
(message) =>
|
|
1501
|
+
`- sg_message_id=${message.sg_message_id ?? 'n/a'} status=${message.status ?? 'n/a'} to=${message.to_email ?? 'n/a'} reason=${message.reason ?? 'n/a'}`,
|
|
1502
|
+
)
|
|
1503
|
+
.join('\n'),
|
|
1504
|
+
response_format,
|
|
1505
|
+
);
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
|
|
1509
|
+
server.registerTool(
|
|
1510
|
+
'delete_suppression',
|
|
1511
|
+
{
|
|
1512
|
+
description:
|
|
1513
|
+
'Remove one recipient from a suppression list: bounce, block, spam_report, invalid_email, global unsubscribe, or one ASM group. Does not delete the group itself.',
|
|
1514
|
+
inputSchema: z
|
|
1515
|
+
.object({
|
|
1516
|
+
confirmToken: ConfirmTokenSchema,
|
|
1517
|
+
email: z.email(),
|
|
1518
|
+
type: z.enum([
|
|
1519
|
+
'bounce',
|
|
1520
|
+
'block',
|
|
1521
|
+
'spam_report',
|
|
1522
|
+
'invalid_email',
|
|
1523
|
+
'global',
|
|
1524
|
+
'group',
|
|
1525
|
+
]),
|
|
1526
|
+
groupId: z
|
|
1527
|
+
.number()
|
|
1528
|
+
.int()
|
|
1529
|
+
.positive()
|
|
1530
|
+
.optional()
|
|
1531
|
+
.describe('Required when type is group'),
|
|
1532
|
+
})
|
|
1533
|
+
.superRefine((value, ctx) => {
|
|
1534
|
+
if (value.type === 'group' && value.groupId === undefined) {
|
|
1535
|
+
ctx.addIssue({
|
|
1536
|
+
code: 'custom',
|
|
1537
|
+
message: 'groupId is required when type is group',
|
|
1538
|
+
path: ['groupId'],
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
}),
|
|
1542
|
+
outputSchema: DeleteSuppressionOutputSchema,
|
|
1543
|
+
},
|
|
1544
|
+
async ({ email, type, groupId }) => {
|
|
1545
|
+
await client.deleteSuppression({ type, email, groupId });
|
|
1546
|
+
return jsonReadResult(
|
|
1547
|
+
{
|
|
1548
|
+
deleted: true,
|
|
1549
|
+
type,
|
|
1550
|
+
email,
|
|
1551
|
+
groupId: groupId ?? null,
|
|
1552
|
+
},
|
|
1553
|
+
`Deleted ${type} suppression for ${email}${groupId !== undefined ? ` in group ${groupId}` : ''}.`,
|
|
1554
|
+
);
|
|
1555
|
+
},
|
|
1556
|
+
);
|
|
1557
|
+
}
|