Skip to main content

agentmail/
lib.rs

1//! Unofficial typed Rust client for [AgentMail](https://agentmail.to), the
2//! email API for agents (official SDKs exist for Python and TypeScript; this
3//! fills the Rust gap).
4//!
5//! Wire shapes follow AgentMail's OpenAPI spec (`docs.agentmail.to/openapi.json`,
6//! API v0), with full coverage of the surface the official SDKs expose:
7//! inboxes, threads, messages, drafts, attachments, webhooks, domains, pods,
8//! allow/block lists, metrics, API keys, and agent onboarding. Everything
9//! deserializes permissively - unknown fields are ignored, optional fields
10//! default, so spec additions don't break callers.
11//!
12//! Resources that exist at more than one scope (threads, webhooks, lists,
13//! domains, ...) are reached through a scope handle - [`Client::org`],
14//! [`Client::inbox`], or [`Client::pod`] - so the compiler rejects an
15//! operation the scope doesn't support. Inbox-only resources (messages, drafts)
16//! live on [`Client::inbox`].
17//!
18//! ```no_run
19//! # async fn demo() -> Result<(), agentmail::Error> {
20//! let client = agentmail::Client::from_env()?; // AGENTMAIL_API_KEY
21//! let inbox = client
22//!     .org()
23//!     .create_inbox(agentmail::CreateInbox {
24//!         username: Some("my-agent".into()),
25//!         display_name: Some("My Agent".into()),
26//!         ..Default::default()
27//!     })
28//!     .await?; // my-agent@agentmail.to
29//!
30//! client
31//!     .inbox(&inbox.inbox_id)
32//!     .send_text("someone@example.com", "Hello", "From an agent's own inbox.")
33//!     .await?;
34//! # Ok(()) }
35//! ```
36//!
37//! # Coverage
38//!
39//! Inboxes, threads, messages (send/reply/forward/raw/batch), drafts,
40//! attachments, webhooks, domains, pods, allow/block lists, metrics, inbox
41//! events, API keys, organization, auth, and agent onboarding. Every list call
42//! is paginated (`Page`), and requests carry automatic retries with backoff.
43//!
44//! # Features
45//!
46//! - **`retries`** (default): automatic retries with exponential backoff. Turn
47//!   it off with `default-features = false` to drop the direct `tokio`
48//!   dependency; tune it with [`Client::with_retry_policy`].
49//! - **`webhook-verify`** (off by default): `verify_webhook_signature` for
50//!   Svix-signed webhook deliveries. Adds `ring` (already the rustls provider)
51//!   and `base64`.
52
53#![warn(missing_docs)]
54#![cfg_attr(docsrs, feature(doc_cfg))]
55
56mod client;
57mod types;
58mod util;
59
60#[cfg(feature = "webhook-verify")]
61mod verify;
62
63pub use types::*;
64#[cfg(feature = "webhook-verify")]
65#[cfg_attr(docsrs, doc(cfg(feature = "webhook-verify")))]
66pub use verify::*;
67
68pub use client::scope::{
69    ApiKeys, Domains, Drafts, InboxScope, Inboxes, Lists, Metrics, OrgScope, PodScope, Scope,
70    Scoped, Threads, Webhooks,
71};
72
73/// The production API host. Override with `Client::new(key, base_url)` for
74/// the EU region (`https://api.agentmail.eu`) or a mock server.
75pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
76
77/// The per-request timeout applied by [`Client::new`] (connect + response).
78pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
79
80/// Everything that can go wrong talking to AgentMail.
81#[derive(Debug, thiserror::Error)]
82pub enum Error {
83    /// [`Client::from_env`] found no `AGENTMAIL_API_KEY`.
84    #[error("AGENTMAIL_API_KEY is not set")]
85    MissingApiKey,
86    /// The request never completed: DNS, TLS, connect, or the
87    /// [`DEFAULT_TIMEOUT`] elapsed.
88    #[error("transport error: {0}")]
89    Transport(#[from] reqwest::Error),
90    /// A non-2xx answer from the API, with whatever body it sent.
91    #[error("AgentMail answered {status}: {body}")]
92    Api {
93        /// The HTTP status the API answered with.
94        status: reqwest::StatusCode,
95        /// The response body, verbatim (AgentMail sends JSON error details).
96        body: String,
97    },
98    /// A 2xx answer whose body didn't decode into the expected type - either
99    /// a bug in this crate's wire shapes or a breaking change in the API.
100    #[error("undecodable AgentMail response ({reason}): {body}")]
101    Decode {
102        /// Why deserialization failed.
103        reason: String,
104        /// The response body, verbatim.
105        body: String,
106    },
107    /// [`Client::download_attachment`] was handed an attachment with no
108    /// `download_url`. Only the attachment-download endpoints populate that
109    /// field; fetch the attachment via `get_*_attachment` first.
110    #[error("attachment has no download_url")]
111    NoDownloadUrl,
112}
113
114/// How the client retries transient failures. Applied to every request at the
115/// one HTTP chokepoint. `Default` retries twice with exponential backoff.
116///
117/// Requires the `retries` feature (on by default).
118#[cfg(feature = "retries")]
119#[cfg_attr(docsrs, doc(cfg(feature = "retries")))]
120#[derive(Clone, Debug)]
121pub struct RetryPolicy {
122    /// Extra attempts after the first. `0` disables retries.
123    pub max_retries: u32,
124    /// Base backoff delay; doubles each attempt.
125    pub base_delay: std::time::Duration,
126    /// Upper bound on a single backoff delay (before jitter).
127    pub max_delay: std::time::Duration,
128}
129
130#[cfg(feature = "retries")]
131impl Default for RetryPolicy {
132    fn default() -> Self {
133        RetryPolicy {
134            max_retries: 2,
135            base_delay: std::time::Duration::from_millis(500),
136            max_delay: std::time::Duration::from_secs(8),
137        }
138    }
139}
140
141/// An authenticated handle on the AgentMail API. Cheap to clone-ish (it owns
142/// a pooled `reqwest::Client`); construct once and share by reference.
143pub struct Client {
144    http: reqwest::Client,
145    base_url: String,
146    api_key: String,
147    #[cfg(feature = "retries")]
148    retry_policy: RetryPolicy,
149}
150
151// Manual impl so an accidental `{:?}` never prints the API key.
152impl std::fmt::Debug for Client {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("Client")
155            .field("base_url", &self.base_url)
156            .field("api_key", &"[redacted]")
157            .finish_non_exhaustive()
158    }
159}
160
161impl Client {
162    /// A client against `base_url` (see [`DEFAULT_BASE_URL`]), with a
163    /// [`DEFAULT_TIMEOUT`] on every request.
164    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
165        // We build reqwest with rustls but no bundled crypto provider, so install
166        // `ring` as the process default (no aws-lc-rs / cmake). This is a global,
167        // set-once operation: it no-ops if the host application already installed
168        // a provider, so it never overrides a deliberate choice.
169        let _ = rustls::crypto::ring::default_provider().install_default();
170        Client {
171            http: reqwest::Client::builder()
172                .timeout(DEFAULT_TIMEOUT)
173                .build()
174                // Infallible for these options; build() can only fail on
175                // TLS-backend misconfiguration.
176                .expect("reqwest client"),
177            base_url: base_url.into().trim_end_matches('/').to_string(),
178            api_key: api_key.into(),
179            #[cfg(feature = "retries")]
180            retry_policy: RetryPolicy::default(),
181        }
182    }
183
184    /// From `AGENTMAIL_API_KEY` (+ optional `AGENTMAIL_BASE_URL`).
185    pub fn from_env() -> Result<Self, Error> {
186        let key = std::env::var("AGENTMAIL_API_KEY").map_err(|_| Error::MissingApiKey)?;
187        let base =
188            std::env::var("AGENTMAIL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
189        Ok(Self::new(key, base))
190    }
191
192    /// Replace the [`RetryPolicy`]. Set `max_retries` to `0` to disable retries.
193    ///
194    /// Requires the `retries` feature (on by default).
195    #[cfg(feature = "retries")]
196    #[cfg_attr(docsrs, doc(cfg(feature = "retries")))]
197    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
198        self.retry_policy = policy;
199        self
200    }
201}