Skip to content

anypost/anypost-rust

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Anypost Rust SDK

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.

Install

cargo add anypost

Or add it to Cargo.toml:

[dependencies]
anypost = "0.1"

Quickstart

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.

Sending

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?;

Batch

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"]);
    }
}

Domains

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.

API keys

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 again

get returns metadata only — key_prefix, never the secret. Permission and restriction changes take up to 5 minutes to propagate through the gateway cache.

Templates

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).

Suppressions

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.

Webhooks

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 again

update 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.

Verifying deliveries

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.

Events

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.

Pagination

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 domain

Errors

A 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.

Retries and idempotency

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?;

Configuration

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.

Blocking client

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.

License

MIT

About

The official Rust crate for the Anypost email API.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages