Skip to content
Browse the docs

Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend

Send email with Axum

An Axum POST handler that posts one release note to POST /emails. The binary uses ureq for HTTP and TLS.

To send email with Axum, route POST /send to a handler that posts JSON to POST /emails. Axum 0.8 builds that router with Router::new().route("/send", post(send_handler)), and a handler is an async function whose Json extractor deserializes the body. There is no Rust package for this API, and this page does not invent one. The standard library has no HTTP client and no TLS, so send uses ureq for the request, the same way Send email with Rust does. The function checks the address, sends a release note with an idempotency key derived from the recipient, and returns the message id or the refusal's code and fix. The suite runs cargo run against a local AgentiSend server. It compiles send; it does not compile the axum crate.

Install

ureq = "=2.12.1"

Add axum and tokio the way the axum crate docs do. The router in those docs is:

let app = Router::new().route("/send", post(send_handler));

The handler is an async function. Take the body with the Json extractor those docs show, then call send(&body.email, &body.note) and write the status and the fields send returns.

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesA key with sending_access.
MAIL_FROMyesThe From address, on a domain you have verified.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The route

// Axum — the function a handler calls.
//
// Axum 0.8 routes a POST to an async handler that extracts Json. That handler
// calls `send` below. This binary does not compile axum: the standard library
// has no HTTP client, so `send` uses ureq for the request and for TLS, the
// same call the handler would make. There is no Rust package for this API.
// AGENTISEND_API_KEY is the key. MAIL_FROM is an address on a domain you have
// verified. AGENTISEND_BASE_URL is optional.
//
// Run: cargo run --quiet

use std::env;
use std::io::{self, Write};

fn env_var(name: &str) -> String {
    env::var(name).unwrap_or_else(|_| panic!("Set {name}."))
}

fn mail_from() -> String {
    let from = env_var("MAIL_FROM");
    if from.is_empty() {
        panic!("Set MAIL_FROM to an address on a domain you have verified.");
    }
    from
}

fn base_url() -> String {
    env::var("AGENTISEND_BASE_URL")
        .unwrap_or_else(|_| "https://api.agentisend.com".to_string())
        .trim_end_matches('/')
        .to_string()
}

fn address_ok(email: &str) -> bool {
    let mut parts = email.split('@');
    match (parts.next(), parts.next(), parts.next()) {
        (Some(local), Some(host), None) if !local.is_empty() && !local.contains(char::is_whitespace) => {
            host.split('.').filter(|label| !label.is_empty()).count() >= 2
        }
        _ => false,
    }
}

fn json_escape(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

fn json_string<'a>(raw: &'a str, key: &str) -> Option<&'a str> {
    let needle = format!("\"{key}\"");
    let start = raw.find(&needle)? + needle.len();
    let rest = raw[start..].trim_start_matches([':', ' ', '\n', '\t']);
    let rest = rest.strip_prefix('"')?;
    let end = rest.find('"')?;
    Some(&rest[..end])
}

struct Outcome {
    status: u16,
    id: Option<String>,
    code: Option<String>,
    fix: Option<String>,
    error: Option<String>,
}

fn send(email: &str, note: &str) -> Outcome {
    let email = email.trim();
    if !address_ok(email) {
        return Outcome {
            status: 400,
            id: None,
            code: None,
            fix: None,
            error: Some("email must be one address, like you@example.com".to_string()),
        };
    }

    let payload = format!(
        "{{\"from\":\"{}\",\"to\":\"{}\",\"subject\":\"Your release is out\",\"text\":\"Release note: {}\"}}",
        json_escape(&mail_from()),
        json_escape(email),
        json_escape(note)
    );
    let url = format!("{}/emails", base_url());
    let idem = format!("release/{email}");
    let key = env_var("AGENTISEND_API_KEY");

    let (status, body) = match ureq::post(&url)
        .set("authorization", &format!("Bearer {key}"))
        .set("content-type", "application/json")
        .set("accept", "application/json")
        .set("idempotency-key", &idem)
        .send_string(&payload)
    {
        Ok(response) => {
            let status = response.status();
            let text = response.into_string().unwrap_or_default();
            (status, text)
        }
        Err(ureq::Error::Status(status, response)) => {
            let text = response.into_string().unwrap_or_default();
            (status, text)
        }
        Err(err) => panic!("request failed: {err}"),
    };

    if status >= 400 {
        return Outcome {
            status,
            id: None,
            code: json_string(&body, "code").map(str::to_string),
            fix: json_string(&body, "fix").map(str::to_string),
            error: None,
        };
    }
    Outcome {
        status,
        id: json_string(&body, "id").map(str::to_string),
        code: None,
        fix: None,
        error: None,
    }
}

fn emit(step: &str, result: &Outcome) {
    let mut out = format!("{{\"step\":\"{step}\",\"status\":{}", result.status);
    if let Some(id) = &result.id {
        out.push_str(&format!(",\"id\":\"{}\"", json_escape(id)));
    }
    if let Some(code) = &result.code {
        out.push_str(&format!(",\"code\":\"{}\"", json_escape(code)));
    }
    if let Some(fix) = &result.fix {
        out.push_str(&format!(",\"fix\":\"{}\"", json_escape(fix)));
    }
    if let Some(error) = &result.error {
        out.push_str(&format!(",\"error\":\"{}\"", json_escape(error)));
    }
    out.push('}');
    let mut stdout = io::stdout().lock();
    writeln!(stdout, "{out}").unwrap();
}

fn main() {
    emit("send", &send("axum@example.com", "first"));
    emit("retry", &send("axum@example.com", "first"));
    emit("invalid", &send("not-an-address", "first"));
    emit("refused", &send("axum@example.com", "second"));
}
examples/axum/main.rs, run by the test suite against a local AgentiSend server

Try it

cargo run --quiet

The binary prints one JSON object per step: the release note, the same request again, a malformed address answered before any request, and a changed note under the same key.

What comes back when it goes wrong

Every refused request carries code, message, fix and docs_url. send returns code and fix with the API's status so the handler can turn them into the response. The error catalogue lists every code.

Next