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#![warn(missing_docs)]
37
38mod client;
39mod types;
40mod util;
41
42#[cfg(feature = "webhook-verify")]
43mod verify;
44
45pub use types::*;
46#[cfg(feature = "webhook-verify")]
47pub use verify::*;
48
49/// The production API host. Override with `Client::new(key, base_url)` for
50/// the EU region (`https://api.agentmail.eu`) or a mock server.
51pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
52
53/// The per-request timeout applied by [`Client::new`] (connect + response).
54pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
55
56/// Everything that can go wrong talking to AgentMail.
57#[derive(Debug, thiserror::Error)]
58pub enum Error {
59    /// [`Client::from_env`] found no `AGENTMAIL_API_KEY`.
60    #[error("AGENTMAIL_API_KEY is not set")]
61    MissingApiKey,
62    /// The request never completed: DNS, TLS, connect, or the
63    /// [`DEFAULT_TIMEOUT`] elapsed.
64    #[error("transport error: {0}")]
65    Transport(#[from] reqwest::Error),
66    /// A non-2xx answer from the API, with whatever body it sent.
67    #[error("AgentMail answered {status}: {body}")]
68    Api {
69        /// The HTTP status the API answered with.
70        status: reqwest::StatusCode,
71        /// The response body, verbatim (AgentMail sends JSON error details).
72        body: String,
73    },
74    /// A 2xx answer whose body didn't decode into the expected type - either
75    /// a bug in this crate's wire shapes or a breaking change in the API.
76    #[error("undecodable AgentMail response ({reason}): {body}")]
77    Decode {
78        /// Why deserialization failed.
79        reason: String,
80        /// The response body, verbatim.
81        body: String,
82    },
83    /// [`Client::download_attachment`] was handed an attachment with no
84    /// `download_url`. Only the attachment-download endpoints populate that
85    /// field; fetch the attachment via `get_*_attachment` first.
86    #[error("attachment has no download_url")]
87    NoDownloadUrl,
88}
89
90/// How the client retries transient failures. Applied to every request at the
91/// one HTTP chokepoint. `Default` retries twice with exponential backoff.
92///
93/// Requires the `retries` feature (on by default).
94#[cfg(feature = "retries")]
95#[derive(Clone, Debug)]
96pub struct RetryPolicy {
97    /// Extra attempts after the first. `0` disables retries.
98    pub max_retries: u32,
99    /// Base backoff delay; doubles each attempt.
100    pub base_delay: std::time::Duration,
101    /// Upper bound on a single backoff delay (before jitter).
102    pub max_delay: std::time::Duration,
103}
104
105#[cfg(feature = "retries")]
106impl Default for RetryPolicy {
107    fn default() -> Self {
108        RetryPolicy {
109            max_retries: 2,
110            base_delay: std::time::Duration::from_millis(500),
111            max_delay: std::time::Duration::from_secs(8),
112        }
113    }
114}
115
116/// An authenticated handle on the AgentMail API. Cheap to clone-ish (it owns
117/// a pooled `reqwest::Client`); construct once and share by reference.
118pub struct Client {
119    http: reqwest::Client,
120    base_url: String,
121    api_key: String,
122    #[cfg(feature = "retries")]
123    retry_policy: RetryPolicy,
124}
125
126// Manual impl so an accidental `{:?}` never prints the API key.
127impl std::fmt::Debug for Client {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("Client")
130            .field("base_url", &self.base_url)
131            .field("api_key", &"[redacted]")
132            .finish_non_exhaustive()
133    }
134}
135
136impl Client {
137    /// A client against `base_url` (see [`DEFAULT_BASE_URL`]), with a
138    /// [`DEFAULT_TIMEOUT`] on every request.
139    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
140        // We build reqwest with rustls but no bundled crypto provider, so install
141        // `ring` as the process default (no aws-lc-rs / cmake). This is a global,
142        // set-once operation: it no-ops if the host application already installed
143        // a provider, so it never overrides a deliberate choice.
144        let _ = rustls::crypto::ring::default_provider().install_default();
145        Client {
146            http: reqwest::Client::builder()
147                .timeout(DEFAULT_TIMEOUT)
148                .build()
149                // Infallible for these options; build() can only fail on
150                // TLS-backend misconfiguration.
151                .expect("reqwest client"),
152            base_url: base_url.into().trim_end_matches('/').to_string(),
153            api_key: api_key.into(),
154            #[cfg(feature = "retries")]
155            retry_policy: RetryPolicy::default(),
156        }
157    }
158
159    /// From `AGENTMAIL_API_KEY` (+ optional `AGENTMAIL_BASE_URL`).
160    pub fn from_env() -> Result<Self, Error> {
161        let key = std::env::var("AGENTMAIL_API_KEY").map_err(|_| Error::MissingApiKey)?;
162        let base =
163            std::env::var("AGENTMAIL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
164        Ok(Self::new(key, base))
165    }
166
167    /// Replace the [`RetryPolicy`]. Set `max_retries` to `0` to disable retries.
168    ///
169    /// Requires the `retries` feature (on by default).
170    #[cfg(feature = "retries")]
171    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
172        self.retry_policy = policy;
173        self
174    }
175}