MailTail is a modern open-source SMTP test inbox focused on mail infrastructure testing. This MVP accepts SMTP traffic, stores full RFC822 messages and session metadata, exposes a REST API, and ships with a React-based web UI.
- SMTP server on port
8025 - Web UI and REST API on port
8080 - Persistent message storage across restarts
- MIME parsing for text, HTML, and attachments
- Search over subject, sender, and recipient
- Full raw message storage
- Rule-driven ARF, XARF v3/v4, original-message reports, and asynchronous DSN bounces
- Local multi-user mailboxes with recipient-domain routing and owner-scoped inboxes
- Extensible SMTP response policy interface for future MailFail behavior
cmd/mailtail
internal/api
internal/models
internal/parser
internal/smtpserver
internal/storage
web
- Go 1.24+
- Node.js 22+
- Docker (for
make lintwith MegaLinter)
cp .env.example .env
make install
make check
make test
make test-web
make lint
make build
make run
make docker-runmake lint runs MegaLinter in Docker for files changed relative to origin/main. make lint-fix enables automatic fixes for those files where supported. Use make lint-all-files for an explicitly confirmed full file-lint run, or make lint-all to include project-level linters.
make check runs formatting and module drift checks, go vet, the race detector, and the coverage floor used by CI.
If .env exists in the project root, make run and make docker-run load it automatically.
make build, make run, and make docker-build run go test ./... first, so OpenAPI drift and other backend test failures block artifact creation.
If the local Node.js version is too old for the current Vite toolchain, make build-web automatically falls back to an official node:24-alpine Docker build so the frontend build path still works.
go mod tidy
go run ./cmd/mailtailThe backend initializes its local data directory automatically.
cd web
npm install
npm run devFor a production-like local run, build the frontend and let the Go server serve the static files:
cd web
npm install
npm run build
cd ..
go run ./cmd/mailtailmake docker-runOpen the UI at http://localhost:8080. Send SMTP mail to localhost:8025.
Useful container commands:
make docker-logs
make docker-stop
make docker-rmData is persisted in the Docker volume mailtail-data by default.
On startup, the container updates the ownership of the configured data directory and then drops privileges to UID/GID 10001. This automatically migrates volumes created by older MailTail images that used a different container user ID. When overriding the container user with --user, make sure that user can write to MAILTAIL_DATA_DIR.
Pushing a Git tag that starts with v creates a GitHub Release and publishes a multi-arch image to GHCR.
Example:
git tag v0.1.0
git push origin v0.1.0Published image:
ghcr.io/vquie/mailtail:v0.1.0
ghcr.io/vquie/mailtail:v0.1
ghcr.io/vquie/mailtail:v0
ghcr.io/vquie/mailtail:0.1.0
ghcr.io/vquie/mailtail:0.1
ghcr.io/vquie/mailtail:0
ghcr.io/vquie/mailtail:latest
The workflow uses the repository GITHUB_TOKEN, so no extra registry secret is required as long as GitHub Actions has permission to write packages.
The Git tag itself must start with v, for example v0.1.0.
Pull requests and merge-queue commits are protected by build, test, lint, dependency, container, vulnerability, and CodeQL checks. See Auto-merge safety gate for the required repository settings and Renovate policy.
MailTail now ships a spec-first OpenAPI reference that is served directly by the application:
GET /api/openapi.jsonreturns the current OpenAPI documentGET /api/docsrenders the interactive API reference used from the user menu in the web UI
The OpenAPI document is checked in automated tests against the implemented HTTP operations so the build fails if the spec drifts from the server.
Core endpoints currently covered by the spec include:
GET /api/appGET /api/sessionGET /api/messagesGET /api/messages/{id}GET /api/messages/{id}/rawGET /api/messages/{id}/attachments/{attachmentId}DELETE /api/messagesDELETE /api/messages/{id}GET /api/settingsPUT /api/settingsGET /api/admin/mailbox-settingsPUT /api/admin/mailbox-settingsGET /api/admin/usersPOST /api/admin/usersPUT /api/admin/users/{id}DELETE /api/admin/users/{id}GET /api/statsPOST /auth/loginPOST /auth/logout
When MAILTAIL_ADMIN_USERNAME and MAILTAIL_ADMIN_PASSWORD are set, MailTail protects both the web UI and the REST API with a session-based login flow.
Authentication flow:
GET /loginreturns the login form.POST /auth/loginaccepts form-encodedusernameandpassword.- On success, MailTail sets:
mailtail_session: HTTP-only session cookiemailtail_csrf: CSRF token cookie used by API clients for mutating requests
POST /auth/logoutclears the session.
Behavior:
- unauthenticated
GET /api/...requests return401 {"error":"authentication required"} - mutating requests without a valid
X-CSRF-Tokenreturn403 {"error":"invalid csrf token"} - login attempts are rate-limited to 5 failed attempts per 15 minutes per client IP
Example login and API usage with curl:
curl -i -c cookies.txt \
-X POST http://localhost:8080/auth/login \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'username=admin&password=change-me'Read-only request with the stored session:
curl -b cookies.txt http://localhost:8080/api/messagesMutating request with CSRF token:
csrf_token="$(awk '$6 == \"mailtail_csrf\" {print $7}' cookies.txt)"
curl -i -b cookies.txt \
-X DELETE http://localhost:8080/api/messages \
-H "X-CSRF-Token: ${csrf_token}"Logout:
csrf_token="$(awk '$6 == \"mailtail_csrf\" {print $7}' cookies.txt)"
curl -i -b cookies.txt \
-X POST http://localhost:8080/auth/logout \
-H "X-CSRF-Token: ${csrf_token}"For request and response schemas, auth requirements, parameters and status codes, use the built-in OpenAPI reference instead of the README so the documentation stays aligned with the implementation.
curl --url smtp://localhost:8025 \
--mail-from sender@example.test \
--mail-rcpt receiver@example.test \
--upload-file sample.emlMailTail can optionally simulate SMTP failures based on the localpart of the sender or recipient address.
MailFail is disabled by default. Rules are now managed directly in the web UI per user.
Enable MailFail in the relevant user settings, then either:
- import the built-in example template
- or create rules manually in the MailFail rules overlay
Example:
rules:
- name: user-unknown
trigger: mf-user-unknown
stage: rcpt
action: reject
code: 550
enhancedCode: "5.1.1"
message: "User unknown"
- name: quota
trigger: mf-quota
stage: data
action: reject
code: 552
enhancedCode: "5.2.2"
message: "Mailbox full"
- name: greylist
trigger: mf-greylist
stage: rcpt
action: greylist
allowAfter: 1
minRetryAfter: 5m
resetAfter: 1h
code: 451
enhancedCode: "4.7.1"
message: "Try again later"Triggering is done via plus-addressing in the localpart:
test+mf-user-unknown@example.testtest+mf-quota@example.test
Supported stages for localpart-driven rules:
mailfromrcptdata
Supported actions:
rejectReturns the configured SMTP failure every time.greylistReturns a temporary failure for the first matching attempts and then accepts later retries for the same sender/recipient/trigger combination.allowAfter: 1means "reject once, accept on the second attempt".minRetryAfterdefines how long the sender must wait before the retry is accepted.resetAfterdefines when the greylist state expires and becomes temporary again. The default is1h.arfAccepts the message, then queues an RFC 5965 feedback report with the original message attached. The registered feedback type can beabuse,fraud,virus,other, ornot-spamand defaults toabuse. An optionalreportRecipientLocalPartsuch asfblreplaces only the local part of the original envelope sender.xarf-v3Accepts the message, then queues a legacy XARF v3 spam report as JSON in the XARF SMTP envelope. It supports the same optionalreportRecipientLocalPartsetting.xarf-v4Accepts the message, then queues a XARF v4.2messaging/spamreport with hashed, base64-encoded evidence. It supports the same optionalreportRecipientLocalPartsetting.original-reportAccepts the message, then queues a Microsoft-style complaint wrapper containing the original RFC822 message as its only inlinemessage/rfc822part. It is deliberately not ARF and supports the same optionalreportRecipientLocalPartsetting.async-bounceAccepts the message first, then queues an RFC 3464 delivery-status notification. Its SMTP envelope sender is empty to prevent bounce loops. The rule can define printable US-ASCII text used in both the human-readable DSN part andDiagnostic-Code.
Report actions run automatically after DATA is accepted, so their stage is not configurable. ARF and XARF use generated human-readable text; original-message reports contain only the original message, while asynchronous bounces can override their diagnostic text per rule.
By default reports are delivered to the original SMTP envelope sender. For ARF, XARF, and original-message reports, reportRecipientLocalPart: fbl changes bounce-123@example.test to fbl@example.test; values containing @ or a domain are rejected.
Reports are suppressed for null reverse-path messages and messages carrying an Auto-Submitted header.
Queued reports are persisted in SQLite and retried without a fixed attempt limit. Temporary SMTP failures use exponential backoff capped at one hour and defer other queued reports to the same recipient domain, preventing a throttled destination from being hammered by the rest of a batch.
Each mailbox can set Report sender in the Rules UI. If it is empty, MailTail derives postmaster@<recipient-domain> only when that domain is configured as an exact accepted recipient domain. Regex and catch-all recipient policies require an explicit report sender. MailTail never copies the original recipient address into the report sender.
See Outbound reports for direct and relay delivery, MIME formats, and sender behavior.
Examples:
550 5.1.1 User unknown451 4.7.1 Try again later552 5.2.2 Mailbox full
For greylisting, the state key is based on:
- rule trigger
- stage
MAIL FROM- matching recipient address
That makes repeated retry tests deterministic for a given sender/recipient pair.
If minRetryAfter is set, retries that arrive too early continue to receive the temporary failure until the wait period has passed.
Bootstrap environment variables:
MAILTAIL_DATA_DIRdefault:dataMAILTAIL_HTTP_ADDRdefault::8080MAILTAIL_SMTP_ADDRdefault::8025MAILTAIL_OUTBOUND_MODEdefault:direct; accepted values aredirectandrelay, and the selected mode applies to every outbound actionMAILTAIL_OUTBOUND_SMTP_HELOdefault:mailtail.local; set this to the public hostname matching the sending IP for direct deliveryMAILTAIL_OUTBOUND_SMTP_ADDRrequired only inrelaymodeMAILTAIL_OUTBOUND_SMTP_TLSdefault:starttls; accepted values arenone,starttls, andtls; relay TLS validates the relay certificateMAILTAIL_OUTBOUND_SMTP_USERNAMEdefault: emptyMAILTAIL_OUTBOUND_SMTP_PASSWORDdefault: emptyMAILTAIL_WEB_DIRdefault:web/distMAILTAIL_ADMIN_USERNAMEdefault: empty, disables login protection for web UI and API and logs a startup warningMAILTAIL_ADMIN_PASSWORDdefault: empty, disables login protection for web UI and API and logs a startup warning
Runtime settings:
- The web UI includes a Settings panel for MailFail, SMTP logging, origin restrictions, SMTP IP restrictions, sender/recipient restrictions, and automatic message deletion.
- These settings are saved and applied live without restarting MailTail.
- The related environment variables are still supported as bootstrap values for the first start or for instances without saved runtime settings yet.
- Once runtime settings have been saved in the UI, the saved values take precedence over the environment.
The runtime-setting bootstrap variables are:
MAILTAIL_ALLOWED_ORIGINSdefault: empty, disables cross-origin browser access. Set this only if you intentionally need browser clients from another origin.MAILTAIL_SMTP_LOG_VERBOSEdefault:false, logs only accepted messages and rejects/errors. Set totruefor per-command SMTP tracing.MAILTAIL_MAILFAIL_ENABLEDdefault:false- MailFail rules are edited in the UI.
MAILTAIL_ALLOWED_REMOTE_IPSdefault: empty, accepts SMTP connections from all IPs and logs a startup warning. Supports IPs and CIDR ranges.MAILTAIL_ACCEPTED_RCPT_DOMAINSdefault: empty, accepts recipients for all domains and logs a startup warning. Values may be exact domains or regular expressions.MAILTAIL_ACCEPTED_FROM_DOMAINSdefault: empty, accepts senders for all domains and logs a startup warning. Values may be exact domains or regular expressions.
For the current multi-user architecture and future extensions, see Multi-user architecture.
Example:
cp .env.example .env
make runTo enable login protection, set both MAILTAIL_ADMIN_USERNAME and MAILTAIL_ADMIN_PASSWORD. If only one is set, MailTail exits on startup.
MailTail then serves a login form and stores an authenticated session in a secure HTTP-only cookie, so you do not need to re-enter credentials on every API request.
Local user passwords are stored with salted PBKDF2-HMAC-SHA256 hashes. Older MailTail password hashes are accepted once and upgraded after a successful login.
This protects the web UI and REST API. SMTP remains unauthenticated in this MVP.
When MailTail runs behind TLS termination, make sure your proxy forwards X-Forwarded-Proto: https or Forwarded: proto=https so the session cookie is marked Secure.
Cross-origin browser access is off by default. If you explicitly need it, set MAILTAIL_ALLOWED_ORIGINS to a comma-separated allow-list such as https://mail.example.com,https://ops.example.com.
To restrict SMTP access, set MAILTAIL_ALLOWED_REMOTE_IPS to a comma-separated list such as 127.0.0.1,10.0.0.0/8,192.168.0.0/16.
Recipient and sender allow-lists accept either exact domains such as example.test or regular expressions such as ^.+@example\\.test$ or (^|\\.)example\\.test$.
For managed local users and the admin mailbox, at least one accepted recipient domain is required before SMTP is routed to that mailbox. A user without a recipient domain can sign in but is intentionally not part of SMTP routing. Once managed users exist, recipients that do not match a routed mailbox are rejected instead of falling through to another user. The bootstrap instance policy remains available when no managed mailbox exists.
MailTail advertises and enforces a maximum SMTP message size of 10 MiB. Oversized DATA payloads are drained and rejected with SMTP status 552 without being stored.
If a sender domain is not allowed, MailTail rejects MAIL FROM with 550 Sender domain not allowed.
If a recipient domain is not allowed, MailTail rejects RCPT TO with 550 Recipient domain not allowed.
MailFail is already available as an initial MVP through the SMTP response policy layer:
type SMTPResponsePolicy interface {
OnConnect(session *SessionMetadata) *ResponseError
OnMailFrom(session *SessionMetadata, from string) *ResponseError
OnRcptTo(session *SessionMetadata, recipient string) *ResponseError
OnData(session *SessionMetadata) *ResponseError
}The current MailFail implementation supports:
- localpart-based triggers via plus-addressing
- UI-managed mailbox rules
rejectgreylist- durable outbound ARF, XARF v3/v4, original-message, and asynchronous-bounce actions
Planned next steps include:
delaydisconnect- artificial timeouts
- probabilistic failures
- more advanced matching beyond the localpart trigger
MailTail is licensed under the MIT License. See LICENSE.

