All posts
Receive email in real time with Rust
Gabe 5 min read

Receive email in real time with Rust

An Axum endpoint can receive each new email as a signed JSON request. This Rust walkthrough retains the original Bytes body, verifies it with the MailKite SDK and then parses the event, with a clear path from a logging demonstration to a durable inbox. MailKite is the platform we build.

Email arrives upstreamSigned POST → Axum Bytes↓ Check UTF-8 without replacementSDK verifies original body↓serde_json → validate type / idLog id → demo JSON response
Do not use a JSON extractor before checking the signed representation.

The runnable source and delivery checks live in the shared receiving-email demo repository.

The downloadable main.rs below runs in an Axum project. The Rust receiving guide has the Cargo commands.

use axum::{body::Bytes, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::post, Router};
use serde_json::Value;

async fn receive(headers: HeaderMap, body: Bytes) -> Response {
    let signature = headers.get("x-mailkite-signature").and_then(|v| v.to_str().ok()).unwrap_or("");
    let raw = match std::str::from_utf8(&body) { Ok(raw) => raw, Err(_) => return StatusCode::BAD_REQUEST.into_response() };
    let secret = std::env::var("MAILKITE_WEBHOOK_SECRET").expect("Set MAILKITE_WEBHOOK_SECRET");
    if !mailkite::verify_webhook(signature, raw, &secret) { return StatusCode::UNAUTHORIZED.into_response(); }
    let event: Value = match serde_json::from_str(raw) { Ok(v) => v, Err(_) => return StatusCode::BAD_REQUEST.into_response() };
    let kind = match event.get("type").and_then(Value::as_str) {
        Some(kind) if !kind.trim().is_empty() => kind,
        _ => return StatusCode::BAD_REQUEST.into_response(),
    };
    if kind == "email.received" {
        let id = match event.get("id").and_then(Value::as_str) {
            Some(id) if !id.trim().is_empty() => id,
            _ => return StatusCode::BAD_REQUEST.into_response(),
        };
        println!("received {id}");
    }
    // Demonstration only: persist/enqueue and deduplicate before production acknowledgement.
    ([(header::CONTENT_TYPE, "application/json")], mailkite::reply_ok()).into_response()
}

#[tokio::main]
async fn main() {
    let secret = std::env::var("MAILKITE_WEBHOOK_SECRET").expect("Set MAILKITE_WEBHOOK_SECRET");
    assert!(!secret.trim().is_empty(), "Set MAILKITE_WEBHOOK_SECRET");
    let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
    let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await.unwrap();
    axum::serve(listener, Router::new().route("/hooks/mailkite", post(receive))).await.unwrap();
}

The extractor chooses the verification boundary

Bytes gives the handler the representation that arrived. The verifier accepts a UTF-8 string, so the receiver checks that conversion without lossy replacement, verifies the signature and freshness, and only then parses JSON. Missing or invalid signatures return 401.

Starting with Axum’s Json<Value> would move parsing before verification. Serializing the Value back into JSON can change the signed representation, even when the values are equivalent. Keep the raw body until the verification boundary has passed.

Signed malformed JSON, a missing or non-string type, and an email.received event without a nonempty string id return 400. Success returns mailkite::reply_ok() with an application/json content type. The acknowledgement describes the demo response, not durable storage.

The SDK helper is local and does not need an API key. Supply MAILKITE_WEBHOOK_SECRET before launch. Configure Axum’s body limit for the events your application expects; blindly removing limits lets incoming request memory grow without a bound.

Axum’s Bytes extractor has a default 2 MiB limit, so an oversized request can be rejected before receive runs. HeaderMap comes before Bytes in the handler because the body-consuming extractor must be last. The explicit Response return type lets early status-code errors and the successful header/body tuple share one handler signature through IntoResponse.

Build the server independently of receiving-domain setup

Use a current Rust toolchain and the mailkite, axum, serde_json and tokio crates. The guide lists the required Tokio features and source-file location. The example binds loopback for local testing; configure your host and reverse proxy appropriately for deployment.

The unchanged receiver compiled and passed local signed-request tests with Rust/Cargo 1.98.1, mailkite 0.13.0, Axum 0.8.9, serde_json 1.0.154 and Tokio 1.53.3 from crates.io. Keep Cargo.lock with your application so another build resolves the same dependency versions. The Unicode test sent actual UTF-8 characters rather than only ASCII JSON escapes.

In MailKite, verify a receiving domain or hosted subdomain and create an address-matched route to your public HTTPS /hooks/mailkite. Test a signed delivery before sending real mail. Your HTTP code handles parsed email events; the SMTP and MIME operations belong upstream of it.

To process an existing mailbox, forward new Gmail or iCloud Mail messages to that receiving address. SMTP and forwarding add delay, so live means event-driven rather than an exact latency promise.

Rust ownership is not durable message ownership

Owning a Bytes value protects an in-process lifetime. It does not preserve the work through a process crash. This verification/logging demo logs repeated ids again; production needs database or durable queue acceptance and deduplication before returning the success response.

Owned Bytes stay in memoryProcess crash → work lostProduction needs a durable boundary Unique id + payloadAwait durable commit↓Acknowledge after acceptanceWorker can recover saved work
Memory safety and durable delivery solve different failures.

Use id as a unique inbox key and make retries return success without another row. Await the actual storage operation. A detached Tokio task is not durable acceptance; after a response, the process can still stop before that task finishes.

A worker needs its own completion state and idempotency for external actions. Download needed attachments there before their signed URLs expire. The delivery docs explain replay; test a repeated message, not only two different valid messages.

Retain optional fields and separate sender trust

Email bodies and subjects can be null. Use text, then textFromHtml, without turning absent values into invented content. from and each to entry are address objects, and authentication results are independent fields.

A valid webhook proves the provider delivered the event; it doesn’t authenticate the original sender’s assertions. Forwarded mail can have different authentication results from direct delivery.

Postmark inbound and SES receipt rules are other choices for an owned domain. A self-hosted SMTP-to-HTTP bridge lets you own the edge, along with MIME parsing and webhook retries. Gmail’s API is preferable when you need mailbox state. The Rust guide, event reference, and downloadable source provide the next implementation steps.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts