Blog
TestingSMTPNode.js

MailHog and Mailpit: Test Email Locally in Node.js

Test email in Node.js with Mailpit, the maintained MailHog replacement: Docker setup, Nodemailer and Email SDK, REST API checks in tests, and SMTP error tests.

November 8, 20268 min read
MailHog and Mailpit card: a local SMTP inbox for Node.js tests on ports 1025 and 8025

To test email from a Node.js app without sending real email, run Mailpit in Docker (docker run -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 axllent/mailpit), point your SMTP client at localhost:1025, and read what arrived at http://localhost:8025 or through Mailpit's REST API. Mailpit is the maintained replacement for MailHog. It listens on the same two ports, so most MailHog setups switch by changing the image name.

This guide covers running Mailpit, sending to it with Nodemailer and with Email SDK, checking captured messages in an automated test, and using Mailpit's Chaos feature to test how your code handles SMTP errors. Everything below was run on 2026-11-08 against axllent/mailpit:v1.33.1, Nodemailer 10.2 and @opencoredev/email-sdk 3.0.1, with bun test. No real email was sent.

MailHog or Mailpit?

MailHog made local SMTP catching popular, but its last release is v1.0.1 from August 2020, and the project is no longer maintained. Mailpit started as a MailHog-inspired rewrite and ships releases often (v1.33.1 came out on 2026-11-07).

For a Node app, the parts that matter:

MailHog Mailpit
SMTP port 1025 1025
Web UI port 8025 8025
Last release v1.0.1, Aug 2020 v1.33.1, Nov 2026
REST API v2 v1, with search filters
SMTP error injection Jim Chaos triggers
HTML client check No Yes

Because the ports match, an app that sends to MailHog today will send to Mailpit with no code change. Test code that reads MailHog's /api/v2 endpoints needs rewriting, since Mailpit's API is different. Jim is MailHog's chaos monkey (-invite-jim), and Chaos is Mailpit's version, covered in the Chaos section below.

Run Mailpit

docker run -d --name mailpit -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 axllent/mailpit

The 127.0.0.1: prefix keeps both ports on your machine. Mailpit's inbox and API have no login by default, so publishing them on every interface lets anyone on your network read the captured mail. Open http://localhost:8025 to see the inbox. In a Compose file next to your app:

services:
  mailpit:
    image: axllent/mailpit
    ports:
      - "127.0.0.1:1025:1025"
      - "127.0.0.1:8025:8025"

If your app also runs as a Compose service, it reaches Mailpit at host mailpit instead of 127.0.0.1, and you can drop the 1025 port mapping.

Mailpit also ships as a single binary (brew install mailpit on macOS) if you'd rather not run Docker. Pin a version tag such as axllent/mailpit:v1.33.1 in CI so a new release can't change your tests.

Send to Mailpit with Nodemailer

Mailpit has no TLS and no auth by default, so connect in plain text:

import nodemailer from "nodemailer";

export const transport = nodemailer.createTransport({
  host: "127.0.0.1",
  port: 1025,
  secure: false,
});

await transport.sendMail({
  from: "Acme <hello@acme.test>",
  to: "new-user@example.com",
  subject: "Welcome to Acme",
  text: "Your account is ready.",
  html: "<p>Your account is ready.</p>",
});

The message shows up in the web UI right away. You don't need auth, because Mailpit accepts mail without it by default. If your app always sends credentials, see Mailpit's SMTP settings for accepting any login.

Send to Mailpit with Email SDK

Email SDK sends through one client to 26 provider APIs and SMTP. In development, give it the SMTP adapter pointed at Mailpit. In production, give it your provider's adapter, and fail at startup if its key is missing so production mail never goes to a local catcher. The send calls stay the same.

npm install @opencoredev/email-sdk
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { smtp } from "@opencoredev/email-sdk/smtp";

function emailAdapter() {
  if (process.env.NODE_ENV !== "production") {
    return smtp({ host: "127.0.0.1", port: 1025 });
  }

  const apiKey = process.env.RESEND_API_KEY;
  if (!apiKey) throw new Error("Set RESEND_API_KEY in production.");

  return resend({ apiKey });
}

export const email = createEmailClient({
  adapters: [emailAdapter()],
  telemetry: false,
});

With no auth, the SMTP adapter doesn't require STARTTLS, so it can talk to Mailpit as is. telemetry: false turns off the SDK's anonymous usage telemetry, so a local run makes no network calls outside your machine. The SMTP adapter runs on Nodemailer, so the MIME message Mailpit captures is the same one Nodemailer would build.

Check the captured email in a test

Mailpit's REST API lets a test find the message it just caused. Three endpoints cover most tests:

  • DELETE /api/v1/messages with no body deletes every message, so each test starts empty.
  • GET /api/v1/search?query=... takes the same filters as the UI search box, such as to:new-user@example.com subject:"Welcome".
  • GET /api/v1/message/{ID} returns the parsed message: addresses, Text, HTML and the attachment list. For header checks, GET /api/v1/message/{ID}/headers returns every header.
