From 24d42a473fa0f067fd21b2f32229c7ff1863a120 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 20:55:29 +0000 Subject: [PATCH 1/4] Add attachments support to send-batch-emails tool Mirror send-email attachment handling in the batch send MCP tool so scheduledAt, tags, and attachments are all passed through to the API. Co-authored-by: cpenned --- src/tools/emails.ts | 128 +++++++++++++++++++++++++++++-------- tests/tools/emails.test.ts | 59 +++++++++++++++++ 2 files changed, 159 insertions(+), 28 deletions(-) diff --git a/src/tools/emails.ts b/src/tools/emails.ts index e79a441..bc464d9 100644 --- a/src/tools/emails.ts +++ b/src/tools/emails.ts @@ -1007,6 +1007,46 @@ export function addEmailTools( .describe( "Optional schedule time. Uses natural language (e.g., 'tomorrow at 10am') or ISO 8601.", ), + attachments: z + .array( + z.object({ + filename: z + .string() + .describe( + 'Name of the file with extension (e.g., "report.pdf")', + ), + filePath: z + .string() + .optional() + .describe('Local file path to read and attach'), + url: z + .string() + .optional() + .describe( + 'URL where the file is hosted (Resend will fetch it)', + ), + content: z + .string() + .optional() + .describe('Base64-encoded file content'), + contentType: z + .string() + .optional() + .describe( + 'MIME type (e.g., "application/pdf"). Auto-derived from filename if not set', + ), + contentId: z + .string() + .optional() + .describe( + 'Content ID for inline images. Reference in HTML with cid:', + ), + }), + ) + .optional() + .describe( + 'Array of file attachments. Each needs filename plus one of: filePath, url, or content. Max 40MB total.', + ), tags: z .array( z.object({ @@ -1015,7 +1055,9 @@ export function addEmailTools( }), ) .optional() - .describe('Custom tags for tracking/analytics'), + .describe( + 'Array of custom tags for tracking/analytics. Each tag has a name and value.', + ), topicId: z .string() .optional() @@ -1035,33 +1077,63 @@ export function addEmailTools( }, }, async ({ emails, idempotencyKey }) => { - const emailRequests = emails.map((email) => { - const fromAddress = email.from ?? senderEmailAddress; - const replyToAddresses = email.replyTo ?? replierEmailAddresses; - - if (typeof fromAddress !== 'string') { - throw new Error( - `from address must be provided for email to ${email.to.join(', ')}`, - ); - } - - const request: Record = { - to: email.to, - subject: email.subject, - text: email.text, - from: fromAddress, - replyTo: replyToAddresses, - }; - - if (email.html) request.html = email.html; - if (email.cc) request.cc = email.cc; - if (email.bcc) request.bcc = email.bcc; - if (email.scheduledAt) request.scheduledAt = email.scheduledAt; - if (email.tags && email.tags.length > 0) request.tags = email.tags; - if (email.topicId) request.topicId = email.topicId; - - return request; - }); + const emailRequests = await Promise.all( + emails.map(async (email) => { + const fromAddress = email.from ?? senderEmailAddress; + const replyToAddresses = email.replyTo ?? replierEmailAddresses; + + if (typeof fromAddress !== 'string') { + throw new Error( + `from address must be provided for email to ${email.to.join(', ')}`, + ); + } + + const request: Record = { + to: email.to, + subject: email.subject, + text: email.text, + from: fromAddress, + replyTo: replyToAddresses, + }; + + if (email.html) request.html = email.html; + if (email.cc) request.cc = email.cc; + if (email.bcc) request.bcc = email.bcc; + if (email.scheduledAt) request.scheduledAt = email.scheduledAt; + if (email.tags && email.tags.length > 0) request.tags = email.tags; + if (email.topicId) request.topicId = email.topicId; + + if (email.attachments && email.attachments.length > 0) { + request.attachments = await Promise.all( + email.attachments.map(async (att) => { + const result: { + filename?: string; + content?: Buffer; + path?: string; + contentType?: string; + contentId?: string; + } = {}; + + if (att.filename) result.filename = att.filename; + if (att.contentType) result.contentType = att.contentType; + if (att.contentId) result.contentId = att.contentId; + + if (att.filePath) { + result.content = await fs.readFile(att.filePath); + } else if (att.url) { + result.path = att.url; + } else if (att.content) { + result.content = Buffer.from(att.content, 'base64'); + } + + return result; + }), + ); + } + + return request; + }), + ); const response = await resend.batch.send( emailRequests as unknown as Parameters[0], diff --git a/tests/tools/emails.test.ts b/tests/tools/emails.test.ts index b0aa152..1bc1697 100644 --- a/tests/tools/emails.test.ts +++ b/tests/tools/emails.test.ts @@ -1,6 +1,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import type { Resend } from 'resend'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { addEmailTools } from '../../src/tools/emails.js'; @@ -195,3 +198,59 @@ describe('send-batch-emails idempotency key', () => { expect(batchSend).toHaveBeenCalledWith(expect.any(Array), undefined); }); }); + +describe('send-batch-emails optional fields', () => { + beforeEach(() => { + vi.clearAllMocks(); + batchSend.mockResolvedValue({ + data: { data: [{ id: 'email_1' }] }, + error: null, + }); + }); + + it('passes scheduledAt, tags, and attachments to the SDK', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'resend-mcp-')); + const filePath = path.join(tmpDir, 'invoice.pdf'); + await fs.writeFile(filePath, 'pdf-content'); + + const client = await makeClient(); + const result = await client.callTool({ + name: 'send-batch-emails', + arguments: { + emails: [ + { + from: 'onboarding@resend.dev', + to: ['foo@example.com'], + subject: 'Receipt', + text: 'Thanks for your purchase', + scheduledAt: 'tomorrow at 10am', + tags: [{ name: 'category', value: 'receipt' }], + attachments: [ + { + filename: 'invoice.pdf', + filePath, + }, + ], + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + expect(batchSend).toHaveBeenCalledWith( + [ + expect.objectContaining({ + scheduledAt: 'tomorrow at 10am', + tags: [{ name: 'category', value: 'receipt' }], + attachments: [ + expect.objectContaining({ + filename: 'invoice.pdf', + content: Buffer.from('pdf-content'), + }), + ], + }), + ], + undefined, + ); + }); +}); From a092d3cb8d07c274623b3dd9ce01b9e29a8d91d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 21:08:21 +0000 Subject: [PATCH 2/4] chore: fix import order in emails test Co-authored-by: cpenned --- tests/tools/emails.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tools/emails.test.ts b/tests/tools/emails.test.ts index 1bc1697..aac3584 100644 --- a/tests/tools/emails.test.ts +++ b/tests/tools/emails.test.ts @@ -1,9 +1,9 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Resend } from 'resend'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { addEmailTools } from '../../src/tools/emails.js'; From 970acdc589649640e271a9df0084914ab5472862 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:16:29 +0000 Subject: [PATCH 3/4] fix: limit batch emails to tags only Remove unsupported scheduledAt and attachments from send-batch-emails. Batch send only supports tags beyond the core email fields. Co-authored-by: cpenned --- src/tools/emails.ts | 133 ++++++++----------------------------- tests/tools/emails.test.ts | 25 +------ 2 files changed, 30 insertions(+), 128 deletions(-) diff --git a/src/tools/emails.ts b/src/tools/emails.ts index bc464d9..c3dcb99 100644 --- a/src/tools/emails.ts +++ b/src/tools/emails.ts @@ -967,9 +967,9 @@ export function addEmailTools( 'send-batch-emails', { title: 'Send Batch Emails', - description: `**Purpose:** Send up to 100 transactional emails in one API call. Each item has the same fields as send-email (to, subject, text, from, etc.). + description: `**Purpose:** Send up to 100 transactional emails in one API call. -**NOT for:** Sending one email (use send-email) or the same content to a segment (use create-broadcast + send-broadcast). +**NOT for:** Sending one email (use send-email), scheduling emails, attaching files, or sending the same content to a segment (use create-broadcast + send-broadcast). Batch emails support tags but not attachments or scheduledAt. **When to use:** User wants to send many individual emails in bulk (e.g. 50 password resets, 100 receipts). Not for one-to-many broadcasts.`, inputSchema: { @@ -1001,52 +1001,6 @@ export function addEmailTools( .array(z.email()) .optional() .describe('BCC email addresses'), - scheduledAt: z - .string() - .optional() - .describe( - "Optional schedule time. Uses natural language (e.g., 'tomorrow at 10am') or ISO 8601.", - ), - attachments: z - .array( - z.object({ - filename: z - .string() - .describe( - 'Name of the file with extension (e.g., "report.pdf")', - ), - filePath: z - .string() - .optional() - .describe('Local file path to read and attach'), - url: z - .string() - .optional() - .describe( - 'URL where the file is hosted (Resend will fetch it)', - ), - content: z - .string() - .optional() - .describe('Base64-encoded file content'), - contentType: z - .string() - .optional() - .describe( - 'MIME type (e.g., "application/pdf"). Auto-derived from filename if not set', - ), - contentId: z - .string() - .optional() - .describe( - 'Content ID for inline images. Reference in HTML with cid:', - ), - }), - ) - .optional() - .describe( - 'Array of file attachments. Each needs filename plus one of: filePath, url, or content. Max 40MB total.', - ), tags: z .array( z.object({ @@ -1077,63 +1031,32 @@ export function addEmailTools( }, }, async ({ emails, idempotencyKey }) => { - const emailRequests = await Promise.all( - emails.map(async (email) => { - const fromAddress = email.from ?? senderEmailAddress; - const replyToAddresses = email.replyTo ?? replierEmailAddresses; - - if (typeof fromAddress !== 'string') { - throw new Error( - `from address must be provided for email to ${email.to.join(', ')}`, - ); - } - - const request: Record = { - to: email.to, - subject: email.subject, - text: email.text, - from: fromAddress, - replyTo: replyToAddresses, - }; - - if (email.html) request.html = email.html; - if (email.cc) request.cc = email.cc; - if (email.bcc) request.bcc = email.bcc; - if (email.scheduledAt) request.scheduledAt = email.scheduledAt; - if (email.tags && email.tags.length > 0) request.tags = email.tags; - if (email.topicId) request.topicId = email.topicId; - - if (email.attachments && email.attachments.length > 0) { - request.attachments = await Promise.all( - email.attachments.map(async (att) => { - const result: { - filename?: string; - content?: Buffer; - path?: string; - contentType?: string; - contentId?: string; - } = {}; - - if (att.filename) result.filename = att.filename; - if (att.contentType) result.contentType = att.contentType; - if (att.contentId) result.contentId = att.contentId; - - if (att.filePath) { - result.content = await fs.readFile(att.filePath); - } else if (att.url) { - result.path = att.url; - } else if (att.content) { - result.content = Buffer.from(att.content, 'base64'); - } - - return result; - }), - ); - } - - return request; - }), - ); + const emailRequests = emails.map((email) => { + const fromAddress = email.from ?? senderEmailAddress; + const replyToAddresses = email.replyTo ?? replierEmailAddresses; + + if (typeof fromAddress !== 'string') { + throw new Error( + `from address must be provided for email to ${email.to.join(', ')}`, + ); + } + + const request: Record = { + to: email.to, + subject: email.subject, + text: email.text, + from: fromAddress, + replyTo: replyToAddresses, + }; + + if (email.html) request.html = email.html; + if (email.cc) request.cc = email.cc; + if (email.bcc) request.bcc = email.bcc; + if (email.tags && email.tags.length > 0) request.tags = email.tags; + if (email.topicId) request.topicId = email.topicId; + + return request; + }); const response = await resend.batch.send( emailRequests as unknown as Parameters[0], diff --git a/tests/tools/emails.test.ts b/tests/tools/emails.test.ts index aac3584..442cd60 100644 --- a/tests/tools/emails.test.ts +++ b/tests/tools/emails.test.ts @@ -1,6 +1,3 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -199,7 +196,7 @@ describe('send-batch-emails idempotency key', () => { }); }); -describe('send-batch-emails optional fields', () => { +describe('send-batch-emails tags', () => { beforeEach(() => { vi.clearAllMocks(); batchSend.mockResolvedValue({ @@ -208,11 +205,7 @@ describe('send-batch-emails optional fields', () => { }); }); - it('passes scheduledAt, tags, and attachments to the SDK', async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'resend-mcp-')); - const filePath = path.join(tmpDir, 'invoice.pdf'); - await fs.writeFile(filePath, 'pdf-content'); - + it('passes tags to the SDK', async () => { const client = await makeClient(); const result = await client.callTool({ name: 'send-batch-emails', @@ -223,14 +216,7 @@ describe('send-batch-emails optional fields', () => { to: ['foo@example.com'], subject: 'Receipt', text: 'Thanks for your purchase', - scheduledAt: 'tomorrow at 10am', tags: [{ name: 'category', value: 'receipt' }], - attachments: [ - { - filename: 'invoice.pdf', - filePath, - }, - ], }, ], }, @@ -240,14 +226,7 @@ describe('send-batch-emails optional fields', () => { expect(batchSend).toHaveBeenCalledWith( [ expect.objectContaining({ - scheduledAt: 'tomorrow at 10am', tags: [{ name: 'category', value: 'receipt' }], - attachments: [ - expect.objectContaining({ - filename: 'invoice.pdf', - content: Buffer.from('pdf-content'), - }), - ], }), ], undefined, From ca3b289c53a79dd546f3849bacd210df1f05634f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:32:36 +0000 Subject: [PATCH 4/4] chore: remove attachment references from batch email tool description Co-authored-by: cpenned --- src/tools/emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/emails.ts b/src/tools/emails.ts index c3dcb99..30b0ba9 100644 --- a/src/tools/emails.ts +++ b/src/tools/emails.ts @@ -969,7 +969,7 @@ export function addEmailTools( title: 'Send Batch Emails', description: `**Purpose:** Send up to 100 transactional emails in one API call. -**NOT for:** Sending one email (use send-email), scheduling emails, attaching files, or sending the same content to a segment (use create-broadcast + send-broadcast). Batch emails support tags but not attachments or scheduledAt. +**NOT for:** Sending one email (use send-email), scheduling emails, or sending the same content to a segment (use create-broadcast + send-broadcast). Batch emails support tags only as an extra per-email field. **When to use:** User wants to send many individual emails in bulk (e.g. 50 password resets, 100 receipts). Not for one-to-many broadcasts.`, inputSchema: {