Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

54 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

maildebug

A self-contained SMTP debugging server in the spirit of MailHog/MailCatcher. One Go binary runs both an SMTP server that swallows outbound mail in development and an HTTP API + React UI for browsing what was captured. Useful for testing transactional email flows without ever risking a real send.

Why this exists

The existing tools in this space (MailHog, MailCatcher) are unmaintained or fiddly to deploy. I wanted:

  • A single static binary. Drop it in any environment, run it, done. No JVM, no Ruby, no Node runtime in production.
  • A modern UI that renders HTML email faithfully (including multipart, encoded subjects, and attachments).
  • A scratch container so it can sit in a docker-compose alongside any stack without bloating the image footprint.
  • No moving parts at runtime — embedded storage, embedded UI, no sidecars.

Every architectural decision below traces back to one of these.

Architecture

                 SMTP (1025)                        HTTP (8100)
                     │                                  │
                     ▼                                  ▼
       ┌─────────────────────────┐         ┌──────────────────────────┐
       │  go-smtp Backend        │         │  bunrouter               │
       │  - AuthPlain            │         │  GET    /messages        │
       │  - Session.Data parses  │         │  GET    /messages/:id/   │
       │    multipart, decodes   │         │           attachments/:i │
       │    base64/qp, walks     │         │  DELETE /messages        │
       │    parts recursively    │         │  GET    /  (embed UI)    │
       └─────────────┬───────────┘         └──────────────┬───────────┘
                     │                                    │
                     │           ┌────────────────────────┘
                     ▼           ▼
              ┌───────────────────────────────┐
              │  storm over bbolt             │
              │  data/<DB_NAME>               │
              │    MailData (Date indexed)    │
              │  data/messages/<id>           │
              │    raw RFC 822 bytes          │
              └───────────────────────────────┘

main.go wires four things together and runs them in one process:

  1. Config — env vars prefixed MAILDEBUG_, layered through dotenv files (maildebug.env.<env>.localmaildebug.env.localmaildebug.env.<env>maildebug.env). godotenv doesn't overwrite already-set vars, so file order encodes precedence.
  2. SMTP server (session/) — built on emersion/go-smtp. Session.Data decodes RFC 2047 headers, walks multipart bodies recursively (parseParts), decodes base64/quoted-printable, and splits each part into either a renderable PartData or a downloadable Attachment. The id is the local-part of the Message-Id header — that's what the API uses to look attachments up later.
  3. HTTP API (api/) — bunrouter with reqlog middleware. Permissive CORS (*) because this is a development tool, not a production service.
  4. Embedded UI//go:embed ui/dist. The React build is baked into the binary at compile time. / and /assets/* served from the embedded FS.

SMTP runs in a goroutine; HTTP runs on the main goroutine. Both are blocking — there's no graceful shutdown because there's no scenario where I'd care.

Why these choices

Go, not Node. emersion/go-smtp is the most maintained SMTP server library across any ecosystem, and Go's static linking is the cheat code for "ship one file." The same binary runs on a developer laptop, a CI runner, and a production scratch image with no runtime install.

bbolt, not SQLite. Mail records are written, listed by date, occasionally fetched by id, and wiped wholesale. There are no joins, no migrations, no concurrent writers. bbolt gives me an embedded transactional KV store with zero CGO and zero schema. Storm adds the indexed Date field and the Select().Limit().Skip() pagination API on top. SQLite would have brought CGO complexity and migrations I don't need.

Raw bytes on disk, parsed metadata in the DB. Each captured message is stored twice: the parsed MailData goes into bbolt (so listing/searching is cheap), and the raw RFC 822 bytes go to data/messages/<id> (so I can re-serve, re-parse, or export them later without lossy reconstruction from the parsed form). This is a deliberate split — the DB shouldn't hold opaque blobs that grow unboundedly, and the filesystem shouldn't be queried for listings.

Embedded UI. Production is one process, one port for HTTP. No reverse proxy, no CORS dance, no separate static-file container. The UI is React 19 + Vite + Tailwind v4 + TanStack Query, with react-letter doing safe HTML email rendering. In development the Vite server runs separately and the API's permissive CORS lets it talk to the Go backend on :8100.

FROM scratch + UPX. The Dockerfile is three stages — bun → golang → scratch. strip --strip-unneeded and upx shave the binary down before it lands in the empty image. Final image: just the binary, no shell, no certs needed (no outbound TLS), no attack surface.

Trade-offs I accepted

  • No SPA fallback in the static handler. If the UI grows top-level routes, they need explicit handlers. I'd rather notice that than silently 404 to index.html.
  • No graceful shutdown. A SIGTERM kills both servers immediately; in-flight SMTP sessions die. Acceptable for a dev tool.
  • No retention. The bbolt file and data/messages/ grow until you wipe them via DELETE /messages. Adding TTL is ~20 lines if it ever matters.
  • Permissive CORS (*). Wrong for a production service. Right for a tool whose entire purpose is to be poked at from localhost:5173.

