Skip to content

Commit e51892f

Browse files
committed
Add batch-send-bulk-email tool
1 parent 09713cc commit e51892f

10 files changed

Lines changed: 419 additions & 140 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ Schema files define a JSON Schema–shaped object for MCP; optional Zod schemas
7373
#### Transactional Email
7474

7575
- **send-email**: Send transactional emails through Mailtrap.
76-
- **batch-send-email**: Send a batch of emails in one Mailtrap API call. Shared fields on `base`; per-recipient overrides in `requests[]`.
76+
- **batch-send-transactional-email**: Send a batch of transactional emails in one Mailtrap API call. Shared fields on `base`; per-recipient overrides in `requests[]`.
77+
- **batch-send-bulk-email**: Send a batch of bulk emails (Mailtrap bulk-stream API) in one call. Same `base` + `requests[]` shape as the transactional variant.
7778

7879
#### Email Logs
7980

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,9 @@ Sends a transactional email through Mailtrap. Supports two mutually exclusive mo
222222
- `template_uuid` (optional): Use a Mailtrap email template instead of inline content. When set, `subject` / `text` / `html` / `category` must be omitted (per Mailtrap API).
223223
- `template_variables` (optional): Object of variables substituted into the template referenced by `template_uuid`. Only allowed together with `template_uuid`.
224224

225-
### batch-send-email
225+
### batch-send-transactional-email
226226

227-
Sends a batch of emails in one Mailtrap API call. Shared fields go on `base`; per-recipient overrides go in `requests[]`. Each request must include `to`. Same inline-vs-template mutual exclusion as `send-email` — checked after merging base with each request.
227+
Sends a batch of transactional emails in one Mailtrap API call (default sending stream). Shared fields go on `base`; per-recipient overrides go in `requests[]`. Each request must include at least one recipient via `to`, `cc`, or `bcc`. Same inline-vs-template mutual exclusion as `send-email` — checked after merging base with each request.
228228

229229
**Parameters:**
230230

@@ -236,11 +236,15 @@ Sends a batch of emails in one Mailtrap API call. Shared fields go on `base`; pe
236236
- `custom_variables` (optional): Default custom variables (string-valued).
237237
- `headers` (optional): Default custom headers.
238238
- `requests` (required): Non-empty array of per-recipient messages. Each entry has:
239-
- `to` (required): Recipient(s) — string, `{ email, name? }`, or an array.
239+
- `to` (optional): Recipient(s) — string, `{ email, name? }`, or an array. Optional if `cc` or `bcc` is provided; at least one of `to` / `cc` / `bcc` must contain a recipient.
240240
- `cc`, `bcc`, `reply_to` (optional).
241241
- Inline (`subject`/`text`/`html`/`category`) or template (`template_uuid`/`template_variables`) overrides; any field omitted falls back to the matching `base` value.
242242
- `custom_variables`, `headers` (optional).
243243

244+
### batch-send-bulk-email
245+
246+
Sends a batch of bulk emails through Mailtrap's bulk-stream API. Same `base` + `requests[]` shape, validation, and inline-vs-template rules as `batch-send-transactional-email` — the only difference is that this tool routes the call through the bulk endpoint instead of the transactional one. See the parameters above.
247+
244248
### list-email-logs
245249

246250
Lists sent email logs (delivery history) with optional pagination and filters. Use to debug delivery issues from the IDE.

src/client.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ function getSandboxClient(inboxId: number): MailtrapClient {
3838
});
3939
}
4040

