The official Rust crate for the Anypost email API.
Requires Rust 1.75+. Async by default, built on reqwest and Tokio. A synchronous client is available behind the blocking feature.
cargo add anypostOr add it to Cargo.toml:
[dependencies]
anypost = "0.1"use anypost::{Client, SendEmail};
#[tokio::main]
async fn main() -> anypost::Result<()> {
let client = Client::new("ap_your_api_key")?;
let email = client
.email
.send(
&SendEmail::new("Acme <you@yourdomain.com>", ["someone@example.com"])
.subject("Hello from Anypost")
.html("<p>It worked.</p>"),
)
.await?;
println!("{}", email["id"]);
Ok(())
}Client::from_env() reads the key from ANYPOST_API_KEY instead. Keep the key server-side; it is a bearer credential.
Requests are built with typed builders: SendEmail::new(from, to) followed by chained setters. Responses come back as anypost::Response, a thin wrapper over serde_json::Value — read fields with indexing (email["id"].as_str()), or call email.deserialize::<T>() to map into a struct of your own.
One of text, html, or template_id is required. All recipients in to, cc, and bcc share one envelope and count against a combined limit of 50.
client
.email
.send(
&SendEmail::new("Acme <you@yourdomain.com>", ["a@example.com", "b@example.com"])
.cc(["team@example.com"])
.reply_to("support@yourdomain.com")
.subject("Receipt #4823")
.html("<p>Thanks for your order.</p>")
.text("Thanks for your order.")
.tag("receipt"),
)
.await?;Attachment content is the raw file bytes — pass what std::fs::read returns and the SDK base64-encodes it. Do not pre-encode it. The request body is capped at 5 MB.
use anypost::Attachment;
client
.email
.send(
&SendEmail::new("you@yourdomain.com", ["someone@example.com"])
.subject("Your report")
.text("Attached.")
.attachment(Attachment::new("report.pdf", std::fs::read("report.pdf")?)),
)
.await?;Send with a published template and per-recipient variables:
use serde_json::json;
client
.email
.send(
&SendEmail::new("you@yourdomain.com", ["someone@example.com"])
.template_id("template_018f2c5e-3a40-7a91-9c25-3a0b1d5e6f78")
.variables(json!({ "name": "Ada", "plan": "pro" })),
)
.await?;Send 1 to 100 independent messages in one request. defaults fills any field an entry omits. Build entries with SendEmail::to(...) to omit from (and any other shared field) and let defaults supply it; an entry that sets its own value wins.
use anypost::{BatchEmail, SendEmail};
use serde_json::json;
let batch = BatchEmail::new([
SendEmail::to(["a@example.com"]).subject("Hi A").text("..."),
SendEmail::to(["b@example.com"]).subject("Hi B").text("..."),
])
.defaults(json!({ "from": "you@yourdomain.com" }));
let result = client.email.send_batch(&batch).await?;For a standalone send, SendEmail::new(from, to) is the constructor — from is required there. SendEmail::to(recipients) is its sibling for batch entries that inherit a shared sender.
A batch with mixed outcomes returns HTTP 207 and resolves normally. Inspect each entry rather than treating it as an error:
println!("{}", result["summary"]); // { total, queued, failed }
for entry in result["data"].as_array().unwrap() {
if entry["status"] == "queued" {
println!("{} {}", entry["index"], entry["id"]);
} else {
println!("{} {} {}", entry["index"], entry["error"]["type"], entry["error"]["message"]);
}
}Manage sending domains under client.domains. Add a domain, publish the records it returns, then verify.
use serde_json::json;
let domain = client.domains.create(json!({ "name": "example.com" })).await?;
for record in domain["dns_records"].as_array().unwrap() {
println!("{} {} -> {}", record["type"], record["name"], record["value"]);
}verify always returns the current domain — a still-pending domain is not an error. Read status and verification_failure, and poll while DNS propagates.
let checked = client.domains.verify(&domain["id"].as_str().unwrap()).await?;
if checked["status"] != "verified" {
println!("{}", checked["verification_failure"]["code"]);
}get, update (tracking config only), and delete round out the resource.
Manage keys under client.api_keys. The plaintext secret comes back only once, on create, as key:
use serde_json::json;
let created = client
.api_keys
.create(json!({
"name": "Production server",
"permissions": "send_only",
"allowed_domains": ["example.com"]
}))
.await?;
println!("{}", created["key"]); // store now; never retrievable againget returns metadata only — key_prefix, never the secret. Permission and restriction changes take up to 5 minutes to propagate through the gateway cache.
Templates use a draft/published model: edits land in a draft, and publish promotes it. A template can't be used for sending until it's published.
use serde_json::json;
let template = client
.templates
.create(json!({ "name": "Welcome email", "kind": "html", "html": "<h1>Welcome, {{ name }}</h1>" }))
.await?;
let id = template["id"].as_str().unwrap();
client
.templates
.update_draft(id, json!({ "subject": "Welcome to Acme", "html": "<h1>Welcome, {{ name }}</h1>" }))
.await?;
client.templates.publish(id).await?;kind is html or markdown and is immutable once set. get_draft, delete_draft, duplicate, get, update (name only), and delete round out the resource. Send with a published template via template_id (see Sending).
A suppression blocks sends to an address, scoped to a topic. The wildcard * blocks every topic; a specific topic (e.g. marketing) leaves transactional traffic untouched. Bounces and complaints write * automatically.
use anypost::SuppressionListParams;
use serde_json::json;
client
.suppressions
.create(json!({ "email": "alice@example.com", "topic": "marketing", "note": "Customer requested removal" }))
.await?;
let row = client.suppressions.get("alice@example.com", "*").await?;
client.suppressions.delete("alice@example.com", "marketing").await?;
let complaints = client
.suppressions
.list(SuppressionListParams::new().reason("complaint"))
.await?;list_for_email returns every row for an address across all topics; delete_for_email removes them all.
Manage webhook subscriptions under client.webhooks. The signing_secret comes back only once, on create; later reads return only signing_secret_prefix.
use serde_json::json;
let webhook = client
.webhooks
.create(json!({
"name": "Production events",
"url": "https://hooks.example.com/anypost",
"events": ["email.delivered", "email.bounced", "email.complained"]
}))
.await?;
println!("{}", webhook["signing_secret"]); // store now; never retrievable againupdate sets the name, URL, events, and status together — set status to "disabled" to pause delivery, "active" to resume. test sends one synthetic webhook.test event and returns the outcome even when the endpoint fails. rotate_secret issues a new secret and keeps the previous one valid for a 24-hour grace window; get, list, and delete round out the resource.
anypost::webhook::verify and unwrap are free functions — they need the signing secret, not an API key, so call them in your handler without a client. Pass the raw request body (the exact bytes, before JSON parsing), the Anypost-Signature header, and the secret. verify returns Ok(()) on success; unwrap does the same and returns the parsed delivery as a Response.
use anypost::webhook;
match webhook::unwrap(raw_body, signature_header, secret) {
Ok(delivery) => {
for event in delivery["events"].as_array().unwrap() {
// event["type"], event["data"]["email_id"], ...
}
}
Err(e) => {
// e.reason(): NoMatch | TimestampOutOfTolerance | ...
return bad_request();
}
}Reach for verify when something else has already parsed the body — keep the raw bytes for the verify step, then use your parsed value once it passes. Deliveries older than five minutes are rejected by default to bound replay; verify_with_options widens, narrows, or disables (0) that check. During a secret rotation the header carries a v1= component per active secret, and a match on any one passes, so deliveries keep verifying while you redeploy.
client.events.list pages the team's event stream, newest-first. The window defaults to the last 24 hours and is clamped to your plan's retention. Events are read-only and not addressable by id — there is no get.
use anypost::EventListParams;
let page = client
.events
.list(EventListParams::new().event_type("email.bounced"))
.await?;
for event in &page.data {
println!("{} {} {}", event["occurred_at"], event["recipient"], event["bounce_classification"]);
}Filter by start, end, event_type, recipient, email_id, message_id, domain, topic, campaign, template_id, and tags. All filters are exact-match, except tags, which takes a list and matches an event carrying any of the given tags. This is also how you backfill the gap after a webhook endpoint was disabled — page the events that occurred during the outage once it's healthy.
List endpoints return a Page with data, has_more, and next_cursor. Read one page, pass next_cursor back as after to fetch the next, or call list_all to collect every page.
use anypost::ListParams;
let page = client.domains.list(ListParams::new().limit(50)).await?;
page.data; // this page's items
page.has_more; // whether another page exists
page.next_cursor; // pass to ListParams::after to fetch it yourself
let all = client.domains.list_all(ListParams::new()).await?; // every domainA failed request returns an anypost::Error. Match on the variant, which corresponds to the stable, machine-readable error.type, rather than on the HTTP status.
use anypost::Error;
match client.email.send(&message).await {
Ok(email) => { /* email["id"] */ }
Err(Error::Validation(e)) => { /* e.errors: field -> [problems] */ }
Err(Error::RateLimit(e)) => { /* e.retry_after: Option<f64> seconds */ }
Err(e) => eprintln!("{} {:?} {}", e.error_type().unwrap_or("?"), e.status(), e),
}| Variant | error.type |
Status |
|---|---|---|
Validation |
validation_error |
400, 422 |
Authentication |
authentication_error |
401 |
Permission |
permission_error |
403 |
NotFound |
not_found |
404 |
Conflict |
idempotency_concurrent, webhook_rotation_in_progress |
409 |
IdempotencyMismatch |
idempotency_mismatch |
422 |
RateLimit |
rate_limit_exceeded |
429 |
PayloadTooLarge |
payload_too_large |
413 |
Api |
internal_error, provisioning_error |
5xx |
Connection |
connection_error |
none |
Every API-level error carries error_type(), status(), request_id(), a message, and the parsed raw body.
The client retries 429, 502, 503, and network failures up to max_retries times (default 2), with exponential backoff and full jitter. It honors Retry-After.
Sends are made safe to retry automatically: when retries are enabled and you do not pass an idempotency key, the client generates one and reuses it across attempts, so a retried send cannot deliver twice. Pass your own key to dedupe across process restarts:
client.email.send_with_idempotency_key(&message, "order-4823").await?;
client.email.send_batch_with_idempotency_key(&batch, "nightly-2026-05-30").await?;use std::time::Duration;
use anypost::Client;
let client = Client::builder()
.api_key("ap_your_api_key")
.base_url("https://api.anypost.com/v1")
.timeout(Duration::from_secs(30))
.max_retries(2)
.default_header("X-My-Header", "value")
.build()?;| Option | Default | Description |
|---|---|---|
base_url |
https://api.anypost.com/v1 |
API base URL. |
timeout |
30s | Per-request timeout. |
max_retries |
2 | Automatic retries for transient failures. |
default_header |
none | Extra header sent on every request (repeatable). |
Omit api_key to read ANYPOST_API_KEY from the environment.
Enable the blocking feature for a synchronous client that owns a Tokio runtime internally:
[dependencies]
anypost = { version = "0.1", features = ["blocking"] }use anypost::blocking::Client;
use anypost::SendEmail;
let client = Client::new("ap_your_api_key")?;
let email = client
.email()
.send(&SendEmail::new("you@yourdomain.com", ["someone@example.com"]).subject("Hi").text("It worked."))?;
println!("{}", email["id"]);Resources are accessed as methods (client.email(), client.domains(), …) rather than fields; every method mirrors the async client.
MIT