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}