Mailtrap bundles an Email Sandbox (a safe test inbox) and an Email API/SMTP for production sending under one SDK. That makes it handy for staging: dev and staging capture messages, production delivers, with the same code path. Read the email overview first: it defines the shared EmailSender, emailPlugin(), headerSafe, escapeHtml(), and the email.suppress job used below.
One client, two destinations
state.email.send(...)MailtrapClientMAILTRAP_MODE picks the destination
defaultEmail Sandboxsandbox: true + testInboxId · captured, never delivered
MAILTRAP_MODE=productionEmail Sendingverified domain · delivered to recipients
The sender defaults to the sandbox. Only an explicit MAILTRAP_MODE=production, validated at boot together with a sending token, delivers real mail, so a missing variable can never email your customers from staging.
1. Provision
For testing, open Email Sandbox -> Inboxes and copy the inbox ID, then create an API token with access to that sandbox only.
For production, open Email Sending -> Sending Domains, verify a domain with the SPF, DKIM, and DMARC records shown, then create a separate API token with Email Sending permission for that domain.
Store the two tokens under different names and give each environment only the token it needs. Staging never holds the sending token.
The toggle fails closed: anything other than an explicit MAILTRAP_MODE=production means sandbox, and defineConfig() refuses to boot when the chosen mode is missing its token or inbox id:
src/email/mailtrap.tsts
import { z } from "zod";import { MailtrapClient } from "mailtrap";import { defineConfig } from "@daloyjs/core";import type { EmailSender } from "../plugins/email.ts";const cfg = await defineConfig({ schema: z .object({ MAILTRAP_MODE: z.enum(["sandbox", "production"]).default("sandbox"), MAILTRAP_SANDBOX_TOKEN: z.string().min(1).optional(), MAILTRAP_TEST_INBOX_ID: z.coerce.number().int().positive().optional(), MAILTRAP_SENDING_TOKEN: z.string().min(1).optional(), EMAIL_FROM_ADDRESS: z.email(), }) .superRefine((c, ctx) => { if (c.MAILTRAP_MODE === "production" && !c.MAILTRAP_SENDING_TOKEN) { ctx.addIssue({ code: "custom", path: ["MAILTRAP_SENDING_TOKEN"], message: "required in production mode" }); } if (c.MAILTRAP_MODE === "sandbox" && (!c.MAILTRAP_SANDBOX_TOKEN || !c.MAILTRAP_TEST_INBOX_ID)) { ctx.addIssue({ code: "custom", path: ["MAILTRAP_TEST_INBOX_ID"], message: "sandbox mode needs a sandbox token and inbox id" }); } }),});const client = cfg.MAILTRAP_MODE === "production" ? new MailtrapClient({ token: cfg.MAILTRAP_SENDING_TOKEN! }) : new MailtrapClient({ token: cfg.MAILTRAP_SANDBOX_TOKEN!, sandbox: true, sandboxId: cfg.MAILTRAP_TEST_INBOX_ID!, // `testInboxId` is the deprecated name for this option });export const sender: EmailSender = { async send(msg) { const res = await client.send({ from: { email: cfg.EMAIL_FROM_ADDRESS, name: "Acme" }, to: [{ email: msg.to }], ...(msg.replyTo ? { reply_to: { email: msg.replyTo } } : {}), subject: msg.subject, headers: msg.headers, ...(msg.html !== undefined ? { html: msg.html, text: msg.text } : { text: msg.text ?? "" }), }); return { id: res.message_ids?.[0] ?? "" }; },};
The same sender works in every environment; only the variables change. Mailtrap's send API has no documented idempotency key, so queue sends through the overview's email.send job.
5. Use it in a route
A feedback form is a contact form, so it goes to your own inbox with the visitor only in replyTo. Mount the overview's /contact route as-is, or copy it under /feedback. Do not send a "thanks for your feedback" auto-reply to the address the visitor typed: that turns the form back into a relay that mails strangers on demand.
Bulk sending
Mailtrap has a separate Bulk Sending stream (host bulk.api.mailtrap.io) for marketing volume. Use a separate client with bulk: true and keep it apart from transactional mail:
ts
const bulkClient = new MailtrapClient({ token: cfg.MAILTRAP_SENDING_TOKEN!, bulk: true });
Create a webhook in Mailtrap's webhook settings for the bounce, spam, unsubscribe, and reject events in JSON format, and copy its signing secret. Mailtrap sends a lowercase hex HMAC-SHA256 of the raw body in the Mailtrap-Signature header, so core's verifyWebhookSignature() verifies it directly (the SDK also exports a Node-only verifyWebhookSignature helper). There is no signed timestamp, so dedupe on each event's event_id. Mailtrap retries anything other than a 200 response, so acknowledge with exactly 200:
src/routes/mailtrap-webhook.tsts
import { z } from "zod";import { App, UnauthorizedError, jobIdempotencyKey, readBodyLimited, safeJsonParse, verifyWebhookSignature,} from "@daloyjs/core";import { config } from "../config.ts";interface MailtrapEvent { event: string; // "delivery", "bounce", "soft bounce", "spam", "unsubscribe", "reject", ... email: string; event_id: string;}export function mailtrapWebhookRoutes(app: App) { app.post( "/webhooks/mailtrap", { operationId: "mailtrapEvents", responses: { 200: { description: "Accepted", body: z.object({ ok: z.literal(true) }) }, 401: { description: "Rejected" }, }, }, async ({ request }) => { const raw = await readBodyLimited(request, 2 * 1_048_576); // up to 500 events per request const ok = await verifyWebhookSignature({ payload: raw, signature: request.headers.get("mailtrap-signature") ?? "", secret: config.MAILTRAP_WEBHOOK_SECRET, }); if (!ok) throw new UnauthorizedError("invalid signature"); const body = safeJsonParse(new TextDecoder().decode(raw)) as { events?: MailtrapEvent[] } | undefined; for (const e of body?.events ?? []) { if (!["bounce", "spam", "unsubscribe"].includes(e.event) || !e.event_id) continue; await app.jobs!.enqueue({ name: "email.suppress", payload: { email: e.email, reason: e.event }, idempotencyKey: jobIdempotencyKey({ name: "email.suppress", key: `mt-${e.event_id}` }), }); } return { status: 200, body: { ok: true as const } }; }, );}
Add MAILTRAP_WEBHOOK_SECRET: z.string().min(1) to the overview's config schema in production. The header name and scheme above match Mailtrap's webhook docs and SDK v5 at the time of writing; recheck them if verification starts failing after a Mailtrap update.
Suppressions
Mailtrap keeps a suppression list for production sending and exposes it through the SDK's suppressions API, which needs your accountId in the client config. Mirror suppressions into your own undeliverable flag through the webhook above so the app stops at enqueue time.
Runtimes
The mailtrap SDK is built on axios with Node http/https keep-alive agents, and its webhook helper uses node:crypto, so it targets Node and Node-compatible runtimes. For Cloudflare Workers, call the REST API with the overview's resilientFetch + fetchGuard client (it never retries the POST). The hosts are send.api.mailtrap.io (production), bulk.api.mailtrap.io (bulk), and sandbox.api.mailtrap.io (sandbox, with the inbox id in the path):
src/email/mailtrap-workers.tsts
import { env } from "cloudflare:workers";import type { EmailSender } from "../plugins/email.ts";import { providerFetch } from "./fetch.ts"; // allowHosts: ["send.api.mailtrap.io", "sandbox.api.mailtrap.io"]const production = env.MAILTRAP_MODE === "production"; // anything else is sandboxconst url = production ? "https://send.api.mailtrap.io/api/send" : `https://sandbox.api.mailtrap.io/api/send/${encodeURIComponent(env.MAILTRAP_TEST_INBOX_ID)}`;const token = production ? env.MAILTRAP_SENDING_TOKEN : env.MAILTRAP_SANDBOX_TOKEN;export const sender: EmailSender = { async send(msg) { const res = await providerFetch(url, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, signal: msg.signal, body: JSON.stringify({ from: { email: env.EMAIL_FROM_ADDRESS, name: "Acme" }, to: [{ email: msg.to }], ...(msg.replyTo ? { reply_to: { email: msg.replyTo } } : {}), subject: msg.subject, text: msg.text, html: msg.html, }), }); if (!res.ok) throw new Error(`Mailtrap send failed with status ${res.status}`); const out = (await res.json()) as { message_ids?: string[] }; return { id: out.message_ids?.[0] ?? "" }; },};