Claude Code with Mailtrap: Email Testing Without Surprises
Why Mailtrap without CLAUDE.md ships test emails to real customers
Mailtrap is two products in one. The first is an email sandbox: a fake SMTP server that captures whatever your application sends, lets you inspect the HTML, check the spam score, and verify the rendering across clients, without ever delivering to a real inbox. The second is a sending service: a transactional and bulk email API that delivers real messages with deliverability tooling, suppression lists, and webhook events. Both share the same dashboard, the same SDK, and a similar API surface. That symmetry is the product's biggest selling point and its most dangerous footgun.
The footgun is that Claude Code, without explicit instructions, treats Mailtrap as a single API and routes test sends and real sends through whatever account credentials are configured. If the production sending account key ends up in a development environment, every test send hits real customer inboxes. If the sandbox key ends up in production, every production send goes to a dashboard nobody monitors and no customer ever receives the email. Both failure modes have happened to teams using Mailtrap.
Claude also defaults to SMTP transport over the JSON API for transactional sends. SMTP works, but it lacks the structured response that lets you correlate sends with webhook events, and it cannot reach the bulk endpoint where rate limits and pricing differ. Without explicit instructions, Claude generates Nodemailer or smtplib code that compiles cleanly and sends successfully, but produces an integration with no message IDs, no tags, no environment isolation, and no path from "the email did not arrive" to "here is exactly what Mailtrap saw and why it was suppressed".
This guide covers the CLAUDE.md configuration that locks Claude Code into Mailtrap's correct model: separate credentials for sandbox and sending, JSON API as the default with SMTP available where structured responses are not needed, tags applied to every send for analytics segmentation, suppression list checked before bulk sends, and webhooks consumed for delivery events. If you are building a Next.js application, Claude Code with Next.js covers the request lifecycle around outbound email. For full-domain transactional email patterns, Claude Code with Resend compares closely with the Mailtrap sending API.
The Mailtrap CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Mailtrap integration it needs to declare: which credentials are which, when to use the sandbox vs the sending account, the transport choice per environment, the mandatory fields on every send, the tagging convention for analytics, and the hard rules that prevent the credential mixups Claude generates most often.
# Mailtrap email rules
## Stack
- mailtrap ^3.x (Node), mailtrap ^2.x (Python)
- TypeScript 5.x strict
- Node.js 20.x or Python 3.11+
- Two account types, two key types:
- MAILTRAP_SANDBOX_TOKEN , api token for the sandbox account (test inboxes)
- MAILTRAP_SANDBOX_INBOX_ID , id of the inbox messages route to in sandbox
- MAILTRAP_API_TOKEN , api token for the sending account (production)
- MAILTRAP_FROM_EMAIL in .env (verified sender address)
- MAILTRAP_FROM_NAME in .env
## Environment routing (MANDATORY)
- NODE_ENV=development | test | preview -> use sandbox token, inbox capture only
- NODE_ENV=production -> use sending account token, real delivery
- NEVER ship a production build that has only the sandbox token
- NEVER ship a development build that has only the sending account token
- Startup MUST assert: the right token is present for the current NODE_ENV
## Project structure
- src/lib/mailtrap.ts , Client factory that returns sandbox or sending client
- src/lib/send-email.ts , Shared wrapper with tag and metadata injection
- src/emails/ , React Email or MJML templates
- src/app/api/webhooks/mailtrap/ , Webhook handler for delivery events
## Transport choice
- Default: JSON API via mailtrap SDK (structured response, message_ids in result)
- SMTP only where: legacy framework needs SMTP transport, or you want to test the SMTP path
- NEVER use SMTP for bulk sends (loses message ID correlation with webhooks)
## Send pattern (MANDATORY fields)
Every send MUST include:
- from: { email, name } (verified sender, never test@example.com)
- to: [{ email, name? }] (recipient array, even for single)
- subject: string (non-empty)
- text: string (plain-text fallback, REQUIRED for deliverability)
- html: string (rendered HTML)
- category: string (e.g. "welcome", "password_reset", "invoice")
Optional but commonly needed:
- custom_variables: { request_id, tenant_id, user_id }
- headers: { 'X-Reply-To': ..., 'X-Idempotency-Key': ... }
- attachments: [{ filename, content }]
## Tagging and segmentation
- category: one of a stable enum, document the full list here
- custom_variables: ALWAYS include request_id, tenant_id, user_id
- NEVER use category for per-customer slugs (it is for type, not identity)
## Suppression list
- Check suppression list before any bulk send
- Respect suppression status: do not retry a hardbounced address
- Webhook event "bounce" or "spam_complaint" MUST add to local suppression cache
## Hard rules
- NEVER hardcode MAILTRAP_SANDBOX_TOKEN or MAILTRAP_API_TOKEN in source
- NEVER use the production sending token in non-production environments
- NEVER skip the text field
- NEVER send from a non-verified address in production
- NEVER omit category on a send
- ALWAYS log message_id from the API response for correlation with webhooks
- ALWAYS check the suppression list before bulk send
Three rules here prevent the majority of production failures Claude generates without them.
The environment routing rule is the most impactful and the most often missed. Mailtrap's API is uniform across sandbox and sending: the SDK method signatures are the same, the response shapes are the same, and the only thing that changes is the base URL and the credentials. Claude defaults to a single client configuration that uses whichever token is in the environment. The CLAUDE.md rule forces an explicit branch on NODE_ENV so a development build cannot accidentally pick up the production token and a production build cannot accidentally pick up the sandbox token. The startup assertion catches the case at boot time rather than at the first send.
The mandatory category rule sets up Mailtrap's analytics dashboards. Without a category on every send, the dashboard shows aggregate counts and you cannot filter by email type. With a category, you can compare "welcome" open rates against "password_reset" open rates and detect when one category starts bouncing. Claude omits this because the SDK does not require it. Declaring it explicitly in CLAUDE.md, alongside a wrapper that injects it, makes Claude generate the right pattern by default.
The suppression list rule prevents the most common production reputation damage. Sending to addresses that have hardbounced or marked previous messages as spam compounds the reputation hit and accelerates blocking. Mailtrap maintains a suppression list per sending account. Checking it before bulk sends and updating it from webhook events is non-negotiable for any send volume above a few hundred a day.
Install and credential setup
Install the Mailtrap SDK:
npm i mailtrap
Set up two sets of credentials. The sandbox token is for the inbox capture account, the API token is for the sending account. Both come from the Mailtrap dashboard but they live in different sections.
# .env.local (development, preview)
MAILTRAP_SANDBOX_TOKEN=your_sandbox_api_token
MAILTRAP_SANDBOX_INBOX_ID=1234567
MAILTRAP_FROM_EMAIL=hello@claudify.tech
MAILTRAP_FROM_NAME=Claudify
# .env.production (production)
MAILTRAP_API_TOKEN=your_sending_account_api_token
MAILTRAP_FROM_EMAIL=hello@claudify.tech
MAILTRAP_FROM_NAME=Claudify
Create the client factory:
// src/lib/mailtrap.ts
import { MailtrapClient } from 'mailtrap';
const isProduction = process.env.NODE_ENV === 'production';
function assertCredentials() {
if (isProduction) {
if (!process.env.MAILTRAP_API_TOKEN) {
throw new Error('MAILTRAP_API_TOKEN is required in production');
}
if (process.env.MAILTRAP_SANDBOX_TOKEN) {
console.warn(
'[mailtrap] sandbox token detected in production env, ignored. Verify deployment config.',
);
}
} else {
if (!process.env.MAILTRAP_SANDBOX_TOKEN) {
throw new Error('MAILTRAP_SANDBOX_TOKEN is required in development/preview/test');
}
if (!process.env.MAILTRAP_SANDBOX_INBOX_ID) {
throw new Error('MAILTRAP_SANDBOX_INBOX_ID is required in development/preview/test');
}
if (process.env.MAILTRAP_API_TOKEN) {
throw new Error(
'[mailtrap] production sending token detected in non-production env. Refusing to boot.',
);
}
}
}
assertCredentials();
export const mailtrap = isProduction
? new MailtrapClient({ token: process.env.MAILTRAP_API_TOKEN! })
: new MailtrapClient({
token: process.env.MAILTRAP_SANDBOX_TOKEN!,
sandbox: true,
testInboxId: Number(process.env.MAILTRAP_SANDBOX_INBOX_ID!),
});
export const isSandbox = !isProduction;
The startup assertion does two things. First, it ensures the right token is present. Second, it actively rejects the wrong token: a production sending token in a development environment is a refuse-to-boot condition. This is more aggressive than a warning because the cost of mistakenly using the production token in a CI run is that real customers receive test emails.
Add the credential routing pattern to CLAUDE.md:
## Credential routing (ENFORCE)
- src/lib/mailtrap.ts is the only file that reads MAILTRAP_* env vars
- It exports a single client that is either sandbox or production based on NODE_ENV
- The startup assertion REFUSES TO BOOT if the wrong token is present for the env
- NEVER bypass this file. NEVER instantiate MailtrapClient elsewhere.
The send wrapper
The send wrapper applies the mandatory fields, the category, and the custom variables. Every send in the application goes through it.
// src/lib/send-email.ts
import { mailtrap, isSandbox } from '@/lib/mailtrap';
import type { MailtrapMailOptions } from 'mailtrap';
const FROM = {
email: process.env.MAILTRAP_FROM_EMAIL ?? 'hello@claudify.tech',
name: process.env.MAILTRAP_FROM_NAME ?? 'Claudify',
};
type Category =
| 'welcome'
| 'password_reset'
| 'magic_link'
| 'invoice'
| 'payment_receipt'
| 'order_confirmation'
| 'usage_alert'
| 'team_invite';
interface SendEmailOptions {
to: Array<{ email: string; name?: string }>;
subject: string;
text: string;
html: string;
category: Category;
customVariables: {
request_id: string;
tenant_id: string;
user_id: string;
};
replyTo?: string;
attachments?: Array<{ filename: string; content: Buffer | string }>;
}
export async function sendEmail(options: SendEmailOptions) {
const payload: MailtrapMailOptions = {
from: FROM,
to: options.to,
subject: options.subject,
text: options.text,
html: options.html,
category: options.category,
custom_variables: options.customVariables,
...(options.replyTo && { headers: { 'Reply-To': options.replyTo } }),
...(options.attachments && {
attachments: options.attachments.map(a => ({
filename: a.filename,
content:
typeof a.content === 'string'
? Buffer.from(a.content).toString('base64')
: a.content.toString('base64'),
})),
}),
};
const response = await mailtrap.send(payload);
console.log('[mailtrap] sent', {
message_ids: response.message_ids,
category: options.category,
sandbox: isSandbox,
request_id: options.customVariables.request_id,
});
return response;
}
Calling it from a route handler:
// src/app/api/auth/password-reset/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail } from '@/lib/send-email';
import { renderPasswordResetEmail } from '@/emails/password-reset';
export async function POST(req: NextRequest) {
const { email, resetToken } = await req.json();
const { html, text } = await renderPasswordResetEmail({ resetToken });
await sendEmail({
to: [{ email }],
subject: 'Reset your password',
text,
html,
category: 'password_reset',
customVariables: {
request_id: req.headers.get('x-request-id') ?? crypto.randomUUID(),
tenant_id: '_anonymous',
user_id: '_anonymous',
},
});
return NextResponse.json({ ok: true });
}
Every send through this wrapper is tagged with a category, custom variables, and a logged message ID. The Mailtrap dashboard shows category breakdowns. The logs correlate with webhook events because the message ID is captured at send time.
Sandbox testing
In development, sends go to the configured sandbox inbox. The Mailtrap dashboard shows a list of messages with HTML preview, spam score, and rendering checks. The inbox is shared across the team, so two developers running tests at the same time will see each other's messages.
For automated tests, point at a per-test-run inbox or clear the inbox before each test:
// tests/helpers/mailtrap.ts
import { MailtrapClient } from 'mailtrap';
const sandbox = new MailtrapClient({
token: process.env.MAILTRAP_SANDBOX_TOKEN!,
sandbox: true,
testInboxId: Number(process.env.MAILTRAP_SANDBOX_INBOX_ID!),
});
export async function clearInbox() {
const messages = await sandbox.testing.messages.get(
Number(process.env.MAILTRAP_ACCOUNT_ID!),
Number(process.env.MAILTRAP_SANDBOX_INBOX_ID!),
);
for (const msg of messages) {
await sandbox.testing.messages.delete(
Number(process.env.MAILTRAP_ACCOUNT_ID!),
Number(process.env.MAILTRAP_SANDBOX_INBOX_ID!),
msg.id,
);
}
}
export async function findLastMessage(toEmail: string) {
const messages = await sandbox.testing.messages.get(
Number(process.env.MAILTRAP_ACCOUNT_ID!),
Number(process.env.MAILTRAP_SANDBOX_INBOX_ID!),
);
const matching = messages
.filter(m => m.to_email === toEmail)
.sort((a, b) => new Date(b.sent_at).getTime() - new Date(a.sent_at).getTime());
return matching[0] ?? null;
}
Using these helpers in a Playwright test:
import { test, expect } from '@playwright/test';
import { clearInbox, findLastMessage } from './helpers/mailtrap';
test.beforeEach(async () => {
await clearInbox();
});
test('password reset email contains the right token', async ({ page }) => {
await page.goto('/forgot-password');
await page.fill('input[name=email]', 'user@example.com');
await page.click('button[type=submit]');
// Wait for the email to land
await page.waitForTimeout(2000);
const message = await findLastMessage('user@example.com');
expect(message).not.toBeNull();
expect(message?.subject).toBe('Reset your password');
expect(message?.text_body).toContain('/reset-password?token=');
});
Add a testing section to CLAUDE.md:
## Testing patterns
- All tests that send email use the sandbox account
- E2E tests: clearInbox() in beforeEach, findLastMessage(email) to verify
- Unit tests: mock the mailtrap client at the module level
- NEVER use the production sending account in tests
- NEVER hit the real Mailtrap sandbox API in unit tests (mock the client)
Webhooks for delivery events
Mailtrap fires webhooks for delivery events: send accepted, delivered, bounced, opened, clicked, spam complaint, unsubscribe. The webhook payload includes the message_id you captured at send time, which lets you correlate the event with your application state.
Configure the webhook endpoint in the Mailtrap dashboard, pointing at:
POST https://your-app.com/api/webhooks/mailtrap
The handler:
// src/app/api/webhooks/mailtrap/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { addToSuppression } from '@/lib/suppression';
import { markDelivered, markBounced } from '@/lib/email-events';
const SECRET = process.env.MAILTRAP_WEBHOOK_SECRET;
function verifySignature(body: string, signature: string): boolean {
if (!SECRET) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
interface MailtrapEvent {
event:
| 'delivery'
| 'open'
| 'click'
| 'unsubscribe'
| 'spam_complaint'
| 'soft_bounce'
| 'bounce'
| 'suspension';
message_id: string;
email: string;
category: string;
custom_variables?: Record<string, string>;
timestamp: number;
reason?: string;
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('mailtrap-signature') ?? '';
if (!verifySignature(body, signature)) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { events } = JSON.parse(body) as { events: MailtrapEvent[] };
for (const event of events) {
switch (event.event) {
case 'delivery':
await markDelivered(event.message_id);
break;
case 'bounce':
case 'suspension':
await markBounced(event.message_id, event.reason);
await addToSuppression(event.email, event.event, event.reason);
break;
case 'spam_complaint':
await addToSuppression(event.email, 'spam_complaint', 'reported as spam');
break;
case 'soft_bounce':
// log but do not suppress, retry next send
break;
default:
// open, click, unsubscribe: feed into analytics
break;
}
}
return NextResponse.json({ ok: true });
}
Set up the secret:
# .env.production
MAILTRAP_WEBHOOK_SECRET=your_webhook_signing_secret
Add the webhook section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/mailtrap/route.ts
- MAILTRAP_WEBHOOK_SECRET in .env.production
- Verify HMAC SHA-256 signature before parsing the body
- Events handled: delivery, bounce, suspension, spam_complaint, soft_bounce
- bounce + suspension: mark message bounced, add to suppression list
- spam_complaint: add to suppression list immediately
- soft_bounce: log only, allow retries
- open, click, unsubscribe: feed analytics, do not suppress
- Idempotent handlers: Mailtrap may deliver the same event more than once
- Return 200 for all valid events, even those you do not act on
Suppression list
The suppression list is the local cache of addresses that should not receive any further sends. It mirrors the Mailtrap account-level suppression but is faster to check at send time and survives connectivity issues with the Mailtrap API.
// src/lib/suppression.ts
import { db } from '@/lib/db';
export async function addToSuppression(
email: string,
reason: 'bounce' | 'suspension' | 'spam_complaint' | 'manual',
detail?: string,
) {
await db.suppressionList.upsert({
where: { email },
create: { email, reason, detail: detail ?? null },
update: { reason, detail: detail ?? null, updatedAt: new Date() },
});
}
export async function isSuppressed(email: string): Promise<boolean> {
const row = await db.suppressionList.findUnique({ where: { email } });
return row !== null;
}
export async function filterSuppressed(emails: string[]): Promise<{
send: string[];
suppress: string[];
}> {
const rows = await db.suppressionList.findMany({
where: { email: { in: emails } },
});
const suppressedSet = new Set(rows.map(r => r.email));
return {
send: emails.filter(e => !suppressedSet.has(e)),
suppress: emails.filter(e => suppressedSet.has(e)),
};
}
Using it in a bulk send:
import { filterSuppressed } from '@/lib/suppression';
import { sendEmail } from '@/lib/send-email';
async function sendBatchAnnouncement(recipients: Array<{ email: string; name: string }>) {
const { send, suppress } = await filterSuppressed(recipients.map(r => r.email));
if (suppress.length > 0) {
console.log(`[announcement] skipping ${suppress.length} suppressed addresses`);
}
const sendableRecipients = recipients.filter(r => send.includes(r.email));
// Mailtrap bulk endpoint accepts up to 1000 recipients per call on the bulk plan
for (let i = 0; i < sendableRecipients.length; i += 1000) {
const batch = sendableRecipients.slice(i, i + 1000);
await sendEmail({
to: batch,
subject: 'New feature: agent-mode',
text: 'See full announcement at https://claudify.tech/blog',
html: '<p>See the <a href="https://claudify.tech/blog">full announcement</a>.</p>',
category: 'team_invite', // or define a new "announcement" category
customVariables: {
request_id: crypto.randomUUID(),
tenant_id: '_broadcast',
user_id: '_broadcast',
},
});
}
}
Add a suppression section to CLAUDE.md:
## Suppression list
- Source of truth: local table `suppression_list` (email, reason, detail, created_at, updated_at)
- ALWAYS call filterSuppressed() before any bulk send
- Webhook handler updates suppression on bounce, suspension, spam_complaint
- NEVER attempt to send to a suppressed address (it will hardbounce again and damage reputation)
- Periodic reconciliation: sync local suppression with Mailtrap API once per day
Common Claude Code mistakes with Mailtrap
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Single client across environments
Claude generates: const mt = new MailtrapClient({ token: process.env.MAILTRAP_TOKEN }); without env routing.
Correct pattern: src/lib/mailtrap.ts branches on NODE_ENV, refuses to boot if the wrong token is present.
2. Missing text fallback
Claude generates: sends with html only.
Correct pattern: every send has both html and text fields.
3. No category on sends
Claude generates: mailtrap.send({ from, to, subject, html, text }) with no category.
Correct pattern: category: 'welcome' | 'password_reset' | ... from a stable enum on every send.
4. SMTP transport for everything
Claude generates: a Nodemailer SMTP transport pointed at Mailtrap.
Correct pattern: JSON API via the Mailtrap SDK as default, SMTP only where structured response is not needed (legacy frameworks, smoke tests).
5. No suppression check before bulk
Claude generates: a loop that sends to every email in a list with no suppression filter.
Correct pattern: filterSuppressed(emails) before the batch, log the suppressed count.
6. Webhook handler missing signature verification
Claude generates: a webhook handler that parses the JSON body and acts on events without verifying the signature.
Correct pattern: HMAC SHA-256 verification with timing-safe comparison before parsing.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.
Permission hooks for email scripts
A Mailtrap integration accumulates scripts: send tests, suppression list export, webhook replay utilities, sandbox clear scripts. Some are read-only. Some send real emails or modify suppression state. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/clear-sandbox.js*)",
"Bash(node scripts/preview-email.js*)",
"Bash(node scripts/list-suppressions.js*)",
"Bash(node scripts/export-events.js*)"
],
"deny": [
"Bash(node scripts/send-batch-production.js*)",
"Bash(node scripts/clear-suppression-list.js*)",
"Bash(node scripts/replay-webhooks.js*)"
]
}
}
Clearing the sandbox and previewing emails are safe in development. Sending a production batch or clearing the suppression list would impact real customer inboxes and account reputation. The deny list forces Claude to surface those operations as prompts.
Building email integrations that test reliably and send reliably
The Mailtrap CLAUDE.md in this guide produces email setups where sandbox and sending credentials are physically separated and the wrong token in the wrong environment refuses to boot, every send carries a category and custom variables for analytics correlation, the suppression list is checked before any bulk send, webhooks verify signatures and update local state on bounces and spam complaints, and tests use sandbox helpers that mirror the production send path.
The underlying principle is the same as any email integration with Claude Code. Mailtrap without a CLAUDE.md produces code that works for the first developer who writes it and silently misroutes test sends to customers or production sends to a sandbox the moment a second developer copies the snippet without the surrounding context. The CLAUDE.md template removes each failure mode by making the credential-routed, categorised, suppression-aware pattern the only pattern Claude can generate.
For the next layer of the email stack, Mailtrap pairs with Claude Code with Next.js for the route handlers that trigger sends, and Claudify includes a Mailtrap-specific CLAUDE.md template with credential routing, send wrappers, suppression patterns, webhook handlers, and all six common-mistake rules pre-configured.
Get Claudify. Ship Mailtrap integrations that never send test emails to real customers.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify