# Rust Email SDK: Async Inbox Worker with Tokio

Published: November 10, 2026

Build an async Rust inbox worker with Tokio and the Robotomail Rust SDK: list mailboxes, send with a sender name, and stream inbound SSE frames.

The Robotomail Rust email SDK is a typed async client for the `https://api.robotomail.com/v1` API. It runs on Tokio, returns `Result` from every call, and gives you three primitives you need for an agent inbox: list mailboxes, send a message, and stream inbound events over SSE. This tutorial builds a small inbox worker that does all three, then handles the parts the SDK deliberately leaves to you: cancellation, reconnection, and send verification.

One note before the code. The crate is a public MIT release but it is not published to crates.io as of this writing, so you install it from the tested git tag. Do not substitute a `cargo add robotomail` command you found elsewhere.

## Install the Rust email SDK

You need Rust 1.88 or newer and a Tokio runtime. Pin the dependency to the `v0.1.0` tag of the `robotomail/robotomail-rust` repository on GitHub:

```toml
# Cargo.toml
[dependencies]
robotomail = { git = "https://github.com/robotomail/robotomail-rust", tag = "v0.1.0" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
```

Authentication is a bearer API key read from the environment on the server side:

```bash
export ROBOTOMAIL_API_KEY=rm_your_api_key
```

`Client::from_env()` reads `ROBOTOMAIL_API_KEY`. Two key scopes matter here. A mailbox-scoped key can send and read messages for its own mailbox. Account-level operations, including listing mailboxes and uploading attachments, need a full-access key. If a call comes back with a permission error, that distinction is usually why. The scope rules are in the [authentication docs](/docs/authentication).

Never ship the key in the binary, in a client-side build, or in a repo. It belongs in the process environment or your secret manager.

## First request: list mailboxes

Start against the mailbox your account already has. This is the smallest end-to-end check that your key, network path, and TLS stack all work:

```rust
use robotomail::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mail = Client::from_env()?;
    let result = mail.list_mailboxes().await?;
    for mailbox in result.mailboxes {
        println!("{}", mailbox.full_address);
    }
    Ok(())
}
```

`cargo run` should print something like `shopping-agent@robotomail.co`. If it prints nothing, the account has no mailbox yet, and you can create one from the CLI:

```bash
npx @robotomail/cli mailbox create shopping-agent
```

Keep the mailbox ID from the listing. Every message operation is scoped to it.

## Send with a sender name

For your first send, address the message to the account's verified owner address. You own that inbox, so you can confirm headers, the From display name, and delivery without touching anyone else's mail.

A send body requires three fields: `to`, `subject`, and `bodyText`. `displayName` sets the friendly From name. In Rust the struct fields are snake_case and serialize to those JSON keys:

```rust
use robotomail::{Client, SendMessageRequest};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mail = Client::from_env()?;
    let mailbox_id = std::env::var("MAILBOX_ID")?;

    let sent = mail
        .send_message(
            &mailbox_id,
            SendMessageRequest {
                to: vec!["owner@example.com".into()],
                subject: "Inbox worker online".into(),
                body_text: "Async worker booted and streaming.".into(),
                display_name: Some("Shopping Agent".into()),
                ..Default::default()
            },
        )
        .await?;

    println!("sent: {}", sent.id);
    Ok(())
}
```

The equivalent HTTP call, if you want to compare the wire format:

```bash
curl -X POST https://api.robotomail.com/v1/mailboxes/mbx_shopping/messages \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"to": ["vendor@example.com"], "subject": "Order #A-4521 delivery update?", "bodyText": "Hi, checking on the status of order A-4521."}'
```

