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}