41+
/**
42+
* Returns a bulk-stream MailtrapClient. Use for bulk emails
43+
*/
44+
function getBulkClient(): MailtrapClient {
45+
if (!MAILTRAP_API_TOKEN) {
46+
throw new Error("MAILTRAP_API_TOKEN environment variable is required");
47+
}
48+
return new MailtrapClient({
49+
token: MAILTRAP_API_TOKEN,
50+
userAgent: config.USER_AGENT,
51+
bulk: true,
52+
...(process.env.MAILTRAP_ACCOUNT_ID &&
53+
!Number.isNaN(Number(process.env.MAILTRAP_ACCOUNT_ID))
54+
? { accountId: Number(process.env.MAILTRAP_ACCOUNT_ID) }
55+
: {}),
56+
});
57+
}
58+
4159
/**
4260
* Returns an organization-scoped MailtrapClient. Organization endpoints
4361
* require a dedicated organization-level API token; both
@@ -93,4 +111,10 @@ function requireClient(
93111
}
94112

95113
// eslint-disable-next-line import/prefer-default-export
96-
export { client, getSandboxClient, getOrganizationClient, requireClient };
114+
export {
115+
client,
116+
getSandboxClient,
117+
getBulkClient,
118+
getOrganizationClient,
119+
requireClient,
120+
};

src/server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
sendEmail,
1717
batchSendTransactionalEmailSchema,
1818
batchSendTransactionalEmail,
19+
batchSendBulkEmailSchema,
20+
batchSendBulkEmail,
1921
} from "./tools/sendEmail";
2022
import {
2123
createTemplate,
@@ -222,6 +224,16 @@ const tools = [
222224
destructiveHint: true,
223225
},
224226
},
227+
{
228+
name: "batch-send-bulk-email",
229+
description:
230+
"Send a batch of bulk emails (Mailtrap bulk-stream API) in one call. Shared fields go on `base`; per-recipient overrides go in `requests[]`. Each request must include at least one of `to`/`cc`/`bcc`.",
231+
inputSchema: batchSendBulkEmailSchema,
232+
handler: batchSendBulkEmail,
233+
annotations: {
234+
destructiveHint: true,
235+
},
236+
},
225237
{
226238
name: "create-template",
227239
description: "Create a new email template",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import batchSendBulkEmail from "../batchSendBulkEmail";
2+
import { getBulkClient } from "../../../client";
3+
4+
const mockClient = {
5+
batchSend: jest.fn(),
6+
};
7+
8+
jest.mock("../../../client", () => ({
9+
getBulkClient: jest.fn(() => mockClient),
10+
}));
11+
12+
describe("batchSendBulkEmail", () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
(getBulkClient as jest.Mock).mockReturnValue(mockClient);
16+
});
17+
18+
it("uses the bulk client and forwards the SDK payload", async () => {
19+
mockClient.batchSend.mockResolvedValue({
20+
success: true,
21+
responses: [{ success: true, message_ids: ["m-1"] }],
22+
});
23+
24+
const result = await batchSendBulkEmail({
25+
base: {
26+
from: { email: "sender@example.com", name: "Sender" },
27+
subject: "Bulk hello",
28+
text: "Hello bulk",
29+
},
30+
requests: [{ to: "alice@example.com" }],
31+
});
32+
33+
expect(getBulkClient).toHaveBeenCalledTimes(1);
34+
expect(mockClient.batchSend).toHaveBeenCalledWith({
35+
base: {
36+
from: { email: "sender@example.com", name: "Sender" },
37+
subject: "Bulk hello",
38+
text: "Hello bulk",
39+
},
40+
requests: [{ to: [{ email: "alice@example.com" }] }],
41+
});
42+
expect(result.isError).toBeUndefined();
43+
});
44+
45+
it("propagates payload validation errors", async () => {
46+
const result = await batchSendBulkEmail({
47+
base: { from: "sender@example.com", subject: "Hi", text: "x" },
48+
requests: [{}],
49+
});
50+
51+
expect(result.isError).toBe(true);
52+
expect(result.content[0].text).toContain(
53+
"Failed to batch send bulk email: requests[0]: provide at least one recipient"
54+
);
55+
expect(mockClient.batchSend).not.toHaveBeenCalled();
56+
});
57+
58+
it("surfaces API errors with a bulk-specific prefix", async () => {
59+
mockClient.batchSend.mockRejectedValue(new Error("bulk rate limited"));
60+
61+
const result = await batchSendBulkEmail({
62+
base: { from: "sender@example.com", subject: "Hi", text: "x" },
63+
requests: [{ to: "alice@example.com" }],
64+
});
65+
66+
expect(result.isError).toBe(true);
67+
expect(result.content[0].text).toBe(
68+
"Failed to batch send bulk email: bulk rate limited"
69+
);
70+
});
71+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { getBulkClient } from "../../client";
2+
import { BatchSendEmailToolRequest } from "../../types/mailtrap";
3+
import buildBatchPayload from "./buildBatchPayload";
4+
import {
5+
buildErrorResponse,
6+
buildSuccessResponse,
7+
ToolResponse,
8+
} from "../utils/responses";
9+
10+
async function batchSendBulkEmail(
11+
body: BatchSendEmailToolRequest
12+
): Promise<ToolResponse> {
13+
try {
14+
const mailtrap = getBulkClient();
15+
16+
const payload = buildBatchPayload(body);
17+
18+
const response = await mailtrap.batchSend(
19+
payload as unknown as Parameters<typeof mailtrap.batchSend>[0]
20+
);
21+
22+
return buildSuccessResponse(JSON.stringify(response, null, 2));
23+
} catch (error) {
24+
return buildErrorResponse("batch send bulk email", error);
25+
}
26+
}
27+
28+
export default batchSendBulkEmail;

src/tools/sendEmail/batchSendTransactionalEmail.ts

Lines changed: 9 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,25 @@
11
import { requireClient } from "../../client";
2-
import {
3-
BatchSendEmailToolRequest,
4-
BatchSendEmailBase,
5-
BatchSendEmailRequest,
6-
} from "../../types/mailtrap";
7-
import {
8-
buildFromAddress,
9-
normalizeAddressList,
10-
normalizeToRecipients,
11-
toMailtrapAddress,
12-
} from "../../utils/mailtrapAddresses";
2+
import { BatchSendEmailToolRequest } from "../../types/mailtrap";
3+
import buildBatchPayload from "./buildBatchPayload";
134
import {
145
buildErrorResponse,
156
buildSuccessResponse,
167
ToolResponse,
178
} from "../utils/responses";
189

19-
const { DEFAULT_FROM_EMAIL } = process.env;
20-
21-
function ensureNoForbiddenFields(
22-
source: BatchSendEmailBase | BatchSendEmailRequest,
23-
scope: string
24-
): void {
25-
if (source.template_uuid === undefined) {
26-
if (source.template_variables !== undefined) {
27-
throw new Error(
28-
`${scope}: 'template_variables' can only be used together with 'template_uuid'`
29-
);
30-
}
31-
return;
32-
}
33-
const forbidden = (
34-
[
35-
["subject", source.subject],
36-
["text", source.text],
37-
["html", source.html],
38-
["category", source.category],
39-
] as const
40-
).filter(([, value]) => value !== undefined && value !== "");
41-
if (forbidden.length > 0) {
42-
const fields = forbidden.map(([name]) => name).join(", ");
43-
throw new Error(
44-
`${scope}: when 'template_uuid' is set, the following fields must be omitted: ${fields}`
45-
);
46-
}
47-
}
48-
49-
async function batchSendTransactionalEmail({
50-
base,
51-
requests,
52-
}: BatchSendEmailToolRequest): Promise<ToolResponse> {
10+
async function batchSendTransactionalEmail(
11+
body: BatchSendEmailToolRequest
12+
): Promise<ToolResponse> {
5313
try {
5414
const mailtrap = requireClient("batch sending transactional email", {
5515
requireAccountId: false,
5616
});
5717

58-
if (!requests || requests.length === 0) {
59-
throw new Error("'requests' must contain at least one entry");
60-
}
61-
62-
if (base) ensureNoForbiddenFields(base, "base");
63-
requests.forEach((req, i) => {
64-
// Effective config = base merged with the request's overrides. A request
65-
// using `template_uuid` cannot also set inline content.
66-
const merged: BatchSendEmailRequest = {
67-
...(base ?? {}),
68-
...req,
69-
} as BatchSendEmailRequest;
70-
ensureNoForbiddenFields(merged, `requests[${i}]`);
71-
72-
const hasTemplate = merged.template_uuid !== undefined;
73-
const hasInlineBody =
74-
(merged.subject !== undefined && merged.subject !== "") ||
75-
(merged.text !== undefined && merged.text !== "") ||
76-
(merged.html !== undefined && merged.html !== "");
77-
if (!hasTemplate) {
78-
if (!merged.subject) {
79-
throw new Error(
80-
`requests[${i}]: 'subject' is required (either on base or per-request) when not using a template`
81-
);
82-
}
83-
if (!merged.html && !merged.text) {
84-
throw new Error(
85-
`requests[${i}]: either 'html' or 'text' body is required when not using a template`
86-
);
87-
}
88-
} else if (hasInlineBody && !merged.template_uuid) {
89-
// Defensive: should be unreachable after ensureNoForbiddenFields.
90-
throw new Error(
91-
`requests[${i}]: cannot mix 'template_uuid' with inline content`
92-
);
93-
}
94-
});
95-
96-
const fromAddress = buildFromAddress(base?.from, DEFAULT_FROM_EMAIL);
97-
98-
const sdkBase: Record<string, unknown> = { from: fromAddress };
99-
if (base?.reply_to) sdkBase.reply_to = toMailtrapAddress(base.reply_to);
100-
if (base?.subject !== undefined) sdkBase.subject = base.subject;
101-
if (base?.text !== undefined) sdkBase.text = base.text;
102-
if (base?.html !== undefined) sdkBase.html = base.html;
103-
if (base?.category !== undefined) sdkBase.category = base.category;
104-
if (base?.template_uuid !== undefined)
105-
sdkBase.template_uuid = base.template_uuid;
106-
if (base?.template_variables !== undefined)
107-
sdkBase.template_variables = base.template_variables;
108-
if (base?.custom_variables !== undefined)
109-
sdkBase.custom_variables = base.custom_variables;
110-
if (base?.headers !== undefined) sdkBase.headers = base.headers;
111-
112-
const sdkRequests = requests.map((req, i) => {
113-
const toAddresses =
114-
req.to !== undefined ? normalizeToRecipients(req.to) : [];
115-
const ccAddresses =
116-
req.cc && req.cc.length > 0 ? normalizeAddressList(req.cc) : [];
117-
const bccAddresses =
118-
req.bcc && req.bcc.length > 0 ? normalizeAddressList(req.bcc) : [];
18+
const payload = buildBatchPayload(body);
11919

120-
if (toAddresses.length + ccAddresses.length + bccAddresses.length === 0) {
121-
throw new Error(
122-
`requests[${i}]: provide at least one recipient via 'to', 'cc', or 'bcc'`
123-
);
124-
}
125-
126-
const r: Record<string, unknown> = {
127-
to: toAddresses,
128-
};
129-
if (ccAddresses.length > 0) r.cc = ccAddresses;
130-
if (bccAddresses.length > 0) r.bcc = bccAddresses;
131-
if (req.reply_to) r.reply_to = [toMailtrapAddress(req.reply_to)];
132-
if (req.subject !== undefined) r.subject = req.subject;
133-
if (req.text !== undefined) r.text = req.text;
134-
if (req.html !== undefined) r.html = req.html;
135-
if (req.category !== undefined) r.category = req.category;
136-
if (req.template_uuid !== undefined) r.template_uuid = req.template_uuid;
137-
if (req.template_variables !== undefined)
138-
r.template_variables = req.template_variables;
139-
if (req.custom_variables !== undefined)
140-
r.custom_variables = req.custom_variables;
141-
if (req.headers !== undefined) r.headers = req.headers;
142-
return r;
143-
});
144-
145-
const response = await mailtrap.batchSend({
146-
base: sdkBase,
147-
requests: sdkRequests,
148-
} as unknown as Parameters<typeof mailtrap.batchSend>[0]);
20+
const response = await mailtrap.batchSend(
21+
payload as unknown as Parameters<typeof mailtrap.batchSend>[0]
22+
);
14923

15024
return buildSuccessResponse(JSON.stringify(response, null, 2));
15125
} catch (error) {

0 commit comments

Comments
 (0)