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