Project layout

.
├── main.go                  # config, wiring, lifecycle
├── api/                     # HTTP handlers, CORS, middleware
├── session/                 # SMTP backend, MIME parsing, decoding
├── storage/                 # storm/bbolt wrapper
├── types/                   # Config, MailData, PartData, Attachment
├── ui/                      # React 19 + Vite + Tailwind v4 + TanStack Query
├── email-test/              # isolated bun workspace; sends sample mail via nodemailer
├── Dockerfile               # bun → golang → scratch (multi-stage, UPX-compressed)
└── Taskfile.yml             # task runner entrypoints

Configuration

All vars are prefixed MAILDEBUG_. Defaults live in maildebug.env.example.

Var Default Purpose
SMTP_PORT 1025 SMTP listen port
API_PORT 8100 HTTP API + UI listen port
USERNAME / PASSWORD AuthPlain credentials
DB_NAME mail.bolt bbolt file under data/
DOMAIN localhost SMTP greeting domain
READ_TIMEOUT 10 seconds
WRITE_TIMEOUT 10 seconds
MAX_MESSAGE_BYTES 26214400 hard cap per message (25 MiB)
MAX_RECIPIENTS 50 per envelope
MAX_LINE_LENGTH 1048576 max SMTP input line, 0 = off
ALLOW_INSECURE_AUTH true required for plaintext local auth

MAX_LINE_LENGTH overrides go-smtp's 2000-char default, which real senders trip constantly: React Email (and most HTML renderers) emit the body unwrapped, so a single line runs to tens of thousands of characters. Over that limit the server answers 500 5.4.0 Too long line, closing connection (or 554 … too long a line in input stream mid-DATA) and drops the connection — which many clients, nodemailer included, surface as a hang until their own socket timeout instead of an error.

Running locally

You'll need Go 1.22+, Bun, and (optionally) Task and air.

cp maildebug.env.example maildebug.env

task client:build       # produces ui/dist (required — main.go embeds it)
task server             # go server with hot reload via air
task client             # vite dev server on :5173 (separate terminal)

Without Task:

cd ui && bun install && bun run build && cd ..
go run .                # SMTP on :1025, HTTP on :8100

To send a test email through the running SMTP server:

task test -- you@example.com    # uses email-test/send.tsx with react-email templates

Running with Docker

task docker:build       # single-arch local image
task docker:push        # multi-arch (amd64 + arm64) buildx + push

# or directly
docker build -t maildebug .
docker run --rm \
  -p 1025:1025 \
  -p 8100:8100 \
  -v $(pwd)/data:/data \
  -e MAILDEBUG_DB_NAME=/data/mail.bolt \
  -e MAILDEBUG_USERNAME=username \
  -e MAILDEBUG_PASSWORD=password \
  -e MAILDEBUG_ALLOW_INSECURE_AUTH=true \
  maildebug

In a docker-compose.yml:

services:
    maildebug:
        image: bazo/maildebug:latest
        ports:
            - "1025:1025" # SMTP
            - "8100:8100" # UI + API
        environment:
            MAILDEBUG_USERNAME: username
            MAILDEBUG_PASSWORD: password
            MAILDEBUG_ALLOW_INSECURE_AUTH: "true"
        volumes:
            - ./data:/data

Point your app at smtp://username:password@maildebug:1025 and browse http://localhost:8100.

API

Method Path Purpose
GET /messages?page=N&maxPerPage=M Paginated list (default 50/page)
GET /messages/:id/attachments/:index Stream attachment with original filename
DELETE /messages Wipe everything
* /mcp MCP server (Streamable HTTP) — see below

MCP — let an LLM verify captured email

maildebug exposes a Model Context Protocol server over HTTP at /mcp, so an AI agent can assert against the mail your app actually sent — e.g. "register a user, then confirm the welcome email arrived at the right address with the right link."

Connect it to Claude Code:

claude mcp add maildebug --transport http http://localhost:8100/mcp

Tools:

Tool Purpose
list_emails List captured emails (newest first) with subject/from/to/date
read_email Full content of one email by id: decoded HTML, plain text, headers
search_emails Find emails by recipient / sender / subject / body (substring, all match)
wait_for_email Poll until a matching email is captured, or time out (for async flows)
clear_emails Delete all captured emails to start a verification run clean

Stack

Go, emersion/go-smtp, uptrace/bunrouter, asdine/storm over etcd-io/bbolt. UI: React 19, Vite, Tailwind v4, TanStack Query, react-letter. Tooling: Bun, oxlint/oxfmt, lefthook, Task.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages