A mock Mailgun service for local development and testing. Accepts real Mailgun API calls, stores data for inspection, and simulates events — without sending real email.
Point your Mailgun client at this service instead of api.mailgun.net and everything Just Works, except no emails actually leave your machine.
- Local development — no Mailgun account needed, no accidental sends
- CI/CD testing — assert on email content, recipients, and events without network calls
- Drop-in replacement — uses the same API shape as real Mailgun, compatible with official SDKs
docker run -p 8025:8025 ghcr.io/bethmaloney/mailgun-mock-api:latestThe web UI is at http://localhost:8025 and the API is available at the same address. Point your Mailgun SDK at http://localhost:8025 and use any string as the API key.
Download from GitHub Releases for Linux (x64), macOS (Apple Silicon), or Windows (x64).
./mailgun-mock-apiBy default the Docker image stores its SQLite database at /data/mailgun-mock.db. Without a volume mount, data is ephemeral — it's discarded when the container is removed. This is fine for CI or throwaway dev sessions.
To persist data across container restarts, mount a volume:
docker run -p 8025:8025 -v mailgun-data:/data ghcr.io/bethmaloney/mailgun-mock-api:latestOr bind-mount a host directory:
docker run -p 8025:8025 -v ./data:/data ghcr.io/bethmaloney/mailgun-mock-api:latestAll settings are configured via environment variables.
| Variable | Default | Description |
|---|---|---|
PORT |
8025 |
HTTP listen port |
DATABASE_URL |
file:mailgun-mock.db |
SQLite connection string (or Postgres URL) |
DB_DRIVER |
sqlite |
Database driver: sqlite or postgres |
AUTH_MODE |
disabled |
Authentication mode: disabled or entra |
Example with Docker:
docker run -p 9090:9090 -e PORT=9090 -v mailgun-data:/data ghcr.io/bethmaloney/mailgun-mock-api:latest| Area | Description |
|---|---|
| Messages | Accept messages via API, validate payload, store for inspection |
| Domains | Domain CRUD, controllable verification status, DNS records |
| Events & Logs | Generate realistic events for sent messages, event polling |
| Webhooks | Register webhooks, deliver event payloads, simulate events |
| Suppressions | Bounces, complaints, unsubscribes, allowlist — full CRUD |
| Templates | Template CRUD, versioning, variable rendering |
| Tags | Store tags on messages, return alongside stats |
| Mailing Lists | List and member CRUD, bulk operations |
| Routes | Inbound route management |
| Web UI | Inspect messages, view events, manage suppressions |
Commands are run via just. Run just with no args to list all recipes.
| Task | Command |
|---|---|
| Go tests (unit + integration) | just test |
| Integration tests only (with optional filter) | just integration / just integration Credentials |
| Playwright frontend e2e tests | just test-e2e |
just test runs everything under ./..., which covers both unit tests in internal/ and the integration suite in tests/integration/. just test-e2e builds the SPA, starts the server, and runs Playwright against it.
Auth is disabled by default. just dev works without any Entra ID setup.
- Create app registration in Azure portal.
- Add the SPA platform and configure redirect URIs:
- For a deployed instance, add your public URL (must match
ENTRA_REDIRECT_URI). - For local Entra testing, add both
http://localhost:5173(Vite dev server viajust dev-ui) andhttp://localhost:8025(Go binary direct viajust dev/just run). The SPA is served from a different port depending on which command you use.
- For a deployed instance, add your public URL (must match
- Under the SPA platform, register a logout URL matching the SPA root (same as
ENTRA_REDIRECT_URIfor deployed instances; for local use the Vite and/or Go URLs from step 2). - Expose an API with scope
access_as_user(or whatever you setENTRA_API_SCOPEto — the scope name must match). - Set
accessTokenAcceptedVersion: 2in the app manifest. Without this, tokens use the v1 issuer and validation fails. - Optional: for future group-based authorization, set
"groupMembershipClaims": "SecurityGroup"in the app manifest and re-consent. Not used yet, but makes future work easier. - Copy Tenant ID and Client ID.
| Variable | Description |
|---|---|
AUTH_MODE |
disabled (default) or entra |
ENTRA_TENANT_ID |
Azure AD tenant (directory) ID |
ENTRA_CLIENT_ID |
App registration client ID |
ENTRA_API_SCOPE |
API scope name, e.g. access_as_user |
ENTRA_REDIRECT_URI |
Public URL of this deployment |
With Docker:
docker run -p 8025:8025 \
-v mailgun-data:/data \
-e AUTH_MODE=entra \
-e ENTRA_TENANT_ID=your-tenant-id \
-e ENTRA_CLIENT_ID=your-client-id \
-e ENTRA_API_SCOPE=access_as_user \
-e ENTRA_REDIRECT_URI=https://mailgun-mock.example.com \
ghcr.io/bethmaloney/mailgun-mock-api:latestAfter deploying, sign in to the UI, navigate to Config → API Keys, and create your first key. Give the key to your test apps — they use it as the Basic Auth password (api:<key>), exactly like a real Mailgun key.
Test apps get 401s — Check that an API key has been created in the UI. Verify the Basic Auth format is api:<key>.
Issuer mismatch during token validation — accessTokenAcceptedVersion is not set to 2 in the app registration manifest.
Redirect loop on sign-in — The redirect URI in the Entra app registration doesn't match ENTRA_REDIRECT_URI, or (for local dev) you're hitting a port that isn't in the SPA redirect URI list.
Token valid but 401 with scope error — The user's token doesn't carry the required scope. Re-consent, or confirm the token is requested with the right scope.
503 Service Unavailable on requests — The server couldn't reach Microsoft's JWKS endpoint. Check egress firewall rules for login.microsoftonline.com.
MIT