Send email with Rust
reqwest and serde, with the payload typed at compile time.
You need a verified sending domain and an API key from Settings › API Keys. If neither exists yet, the quickstart covers both in a couple of minutes.
Install
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }Send
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Email<'a> {
from: &'a str,
to: &'a str,
subject: &'a str,
html: &'a str,
idempotency_key: &'a str,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("SENDING_API_KEY")?;
let res = reqwest::Client::new()
.post("https://sending.dev/api/v1/emails")
.bearer_auth(key)
.json(&Email {
from: "Acme <[email protected]>",
to: "[email protected]",
subject: "Welcome",
html: "<p>You're in.</p>",
idempotency_key: "welcome-sara-001",
})
.send()
.await?;
println!("{}", res.status());
Ok(())
}A successful call answers 202 with the message id: accepted and queued, not yet delivered. Delivery, opens, bounces and complaints arrive later, on webhooks or in the dashboard.
What changes in Rust
serde(rename_all = "camelCase") is what turns idempotency_key into idempotencyKey. Without it the field is simply absent and the request is refused.
The thing that catches people
reqwest::Client is meant to be built once and reused: constructing one per send creates a new connection pool every time, and it shows up as latency long before it shows up as an error.
Next
- All the fields: cc and bcc, reply-to, attachments, templates, scheduling.
- Webhooks: delivery, bounce and complaint events, signed with HMAC.
- Domains: SPF, DKIM and MAIL FROM, and why sending is refused until they are in place.
- MCP: the same operations as tools, when the one writing the code is an agent.