Exact struct and builder names for your pinned tag are in the [Rust section of the SDK reference](/docs/sdks#rust). Check there rather than guessing if the compiler disagrees with a field name.

### Replies and attachments

To keep a reply in the same thread, set `inReplyTo` to the RFC Message-ID of the inbound message you are answering. The SDK does not infer threading for you. Background on how clients assemble conversations is in [email threading](/docs/concepts/threading).

Attachment IDs go in `attachments`. Upload first with a full-access key, then reference the returned IDs.

## Build the async inbox worker

The SDK exposes inbound activity as a server-sent events stream. Each frame carries an event name and a JSON payload in the same shape your webhook endpoint would receive: `{ event, timestamp, data }` with snake_case data fields.

```rust
use futures_util::StreamExt;
use robotomail::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mail = Client::from_env()?;
    let mut stream = mail.events(None).await?;
    let mut last_id: Option<String> = None;

    while let Some(frame) = stream.next().await {
        let frame = frame?;
        last_id = frame.id.clone();

        if frame.event.as_deref() != Some("message.received") {
            continue;
        }

        let payload: serde_json::Value = serde_json::from_str(&frame.data)?;
        let from = payload["data"]["from"].as_str().unwrap_or_default();
        let subject = payload["data"]["subject"].as_str().unwrap_or_default();
        let thread = payload["data"]["thread_id"].as_str().unwrap_or_default();

        println!("inbound from {from}: {subject} (thread {thread})");
        // hand off to your agent here
    }

    println!("stream closed, last id = {last_id:?}");
    Ok(())
}
```

I am parsing with `serde_json::Value` rather than a hand-written struct so the example stays honest about the documented field names: `message_id`, `mailbox_id`, `mailbox_address`, `from`, `subject`, `body_text`, `thread_id`, `received_at`.

Do real work off the stream task. If your handler blocks for seconds, you stall frame consumption. Spawn a task per message, or push IDs onto a bounded channel and let a pool of workers drain it. The SDK does not schedule or queue anything on your behalf.

### Cancel by dropping the stream

There is no `close()` ceremony. Dropping the stream value ends the HTTP response and tears down the connection. That makes graceful shutdown a normal Tokio `select!`:

```rust
tokio::select! {
    _ = consume(&mut stream) => {}
    _ = tokio::signal::ctrl_c() => {
        // drop happens on scope exit, connection closes
    }
}
```

The same trick works for a lease-based worker: hold the stream inside a scope, drop the scope when the lease expires.

### Reopen with Last-Event-ID

The SDK does not reconnect for you. Streams end for ordinary reasons: idle timeouts, load balancer recycling, a redeploy of your worker. Your job is to track the last frame ID you processed and pass it back when you reopen, so the server resumes after that point instead of replaying from the beginning:

```rust
let mut last_id: Option<String> = None;
let mut backoff = std::time::Duration::from_secs(1);

loop {
    match mail.events(last_id.clone()).await {
        Ok(mut stream) => {
            backoff = std::time::Duration::from_secs(1);
            while let Some(Ok(frame)) = stream.next().await {
                if frame.id.is_some() {
                    last_id = frame.id.clone();
                }
                handle(&frame).await;
            }
        }
        Err(err) => {
            eprintln!("stream open failed: {err}");
        }
    }

    tokio::time::sleep(backoff).await;
    backoff = (backoff * 2).min(std::time::Duration::from_secs(30));
}
```

Persist `last_id` somewhere durable if the worker can restart. An in-memory value is lost on deploy, and you will reopen without a resume point.

If you would rather not hold a long-lived connection at all, register a webhook instead and take inbound mail as HTTP POSTs. The tradeoffs between the two models are covered in [webhooks vs. SSE and WebSockets](/blog/webhooks-vs-websockets).

## Errors are Results, not panics

Every SDK call returns `Result`. Nothing panics on a 4xx, nothing retries silently, and nothing swallows a transport failure. Handle the three categories separately:

| Category | What it means | What to do |
| --- | --- | --- |
| Request errors (4xx) | Missing `to`/`subject`/`bodyText`, bad mailbox ID, key scope too narrow | Fix and resend. Retrying is pointless. |
| Rate limits | Plan send or receive ceiling hit | Back off, then retry. See [limits](/docs/concepts/limits). |
| Transport or timeout | Connection dropped mid-request, ambiguous outcome | The send may have succeeded. Verify before resending. |

That last row matters. Because the SDK does not auto-retry sends, an ambiguous error leaves you with one safe move: list the mailbox's sent messages and check whether the message is there before you try again. Blind retries are how agents send duplicate emails to customers.

## Troubleshooting

**`Client::from_env()` returns an error.** `ROBOTOMAIL_API_KEY` is unset in the process environment. A `.env` file does not populate it unless something loads it.

**`list_mailboxes()` fails with a permission error.** You are holding a mailbox-scoped key. Account operations need full access.

**Send rejected as invalid.** Confirm all three required fields are present and non-empty: `to` (a list, even for one recipient), `subject`, `bodyText`. `displayName` is optional.

**Reply shows up as a new conversation.** `inReplyTo` must be the RFC Message-ID from the inbound message, not the Robotomail `message_id`.

**Stream exits immediately with no frames.** Usually a key scope or mailbox ID problem, not a stream problem. Confirm a plain `list_mailboxes()` call works first.

**Frames arrive but nothing processes.** Check your event-name filter. The inbound event is `message.received`; other event types flow down the same stream.

**Worker reconnects and replays old mail.** You reopened without `last_id`. Persist it.

## FAQ

### Is the Rust SDK on crates.io?

Not as of this writing. Install it from the `v0.1.0` tag of the `robotomail/robotomail-rust` GitHub repository using a git dependency in `Cargo.toml`. The crate is MIT licensed.

### Does the SDK reconnect the SSE stream automatically?

No. It also does not retry sends or schedule background work. You own the reconnect loop, the backoff policy, and the `Last-Event-ID` bookkeeping. The examples above show a working pattern for all three.

### Can I use async-std or a blocking runtime instead of Tokio?

The client is built for Tokio. Pair it with `tokio` 1.x and the `macros` plus `rt-multi-thread` features. If you are in a blocking context, run calls inside a Tokio runtime handle.

### Do I need webhooks if I am streaming events?

No, they are alternatives. SSE suits a long-running worker process. Webhooks suit serverless handlers and anything that should not hold a connection open. Both deliver the same `{ event, timestamp, data }` payload, so your handler code is reusable.

Create a mailbox, point a Rust worker at it, and have your agent sending and receiving inside an afternoon. Start at [robotomail.com](https://robotomail.com), or read the [SDK reference](/docs/sdks#rust) for the full typed surface.
