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}