Hyvor Relay: a self-hosted email API with its own SMTP stack

July 10, 2026

|repo-review

by Florian Narr

Hyvor Relay: a self-hosted email API with its own SMTP stack

Hyvor Relay is a self-hosted email API — you point your app at it the way you'd point it at SES or Mailgun, except it runs on your own infrastructure and sends over your own IPs.

What it does

It's a transactional/bulk email sending service: an API to submit emails, a queueing and retry system, DNS automation for the sending domains, bounce and complaint handling, and dashboards for logs and SMTP conversations. Docker compose or swarm to deploy it.

Why I starred it

Most "self-hosted SES" projects stop at "wrap Postfix in a nice UI." Relay doesn't. It writes its own outbound SMTP client, its own authoritative DNS server, and its own queue on top of Postgres row locking instead of reaching for Redis or RabbitMQ. For a project with 848 stars, that's an unusual amount of infrastructure to build from scratch, and I wanted to see if it held up under a read.

How it works

The repo splits into three pieces: backend (PHP/Symfony, the API and admin/console UI), frontend (SvelteKit), and worker (Go — everything that touches the network: SMTP sending, the incoming mail server, webhooks, and DNS). The split maps cleanly to "control plane vs. data plane" — Symfony owns state and config, Go does the actual byte-pushing.

The queue is Postgres, no broker. worker/send_pg.go:47 fetches the next send with:

WITH ids AS MATERIALIZED (
    SELECT id, uuid, from_address, queue_name
    FROM sends
    WHERE queued = true AND queue_id = $1 AND send_after < NOW()
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE sends
SET queued = false, updated_at = NOW()
WHERE id = ANY(SELECT id FROM ids)
RETURNING id, uuid, from_address, queue_name

FOR UPDATE SKIP LOCKED is the trick — every worker goroutine runs this in its own transaction, and Postgres guarantees no two workers claim the same row, without an external queue. worker/email_worker.go:187 runs this in a tight loop per IP, one goroutine per configured workersPerIp. If the row is claimed, it retries in 1 second; no polling backoff beyond that. It's a simple design that leans entirely on Postgres row locking to do what a message broker would normally do, and it means the only infra dependency for queueing is the database you already have.

They forked net/smtp on purpose, and said why. Rather than use Go's standard library SMTP client as-is, worker/smtp/smtp.go is a copy with a documented diff in worker/smtp/smtp.md:

  • Every command returns a Reply struct instead of just an error, so the caller gets the raw server response
  • No automatic status-code validation — the caller decides what counts as success
  • Enhanced status codes (RFC 3463) are parsed out
  • Auth, Verify, and SendMail were stripped — a relay doesn't need to authenticate outbound or run VRFY

That's a deliberate tradeoff: the stdlib client is built to either succeed or return one error, but a sending service needs to know which SMTP step failed and with what code, because a 4xx at RCPT TO means "retry" and a 5xx means "bounce forever." worker/send.go walks the whole conversation step by step (SmtpStepDial, SmtpStepHello, SmtpStepStartTLS, SmtpStepMail, SmtpStepRcpt, SmtpStepData, SmtpStepDataClose, SmtpStepQuit), records every step into a SmtpConversation, and that whole conversation gets serialized into the send_attempts table (smtp_conversations column) so the dashboard can show you exactly what the receiving MX server said, command by command, up to 30 days later.

Recipients within one send are handled per-domain, per-recipient, independently. sendEmailToHostHandler in send.go issues one RCPT TO per recipient and tracks acceptance per-address — if you send to three addresses at the same domain and one gets rejected at RCPT while the other two are accepted, only the accepted ones proceed to DATA. Retry math is a fixed backoff ladder in getSendAfterInterval: 15 minutes, 1 hour, 2, 4, 8, 16 hours, then daily, capped at MAX_SEND_TRIES = 7.

The DNS server is genuinely a DNS server. worker/dns.go uses miekg/dns to run an authoritative UDP nameserver (ListenAndServe on :53) that answers A/AAAA/CNAME/MX/TXT queries straight from Postgres-backed GoStateDnsRecord rows pushed down from the state sync (state.go:60). The pitch — delegate a subdomain to Relay's nameserver and never hand-manage SPF/DKIM/DMARC records again — only works because they actually run the resolver instead of generating a list of records you paste into Cloudflare.

Using it

Self-hosting is a git clone plus compose.yaml — Symfony, the Go worker binary, Postgres, and the SvelteKit frontend as separate services. Sending is a plain HTTP call once it's running:

curl -X POST https://relay.yourdomain.com/api/console/sends \
  -H "Authorization: Bearer $API_KEY" \
  -d from=you@yourdomain.com \
  -d to=user@example.com \
  -d subject="Hello" \
  -d body_text="This is the text"

They ship a k6 load script (benchmark/benchmark.js) at 10 virtual users hitting the sends endpoint for 240 seconds — not a rigorous benchmark, but it's a sign they've at least thought about throughput under load rather than shipping blind.

Rough edges

Test coverage is real but not exhaustive: roughly 4,600 lines of Go source against about 4,200 lines of Go tests, and 324 PHP source files against 150 PHP test files in the backend. worker/send.go and send_pg.go — the two files that decide whether an email gets marked bounced or retried — are well covered; I didn't check every corner of the Symfony side.

The recent commit history (dc6a007, "fix: concurrent locks fail using postgres advisory locks") shows they hit a real bug with Symfony's default lock backend under concurrent TLS certificate generation and had to write a custom AdvisoryProcessor env-var processor to switch the DSN to postgresql+advisory://. It's a small fix, but it's evidence the "just use Postgres for everything" strategy occasionally needs patching in production, not just in the queue.

Licensing is AGPL-3.0 with a paid enterprise license option for anyone who doesn't want AGPL's obligations — standard for this category (Sentry, Plausible do the same), but worth knowing before you build on it commercially.

The project itself is young — no visible release tags before 0.0.6, now at 0.0.46 as of this review, so the API surface is still moving. If you self-host, expect migrations.

Bottom line

If you're already comfortable running Postgres and want SES-level control without SES billing or an AWS account, Relay is worth a serious look — the SMTP internals are more carefully built than the star count suggests. If you want something battle-tested with years of production mileage, it's not there yet.

hyvor/relay on GitHub
hyvor/relay