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 (incl. search and authorization), threads, messages
40//! (send/reply/forward/raw/batch, open tracking), drafts (incl. attachment
41//! deltas), attachments, webhooks (incl. custom delivery headers), domains
42//! (incl. provider setup links), pods, allow/block lists, metrics
43//! (events/usage/rates), inbox events, calendars, accounts, apps, API keys
44//! (bearer and public-key), organization, auth, and agent onboarding. Every
45//! list call is paginated (`Page`), and requests carry automatic retries with
46//! backoff.
47//!
48//! Not bound as REST: the official SDKs' **WebSocket / realtime** event
49//! stream is bound behind the `websockets` feature (see
50//! [`RealtimeStream`]); the REST surface here is complete without it.
51//!
52//! # Features
53//!
54//! - **`retries`** (default): automatic retries with exponential backoff. Turn
55//!   it off with `default-features = false` to drop the direct `tokio`
56//!   dependency; tune it with [`Client::with_retry_policy`].
57//! - **`webhook-verify`** (off by default): `verify_webhook_signature` for
58//!   Svix-signed webhook deliveries. Adds `ring` (already the rustls provider)
59//!   and `base64`.
60//! - **`websockets`** (off by default): [`Client::connect_realtime`] for the
61//!   realtime event stream, so agents without a public webhook URL can
62//!   receive mail. Adds `tokio-tungstenite` (rustls, webpki roots) and
63//!   `futures-util`; see the [`RealtimeStream`] type.
64
65#![warn(missing_docs)]
66#![cfg_attr(docsrs, feature(doc_cfg))]
67
68mod client;
69mod types;
70mod util;
71
72#[cfg(feature = "websockets")]
73mod realtime;
74#[cfg(feature = "webhook-verify")]
75mod verify;
76
77#[cfg(feature = "websockets")]
78#[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
79pub use realtime::*;
80pub use types::*;
81#[cfg(feature = "webhook-verify")]
82#[cfg_attr(docsrs, doc(cfg(feature = "webhook-verify")))]
83pub use verify::*;
84
85pub use client::scope::{
86    ApiKeys, Domains, Drafts, InboxScope, Inboxes, Lists, Metrics, OrgScope, PodScope, Scope,
87    Scoped, Threads, Webhooks,
88};
89
90/// The production API host. Override with `Client::new(key, base_url)` for
91/// the EU region (`https://api.agentmail.eu`) or a mock server.
92pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
93
94/// The per-request timeout applied by [`Client::new`] (connect + response).
95pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
96
97/// Everything that can go wrong talking to AgentMail.
98#[derive(Debug, thiserror::Error)]
99pub enum Error {
100    /// A websocket-level failure on the realtime stream.
101    #[cfg(feature = "websockets")]
102    #[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
103    #[error("websocket error: {0}")]
104    Realtime(String),
105    /// [`Client::from_env`] found no `AGENTMAIL_API_KEY`.
106    #[error("AGENTMAIL_API_KEY is not set")]
107    MissingApiKey,
108    /// The request never completed: DNS, TLS, connect, or the
109    /// [`DEFAULT_TIMEOUT`] elapsed.
110    #[error("transport error: {0}")]
111    Transport(#[from] reqwest::Error),
112    /// A non-2xx answer from the API, with whatever body it sent.
113    #[error("AgentMail answered {status}: {body}")]
114    Api {
115        /// The HTTP status the API answered with.
116        status: reqwest::StatusCode,
117        /// The response body, verbatim (AgentMail sends JSON error details).
118        body: String,
119    },
120    /// A 2xx answer whose body didn't decode into the expected type: either
121    /// a bug in this crate's wire shapes or a breaking change in the API.
122    #[error("undecodable AgentMail response ({reason}): {body}")]
123    Decode {
124        /// Why deserialization failed.
125        reason: String,
126        /// The response body, verbatim.
127        body: String,
128    },
129    /// [`Client::download_attachment`] was handed an attachment with no
130    /// `download_url`. Only the attachment-download endpoints populate that
131    /// field; fetch the attachment via `get_*_attachment` first.
132    #[error("attachment has no download_url")]
133    NoDownloadUrl,
134}
135
136/// How the client retries transient failures. Applied to every request at the
137/// one HTTP chokepoint. `Default` retries twice with exponential backoff.
138///
139/// Requires the `retries` feature (on by default).
140#[cfg(feature = "retries")]
141#[cfg_attr(docsrs, doc(cfg(feature = "retries")))]
142#[derive(Clone, Debug)]
143pub struct RetryPolicy {
144    /// Extra attempts after the first. `0` disables retries.
145    pub max_retries: u32,
146    /// Base backoff delay; doubles each attempt.
147    pub base_delay: std::time::Duration,
148    /// Upper bound on a single backoff delay (before jitter).
149    pub max_delay: std::time::Duration,
150}
151
152#[cfg(feature = "retries")]
153impl Default for RetryPolicy {
154    fn default() -> Self {
155        RetryPolicy {
156            max_retries: 2,
157            base_delay: std::time::Duration::from_millis(500),
158            max_delay: std::time::Duration::from_secs(8),
159        }
160    }
161}
162
163/// An authenticated handle on the AgentMail API. Cheap to clone-ish (it owns
164/// a pooled `reqwest::Client`); construct once and share by reference.
165pub struct Client {
166    http: reqwest::Client,
167    base_url: String,
168    api_key: String,
169    #[cfg(feature = "retries")]
170    retry_policy: RetryPolicy,
171    #[cfg(feature = "websockets")]
172    ws_url_override: Option<String>,
173}
174
175// Manual impl so an accidental `{:?}` never prints the API key.
176impl std::fmt::Debug for Client {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("Client")
179            .field("base_url", &self.base_url)
180            .field("api_key", &"[redacted]")
181            .finish_non_exhaustive()
182    }
183}
184
185impl Client {
186    /// A client against `base_url` (see [`DEFAULT_BASE_URL`]), with a
187    /// [`DEFAULT_TIMEOUT`] on every request.
188    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
189        // We build reqwest with rustls but no bundled crypto provider, so install
190        // `ring` as the process default (no aws-lc-rs / cmake). This is a global,
191        // set-once operation: it no-ops if the host application already installed
192        // a provider, so it never overrides a deliberate choice.
193        let _ = rustls::crypto::ring::default_provider().install_default();
194        Client {
195            http: reqwest::Client::builder()
196                .timeout(DEFAULT_TIMEOUT)
197                .build()
198                // Infallible for these options; build() can only fail on
199                // TLS-backend misconfiguration.
200                .expect("reqwest client"),
201            base_url: base_url.into().trim_end_matches('/').to_string(),
202            api_key: api_key.into(),
203            #[cfg(feature = "retries")]
204            retry_policy: RetryPolicy::default(),
205            #[cfg(feature = "websockets")]
206            ws_url_override: None,
207        }
208    }
209
210    /// From `AGENTMAIL_API_KEY` (+ optional `AGENTMAIL_BASE_URL`).
211    pub fn from_env() -> Result<Self, Error> {
212        let key = std::env::var("AGENTMAIL_API_KEY").map_err(|_| Error::MissingApiKey)?;
213        let base =
214            std::env::var("AGENTMAIL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
215        let mut client = Self::new(key, base);
216        #[cfg(feature = "websockets")]
217        if let Ok(url) = std::env::var("AGENTMAIL_WEBSOCKET_URL") {
218            client.ws_url_override = Some(url.trim_end_matches('/').to_string());
219        }
220        Ok(client)
221    }
222
223    /// Replace the [`RetryPolicy`]. Set `max_retries` to `0` to disable retries.
224    ///
225    /// Requires the `retries` feature (on by default).
226    #[cfg(feature = "retries")]
227    #[cfg_attr(docsrs, doc(cfg(feature = "retries")))]
228    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
229        self.retry_policy = policy;
230        self
231    }
232}