import { beforeEach, expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { smtp } from "@opencoredev/email-sdk/smtp";

const MAILPIT = "http://127.0.0.1:8025";

type Summary = { ID: string; Subject: string };

async function search(query: string): Promise<Summary[]> {
  const response = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(query)}`);
  if (!response.ok) throw new Error(`Mailpit search failed: ${response.status}`);
  const body = (await response.json()) as { messages: Summary[] };
  return body.messages;
}

const email = createEmailClient({
  adapters: [smtp({ host: "127.0.0.1", port: 1025 })],
  telemetry: false,
});

beforeEach(async () => {
  await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
});

test("sign-up sends a welcome email", async () => {
  await email.send({
    from: "Acme <hello@acme.test>",
    to: "new-user@example.com",
    subject: "Welcome to Acme",
    text: "Your account is ready.",
    html: "<p>Your account is ready.</p>",
  });

  const messages = await search('to:new-user@example.com subject:"Welcome to Acme"');
  expect(messages).toHaveLength(1);

  const response = await fetch(`${MAILPIT}/api/v1/message/${messages[0]?.ID}`);
  const message = (await response.json()) as { Text: string; HTML: string };
  expect(message.Text).toContain("Your account is ready.");
  expect(message.HTML).toContain("<p>Your account is ready.</p>");
});

SMTP delivery is finished when send resolves, so the message is already searchable and the test needs no sleep. In a real app, call the code that sends (your sign-up handler, say) instead of email.send directly. Tests that share one Mailpit must run one at a time, because DELETE /api/v1/messages clears every message, including one another test is about to look for.

Test SMTP errors with Mailpit Chaos

A local inbox that always accepts mail never exercises your error handling. Mailpit's Chaos feature makes its SMTP server reply with an error code you choose. Start Mailpit with --enable-chaos (command: --enable-chaos in Compose), then set triggers through PUT /api/v1/chaos. A trigger has an ErrorCode from 400 to 599 and a Probability from 0 to 100, and can fire on Sender, Recipient or Authentication. Sending {} resets them all.

Email SDK's SMTP adapter treats a 4xx reply as temporary and retryable, and a 5xx reply as permanent. Both count as "not sent", because the server refused the message. These two tests prove that against a real SMTP conversation:

import { afterEach, expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
import { smtp } from "@opencoredev/email-sdk/smtp";
import { memoryAdapter } from "@opencoredev/email-sdk/testing";

const MAILPIT = "http://127.0.0.1:8025";

async function chaos(triggers: object) {
  const response = await fetch(`${MAILPIT}/api/v1/chaos`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(triggers),
  });
  if (!response.ok) throw new Error(`Mailpit chaos failed: ${response.status}`);
}

const message = {
  from: "hello@acme.test",
  to: "user@example.com",
  subject: "Hi",
  text: "Hello.",
};

afterEach(async () => {
  await chaos({});
});

test("a 451 reply is retried", async () => {
  await chaos({ Recipient: { ErrorCode: 451, Probability: 100 } });

  const email = createEmailClient({
    adapters: [smtp({ host: "127.0.0.1", port: 1025 })],
    retry: { maxAttempts: 3, delay: () => 0 },
    plugins: [capturePlugin()],
    telemetry: false,
  });

  await expect(email.send(message)).rejects.toThrow("All configured email adapters failed.");
  expect(email.capture.events.map((event) => event.type)).toEqual([
    "beforeSend",
    "retry",
    "retry",
    "error",
  ]);
});

test("a 550 reply falls back to the next adapter", async () => {
  await chaos({ Recipient: { ErrorCode: 550, Probability: 100 } });

  const backup = memoryAdapter("backup");
  const email = createEmailClient({
    adapters: [smtp({ host: "127.0.0.1", port: 1025 }), backup],
    fallback: { adapters: ["backup"] },
    telemetry: false,
  });

  const result = await email.send(message);
  expect(result.adapter).toBe("backup");
  expect(backup.raw?.sent).toHaveLength(1);
});

maxAttempts counts every call, so 3 means one send and two retries. The client doesn't retry unless you set retry. Fallback after a 550 is safe because the server refused the message. A timeout after the message data went out is different. The SDK marks that delivery "unknown" and, by default, stops instead of trying the next adapter, because the first server may have accepted it. Retries and provider fallback covers that rule and the onUnknownDelivery option.

Mailpit, Ethereal, a memory adapter or a provider sandbox?

Tool What it checks
Memory adapter The message object your code builds
Mailpit or MailHog The real MIME message, in a local inbox
Ethereal The real MIME message, in a hosted inbox
Provider sandbox That the provider accepts your request

memoryAdapter() runs in the test process with no network. Mailpit runs as a local container or binary. Ethereal is a free fake SMTP service aimed at Nodemailer users that never delivers messages, and nodemailer.createTestAccount() creates an inbox. Provider sandboxes, such as SendGrid's sandbox mode and Mailgun's o:testmode, call the real API.

Most apps want two of these. Unit tests use the memory adapter because it's fast and needs nothing running. Integration tests and local development use Mailpit to look at the real message. A provider sandbox only checks the provider's side, and some providers bill test-mode messages (Mailgun does, see the Mailgun API guide). None of these prove inbox placement. That still needs a real send from your own account.

Questions

Is MailHog still maintained?

No. Its last release is v1.0.1 from August 2020. Mailpit is the usual replacement and uses the same ports, 1025 for SMTP and 8025 for the web UI.

Does Mailpit work with Nodemailer?

Yes. Create a transport with host: "127.0.0.1", port: 1025 and secure: false, and leave auth out. Email SDK's smtp() adapter does the same thing with smtp({ host: "127.0.0.1", port: 1025 }).

How do I read the email in a test?

Search with GET /api/v1/search?query=to:user@example.com, then fetch GET /api/v1/message/{ID} for the text, HTML and attachments. Clear the inbox between tests with DELETE /api/v1/messages.

Can Mailpit simulate a bounce or SMTP error?

Start it with --enable-chaos and set a trigger, for example {"Recipient": {"ErrorCode": 550, "Probability": 100}}, with PUT /api/v1/chaos. The next send gets that reply during the SMTP conversation. It doesn't simulate a bounce that arrives later, after the server accepted the message.

Related