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