doevoe is a self-hosted transactional email API: point your app at it and it queues, signs (DKIM), delivers, and retries mail directly to recipient MX servers — no third-party ESP, no per-email fee. It ships as a single Go binary with an embedded SQLite store, a JSON send/status API, and a mobile-friendly admin UI for domain setup, API keys, and delivery logs.
Before you deploy, make sure the host you're using can actually send mail:
- Outbound TCP port 25 must be open. doevoe delivers straight to
recipients' MX servers over SMTP — there is no smarthost/relay fallback.
Most consumer clouds block outbound port 25 by default:
- Hetzner, OVH and most classic VPS/dedicated providers allow it, sometimes after a support-ticket request to unblock it.
- AWS, GCP and Azure block it by default and require a formal request to lift the block (AWS: "Remove email sending limitations" support case; GCP largely does not support outbound 25 on standard instances). Budget time for this before you commit to a provider.
- A static egress IP with a PTR record you control. doevoe's generated
SPF record pins to a single IP (
DOEVOE_EGRESS_IP), and Gmail/Outlook/etc. will junk or reject mail from an IP whose reverse DNS (PTR) doesn't resolve back toDOEVOE_HOSTNAME. Set the PTR record with your hosting provider (not in the sending domain's own DNS) before sending real traffic. - Put a TLS-terminating reverse proxy in front of it. doevoe's
/adminand/apilisten on plain HTTP (DOEVOE_LISTEN, default:8080) and the admin session cookie is not markedSecure. Never expose port 8080 directly to the internet — put Caddy, nginx, or Traefik in front of it terminating TLS, and only forward to doevoe over localhost/a private network. - Inbound TCP port 25 must be reachable from the internet for the
built-in DMARC report receiver (
DOEVOE_INBOUND_LISTEN, default:25) — other mail providers connect to it directly to deliver aggregate reports, the same way doevoe connects out to their MX servers.docker-compose.ymlpublishes25:25for this; open it in any host firewall/security group, or setDOEVOE_INBOUND_LISTEN=(empty) to disable the receiver if you don't want it.
git clone <this repo> && cd doevoe
cp .env.example .env
# edit .env: hostname, egress IP, admin password/email, system from-address
docker compose up -dThen:
- Open
https://your-proxy/admin(behind your reverse proxy) and log in withDOEVOE_ADMIN_PASSWORD. - Add a domain (e.g.
client.example). doevoe generates a DKIM keypair for it and shows you the exact SPF, DKIM, DMARC, and PTR records to add at your DNS provider. - Add those DNS records, then click Verify on the domain page (doevoe also re-checks every domain automatically once an hour).
- Once SPF/DKIM/DMARC all show verified, create an API key scoped to that domain under Keys. The plaintext token is shown exactly once — copy it now.
- Use the key to send:
curl -s https://your-proxy/api/v1/emails \
-H "Authorization: Bearer $DOEVOE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"from":"hello@client.example","to":"someone@gmail.com","subject":"Hi","text":"Hello!"}'Keep the API key server-side — never ship it to the browser. A typical route handler:
// app/api/contact/route.ts — the secret key stays server-side
export async function POST(req: Request) {
const { email } = await req.json();
const res = await fetch("https://doevoe.example.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOEVOE_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
from: "hello@client.example",
to: email,
subject: "Thanks for reaching out",
text: "We got your message and will reply soon.",
}),
});
return Response.json(await res.json(), { status: res.status });
}Idempotency-Key is optional but recommended: resending the same key
returns the original queued/sent result instead of sending a duplicate
email, which makes retries on the caller's side safe.
from and to are parsed as RFC 5322 addresses (mail.ParseAddress). A
display name in the "Name <addr>" form is preserved: doevoe renders the
From/To headers as Name <addr> (with the name RFC 2047-encoded when it
isn't plain ASCII), while the bare addr is what's used for the SMTP envelope,
MX routing, and the from-domain check. from must be on the domain your API
key is scoped to.
The JSON body accepts:
| Field | Required | Meaning |
|---|---|---|
from |
yes | Sender address; its domain must match the API key's domain. "Name <addr>" display name preserved. |
to |
yes | Recipient address. "Name <addr>" display name preserved. |
subject |
yes | Subject line. |
text |
text or html | Plain-text body. |
html |
text or html | HTML body. Supply both text and html and doevoe builds a multipart/alternative message (the deliverability-preferred form); html alone sends text/html, text alone sends text/plain. |
reply_to |
no | Reply-To header (display name also preserved). |
headers |
no | Extra headers as a { "Name": "value" } object. Headers doevoe sets itself (From, To, Subject, Date, Message-ID, MIME-Version, Content-Type, Content-Transfer-Encoding, Content-Disposition, Content-ID, DKIM-Signature) are reserved and rejected. |
attachments |
no | Array of files to attach — see Attachments below. |
At least one of text or html is required.
attachments is an array; each entry carries its bytes base64-encoded in
content:
curl -s https://your-proxy/api/v1/emails \
-H "Authorization: Bearer $DOEVOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@client.example",
"to": "someone@gmail.com",
"subject": "Your invoice",
"html": "<p>Invoice attached.</p><img src=\"cid:logo\">",
"attachments": [
{ "filename": "invoice.pdf", "content": "'"$(base64 -w0 invoice.pdf)"'" },
{ "filename": "logo.png", "content": "'"$(base64 -w0 logo.png)"'",
"disposition": "inline", "content_id": "logo" }
]
}'| Field | Required | Meaning |
|---|---|---|
filename |
yes | Name the recipient sees. Must not contain /, \, CR, LF or NUL; max 255 bytes. Non-ASCII names are RFC 2231-encoded (filename*=utf-8''…) so they survive intact. |
content |
yes | The file's bytes, base64-encoded. Line breaks in the base64 are fine, padding is optional. |
content_type |
no | MIME type. Guessed from the filename extension when omitted, falling back to application/octet-stream. multipart/* is rejected (attach the parts individually); message/rfc822 is fine, for attaching a forwarded email. |
disposition |
no | attachment (default) or inline. |
content_id |
no | Id an inline part is referenced by, without angle brackets: "content_id": "logo" is what <img src="cid:logo"> in the HTML body resolves to. |
Inline images. An inline attachment with a content_id goes into a
multipart/related alongside the body, which is what makes a client render it
in place instead of listing it as a download — the usual want for a logo or a
chart in a transactional email. Plain attachment entries go into an outer
multipart/mixed. Use both at once and the message nests
mixed > related > alternative, which is the structure every mainstream client
expects.
Limits. These are the real per-message ceiling:
| Limit | Value |
|---|---|
| Per attachment | 10 MB of file data |
| All attachments in one message | 15 MB of file data |
| Number of attachments | 20 |
The numbers are actual file bytes, not the base64 length: 15 MB of files is
~20.5 MB once base64-encoded into the message, which stays under the ~25 MB
total-message limit most large mailbox providers enforce. Providers reject an
oversized message with a permanent 5xx, so a message over their limit is a
wasted send rather than something doevoe can retry — the caps are set to keep
you below it. Exceeding a byte limit returns 413; a malformed attachment
(bad base64, illegal filename, unknown disposition) returns 422, and
nothing is queued either way.
The request body itself is capped at 32 MB, sized to hold the base64 form of
the above plus the bodies and headers; a body over that returns 413 before
it is decoded.
Attachment bytes are stored in the database next to the email and re-read on every delivery attempt, so a retry hours later (or an admin retry after fixing the recipient) still sends the same files. The admin email detail page lists what is attached — filename, type and size — but deliberately offers no download link.
Set DOEVOE_MCP_TOKEN and doevoe serves a Model Context Protocol
endpoint at POST /mcp (Streamable HTTP transport via the official Go SDK —
protocol revisions 2024-11-05 through 2026-07-28 negotiated per connection).
Authentication is Authorization: Bearer $DOEVOE_MCP_TOKEN; the token is
admin-scoped, unlike domain-scoped API keys, so treat it like the admin
password. Unset means the endpoint is not mounted at all.
Tools: send_email (including attachments, same fields and limits as the
JSON API), get_email_status, create_domain (returns the DNS records to
add), verify_domain, create_api_key (returns the token once),
revoke_api_key, get_sending_stats.
All configuration is environment variables, read once at startup
(internal/config). The five marked required must be set or doevoe
exits immediately with an error.
| Variable | Required | Default | Meaning |
|---|---|---|---|
DOEVOE_HOSTNAME |
yes | — | EHLO hostname; must match the PTR record for DOEVOE_EGRESS_IP |
DOEVOE_EGRESS_IP |
yes | — | Public IPv4 this host sends from; baked into the generated SPF record and checked against the PTR |
DOEVOE_ADMIN_PASSWORD |
yes | — | Password for the single /admin account |
DOEVOE_ADMIN_EMAIL |
yes | — | Recipient for failure digests, rate alerts, monthly stats, key lifecycle notices, and the DMARC rua address |
DOEVOE_SYSTEM_FROM |
yes | — | From-address doevoe uses for its own notifications; its domain must be one of the domains verified in doevoe |
DOEVOE_PUBLIC_URL |
no | http://$DOEVOE_HOSTNAME |
Public base URL (e.g. https://mail.example.com, no trailing slash) used to build the deep links in doevoe's own notification emails; set this to your reverse proxy's HTTPS address, since the default is plain HTTP and won't match it |
DOEVOE_LISTEN |
no | :8080 |
HTTP listen address for the API + admin UI |
DOEVOE_DATA_DIR |
no | /data |
Directory for the SQLite database (doevoe.db) |
DOEVOE_SMTP_PORT |
no | 25 |
Outbound SMTP port used to connect to recipient MX servers |
DOEVOE_FAILURE_RATE_MIN_VOLUME |
no | 10 |
Minimum delivery attempts in the trailing hour before the failure-rate alert can fire for a domain |
DOEVOE_FAILURE_RATE_THRESHOLD |
no | 0.2 |
Failure ratio (0–1) over the trailing hour that triggers the failure-rate alert |
DOEVOE_INBOUND_LISTEN |
no | :25 |
SMTP listen address for the built-in DMARC aggregate-report receiver; set to an empty value to disable it entirely |
DOEVOE_MCP_TOKEN |
no | — (disabled) | Bearer token for the MCP server at /mcp; unset disables the endpoint entirely. Admin-level credential: MCP tools can create domains and API keys and send from any verified domain |
Emails are delivered directly to the recipient's MX servers (with an RFC 5321 fallback to the A record if there's no MX). A 5xx (permanent) SMTP response fails the email immediately; anything else (4xx, timeouts, connection errors) is retried on a fixed backoff schedule, in order:
1m → 5m → 15m → 1h → 4h → 12h → 24h
After the 24-hour attempt fails, the email is marked permanently failed and no further attempts are made.
An email's status is one of queued, sending, sent, failed, or
canceled (the API's GET /api/v1/emails/{id} reports the same values).
/admin/emails opens on the last 7 days. The period chips (24h, 7d,
30d, 90d, All) sit above the filter panel rather than inside it, so
switching window is always one tap and a bounded list never looks like the
whole table. They're plain links that keep the status, domain and search
filters.
Filling in either of the panel's From/To dates switches the period to a custom range, which the chip row then shows in place of a preset; picking a chip again clears the dates. The window travels with the other filters through pagination links, and the empty state links straight to the same query over all time.
/admin/emails/{id} can intervene on an email that isn't currently being
delivered — i.e. anything queued (still waiting out its backoff), failed
(retries exhausted), or canceled:
- Retry now re-queues it for immediate delivery and restarts the backoff schedule from the first attempt.
- Recipient can be edited before retrying, which is the fix for a wrong
or typo'd recipient domain — e.g. an error like
dial tcp :25: connect: connection refusedusually means the recipient's domain has no reachable mail server, so retrying the same address will just fail again. The original address is kept and shown struck through on the detail page. A"Name <addr>"display name is accepted here and stored separately from the routing address. - Stop retrying (queued emails only) abandons the remaining attempts without deleting anything: the email keeps its last error, and you can still correct the recipient and retry it later. Canceled emails are excluded from the dashboard's success rate, since they never got a delivery verdict.
An email that a worker is actively sending can't be retried or cancelled —
that would risk a double delivery — so those actions answer 409 for the
duration of the send.
doevoe emails DOEVOE_ADMIN_EMAIL (from DOEVOE_SYSTEM_FROM, itself queued
through the normal delivery pipeline — so the system domain must be
verified for notifications to actually go out) in these cases:
- Failure digest — batches every email that permanently failed since the
last digest into one message, with links to
/admin/emails/{id}. Sent at most once per hour (a cooldown, not a fixed schedule): it fires on the first check after an hour has passed since the last digest, but only if there's something new to report. - Elevated failure-rate alert — per domain, if at least
DOEVOE_FAILURE_RATE_MIN_VOLUMEdelivery attempts happened in the trailing hour and the failure ratio is at or aboveDOEVOE_FAILURE_RATE_THRESHOLD, sends one alert (not repeated every check) and re-arms once the rate drops back below threshold. - API key created / revoked — one email per key lifecycle event.
- Monthly stats — on the first check of a new calendar month, a summary of the previous month: sent/failed counts and delivery rate per domain (each with a comparison against the month before and the delivery-rate delta; a domain that went quiet still shows up with its prior numbers), top failure reasons, and current SPF/DKIM/DMARC verification status per domain. A fresh install does not get a phantom report for its (incomplete) install month.
All of the above run on a one-minute internal ticker; none of it blocks sending.
Every hour, doevoe checks its egress IP against Spamhaus ZEN, SpamCop, and
Barracuda, and every verified domain against Spamhaus DBL and SURBL. A new
listing or a delisting emails DOEVOE_ADMIN_EMAIL, and the admin dashboard
shows current status plus listing history for the egress IP and each domain.
If a lookup's answer isn't a clear listed/not-listed (for example, Spamhaus
returns a distinct code when it refuses to answer public resolvers like
Google's 8.8.8.8 or Cloudflare's 1.1.1.1), doevoe skips that check rather
than treating it as a listing — run doevoe on a host using its own
resolver (not a public DNS forwarder) for reliable results. The egress-IP
checks are IPv4-only: if DOEVOE_EGRESS_IP isn't a valid IPv4 address, those
checks report inconclusive rather than a listing.
doevoe has a built-in SMTP receiver for DMARC aggregate reports, listening
on DOEVOE_INBOUND_LISTEN (default :25) at dmarc@<system domain> (the
domain part of DOEVOE_SYSTEM_FROM). Every domain's generated DNS records
page now points its rua at that address, so once a domain is verified,
other mail providers start sending it their aggregate reports automatically
— no separate mailbox or third-party report-viewing service required.
The system domain itself needs two extra one-time DNS records, shown on its
own domain page: an MX record pointing at DOEVOE_HOSTNAME (so other
providers can actually connect and deliver), and a wildcard
*._report._dmarc TXT record (the RFC 7489 authorization that lets every
other domain's DMARC policy legitimately name this system domain as its
report destination).
Incoming reports are parsed and stored, viewable per-domain at
/admin/dmarc, and pruned automatically after 90 days
(hourlyMaintenanceLoop in cmd/doevoe/main.go, alongside the blacklist
checks). If a report shows DOEVOE_EGRESS_IP itself failing both DKIM and
SPF for a domain, doevoe sends an alert to DOEVOE_ADMIN_EMAIL — that's a
signal something is wrong with doevoe's own sending setup (SPF/DKIM
misconfiguration, IP reputation issue), not with the recipient's domain.
Set DOEVOE_INBOUND_LISTEN= (empty) to disable the receiver if you don't
want doevoe listening on port 25 at all; a bind failure (e.g. port already
in use, or the process lacks permission to bind privileged ports) is treated
as a startup misconfiguration and exits immediately, the same as a missing
required environment variable. Disabling the receiver does not change
the recommended rua= DNS records — only disable it if you don't want DMARC
reports at all, otherwise other providers' reports will keep being sent to a
dead MX.
This version listens on :25 by default and exits at startup if it
cannot bind that address — previous versions never touched port 25 at
all. This matters most on bare-metal/non-root hosts, or hosts already
running another MTA on port 25 (Docker deployments are unaffected, since
docker-compose.yml's 25:25 publish is new and additive). If you're
upgrading and don't want this behavior yet, set DOEVOE_INBOUND_LISTEN=
(empty) to keep the prior behavior of not listening on port 25.
The database is a single SQLite file at $DOEVOE_DATA_DIR/doevoe.db,
opened in WAL mode. You can back it up without stopping doevoe:
# safe online backup, no downtime
sqlite3 /path/to/doevoe.db ".backup '/path/to/backup.db'"A plain cp of the file while doevoe is running is not guaranteed
consistent (it can race with in-flight WAL writes) — use .backup (or stop
the container first) for anything you actually intend to restore from.
docker build -t doevoe .
docker compose up -d # reads .env for the required DOEVOE_* variablesThe image is a static binary (CGO_ENABLED=0) on distroless/static-debian12
— no shell, no package manager, minimal attack surface. Data persists in the
data named volume mounted at /data.
The following are deliberate cuts for this first version, not oversights — listed here so they're a documented decision rather than a surprise:
- No CSRF tokens — admin form-post routes rely solely on the session
cookie's
SameSite=Laxattribute for CSRF protection, not an explicit per-form token. - The container runs as root — the distroless image doesn't drop
privileges to a non-root user; it relies on the container boundary and the
lack of a shell/package manager in the image, not a
USERdirective, for isolation.