Building in Rust: an architecture tour
Sukhovei Mail is about ten Rust crates and a Next.js app. This is how they fit together, the rule that keeps them consistent, and the testing habit we would not give up.
By Arin Prajapati, founder, 5 min read
On this page (7)
Sukhovei Mail is a Cargo workspace of ten crates and a Next.js app. It receives mail on port 25, sends it through Amazon SES, serves a REST API, an MCP server, SMTP submission and IMAP, and runs a job worker. One rule holds all of that together, and one testing habit keeps it honest.
The map
core: every business rule, as methods on oneCoretype.api(axum): REST under/v1, the WebSocket event stream and the long poll wait.mcp(rmcp): the MCP server over streamable HTTP, plus a stdio proxy.inbound: our ESMTP server for mail from the internet.submission: SMTP for apps and mail clients.imap: IMAP for Apple Mail, Outlook and Thunderbird.outbound: an Amazon SES transport and an SQS consumer for delivery events.worker: jobs and webhook delivery.dns: record plans, live checks and DNS host writers.cli: themailbaycommand.
One core, four doors
Creating an inbox is POST /v1/inboxes, mailbay inbox create, the MCP tool create_inbox and the “New inbox” button. All four end up in the same method in core. The API handler, the tool and the CLI command parse input, call core, and format the answer. They hold no rules of their own, so the four can never disagree about limits, permissions or error messages.
Every core method takes an Actor, which is a session or an API key, and checks it before touching data:
actor.require(Permission::InboxRead, &Resource::inbox(project_id, inbox_id))?;
// sessions: workspace role, project role, optional inbox scope
// keys: full, sending (optionally one domain) or one inbox
// invisible resources answer 404, visible but not allowed 403Returning 404 for something you cannot see, rather than 403, means a scoped key cannot even learn that another project's inbox exists. Tests assert this for every endpoint and every role.
Data access is sqlx with plain SQL against Postgres. There is no ORM; queries map to structs with FromRow, and every query runs in some test against a real database.
The inbound server
The SMTP server is a tokio task per connection with rustls for STARTTLS. It advertises PIPELINING, 8BITMIME, SMTPUTF8, ENHANCEDSTATUSCODES and a 25 MB SIZE. The interesting decisions are about when to say what:
- Unknown recipients are refused at
RCPT TOso a message for a typo is never accepted and bounced later. - After
DATA, themail-authcrate checks SPF, DKIM and DMARC, andrspamdgives a verdict over HTTP. A reject verdict is a550in the same session. - The message is parsed with
mail-parser, the raw bytes go to object storage, and the rows, attachments and thread link are written in one transaction. Only then does the client get250.
Sending through Amazon SES
We did not write the final delivery network. Queues, retries, provider feedback and ISP relationships are years of work, so production sends use Amazon SES. Sukhovei Mail submits one raw MIME message per recipient with an internal message id and non-secret routing tags.
- Customer domains use aligned DKIM and a custom MAIL FROM subdomain. The dashboard treats inbound readiness and outbound SES verification as separate states.
- Each delivery, delay, bounce, complaint and rejection comes through SNS and SQS to the
outboundcrate, which updates per-recipient status, emitsmessage.*events and adds suppressions. - KumoMTA remains an explicit optional adapter for self-hosters and local testing. Production fails closed when SES configuration is missing; it never silently falls back to another provider.
Postgres as the queue
There is no Redis and no message broker. Background work is rows in a jobs table, claimed by any number of workers without stepping on each other:
WITH due AS (
SELECT id FROM jobs
WHERE run_at <= now() AND (locked_until IS NULL OR locked_until < now())
ORDER BY run_at LIMIT $1 FOR UPDATE SKIP LOCKED)
UPDATE jobs j SET locked_until = now() + make_interval(secs => $2), attempts = j.attempts + 1
FROM due WHERE j.id = due.id RETURNING j.id, j.kind, j.payloadSKIP LOCKED lets each worker take rows nobody else holds, and the lease means a crashed worker only delays a job. Webhook deliveries are jobs too: signed with the Standard Webhooks scheme and retried after 5 seconds, 5 minutes, 30 minutes, 2, 5 and 10 hours, seven attempts over about 18 hours.
Latency comes from LISTEN/NOTIFY. Triggers notify inside the writing transaction, so a notice arrives only after commit; each process keeps one listening connection and fans notices out to waiting requests, WebSocket clients and IMAP IDLE sessions. A slow safety poll catches anything a dropped connection missed.
Errors with a fix
Every error in core is the same struct: a kind (which becomes the HTTP status), a stable code, a message, and a fix the user can act on:
Error::new(Kind::Forbidden, "sender_not_verified",
format!("{domain} is not a domain of this project, so Sukhovei Mail cannot send as {from}"))
.fix(format!("Add the domain with `mailbay domain add {domain}`, then verify it with `mailbay domain verify {domain}`"))The API returns it as JSON, the MCP server as a tool result, the CLI as text, and the SMTP submission server puts it after 550 5.7.1. Writing the fix at the place the error is raised is a small habit that makes the whole product easier to use, for people and for agents.
Tests send real mail
Our one firm testing rule: nothing in the mail path is mocked. Database tests run against real Postgres with #[sqlx::test], which gives each test a fresh database with every migration applied. SMTP tests open real TCP connections, with TLS where the protocol needs it.
- The inbound tests speak SMTP to the server and then read the message back through core.
- The submission tests log in over TLS with a real client and check the email reaches the send log.
- The IMAP tests use a real IMAP client: log in, fetch bodies, round trip flags, and receive IDLE pushes.
- The MCP tests drive the server with a real MCP client.
- Webhook signatures are checked against the Standard Webhooks reference test vector.
- The dashboard has Playwright tests that run against a real local API and mail sink.
It makes the suite slower than one full of mocks. In exchange it exercises what mocks skip: TLS handshakes, line endings, header folding and the order in which transactions commit.

Arin Prajapati, founder of Sukhovei MailA software engineer in Mumbai who builds Sukhovei Mail's mail servers, API and site. More about Arin and why he built Sukhovei Mail.
Read next
- Archived: self-hosted outbound mail designArchived: this describes the original self-hosted outbound design. Amazon SES is now the production outbound provider; Sukhovei Mail still operates inbound SMTP and stored inboxes.
- Giving AI agents an email address: design notesAn agent is not a person with a mail client. It cannot glance at an inbox or click around. These are the decisions we made so an agent can use email in as few steps as possible.
- QuickstartSign in, create an API key and an inbox, and receive your first email.
Put Sukhovei Mail to work
Get inboxes, sending, your own domains and MCP in one workspace. The free plan includes 3 inboxes, 3,000 emails a month and 3 custom domains, with no card.