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
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults 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"));
}Try it
cargo run --quietThe 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.