MailBear is an open source, self hosted forms backend. Just do a post request to the API with some form data, and MailBear will make sure the submission is sent to you via mail!
MailBear will always hide the email address of the recepient, since the forms are accessed by a unique key.
You can easily run MailBear with Docker. Copy config_sample.yml to config.yml
(it holds only your forms), then pass the operational settings as environment
variables:
docker run \
-v $(PWD)/config.yml:/mailbear/config.yml \
-e SMTP_HOST=smtp.example.com \
-e SMTP_FROM_EMAIL=no-reply@example.com \
-p 1234:1234 \
ghcr.io/laputalabs/mailbear:latest
A docker-compose.yml file is provided with the full set of environment variables.
In production, terminate TLS with a reverse proxy and don't expose MailBear's
ports publicly. Caddy works out of the box — it
fetches a certificate automatically and appends X-Forwarded-For, which MailBear
uses to recover the real client IP for rate limiting (see
Rate Limiting & Reverse Proxies).
Caddyfile:
forms.example.com {
reverse_proxy mailbear:1234
}Use mailbear:1234 when Caddy shares a Docker network with MailBear (the compose
service name); use localhost:1234 if Caddy runs directly on the host.
Crucially, only Caddy should be publicly reachable. Run them together and give MailBear no published ports — Caddy reaches it over the internal network:
services:
caddy:
image: caddy:2ports:
- "80:80"
- "443:443"volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/datadepends_on:
- mailbearmailbear:
image: ghcr.io/laputalabs/mailbear:v0.0.2command: serve# No `ports:` — MailBear is reachable only to Caddy, as "mailbear:1234".environment:
SMTP_HOST: smtp.example.comSMTP_FROM_EMAIL: no-reply@example.com# ... other settingsvolumes:
- ./config.yml:/mailbear/config.ymlvolumes:
caddy_data:If MailBear published 1234 to the host, clients could hit it directly —
bypassing TLS and forging X-Forwarded-For to defeat the rate limiter. Keep the
metrics port (:9090) internal as well; don't proxy it publicly.
Copy config_sample.yml to config.yml, then run the serve command:
SMTP_HOST=smtp.example.com SMTP_FROM_EMAIL=no-reply@example.com \
go run ./cmd/mailbear serve --config config.yml
Common tasks are wrapped in the Makefile (run make help for the full list):
make setup # install dev tools (gofumpt, golangci-lint, govulncheck)
make dev # build ./bin/mailbear
make test # run tests with the race detector
make lint # run golangci-lint
make docker # build the Docker image
MailBear shuts down gracefully on SIGINT/SIGTERM, draining in-flight requests.
To check a config without starting the server (handy in CI or before a deploy),
use validate — it runs the same checks the server does at startup (parses the
forms config, loads and parses templates, compiles each form's subject, and
requires SMTP settings when a form sends email) and exits non-zero on the first
problem:
mailbear validate --config config.yml --templatesDir templates/
MailBear is configured in two places: operational settings via flags or environment variables, and the forms list via a YAML file.
Every flag has an environment-variable equivalent. Run mailbear serve --help
for the authoritative list.
| Flag | Env var | Default | Description |
|---|---|---|---|
--config | CONFIG_FILE | config.yml | Path to the forms config file |
--httpAddress | HTTP_ADDRESS | :1234 | API server listen address |
--metricsAddress | METRICS_ADDRESS | :9090 | Prometheus metrics listen address |
--rateLimit | RATE_LIMIT | 5 | Max submissions per client IP per minute |
--smtpHost | SMTP_HOST | — (required) | SMTP server hostname |
--smtpPort | SMTP_PORT | 587 | SMTP server port |
--smtpUser | SMTP_USER | — | SMTP username |
--smtpPassword | SMTP_PASSWORD | — | SMTP password |
--smtpDisableTLS | SMTP_DISABLE_TLS | false | Disable STARTTLS |
--smtpFromEmail | SMTP_FROM_EMAIL | — (required) | From address for outgoing mail |
--smtpFromName | SMTP_FROM_NAME | MailBear | From name for outgoing mail |
--turnstileSecret | TURNSTILE_SECRET | — | Cloudflare Turnstile secret key (empty disables captcha) |
--honeypotField | HONEYPOT_FIELD | verify | Name of the hidden honeypot form field |
--auditLog | AUDIT_LOG | — | Path to a JSONL submission audit log (empty disables it) |
--auditLogMaxSizeMB | AUDIT_LOG_MAX_SIZE_MB | 100 | Rotate the audit log once it exceeds this size (MB) |
--auditLogMaxBackups | AUDIT_LOG_MAX_BACKUPS | 10 | Rotated audit-log files to retain (0 keeps all) |
--auditLogMaxAgeDays | AUDIT_LOG_MAX_AGE_DAYS | 90 | Maximum age of rotated audit-log files in days (0 = no limit) |
--auditLogCompress | AUDIT_LOG_COMPRESS | true | gzip rotated audit-log files |
--logLevel | LOG_LEVEL | info | Log level (debug, info, warn, error) |
--prettyLog | PRETTY_LOG | true | Pretty-print logs to the console |
Define as many forms as you want:
forms:
some-form-name:
key: some-random-keyallowed_domains:
- localhost:8080
- example.comto_email:
- recipient@example.com# Optional: display name on this form's outgoing mail (falls back to# SMTP_FROM_NAME). Cosmetic only — the from address is unchanged, so it# has no deliverability impact.from_name: "Acme Contact Form"Each form also supports several optional fields covered in their own sections
below: template / subject (Custom Email Templates),
webhook_url (Webhook Forwarding), redirect_url /
error_redirect_url (Redirect After Submit),
and autoresponder (Autoresponder). A full annotated example
is in examples/config.example.yml.
MailBear rate-limits submissions per client IP address. To determine the client
IP it trusts the X-Forwarded-For header, but only when the request arrives
from a loopback, link-local, or private-network address (i.e. a reverse proxy
running on the same host or private network). Requests from any other source
have their X-Forwarded-For header ignored and are rate-limited by their real
connection IP. This prevents a client from spoofing the header to bypass the
rate limiter.
⚠️ This protection depends on your reverse proxy appending the real client IP toX-Forwarded-For, not blindly passing through whatever the client sent. If your proxy forwards the client-supplied header verbatim, a client can still spoof its IP and evade rate limiting.
- Caddy (
reverse_proxy), Traefik, and nginx (using$proxy_add_x_forwarded_for) all append correctly by default — no action needed.- Also make sure MailBear itself is not reachable directly on a public interface, bypassing the proxy. Bind it to localhost or a private network and let only the proxy reach it.
If you run MailBear without a reverse proxy (exposed directly to clients), no configuration is needed: the connection IP is used directly and cannot be spoofed.
The form endpoint is public, so it needs protection against bots and abuse. MailBear applies these defences, from cheapest to strongest:
- Rate limiting — per client IP (see the section above).
- Honeypot — every submission may include a hidden decoy field (named
verifyby default). Legitimate front-ends keep it empty; bots that fill every field trip it. A tripped honeypot returns a normal success response but silently drops the message (so bots don't learn the field is a trap). No configuration needed, but you can rename the field with--honeypotField/HONEYPOT_FIELD; pick a non-obvious name (avoid the_gotchathat other form backends use, which bots recognise and skip). Update your form's hidden field to match. - Cloudflare Turnstile — the real gate against non-browser abuse. The
allowed_domains/Origincheck alone is not sufficient, because any script can forge theOriginheader; only a client-side challenge like Turnstile actually stops automated submissions.
- Create a Turnstile widget in the Cloudflare dashboard and pass the secret
key via
--turnstileSecret/TURNSTILE_SECRET. When it is empty, Turnstile verification is disabled and only the honeypot + rate limiting apply. - Add the Turnstile widget to your form's front-end and submit the resulting
token as
cf-turnstile-response(the widget's default field name):
<form...><!-- your fields --><!-- honeypot: keep it visually hidden (name must match --honeypotField) --><inputtype="text" name="verify" style="display:none" tabindex="-1" autocomplete="off"><!-- Turnstile widget (renders the cf-turnstile-response field) --><divclass="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div></form><scriptsrc="https://challenges.cloudflare.com/turnstile/v0/api.js" asyncdefer></script>MailBear verifies the token server-side against Cloudflare's siteverify
endpoint on every submission and rejects any that fail.
Once MailBear is running you can send requests with form data in the JSON body:
curl \
-X POST \
http://localhost:1234/api/v1/form/some-random-key \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8080' \
-d '{"name":"Joe","email":"joe@example.com", "subject": "Some subject", "content": "Maecenas faucibus mollis interdum. Sed posuere consectetur est at lobortis."}'By default MailBear sends a built-in email. You can give each form its own email
by pointing --templatesDir / TEMPLATES_DIR at a directory of template files:
templates/
default.html # overrides the built-in default (optional)
contact.html # used by forms with `template: contact`
contact.txt # optional plain-text part
- A template is a pair of files named
<name>.htmland<name>.txt. The.htmlpart is required; the.txtpart is optional. - When both exist, the email is sent as multipart/alternative (plain-text +
HTML). When only
.htmlexists, it is HTML-only. - A form selects its template with the
template:field inconfig.yml(the value is the file<name>). Forms with notemplateuse the built-indefault, which you can override by placing your owndefault.htmlin the directory. - The subject line is set per form with the optional
subject:field, rendered as a Gotext/template.
Templates are Go templates. HTML templates use
html/template (which auto-escapes all
values, so user input can't inject markup); text templates use
text/template. Available variables:
| Variable | Description |
|---|---|
{{ .Name }} | Submitter's name |
{{ .Email }} | Submitter's email |
{{ .Subject }} | Submitter's subject |
{{ .Content }} | Submitted message |
{{ .FormName }} | The form's config key (human-readable name) |
A helper {{ nl2br .Content }} is available in HTML templates to render newlines
as <br> (it escapes the content first, so it stays injection-safe).
Example contact.html:
<p>New message from <b>{{ .Name }}</b> ({{ .Email }}):</p><p>{{ nl2br .Content }}</p>Templates are loaded and validated at startup, so a missing template file or a syntax error fails fast rather than at send time.
A plain HTML form (no JavaScript) that posts straight to MailBear would otherwise
land the visitor on a page showing the raw JSON response. Set redirect_url on
the form and, for browser form posts, MailBear replies with a 303 See Other to
that page on success instead. Add error_redirect_url to send failures (bad
input, spam, delivery errors) to an error page.
forms:
contact:
key: contact-keyallowed_domains: [example.com]to_email: [me@example.com]redirect_url: https://example.com/thankserror_redirect_url: https://example.com/oops # optional<formaction="https://mailbear.example.com/api/v1/form/contact-key" method="POST"><inputtype="email" name="email" required><inputtype="text" name="subject" required><textareaname="content" required></textarea><buttontype="submit">Send</button></form>Redirects apply only to browser form posts (application/x-www-form-urlencoded /
multipart/form-data). JSON/AJAX clients always receive a JSON response, so the
existing integrations are unaffected. When error_redirect_url is not set,
failures fall back to a JSON body. Both URLs are operator config, so there is no
open-redirect risk from client input.
A form can send a confirmation email back to the submitter after a successful
submission. Add an autoresponder block referencing its own template (a
<name>.html / <name>.txt pair in the --templatesDir); the subject is
optional and rendered as a text/template:
forms:
contact:
key: contact-keyallowed_domains: [example.com]to_email: [me@example.com]autoresponder:
template: contact-acksubject: "Thanks for contacting us, {{.Name}}"The confirmation uses the same template variables as any other template
({{.Name}}, {{.Content}}, …), goes out from the configured SMTP from
address, and sets Reply-To to the form's first recipient so replies reach you.
It is best-effort: the owner notification is sent first, and if the
confirmation later fails to send, the request still succeeds (so the submitter is
never prompted to resubmit and re-notify you). Outcomes are counted in
mailbear_autoresponder_deliveries_total.
An autoresponder emails whatever address the submitter typed, so it can be abused to send mail to arbitrary addresses. It only fires after the honeypot, Origin, Turnstile, and rate-limit checks pass — enabling Turnstile is strongly recommended when using it.
Each form can POST its submissions as JSON to a URL via the webhook_url field —
useful for Slack, Discord, Zapier, n8n, or any HTTP endpoint. A form needs at
least one of to_email or webhook_url; set only webhook_url for a form that
delivers by webhook and sends no email at all (in which case no SMTP config is
required).
forms:
contact: # email + webhookkey: contact-keyallowed_domains: [example.com]to_email: [me@example.com]webhook_url: https://hooks.example.com/contactalerts: # webhook only, no emailkey: alerts-keyallowed_domains: [example.com]webhook_url: https://n8n.example.com/webhook/abcThe payload is a JSON object:
{"form":"contact","name":"Joe","email":"joe@example.com","subject":"Hi","content":"the message"}Delivery semantics:
- Email is the primary channel. When a form has
to_email, a delivery failure fails the request (the submitter sees an error and can retry). - A webhook is best-effort when email is also configured: if the email is sent
but the webhook POST fails, the request still succeeds and the failure is logged
(and counted in
mailbear_webhook_deliveries_total{outcome="failure"}). - When the webhook is the only channel, a non-2xx response or a connection error fails the request.
The
webhook_urlis operator configuration (fromconfig.yml), never user input, so it is trusted and not SSRF-filtered. Only point it at endpoints you control or trust.
Ready-to-use integration snippets live in the examples/ directory:
plain_html.html— a no-JavaScript HTML form (usesredirect_urlfor the result page).fetch.html— vanilla JSfetch()with a honeypot and a Cloudflare Turnstile widget.react_example.jsx— a React (hooks) form component.vuejs_example.vue— a Vue single-file component (shown below).
<template><divid="contact"><divclass="form" ><form@submit.prevent="submit"><divclass="form-overlay" v-if="loading"><font-awesome-iconicon="circle-notch" spin/></div><!-- form-overlay --><div><divclass="status" v-if="status !== ''"><spanv-if="status === 'success'">Your email has successfully been sent.</span><spanv-if="status === 'error'">Something went wrong while sending your email.</span></div></div><div><inputtype="text" name="name" v-model="form_data.name" placeholder="Name or Company" required/></div><div><inputtype="email" name="email" v-model="form_data.email" placeholder="Email" required/></div><div><inputtype="text" name="subject" v-model="form_data.subject" placeholder="Subject" required/></div><div><textareatype="text" name="content" v-model="form_data.content" placeholder="Message" rows="6" required/></div><div><buttontype="submit">Send</button></div></form></div></div><!-- contact --></template><script>importconfigfrom'../config'exportdefault{name: 'Contact',components: {},data: function(){return{contact_text: "",form_data: {name: "",email: "",subject: "",content: ""},status: "",loading: false}},created(){},mounted(){},methods: {clearForm: function(){this.form_data.name="";this.form_data.email="";this.form_data.subject="";this.form_data.content="";},submit: function(e){e.preventDefault();varself=thisself.loading=truethis.axios.post(config.MAILBEAR_URL+`/api/v1/form/10810dce-1074-4988-a8f5-4c538a749a95`,this.form_data).then(response=>{self.status="success"self.clearForm()returnresponse}).catch(error=>{self.status="error"console.log(error)}).then(function(){// always executedself.loading=false})}}}</script><stylelang="scss">/* * Style was left out of this example. * Go find it in ./examples/vuejs_example.vue */</style>Prometheus metrics are served on :9090/metrics by default. The exposed metrics:
| Metric | Labels | Description |
|---|---|---|
mailbear_form_requests_total | form, result | Every submission request by outcome. result is one of success, honeypot, invalid, forbidden_origin, captcha_failed, captcha_error, send_error, not_found. |
mailbear_form_submissions_total | form | Submissions successfully delivered (email sent and/or webhook accepted). |
mailbear_webhook_deliveries_total | form, outcome | Webhook POSTs, outcome = success / failure. |
mailbear_autoresponder_deliveries_total | form, outcome | Autoresponder confirmation emails, outcome = success / failure. |
mailbear_rate_limited_total | — | Requests rejected by the rate limiter. |
For example, a spam/rejection breakdown per form comes from
mailbear_form_requests_total, while mailbear_form_submissions_total counts
what actually got delivered.
The endpoint uses the standard Prometheus exposition format, so any Prometheus-compatible stack can scrape it — build whatever dashboards or alerts you like on top of these metrics in the tool of your choice.
MailBear exposes two unauthenticated probe endpoints on the API port for load balancers and Kubernetes liveness/readiness probes:
GET /healthz— livenessGET /readyz— readiness
Both return 200 OK when the server is serving. They bypass the rate limiter and
request logging, so probing them frequently is safe.
Set --auditLog / AUDIT_LOG to a file path and MailBear appends every accepted
submission — with its delivery outcome — as a line of JSON
(JSON Lines). It's off by default. The record is written
regardless of whether delivery succeeded, so nothing is lost if your mail server
is down: you can inspect or replay the file later.
{"ts":"2026-07-28T10:15:03Z","form":"contact","name":"Ada","email":"ada@example.com","subject":"Question","content":"How much?","delivered":true}
{"ts":"2026-07-28T10:16:41Z","form":"contact","name":"Grace","email":"grace@example.com","subject":"Hi","content":"hello","delivered":false}
Only submissions that pass validation and the anti-abuse checks are logged (spam
and honeypot hits are not). Query it with standard tools, e.g.
jq 'select(.delivered==false)' audit.jsonl to find deliveries that failed.
The log is rotated automatically: once the active file passes
--auditLogMaxSizeMB (default 100 MB) it's rolled over, older files beyond
--auditLogMaxBackups (default 10) or --auditLogMaxAgeDays (default 90) are
deleted, and rotated files are gzipped unless --auditLogCompress=false. Tune
these to match your retention needs.
⚠️ This stores personal data (names, emails, message contents) on disk. Put the file on a persistent, access-controlled volume, and set the retention knobs above to whatever your data-protection obligations require (the defaults keep roughly 90 days / 10 rotations).
- github.com/spf13/cobra
- github.com/go-chi/chi
- github.com/go-chi/httprate
- github.com/rs/zerolog
- github.com/go-yaml/yaml
- github.com/go-mail/mail
- github.com/badoux/checkmail
- github.com/prometheus/client_golang
Forked from MailBear by Mathias Beke.