mohamedboukari/bunmail
mohamedboukari/bunmail has 34 GitHub stars and 3 forks. GitHot measured +1 stars in 24 hours. Last checked 2026-11-09 15:54 UTC. Last push 2026-11-08.
Self-hosted email API for developers free alternative to SendGrid/Resend
What you can do
- SMTP submission: apps (Infisical, Netbird, Dify, any Nodemailer backend) send through BunMail over SMTP using an API key as the password; switch an app off SendGrid/Brevo with a credentials-only change (docs/smtp-submission.md)
- DKIM signing: auto-generates 2048-bit RSA keys per domain, under a selector you can choose, with zero-downtime key rotation; over-signs From, Subject and the other fields a reader sees, so a header added in transit breaks the signature
- SPF / DKIM / DMARC verification: DNS checks built into the dashboard, with the records to publish per domain, the parsed DMARC policy and where the MX points, re-run daily in the background so a record that drifts is caught (domain.verification_failed)
- Email queue: Postgres-backed with MTA-style retry backoff (up to 48 h for temporary failures such as greylisting), lease-based crash recovery that is safe with several replicas, and no double sends between concurrent workers
Measured star growth
- in 24h
- +1
- in 7 days
- Update delayed
- in 15 days
- Update delayed
- in 1 month
- Update delayed
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
OriginalBunMail
Send transactional email from your own server. No subscriptions. No per-email pricing. No vendor lock-in.
A self-hosted alternative to SendGrid, Resend, and Postmark: built in TypeScript on Bun + Elysia, with a REST API, web dashboard, DKIM signing, an email queue with retries, webhooks, templates, inbound SMTP, and Gmail-style trash. Ships in a single Docker container.
Why BunMail?
| BunMail | SendGrid / Resend | Postal | Mailcow | |
|---|---|---|---|---|
| Self-hosted | ✅ | ❌ | ✅ | ✅ |
| Free | ✅ MIT | $20/mo+ | ✅ MIT | ✅ |
| API-first | ✅ | ✅ | ✅ | ❌ (mail hosting) |
| One container | ✅ | n/a | ❌ (multiple services) | ❌ (heavy stack) |
| Modern stack | Bun + Elysia + Drizzle | n/a | Ruby + RabbitMQ | PHP + many services |
| Web dashboard | ✅ | ✅ | ✅ | ✅ |
| Templates | ✅ | ✅ | ❌ | ❌ |
| Inbound SMTP | ✅ | $$ | ✅ | ✅ |
BunMail targets developers who want a programmatic transactional email API and nothing else: Postal is heavier, Mailcow targets full mail-server hosting with end-user webmail, and the SaaS options charge per email.
Quick Start (60 seconds)
git clone https://github.com/mohamedboukari/bunmail.git && cd bunmail
cp .env.example .env
# docker compose runs BunMail in production mode, which refuses to start
# without these. Later lines in .env win over the template's defaults.
cat >> .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 16)
DKIM_ENCRYPTION_KEY=$(openssl rand -base64 32)
MAIL_HOSTNAME=mail.yourcompany.com
[email protected]
EOF
docker compose up -d
Replace mail.yourcompany.com with this server's real hostname (placeholders such as *.example.com are refused) and [email protected] with your email: it becomes the dashboard login. The app listens on 127.0.0.1:3000 only: reach it from outside through a reverse proxy with TLS (docs/self-hosting.md, section 6); host-specific ports such as inbound SMTP go in a docker-compose.override.yml copied from docker-compose.override.example.yml.
Then seed your first API key and send a test email:
docker compose exec app bun run src/db/seed.ts
# copy the bm_live_... key it prints
curl -X POST http://localhost:3000/api/v1/emails/send \
-H "Authorization: Bearer bm_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Hello from BunMail",
"html": "<p>It works.</p>"
}'
In production mode the from domain must first be registered (see docs/self-hosting.md), otherwise the send is rejected with 422 SENDER_DOMAIN_NOT_REGISTERED. Publish and verify its DKIM, SPF and DMARC records before sending real mail: until then the response lists warnings such as dkim_unverified, and REQUIRE_VERIFIED_DOMAIN=true refuses the send instead.
To open the dashboard, claim the owner account: on first boot BunMail logs a one-time setup link (docker compose logs app | grep 'Owner setup link'). Open it, choose a password (15 characters or more), and you are signed in at http://localhost:3000/dashboard. After that, sign in with BUNMAIL_OWNER_EMAIL and that password. Lost the link? docker compose exec app bun run src/db/reset-owner.ts prints a new one.
Local dev without Docker? See docs/self-hosting.md.
Will my mail land in inbox?
Honest answer: it depends on your sending setup, not on BunMail. Self-hosted transactional email is a reputation problem, not a code problem.
- ✅ You'll land in inbox if you send from a domain with a clean reputation, your server's IP isn't on a blacklist, and you've published correct SPF / DKIM / DMARC records (BunMail signs with DKIM, lists the records to publish and checks SPF, DKIM and DMARC for each domain; you publish them at your DNS provider, see docs/self-hosting.md).
- ⚠️ You'll likely land in spam if you send from a brand-new
.xyz/.top-style domain on a fresh budget VPS IP. That's not a BunMail bug: Gmail and Outlook penalise both factors heavily for any sender. - 🛠️ What helps: warm up the domain and IP over 2 to 3 weeks (low volume, real recipients who open and reply), set the reverse DNS of your IP to
MAIL_HOSTNAME, and keep the dashboard's checks green: the Server identity card (reverse DNS), the Blocklists card (DNSBL listings of your sending IP) and each domain's SPF, DKIM and DMARC status. BunMail always delivers directly; it does not hand mail to a third-party provider.
Run mail-tester.com to get a deliverability score for your specific setup before deploying.
Screenshots

