An email and SMS testing tool for developers. Send test emails and SMS messages from your application and inspect them in a clean web UI — nothing reaches real inboxes or real phones.
MessagePit is a fork of Mailpit extended with a Twilio-compatible SMS ingest endpoint, a SendGrid v3 Mail Send API stub, SMS/email delivery status callbacks, an SMS inbox UI, and a dedicated SMS ingest server.
- Email: SMTP server, SendGrid v3 API stub, web UI, REST API, WebSocket live updates, search, tagging, POP3 server
- SMS: Twilio-compatible HTTP ingest, SMS inbox with read/unread tracking, live WebSocket updates
- Webhook capture: Dedicated HTTP server that captures any incoming request on any path/method and displays it in the UI — useful for inspecting outbound webhook calls from your app in development
- Delivery callbacks: Signed SMS status callbacks and SendGrid-style email event webhooks for end-to-end delivery tracking
- Shared: Multi-arch Docker image, optional HTTP basic auth, Prometheus metrics
| Port | Protocol | Purpose |
|---|---|---|
| 1025 | SMTP | Email ingest (mirrors port 25) |
| 1110 | POP3 | POP3 server (optional) |
| 8025 | HTTP | Web UI and management API |
| 8100 | HTTP | SendGrid v3 Mail Send stub — POST /v3/mail/send |
| 8200 | HTTP | SMS ingest — Twilio-compatible |
| 8300 | HTTP | Webhook capture — accepts any request on any path/method |
# Docker
docker run -p 1025:1025 -p 8025:8025 -p 8100:8100 -p 8200:8200 -p 8300:8300 ghcr.io/coreydaley/messagepit
# From source
make runOpen http://localhost:8025 in your browser.
Point your application's Twilio SDK at the SMS ingest server instead of api.twilio.com:
http://localhost:8200
The SMS server implements the Twilio Messages API endpoint:
POST /2010-04-01/Accounts/{AccountSid}/Messages.json
Required form fields: From, To, Body. Authentication uses HTTP Basic Auth (AccountSid:AuthToken).
When MP_TWILIO_WEBHOOK_URL is set (or a per-request StatusCallback form field is provided), MessagePit fires a signed POST to that URL after capturing each message — mirroring how Twilio notifies your app of delivery status.
The callback body is application/x-www-form-urlencoded with MessageSid, MessageStatus, To, and From. When MP_TWILIO_AUTH_TOKEN is set the request includes an X-Twilio-Signature HMAC-SHA1 header so your webhook handler can validate it with the standard Twilio SDK.
Priority: the StatusCallback field in the send request takes precedence over the global MP_TWILIO_WEBHOOK_URL.
MessagePit runs a dedicated HTTP server (port 8300 by default) that accepts any incoming HTTP request on any path and method, stores it, and displays it in the Webhooks tab of the UI. This is useful for developing and testing outbound webhook delivery from your application without needing a public endpoint.
Point your webhook URL at the capture server:
http://localhost:8300/any/path/you/like
Any HTTP method works: POST, GET, PUT, PATCH, DELETE. The full request — method, path, headers, body, source IP — is captured and displayed in real time via WebSocket.
The capture server is enabled by default. Set --webhook "" (or MP_WEBHOOK_BIND_ADDR="") to disable it.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/webhooks |
List captured requests (paginated) |
| GET | /api/v1/webhooks/search |
Search captured requests |
| GET | /api/v1/webhook/{id} |
Get a single captured request |
| DELETE | /api/v1/webhook/{id} |
Delete a single captured request |
| DELETE | /api/v1/webhooks |
Delete all captured requests |
MessagePit exposes a SendGrid v3 Mail Send stub on port 8100:
POST /v3/mail/send
Authorization: Bearer <MP_SENDGRID_API_KEY>
Content-Type: application/json
Point your application's SendGrid SDK at the stub by setting the API base URL to http://localhost:8100. The endpoint accepts the standard SendGrid v3 JSON payload (personalizations, from, subject, content, custom_args) and stores each message in the MessagePit mailbox. Authentication is skipped when MP_SENDGRID_API_KEY is empty.
The SendGrid server defaults to 127.0.0.1:8100 (loopback only). Set MP_SENDGRID_BIND_ADDR=0.0.0.0:8100 to expose it on all interfaces (e.g. inside Docker). Set MP_SENDGRID_BIND_ADDR="" to disable it entirely.
When MP_EMAIL_WEBHOOK_URL is set, MessagePit fires a SendGrid-style event webhook after capturing each email that contains a notification_id key in custom_args. The webhook payload is a JSON array of event objects:
[
{
"notification_id": "<value from custom_args>",
"event": "delivered",
"email": "recipient@example.com",
"timestamp": 1714000000
}
]Webhooks are signed using ECDSA P-256 / SHA-256, with the signature and timestamp in the same headers real SendGrid uses:
| Header | Description |
|---|---|
X-Twilio-Email-Event-Webhook-Signature |
Base64-encoded ECDSA signature |
X-Twilio-Email-Event-Webhook-Timestamp |
Unix timestamp as a string |
The payload that is signed is timestamp + body (timestamp string concatenated with the raw JSON body).
Provide a stable key pair so the public key doesn't change across restarts:
# Generate private key (SEC1 DER, base64) — set as MP_EMAIL_WEBHOOK_SIGNING_KEY
openssl ecparam -name prime256v1 -genkey -noout \
| openssl ec -outform DER 2>/dev/null \
| base64
# Derive the matching public key (PKIX DER, base64) — set as SENDGRID_WEBHOOK_PUBLIC_KEY in your app
openssl ecparam -name prime256v1 -genkey -noout \
| openssl ec -outform DER 2>/dev/null \
| openssl ec -inform DER -pubout -outform DER 2>/dev/null \
| base64If MP_EMAIL_WEBHOOK_SIGNING_KEY is empty and MP_EMAIL_WEBHOOK_URL is set, MessagePit auto-generates a key pair at startup and logs the public key — useful for one-off testing but not stable across restarts.
The email webhook only fires for messages received via the /v3/mail/send endpoint, since only that path carries custom_args. Emails delivered over SMTP do not carry custom_args and will not trigger the webhook.
Requires Go 1.21+ and Node 22+.
make run # build UI + binary and run with dev defaults
make install # build UI + binary and install to $GOPATH/bin
make test # run Go test suite
make ui # build frontend assets only
make build # compile the binary only (requires ui assets)All flags can also be set via environment variables (e.g. --smtp → MP_SMTP_BIND_ADDR, --twilio → MP_TWILIO_BIND_ADDR).
| Flag | Env var | Default | Description |
|---|---|---|---|
--smtp |
MP_SMTP_BIND_ADDR |
0.0.0.0:1025 |
SMTP bind address |
--sendgrid |
MP_SENDGRID_BIND_ADDR |
127.0.0.1:8100 |
SendGrid v3 Mail Send API bind address (empty to disable) |
--sendgrid-api-key |
MP_SENDGRID_API_KEY |
Expected Bearer token for /v3/mail/send (skipped when empty) |
|
--twilio |
MP_TWILIO_BIND_ADDR |
[::]:8200 |
Twilio SMS ingest bind address |
--twilio-auth-token |
MP_TWILIO_AUTH_TOKEN |
Twilio auth token — validates Basic Auth on inbound SMS; signs outgoing delivery callbacks | |
--twilio-webhook-url |
MP_TWILIO_WEBHOOK_URL |
URL to POST SMS delivery callbacks to (fallback when no per-request StatusCallback) |
|
--webhook |
MP_WEBHOOK_BIND_ADDR |
[::]:8300 |
HTTP webhook capture bind address (empty to disable) |
--listen |
MP_UI_BIND_ADDR |
0.0.0.0:8025 |
HTTP UI/API bind address |
--db |
MP_DATABASE |
(in-memory) | SQLite database file path |
--email-webhook-url |
MP_EMAIL_WEBHOOK_URL |
URL to POST email delivery event webhooks to | |
--email-webhook-signing-key |
MP_EMAIL_WEBHOOK_SIGNING_KEY |
Base64-encoded SEC1 DER ECDSA P-256 private key (auto-generated when empty) |
Run messagepit --help for the full list.
The REST API is documented at http://localhost:8025/api/v1.
SMS endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/sms/messages |
List SMS messages |
| GET | /api/v1/sms/message/{id} |
Get single SMS message |
| PUT | /api/v1/sms/message/{id}/read |
Mark as read |
| DELETE | /api/v1/sms/message/{id} |
Delete message |
| DELETE | /api/v1/sms/messages |
Delete all messages |
Images are published to the GitHub Container Registry on every push to main and on tagged releases:
docker pull ghcr.io/coreydaley/messagepit:latestThe example below enables full delivery callback support for both SMS and email. The extra_hosts entry is required on Linux so host.docker.internal resolves to the host machine (Docker Desktop handles this automatically on Mac and Windows).
services:
messagepit:
image: ghcr.io/coreydaley/messagepit:latest
restart: always
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
- "8100:8100" # SendGrid v3 Mail Send stub
- "8200:8200" # SMS ingest (Twilio-compatible)
- "8300:8300" # Webhook capture
environment:
# SendGrid v3 stub — must match SENDGRID_API_KEY in your app
MP_SENDGRID_API_KEY: your-sendgrid-api-key
# Expose SendGrid on all interfaces inside the container
MP_SENDGRID_BIND_ADDR: "0.0.0.0:8100"
# Twilio SMS — must match TWILIO_AUTH_TOKEN in your app
MP_TWILIO_AUTH_TOKEN: your-twilio-auth-token
# Fallback SMS callback URL (per-request StatusCallback takes priority)
MP_TWILIO_WEBHOOK_URL: http://host.docker.internal:3000/webhooks/v1/sms
# Email delivery webhook URL
MP_EMAIL_WEBHOOK_URL: http://host.docker.internal:3000/webhooks/v1/email
# ECDSA private key for signing email webhooks (generate with openssl, see above)
MP_EMAIL_WEBHOOK_SIGNING_KEY: "<base64-encoded SEC1 DER private key>"
healthcheck:
test: ["CMD", "/messagepit", "readyz"]
interval: 10s
timeout: 5s
retries: 5MessagePit is designed to be a drop-in local replacement for Twilio and SendGrid, using the same official gems your production app uses.
# Gemfile
gem "twilio-ruby", "~> 7"
gem "sendgrid-actionmailer"# SMS
TWILIO_ACCOUNT_SID=test
TWILIO_AUTH_TOKEN=test # must match MP_TWILIO_AUTH_TOKEN
TWILIO_ACCOUNT_NUMBER=+15550000000
TWILIO_API_URL=http://localhost:8200
# Callback URL must use host.docker.internal so MessagePit (in Docker)
# can reach your Rails app running on the host.
TWILIO_STATUS_CALLBACK_URL=http://host.docker.internal:3000/webhooks/v1/sms
# Email
SENDGRID_API_KEY=test # must match MP_SENDGRID_API_KEY
SENDGRID_API_URL=http://localhost:8100
# PKIX DER base64 public key matching MP_EMAIL_WEBHOOK_SIGNING_KEY
SENDGRID_WEBHOOK_PUBLIC_KEY=<base64-encoded PKIX DER public key>config.action_mailer.delivery_method = :sendgrid_actionmailer
config.action_mailer.sendgrid_actionmailer_settings = {
api_key: ENV.fetch("SENDGRID_API_KEY", "test"),
host: ENV.fetch("SENDGRID_API_URL", "https://api.sendgrid.com"),
raise_delivery_errors: true
}Route twilio-ruby to MessagePit by rewriting the API base URL. When TWILIO_API_URL is not set or equals https://api.twilio.com the real Twilio API is used.
require "twilio-ruby"
class SmsSender
def self.call(to:, body:)
sid = ENV.fetch("TWILIO_ACCOUNT_SID")
token = ENV.fetch("TWILIO_AUTH_TOKEN")
from = ENV.fetch("TWILIO_ACCOUNT_NUMBER")
callback = ENV["TWILIO_STATUS_CALLBACK_URL"]
client = build_client(sid, token)
params = { body: body, from: from, to: to }
params[:status_callback] = callback if callback.present?
message = client.messages.create(**params)
{ status: "Sent", code: 201, sid: message.sid }
end
def self.build_client(sid, token)
base_url = ENV.fetch("TWILIO_API_URL", "https://api.twilio.com")
return Twilio::REST::Client.new(sid, token) if base_url == "https://api.twilio.com"
Twilio::REST::Client.new(sid, token, nil, nil, ProxyHttpClient.new(base_url))
end
# Rewrites the Twilio API host so the gem can target MessagePit.
class ProxyHttpClient < Twilio::HTTP::Client
def initialize(base_url)
super()
uri = URI.parse(base_url)
@proxy_host = "#{uri.scheme}://#{uri.host}"
@proxy_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
@proxy_port = uri.port
end
def request(host, port, method, url, params = {}, data = {}, headers = {}, auth = nil, timeout = nil)
rewritten_url = url.sub(%r{\Ahttps?://[^/]+}, @proxy_base)
super(@proxy_host, @proxy_port, method, rewritten_url, params, data, headers, auth, timeout)
end
end
endSet custom_args: { notification_id: record.id.to_s } in your mail() call. MessagePit extracts this value and includes it in the webhook payload so your app can update the delivery status on the corresponding record.
mail(
to: recipient,
subject: "Your subject",
custom_args: { notification_id: @notification.id.to_s }
)SMS — validate with Twilio::Security::RequestValidator:
require "twilio-ruby"
class SmsWebhookController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_twilio_signature
def update
notification = Notification.find_by(sms_id: params[:MessageSid])
notification&.update_columns(sms_delivery_status: params[:MessageStatus])
head :ok
end
private
def verify_twilio_signature
token = ENV["TWILIO_AUTH_TOKEN"]
validator = Twilio::Security::RequestValidator.new(token)
unless validator.validate(request.original_url, request.POST, request.headers["X-Twilio-Signature"].to_s)
head :forbidden
end
end
endEmail — validate with ECDSA using the SENDGRID_WEBHOOK_PUBLIC_KEY:
class EmailWebhookController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_sendgrid_signature
def update
(params["_json"] || []).each do |event|
next unless event["event"] == "delivered" && event["notification_id"].present?
Notification.find_by(id: event["notification_id"])
&.update_columns(email_delivery_status: "delivered")
end
head :ok
end
private
def verify_sendgrid_signature
public_key = OpenSSL::PKey.read(Base64.decode64(ENV["SENDGRID_WEBHOOK_PUBLIC_KEY"]))
signature = Base64.decode64(request.headers["X-Twilio-Email-Event-Webhook-Signature"].to_s)
payload = request.headers["X-Twilio-Email-Event-Webhook-Timestamp"].to_s + request.raw_post
head :forbidden unless public_key.verify(OpenSSL::Digest::SHA256.new, signature, payload)
rescue OpenSSL::PKey::PKeyError, ArgumentError
head :forbidden
end
endMIT — see LICENSE.
Portions of this project are derived from Mailpit by Ralph Slooten, also MIT licensed.