diff --git a/CLAUDE.md b/CLAUDE.md index e048236..c582051 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,7 @@ Schema files define a JSON Schema–shaped object for MCP; optional Zod schemas #### Sandbox Testing - **send-sandbox-email**: Send email in sandbox mode to a test inbox. +- **batch-send-sandbox-email**: Send a batch of emails in sandbox mode to a test inbox in one call. Same `base` + `requests[]` shape as the transactional/bulk batch tools, plus `sandbox_id` (falls back to `MAILTRAP_SANDBOX_ID`). - **get-sandbox-messages**: Get list of messages from the sandbox test inbox. - **show-sandbox-email-message**: Show sandbox email message details and content from the sandbox test inbox. - **list-sandbox-projects** / **create-sandbox-project** / **get-sandbox-project** / **update-sandbox-project** / **delete-sandbox-project**: Manage sandbox projects (group of inboxes). diff --git a/README.md b/README.md index 225fb9d..133ac0b 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ Before using this MCP server, you need to: **Required Environment Variables:** - `MAILTRAP_API_TOKEN` - Required for all functionality -- `MAILTRAP_ACCOUNT_ID` - Required for templates, stats, email logs, sandbox list/show, and sending domains. Optional only for send-email and send-sandbox-email. +- `MAILTRAP_ACCOUNT_ID` - Required for templates, stats, email logs, sandbox list/show, and sending domains. Optional only for the send tools (send-email, send-sandbox-email, and the batch-send-\* tools). **Optional (can be passed as tool parameters instead):** -- `DEFAULT_FROM_EMAIL` - Default sender email when `from` is not provided to send-email or send-sandbox-email. Enables switching sender per call via the `from` parameter. -- `MAILTRAP_TEST_INBOX_ID` - Default test inbox ID for sandbox tools when `test_inbox_id` is not provided. Enables switching between inboxes per call via the `test_inbox_id` parameter. +- `DEFAULT_FROM_EMAIL` - Default sender email when `from` is not provided to send-email, send-sandbox-email, or the batch-send-\* tools (where it fills `base.from`). Enables switching sender per call via the `from` parameter. - `MAILTRAP_SANDBOX_ID` - Default sandbox ID for sandbox tools when `sandbox_id` is not provided. Enables switching between sandboxes per call via the `sandbox_id` parameter. +- `MAILTRAP_TEST_INBOX_ID` - Default test inbox ID for sandbox tools when `test_inbox_id` is not provided. Enables switching between inboxes per call via the `test_inbox_id` parameter. Legacy alias for `MAILTRAP_SANDBOX_ID`, still honored as a fallback. - `MAILTRAP_ORGANIZATION_ID` - Required for organization tools (`list-sub-accounts`, `create-sub-account`). - `MAILTRAP_ORGANIZATION_API_TOKEN` - Organization-scoped API token. Required for organization tools (separate from `MAILTRAP_API_TOKEN`). @@ -358,8 +358,17 @@ Sends an email to your Mailtrap test inbox for development and testing purposes. - `template_uuid` (optional): Use a Mailtrap email template instead of inline content. When set, `subject` / `text` / `html` / `category` must be omitted. - `template_variables` (optional): Object of variables substituted into the template referenced by `template_uuid`. Only allowed together with `template_uuid`. +### batch-send-sandbox-email + +Sends a batch of emails to your Mailtrap test inbox in one API call, without delivering to real recipients. Same `base` + `requests[]` shape, validation, and inline-vs-template rules as `batch-send-transactional-email` — the difference is that this tool routes the call through the sandbox endpoint for a single test inbox. + +**Parameters:** + +- `sandbox_id` (optional): Mailtrap sandbox (test inbox) ID. Required unless `MAILTRAP_SANDBOX_ID` is set; pass per call to target a specific sandbox. +- `base` (optional), `requests` (required): See `batch-send-transactional-email` above. + > [!NOTE] -> For sandbox tools, provide `test_inbox_id` in the tool call or set the `MAILTRAP_TEST_INBOX_ID` environment variable. You can switch between inboxes per call by passing `test_inbox_id`. +> For sandbox tools, provide `test_inbox_id` in the tool call or set the `MAILTRAP_TEST_INBOX_ID` environment variable. You can switch between inboxes per call by passing `test_inbox_id`. Tools taking `sandbox_id` use `MAILTRAP_SANDBOX_ID` first. ### get-sandbox-messages diff --git a/src/server.ts b/src/server.ts index d7654cf..14582c0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,8 @@ import { import { sendSandboxEmail, sendSandboxEmailSchema, + batchSendSandboxEmail, + batchSendSandboxEmailSchema, getMessages, getMessagesSchema, showEmailMessage, @@ -331,6 +333,16 @@ const tools = [ destructiveHint: false, }, }, + { + name: "batch-send-sandbox-email", + description: + "Send a batch of emails in sandbox mode to a test inbox in one Mailtrap API call. Shared fields go on `base`; per-recipient overrides go in `requests[]`. Requires `sandbox_id` or the MAILTRAP_SANDBOX_ID env var.", + inputSchema: batchSendSandboxEmailSchema, + handler: batchSendSandboxEmail, + annotations: { + destructiveHint: false, + }, + }, { name: "get-sandbox-messages", description: "Get list of messages from the sandbox test inbox", diff --git a/src/tools/sandbox/__tests__/batchSendSandboxEmail.test.ts b/src/tools/sandbox/__tests__/batchSendSandboxEmail.test.ts new file mode 100644 index 0000000..725190c --- /dev/null +++ b/src/tools/sandbox/__tests__/batchSendSandboxEmail.test.ts @@ -0,0 +1,144 @@ +import batchSendSandboxEmail from "../batchSendSandboxEmail"; +import { getSandboxClient } from "../../../client"; + +const mockClient = { + batchSend: jest.fn(), +}; + +jest.mock("../../../client", () => ({ + getSandboxClient: jest.fn(() => mockClient), +})); + +describe("batchSendSandboxEmail", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + (getSandboxClient as jest.Mock).mockReturnValue(mockClient); + process.env = { ...originalEnv }; + delete process.env.MAILTRAP_SANDBOX_ID; + delete process.env.MAILTRAP_TEST_INBOX_ID; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("uses the sandbox client for the given inbox and forwards the SDK payload", async () => { + mockClient.batchSend.mockResolvedValue({ + success: true, + responses: [{ success: true, message_ids: ["m-1"] }], + }); + + const result = await batchSendSandboxEmail({ + sandbox_id: 4242, + base: { + from: { email: "sender@example.com", name: "Sender" }, + subject: "Sandbox hello", + text: "Hello sandbox", + }, + requests: [{ to: "alice@example.com" }], + }); + + expect(getSandboxClient).toHaveBeenCalledWith(4242); + expect(mockClient.batchSend).toHaveBeenCalledWith({ + base: { + from: { email: "sender@example.com", name: "Sender" }, + subject: "Sandbox hello", + text: "Hello sandbox", + }, + requests: [{ to: [{ email: "alice@example.com" }] }], + }); + expect(result.isError).toBeUndefined(); + }); + + it("does not forward sandbox_id into the SDK payload", async () => { + mockClient.batchSend.mockResolvedValue({ success: true, responses: [] }); + + await batchSendSandboxEmail({ + sandbox_id: 4242, + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + const payload = mockClient.batchSend.mock.calls[0][0]; + expect(payload.base).not.toHaveProperty("sandbox_id"); + expect(payload).not.toHaveProperty("sandbox_id"); + }); + + it("falls back to MAILTRAP_SANDBOX_ID when sandbox_id is omitted", async () => { + process.env.MAILTRAP_SANDBOX_ID = "777"; + mockClient.batchSend.mockResolvedValue({ success: true, responses: [] }); + + const result = await batchSendSandboxEmail({ + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + expect(getSandboxClient).toHaveBeenCalledWith(777); + expect(result.isError).toBeUndefined(); + }); + + it("falls back to the legacy MAILTRAP_TEST_INBOX_ID, but MAILTRAP_SANDBOX_ID wins", async () => { + process.env.MAILTRAP_TEST_INBOX_ID = "777"; + mockClient.batchSend.mockResolvedValue({ success: true, responses: [] }); + + await batchSendSandboxEmail({ + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + expect(getSandboxClient).toHaveBeenCalledWith(777); + + process.env.MAILTRAP_SANDBOX_ID = "888"; + + await batchSendSandboxEmail({ + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + expect(getSandboxClient).toHaveBeenLastCalledWith(888); + }); + + it("errors when no sandbox is configured", async () => { + const result = await batchSendSandboxEmail({ + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + "Failed to batch send sandbox email: Provide sandbox_id or set MAILTRAP_SANDBOX_ID environment variable for sandbox mode" + ); + expect(mockClient.batchSend).not.toHaveBeenCalled(); + }); + + it("propagates payload validation errors", async () => { + const result = await batchSendSandboxEmail({ + sandbox_id: 4242, + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{}], + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + "Failed to batch send sandbox email: requests[0]: provide at least one recipient" + ); + expect(mockClient.batchSend).not.toHaveBeenCalled(); + }); + + it("surfaces API errors with a sandbox-specific prefix", async () => { + mockClient.batchSend.mockRejectedValue(new Error("inbox is full")); + + const result = await batchSendSandboxEmail({ + sandbox_id: 4242, + base: { from: "sender@example.com", subject: "Hi", text: "x" }, + requests: [{ to: "alice@example.com" }], + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + "Failed to batch send sandbox email: inbox is full" + ); + }); +}); diff --git a/src/tools/sandbox/batchSendSandboxEmail.ts b/src/tools/sandbox/batchSendSandboxEmail.ts new file mode 100644 index 0000000..18ce665 --- /dev/null +++ b/src/tools/sandbox/batchSendSandboxEmail.ts @@ -0,0 +1,32 @@ +import { getSandboxClient } from "../../client"; +import { BatchSendSandboxEmailToolRequest } from "../../types/mailtrap"; +import buildBatchPayload from "../sendEmail/buildBatchPayload"; +import { + buildErrorResponse, + buildSuccessResponse, + ToolResponse, +} from "../utils/responses"; +import resolveSandboxId from "./utils/resolveSandboxId"; + +async function batchSendSandboxEmail({ + sandbox_id, + ...body +}: BatchSendSandboxEmailToolRequest): Promise { + try { + const inboxId = resolveSandboxId(sandbox_id); + + const payload = buildBatchPayload(body); + + const mailtrap = getSandboxClient(inboxId); + + const response = await mailtrap.batchSend( + payload as unknown as Parameters[0] + ); + + return buildSuccessResponse(JSON.stringify(response, null, 2)); + } catch (error) { + return buildErrorResponse("batch send sandbox email", error); + } +} + +export default batchSendSandboxEmail; diff --git a/src/tools/sandbox/index.ts b/src/tools/sandbox/index.ts index 87d3ca7..1b7cf4c 100644 --- a/src/tools/sandbox/index.ts +++ b/src/tools/sandbox/index.ts @@ -1,5 +1,7 @@ import sendSandboxEmailSchema from "./schemas/sendSandboxEmail"; import sendSandboxEmail from "./sendSandboxEmail"; +import batchSendSandboxEmailSchema from "./schemas/batchSendSandboxEmail"; +import batchSendSandboxEmail from "./batchSendSandboxEmail"; import getMessagesSchema from "./schemas/getMessages"; import getMessages from "./getSandboxMessages"; import showEmailMessageSchema from "./schemas/showEmailMessage"; @@ -58,6 +60,8 @@ import getSandboxAttachment from "./getSandboxAttachment"; export { sendSandboxEmailSchema, sendSandboxEmail, + batchSendSandboxEmailSchema, + batchSendSandboxEmail, getMessagesSchema, getMessages, showEmailMessageSchema, diff --git a/src/tools/sandbox/schemas/batchSendSandboxEmail.ts b/src/tools/sandbox/schemas/batchSendSandboxEmail.ts new file mode 100644 index 0000000..a66072a --- /dev/null +++ b/src/tools/sandbox/schemas/batchSendSandboxEmail.ts @@ -0,0 +1,15 @@ +import batchSendStreamEmailSchema from "../../sendEmail/schemas/batchSendStreamEmail"; + +const batchSendSandboxEmailSchema = { + ...batchSendStreamEmailSchema, + properties: { + sandbox_id: { + type: "number", + description: + "Mailtrap sandbox (test inbox) ID. Optional if MAILTRAP_SANDBOX_ID env var is set. Use to target a specific sandbox.", + }, + ...batchSendStreamEmailSchema.properties, + }, +}; + +export default batchSendSandboxEmailSchema; diff --git a/src/types/mailtrap.ts b/src/types/mailtrap.ts index 9195526..09a783d 100644 --- a/src/types/mailtrap.ts +++ b/src/types/mailtrap.ts @@ -82,6 +82,11 @@ export interface BatchSendEmailToolRequest { requests: BatchSendEmailRequest[]; } +export interface BatchSendSandboxEmailToolRequest + extends BatchSendEmailToolRequest { + sandbox_id?: number; +} + export interface CreateTemplateRequest { name: string; subject: string;