![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Features
- Direct SMTP delivery: sends straight to recipient MX servers, failing over across MX hosts (and to the implicit MX), no relay needed; honours recipient domains' MTA-STS policies (verified TLS to the MX hosts they name, in enforce mode); throttled per provider (Microsoft, Google, Yahoo, Apple, or the MX's registrable domain), with an optional per-provider message rate, both across every replica with the optional
REDIS_URL - SMTP submission: apps (Infisical, Netbird, Dify, any Nodemailer backend) send through BunMail over SMTP using an API key as the password; switch an app off SendGrid/Brevo with a credentials-only change (docs/smtp-submission.md)
- DKIM signing: auto-generates 2048-bit RSA keys per domain, under a selector you can choose, with zero-downtime key rotation; over-signs
From,Subjectand the other fields a reader sees, so a header added in transit breaks the signature - SPF / DKIM / DMARC verification: DNS checks built into the dashboard, with the records to publish per domain, the parsed DMARC policy and where the MX points, re-run daily in the background so a record that drifts is caught (
domain.verification_failed) - Email queue: Postgres-backed with MTA-style retry backoff (up to 48 h for temporary failures such as greylisting), lease-based crash recovery that is safe with several replicas, and no double sends between concurrent workers
- Several recipients and batch sends:
totakes an array of up to 50 addresses for one message (100 recipients with cc and bcc, delivered per recipient domain, bcc never in a header), andPOST /api/v1/emails/batchqueues up to 100 distinct emails in one request, each item validated and created on its own with per-item idempotency keys (docs/api.md) - Idempotency keys: an optional
Idempotency-Keyheader on the send API makes retries safe; a repeat with the same key and body returns the first response instead of sending twice, even under concurrent retries (docs/api.md) - Templates: Mustache-style
{{variable}}substitution, HTML-escaped in the HTML body ({{{variable}}}inserts raw HTML) - Webhooks: HMAC-signed events with timestamp-bound replay protection:
email.queued,email.sent,email.failed,email.bounced,email.complained(feedback-loop spam complaints, #191),email.received,email.unsubscribed,email.openedandemail.clicked(open and click tracking, opt-in),domain.verification_failed,domain.verified - Inbound SMTP: receive and store incoming mail with DNSBL, recipient validation, and per-IP rate limiting; reply from the dashboard with RFC 5322 threading headers, and see which of your emails a received reply answers
- Inbound notifications: per-domain "you have new mail" email on receive, DKIM-signed from the recipient domain, with no content of the inbound message by default, automatic mail skipped and per-address and per-domain throttles; opt-in via each domain's
notify_email - API key auth: SHA-256 hashed Bearer tokens with per-key rate limiting, shared across replicas with the optional
REDIS_URL - Trash + auto-purge: Gmail-style soft delete on outbound and inbound, restorable until purged after
TRASH_RETENTION_DAYS(default 7) - Suppression list: per-API-key list of addresses we refuse to send to; gate runs at
POST /emails/sendso bounced/unsubscribed recipients can't re-tank IP reputation; a mailbox still full (4.2.2) when retries run out is suppressed for 24 h, andSUPPRESSIONS_SHARED_HARD=truealso refuses addresses any key hard-bounced on or got a complaint for - Sender display name and send category: an optional
fromName(checked so it cannot pose as an address) andcategory(transactionalmail goes out withoutList-Unsubscribe,marketingmail is refused unless it can carry the one-click link); HTML-only mail gets a derived text part and fragments a document wrapper at send time - Tags: label a send with up to 10
key: valuetags (RESTtags, orX-BunMail-Tagon SMTP submission), filter the email log with?tag=key:value(several must all match) or the dashboard's tag filter, and route webhooks on thetagseveryemail.*event carries - Attachments: up to 10 files per send (REST
attachments, Resend-compatible, or the parts of an SMTP-submitted message),ATTACHMENTS_MAX_TOTAL_MBin total (default 5, up to 18); executables, scripts and installers refused by extension and by sniffed content, types derived from the bytes, inlinecid:images; files kept only until the email is sent, fails or bounces, and downloadable from the dashboard as forced downloads until then (docs/api.md) - One-click unsubscribe: single-recipient mail carries a per-recipient RFC 8058
List-Unsubscribelink (and anunsub+mailto when inbound SMTP is on); using it suppresses that recipient for the sending key and firesemail.unsubscribed(docs/suppressions.md) - Open and click tracking (off by default): a 1x1 pixel and signed redirects on your own host, opted in per domain by admins and per send, single-recipient mail only, with no IP address or user agent stored (docs/tracking.md)
- DMARC aggregate report ingest: daily
ruareports from Microsoft / Google / Yahoo are auto-parsed (gzip / zip / raw XML) and surfaced in the dashboard so misaligned source IPs are visible at a glance - DANE (opt-in): with
DANE_ENABLED=trueand a local validating resolver (the composedaneprofile), recipient domains with DNSSEC-signed MX and TLSA records (RFC 7672) only get mail over TLS whose certificate matches them, never in plaintext; DANE wins over MTA-STS (docs/emails.md) - TLS reports (opt-in): with
TLSRPT_ENABLED=true, BunMail sends recipient domains that publish a_smtp._tlsrecord (Gmail, Outlook, Yahoo) a daily SMTP TLS report (RFC 8460) on how its TLS sessions to them went, by HTTPS (through the SSRF guard) or DKIM-signed mail (docs/tls-rpt.md) - Web dashboard: server-rendered (Elysia JSX), bulk operations, real-time stats (24h sent, success rate, bounce rate with a warning above 2%, queue depth), a per-MX delivery table on each email with a badge for sessions sent without TLS; owner account login with revocable server-side sessions, invite-only teammate accounts (admin or member), a Users page to change roles, deactivate or remove accounts, issue one-time password reset links and transfer ownership, a Change password page for every account (signs out every other device), an Active sessions page for every account (device, IP, optional approximate location from an offline GeoIP database; sign out one device or all others), an email alert on sign-in from a new device (no links), optional TOTP two-factor authentication with recovery codes for every account (
REQUIRE_2FAmakes it mandatory for the owner or admins), passkeys (WebAuthn) for a phishing-resistant passwordless sign-in or second factor (#223), single sign-on with any OpenID Connect provider (Google and GitLab presets need only a client id and secret; Microsoft Entra, Keycloak, Authentik, Okta, ... take their issuer): a branded "Sign in with" button per provider for accounts that linked their identity (never creating accounts, BunMail's own 2FA still asked, optional link by verified email for members) (#224), an append-only audit log of security-relevant actions (sign-ins, account, two-factor, single sign-on, API key, domain, webhook and suppression changes, with actor, IP and user agent) for admins, and per-user API keys (members manage their own restricted keys; deactivating an account disables its keys) - OpenAPI 3.0: interactive docs at
/api/docs - Observability: structured JSON logs with request, job and SMTP-session IDs (docs/logging.md), and Prometheus metrics at
/metricsbehind a scrape token: queue depth and overdue age, sends by final status, delivery latency, MX deliveries by TLS, SMTP refusals by reason, webhook backlog, HTTP rate and latency, readable by Prometheus, the OpenTelemetry Collector and the Datadog Agent (docs/metrics.md); admins also see a System health card on the dashboard home; optional OpenTelemetry traces over OTLP for HTTP requests, send attempts and MX deliveries, webhook deliveries (withtraceparent) and SMTP sessions, off by default (docs/tracing.md) - Type-safe: strict TypeScript, Drizzle ORM, no
any
API Endpoints
All endpoints (except /health, the public unsubscribe pages at /u/:token, the tracking pixel and redirect at /t/, and /metrics, which takes its own METRICS_TOKEN) require Authorization: Bearer <api-key>. Full reference: docs/api.md.
Emails
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/emails/send |
Queue an email (direct or template), to one address or an array of up to 50 |
| POST | /api/v1/emails/batch |
Queue 1 to 100 distinct emails, one result per item |
| GET | /api/v1/emails |
List emails (excludes trash) |
| GET | /api/v1/emails/trash |
List trashed emails |
| GET | /api/v1/emails/:id |
Get email by ID |
| DELETE | /api/v1/emails/:id |
Move to trash |
| POST | /api/v1/emails/bulk-delete |
Bulk move to trash |
| POST | /api/v1/emails/:id/restore |
Restore from trash |
| DELETE | /api/v1/emails/:id/permanent |
Permanently delete a trashed email |
| POST | /api/v1/emails/trash/empty |
Empty trash |
| GET | /api/v1/emails/tombstones |
List post-purge audit snapshots (optional ?messageId=) |
| GET | /api/v1/emails/tombstones/:id |
Get a tombstone by the original email ID |
Inbound
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/inbound |
List received emails |
| GET | /api/v1/inbound/trash |
List trashed inbound |
| GET | /api/v1/inbound/:id |
Get inbound email by ID |
| DELETE | /api/v1/inbound/:id |
Move to trash |
| POST | /api/v1/inbound/bulk-delete |
Bulk move to trash |
| POST | /api/v1/inbound/:id/restore |
Restore from trash |
| DELETE | /api/v1/inbound/:id/permanent |
Permanently delete a trashed inbound |
| POST | /api/v1/inbound/trash/empty |
Empty inbound trash |
Suppressions
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/suppressions |
Add an address to the suppression list (idempotent) |
| GET | /api/v1/suppressions |
List suppressions (paginated, optional ?email= filter) |
| GET | /api/v1/suppressions/:id |
Get a suppression by ID |
| DELETE | /api/v1/suppressions/:id |
Remove (recipient becomes eligible immediately) |
Sends to suppressed recipients return 422 with code: "RECIPIENT_SUPPRESSED" and suppressionId (null when SUPPRESSIONS_SHARED_HARD=true applied another key's hard bounce or verified feedback-loop complaint). See docs/suppressions.md and docs/bounces.md for the auto-suppression flow on hard bounces.
DMARC Reports
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/dmarc-reports |
List DMARC aggregate reports (paginated, optional ?domain=) |
| GET | /api/v1/dmarc-reports/:id |
Get a single report with its per-source-IP records |
DMARC rua aggregate reports that arrive at BunMail's inbound SMTP are auto-detected, decompressed (gzip / zip / raw XML), parsed, and stored. The dashboard at /dashboard/dmarc-reports highlights misaligned source IPs in amber. See docs/dmarc-reports.md.
Domains, Templates, API Keys, Webhooks, SMTP Submission
See docs/api.md for full coverage of /api/v1/domains, /api/v1/templates, /api/v1/api-keys, /api/v1/webhooks and /api/v1/smtp-submission/stats.
Send with a Template
# Create a template
curl -X POST http://localhost:3000/api/v1/templates \
-H "Authorization: Bearer $BM_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Welcome",
"subject": "Welcome, {{name}}!",
"html": "<h1>Hi {{name}}</h1><p>Welcome to {{company}}.</p>",
"variables": ["name", "company"]
}'
# Send using it
curl -X POST http://localhost:3000/api/v1/emails/send \
-H "Authorization: Bearer $BM_KEY" -H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"templateId": "tpl_YOUR_TEMPLATE_ID",
"variables": { "name": "Alice", "company": "Acme Inc" }
}'
Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Bun |
| Framework | Elysia |
| ORM | Drizzle ORM (drizzle-orm/bun-sql) |
| Database | PostgreSQL 16+ |
| Shared state (optional) | Redis or Valkey via Bun's built-in client, only to run several replicas (REDIS_URL, #185) |
| SMTP outbound | Nodemailer (one transport per recipient MX on port 25 + DKIM) |
| SMTP inbound | smtp-server + mailparser |
| Dashboard | Elysia JSX (@elysiajs/html + @kitajs/html) |
| Deployment | Docker + Docker Compose |
Architecture
HTTP (REST API + dashboard) ─────► Elysia plugins (modules/)
│
▼
Services layer
│
┌─────────────┼─────────────┐
▼ ▼ ▼
PostgreSQL Email queue Webhook dispatch
(poll) (HMAC-signed)
│
▼
Nodemailer + DKIM signing
│
▼
Recipient MX servers (port 25)
Inbound mail arrives via the built-in SMTP server (port 25/2525), gets parsed by mailparser, and is stored in inbound_emails. Webhooks fire email.received on inbound and email.sent / email.failed on outbound. See ARCHITECTURE.md for full diagrams.
Development
bun install
bun run dev # build the dashboard CSS, start dev server (--watch)
bun run dev:css # optional, 2nd terminal: rebuild the CSS as classes change
bun test # run all tests
bun test test/unit # unit tests only
bun test test/e2e # e2e (plugin) tests only
bun run test:integration # real-Postgres tests (bun run test:integration:setup first)
bunx tsc --noEmit # type check (TypeScript 7, native)
bun run lint # oxlint (type-aware)
cp docker-compose.override.example.yml docker-compose.override.yml # once: Postgres on localhost:5432
docker compose up -d # full stack with Postgres
See docs/ for module-level documentation:
- docs/api.md: Full API reference
- docs/dashboard.md: Dashboard routes and structure
- docs/emails.md: Outbound module
- docs/inbound.md: Inbound module
- docs/dmarc-reports.md: DMARC
ruaaggregate report ingest - docs/tls-rpt.md: SMTP TLS reports (RFC 8460) sent to recipient domains
- docs/users.md: Dashboard accounts, roles, invitations, sessions, password reset links, password change, two-factor authentication, passkeys and single sign-on
- docs/logging.md: Log format, correlation IDs and tracing a request, email or SMTP session
- docs/self-hosting.md: Production deployment with DNS records
- THREAT_MODEL.md: Assets, attackers, controls, and operator responsibilities
- SECURITY.md: Reporting a vulnerability
Deliverability Setup
For inbox placement on Gmail / Outlook / Yahoo, you need:
- A clean sender domain: older, no spam history.
- A clean IP: BunMail checks its sending IPs daily on Spamhaus ZEN, Barracuda and SpamCop (dashboard home, Blocklists card); mxtoolbox.com/blacklists checks many more.
- PTR (reverse DNS) matching
MAIL_HOSTNAMEin.env. - SPF, DKIM, DMARC records: BunMail's domain page lists what to publish per domain (with the A, PTR and optional MX records) and Verify checks SPF, DKIM and DMARC. The A and PTR records are checked at boot and the sending IPs' blocklist status daily (#250); your IP's reputation is not checked.
- Patience: fresh domain + IP combos take 2 to 3 weeks of low-volume sending to build reputation.
See docs/self-hosting.md for the exact DNS record values.
Roadmap
Active issues: github.com/mohamedboukari/bunmail/issues
Near-term highlights:
- Bun-native SMTP client (#60)
Contributing
Issues, PRs, and discussions welcome. See CONTRIBUTING.md and the good first issue label for ways to start.
License
Explore related projects
awesome-selfhosted/awesome-selfhosted
A list of Free Software network services and web applications which can be hosted on your own servers
335,563 starsimmich-app/immich
High performance self-hosted photo and video management solution.
118,645 starsStirling-Tools/Stirling-PDF
#1 PDF Application on GitHub that lets you edit PDFs on any device anywhere
96,149 starslouislam/uptime-kuma
A fancy self-hosted monitoring tool
93,425 stars




