Skip to content
JensderondPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

doevoe

doevoe icon

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.

Hard requirements

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 to DOEVOE_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 /admin and /api listen on plain HTTP (DOEVOE_LISTEN, default :8080) and the admin session cookie is not marked Secure. 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.yml publishes 25:25 for this; open it in any host firewall/security group, or set DOEVOE_INBOUND_LISTEN= (empty) to disable the receiver if you don't want it.

Quickstart

git clone <this repo> && cd doevoe
cp .env.example .env
# edit .env: hostname, egress IP, admin password/email, system from-address
docker compose up -d

Then:

  1. Open https://your-proxy/admin (behind your reverse proxy) and log in with DOEVOE_ADMIN_PASSWORD.
  2. 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.
  3. Add those DNS records, then click Verify on the domain page (doevoe also re-checks every domain automatically once an hour).
  4. 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.
  5. 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!"}'

API usage from Next.js

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

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.

MCP server

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.

Configuration

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

Delivery and retries

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).

Browsing the email list

/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.

Retrying and cancelling from the admin UI

/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 refused usually 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.

Notifications

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_VOLUME delivery attempts happened in the trailing hour and the failure ratio is at or above DOEVOE_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.

Blacklist monitoring

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.

DMARC reports

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.

Upgrading

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.

Backups

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

docker build -t doevoe .
docker compose up -d      # reads .env for the required DOEVOE_* variables

The 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.

v1 scope notes

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=Lax attribute 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 USER directive, for isolation.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages