Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ You can pass additional arguments to configure the local server:

Resend's MCP server gives your AI agent native access to the full Resend platform through a single integration. You can manage all aspects of your email infrastructure using natural language.

- **Emails**: Send, list, get, cancel, update, and batch send emails. Supports HTML, plain text, attachments (local file, URL, or base64), CC/BCC, reply-to, scheduling, tags, and topic-based sending.
- **Emails**: Send, list, get, cancel, update, and batch send emails. Supports HTML, plain text, attachments (local file, URL, or base64), CC/BCC, reply-to, scheduling, tags, custom headers, topic-based sending, and idempotency keys.
- **Received Emails**: List and read inbound emails. List and download received email attachments.
- **Templates**: Create, list, get, update, publish, duplicate, and remove email templates. Supports composing template content and `{{{VARIABLE}}}` placeholders.
- **Contacts**: Create, list, get, update, and remove contacts. Manage segment memberships, topic subscriptions, and CSV contact imports. Supports custom contact properties.
Expand Down
21 changes: 21 additions & 0 deletions src/tools/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export function addEmailTools(
.describe(
'Topic ID for subscription-based sending. When set, the email respects contact subscription preferences for this topic.',
),
headers: z
.record(z.string(), z.string())
.optional()
.describe(
'Optional custom email headers as key/value pairs (e.g. {"List-Unsubscribe": "<https://example.com/unsubscribe>", "X-Entity-Ref-ID": "unique-id"}). Use for one-click unsubscribe, preventing Gmail threading, or other MIME headers Resend accepts.',
),
idempotencyKey: z
.string()
.max(256)
Expand Down Expand Up @@ -165,6 +171,7 @@ export function addEmailTools(
attachments,
tags,
topicId,
headers,
idempotencyKey,
}) => {
const fromEmailAddress = from ?? senderEmailAddress;
Expand Down Expand Up @@ -207,6 +214,7 @@ export function addEmailTools(
value: string;
}>;
topicId?: string;
headers?: Record<string, string>;
} = {
to,
subject,
Expand Down Expand Up @@ -273,6 +281,10 @@ export function addEmailTools(
emailRequest.topicId = topicId;
}

if (headers && Object.keys(headers).length > 0) {
emailRequest.headers = headers;
}

const response = await resend.emails.send(
emailRequest,
idempotencyKey ? { idempotencyKey } : undefined,
Expand Down Expand Up @@ -1020,6 +1032,12 @@ export function addEmailTools(
.string()
.optional()
.describe('Topic ID for subscription-based sending'),
headers: z
.record(z.string(), z.string())
.optional()
.describe(
'Optional custom email headers as key/value pairs (e.g. {"List-Unsubscribe": "<https://example.com/unsubscribe>", "X-Entity-Ref-ID": "unique-id"}).',
),
}),
)
.min(1)
Expand Down Expand Up @@ -1059,6 +1077,9 @@ export function addEmailTools(
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.headers && Object.keys(email.headers).length > 0) {
request.headers = email.headers;
}

return request;
});
Expand Down
108 changes: 108 additions & 0 deletions tests/tools/emails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,111 @@ describe('send-batch-emails idempotency key', () => {
expect(batchSend).toHaveBeenCalledWith(expect.any(Array), undefined);
});
});

describe('send-email custom headers', () => {
beforeEach(() => {
vi.clearAllMocks();
send.mockResolvedValue({
data: { id: 'email_1' },
error: null,
});
});

it('passes headers through to the SDK email payload', async () => {
const client = await makeClient();
const result = await client.callTool({
name: 'send-email',
arguments: {
from: 'Acme <onboarding@resend.dev>',
to: ['delivered@resend.dev'],
subject: 'hello',
text: 'world',
headers: {
'List-Unsubscribe': '<https://example.com/unsubscribe>',
'X-Entity-Ref-ID': 'order-123',
},
},
});

expect(result.isError).toBeFalsy();
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'List-Unsubscribe': '<https://example.com/unsubscribe>',
'X-Entity-Ref-ID': 'order-123',
},
}),
undefined,
);
});

it('omits headers from the SDK payload when not provided', async () => {
const client = await makeClient();
await client.callTool({
name: 'send-email',
arguments: {
from: 'onboarding@resend.dev',
to: ['delivered@resend.dev'],
subject: 'hello',
text: 'world',
},
});

const [payload] = send.mock.calls[0];
expect(payload).not.toHaveProperty('headers');
});
});

describe('send-batch-emails custom headers', () => {
beforeEach(() => {
vi.clearAllMocks();
batchSend.mockResolvedValue({
data: { data: [{ id: 'email_1' }, { id: 'email_2' }] },
error: null,
});
});

it('passes per-email headers through to the SDK batch payload', async () => {
const client = await makeClient();
const result = await client.callTool({
name: 'send-batch-emails',
arguments: {
emails: [
{
from: 'Acme <onboarding@resend.dev>',
to: ['foo@example.com'],
subject: 'hello',
text: 'one',
headers: {
'X-Entity-Ref-ID': 'batch-1',
},
},
{
from: 'Acme <onboarding@resend.dev>',
to: ['bar@example.com'],
subject: 'hello',
text: 'two',
},
],
},
});

expect(result.isError).toBeFalsy();
expect(batchSend).toHaveBeenCalledWith(
[
expect.objectContaining({
to: ['foo@example.com'],
headers: {
'X-Entity-Ref-ID': 'batch-1',
},
}),
expect.objectContaining({
to: ['bar@example.com'],
}),
],
undefined,
);
const [, secondEmail] = batchSend.mock.calls[0][0];
expect(secondEmail).not.toHaveProperty('headers');
});
});