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). Coverage focuses on the transactional core: inboxes, messages
7//! (send/list/get), and webhooks. Everything deserializes permissively -
8//! unknown fields are ignored, optional fields default, so spec additions
9//! don't break callers.
10//!
11//! ```no_run
12//! # async fn demo() -> Result<(), agentmail::Error> {
13//! let client = agentmail::Client::from_env()?; // AGENTMAIL_API_KEY
14//! let inbox = client
15//!     .create_inbox(agentmail::CreateInbox {
16//!         username: Some("my-agent".into()),
17//!         display_name: Some("My Agent".into()),
18//!         ..Default::default()
19//!     })
20//!     .await?;
21//! client
22//!     .send_message(
23//!         &inbox.inbox_id,
24//!         agentmail::SendMessage {
25//!             to: vec!["someone@example.com".into()],
26//!             subject: Some("Hello".into()),
27//!             text: Some("From an agent's own inbox.".into()),
28//!             ..Default::default()
29//!         },
30//!     )
31//!     .await?;
32//! # Ok(()) }
33//! ```
34
35#![warn(missing_docs)]
36
37use serde::{Deserialize, Serialize};
38
39/// The production API host. Override with `Client::new(key, base_url)` for
40/// the EU region (`https://api.agentmail.eu`) or a mock server.
41pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
42
43/// The per-request timeout applied by [`Client::new`] (connect + response).
44pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
45
46/// Everything that can go wrong talking to AgentMail.
47#[derive(Debug, thiserror::Error)]
48pub enum Error {
49    /// [`Client::from_env`] found no `AGENTMAIL_API_KEY`.
50    #[error("AGENTMAIL_API_KEY is not set")]
51    MissingApiKey,
52    /// The request never completed: DNS, TLS, connect, or the
53    /// [`DEFAULT_TIMEOUT`] elapsed.
54    #[error("transport error: {0}")]
55    Transport(#[from] reqwest::Error),
56    /// A non-2xx answer from the API, with whatever body it sent.
57    #[error("AgentMail answered {status}: {body}")]
58    Api {
59        /// The HTTP status the API answered with.
60        status: reqwest::StatusCode,
61        /// The response body, verbatim (AgentMail sends JSON error details).
62        body: String,
63    },
64    /// A 2xx answer whose body didn't decode into the expected type - either
65    /// a bug in this crate's wire shapes or a breaking change in the API.
66    #[error("undecodable AgentMail response ({reason}): {body}")]
67    Decode {
68        /// Why deserialization failed.
69        reason: String,
70        /// The response body, verbatim.
71        body: String,
72    },
73}
74
75/// An authenticated handle on the AgentMail API. Cheap to clone-ish (it owns
76/// a pooled `reqwest::Client`); construct once and share by reference.
77pub struct Client {
78    http: reqwest::Client,
79    base_url: String,
80    api_key: String,
81}
82
83// Manual impl so an accidental `{:?}` never prints the API key.
84impl std::fmt::Debug for Client {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("Client")
87            .field("base_url", &self.base_url)
88            .field("api_key", &"[redacted]")
89            .finish_non_exhaustive()
90    }
91}
92
93impl Client {
94    /// A client against `base_url` (see [`DEFAULT_BASE_URL`]), with a
95    /// [`DEFAULT_TIMEOUT`] on every request.
96    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
97        // We build reqwest with rustls but no bundled crypto provider, so install
98        // `ring` as the process default (no aws-lc-rs / cmake). This is a global,
99        // set-once operation: it no-ops if the host application already installed
100        // a provider, so it never overrides a deliberate choice.
101        let _ = rustls::crypto::ring::default_provider().install_default();
102        Client {
103            http: reqwest::Client::builder()
104                .timeout(DEFAULT_TIMEOUT)
105                .build()
106                // Infallible for these options; build() can only fail on
107                // TLS-backend misconfiguration.
108                .expect("reqwest client"),
109            base_url: base_url.into().trim_end_matches('/').to_string(),
110            api_key: api_key.into(),
111        }
112    }
113
114    /// From `AGENTMAIL_API_KEY` (+ optional `AGENTMAIL_BASE_URL`).
115    pub fn from_env() -> Result<Self, Error> {
116        let key = std::env::var("AGENTMAIL_API_KEY").map_err(|_| Error::MissingApiKey)?;
117        let base =
118            std::env::var("AGENTMAIL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
119        Ok(Self::new(key, base))
120    }
121
122    async fn request<T: serde::de::DeserializeOwned>(
123        &self,
124        method: reqwest::Method,
125        path: &str,
126        query: &[(&str, String)],
127        body: Option<serde_json::Value>,
128    ) -> Result<T, Error> {
129        let mut req = self
130            .http
131            .request(method, format!("{}{path}", self.base_url))
132            .bearer_auth(&self.api_key);
133        if !query.is_empty() {
134            req = req.query(query);
135        }
136        if let Some(body) = body {
137            req = req.json(&body);
138        }
139        let resp = req.send().await?;
140        let status = resp.status();
141        let text = resp.text().await.unwrap_or_default();
142        if !status.is_success() {
143            return Err(Error::Api { status, body: text });
144        }
145        // DELETE endpoints answer with an empty body; map that to null so
146        // `()` (unit) deserializes.
147        let text = if text.trim().is_empty() {
148            "null"
149        } else {
150            &text
151        };
152        serde_json::from_str(text).map_err(|e| Error::Decode {
153            reason: e.to_string(),
154            body: text.to_string(),
155        })
156    }
157
158    // ── Inboxes ──────────────────────────────────────────────────────────────
159
160    /// POST /v0/inboxes, a new agent-owned email address. Free plans get
161    /// `{username}@agentmail.to`; custom domains must be verified first.
162    pub async fn create_inbox(&self, inbox: CreateInbox) -> Result<Inbox, Error> {
163        self.request(
164            reqwest::Method::POST,
165            "/v0/inboxes",
166            &[],
167            Some(serde_json::to_value(inbox).expect("serializable")),
168        )
169        .await
170    }
171
172    /// GET /v0/inboxes (first page; see [`Client::list_inboxes_page`]).
173    pub async fn list_inboxes(&self) -> Result<InboxList, Error> {
174        self.list_inboxes_page(Page::default()).await
175    }
176
177    /// GET /v0/inboxes with pagination. Feed [`InboxList::next_page_token`]
178    /// back in as [`Page::page_token`] until it comes back `None`.
179    pub async fn list_inboxes_page(&self, page: Page) -> Result<InboxList, Error> {
180        self.request(reqwest::Method::GET, "/v0/inboxes", &page.query(), None)
181            .await
182    }
183
184    /// GET /v0/inboxes/{inbox_id}
185    pub async fn get_inbox(&self, inbox_id: &str) -> Result<Inbox, Error> {
186        self.request(
187            reqwest::Method::GET,
188            &format!("/v0/inboxes/{}", urlish(inbox_id)),
189            &[],
190            None,
191        )
192        .await
193    }
194
195    /// DELETE /v0/inboxes/{inbox_id}
196    pub async fn delete_inbox(&self, inbox_id: &str) -> Result<(), Error> {
197        self.request(
198            reqwest::Method::DELETE,
199            &format!("/v0/inboxes/{}", urlish(inbox_id)),
200            &[],
201            None,
202        )
203        .await
204    }
205
206    // ── Messages ─────────────────────────────────────────────────────────────
207
208    /// POST /v0/inboxes/{inbox_id}/messages/send
209    pub async fn send_message(
210        &self,
211        inbox_id: &str,
212        message: SendMessage,
213    ) -> Result<SentMessage, Error> {
214        self.request(
215            reqwest::Method::POST,
216            &format!("/v0/inboxes/{}/messages/send", urlish(inbox_id)),
217            &[],
218            Some(serde_json::to_value(message).expect("serializable")),
219        )
220        .await
221    }
222
223    /// GET /v0/inboxes/{inbox_id}/messages (first page; see
224    /// [`Client::list_messages_page`]).
225    pub async fn list_messages(&self, inbox_id: &str) -> Result<MessageList, Error> {
226        self.list_messages_page(inbox_id, Page::default()).await
227    }
228
229    /// GET /v0/inboxes/{inbox_id}/messages with pagination. Feed
230    /// [`MessageList::next_page_token`] back in as [`Page::page_token`]
231    /// until it comes back `None`.
232    pub async fn list_messages_page(
233        &self,
234        inbox_id: &str,
235        page: Page,
236    ) -> Result<MessageList, Error> {
237        self.request(
238            reqwest::Method::GET,
239            &format!("/v0/inboxes/{}/messages", urlish(inbox_id)),
240            &page.query(),
241            None,
242        )
243        .await
244    }
245
246    /// GET /v0/inboxes/{inbox_id}/messages/{message_id}
247    pub async fn get_message(&self, inbox_id: &str, message_id: &str) -> Result<Message, Error> {
248        self.request(
249            reqwest::Method::GET,
250            &format!(
251                "/v0/inboxes/{}/messages/{}",
252                urlish(inbox_id),
253                urlish(message_id),
254            ),
255            &[],
256            None,
257        )
258        .await
259    }
260
261    // ── Webhooks ─────────────────────────────────────────────────────────────
262
263    /// POST /v0/webhooks, subscribe an HTTPS endpoint to events
264    /// (e.g. `message.received`). The response carries the signing `secret`
265    /// exactly once; store it.
266    pub async fn create_webhook(&self, webhook: CreateWebhook) -> Result<Webhook, Error> {
267        self.request(
268            reqwest::Method::POST,
269            "/v0/webhooks",
270            &[],
271            Some(serde_json::to_value(webhook).expect("serializable")),
272        )
273        .await
274    }
275
276    /// GET /v0/webhooks (first page; see [`Client::list_webhooks_page`]).
277    pub async fn list_webhooks(&self) -> Result<WebhookList, Error> {
278        self.list_webhooks_page(Page::default()).await
279    }
280
281    /// GET /v0/webhooks with pagination. Feed [`WebhookList::next_page_token`]
282    /// back in as [`Page::page_token`] until it comes back `None`.
283    pub async fn list_webhooks_page(&self, page: Page) -> Result<WebhookList, Error> {
284        self.request(reqwest::Method::GET, "/v0/webhooks", &page.query(), None)
285            .await
286    }
287
288    /// DELETE /v0/webhooks/{webhook_id}
289    pub async fn delete_webhook(&self, webhook_id: &str) -> Result<(), Error> {
290        self.request(
291            reqwest::Method::DELETE,
292            &format!("/v0/webhooks/{}", urlish(webhook_id)),
293            &[],
294            None,
295        )
296        .await
297    }
298}
299
300/// Minimal percent-encoding for path segments (ids are URL-safe in practice;
301/// this keeps a stray space, slash, or non-ASCII char from corrupting the
302/// path). Encodes per UTF-8 byte, as percent-encoding requires.
303fn urlish(segment: &str) -> String {
304    segment
305        .bytes()
306        .map(|b| match b {
307            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'@' | b'+' => {
308                (b as char).to_string()
309            }
310            other => format!("%{other:02X}"),
311        })
312        .collect()
313}
314
315// ─── Types (wire shapes from the OpenAPI spec) ────────────────────────────────
316
317/// Pagination controls for the `list_*_page` calls. `Default` is the API's
318/// own defaults (first page, server-chosen page size).
319#[derive(Clone, Debug, Default)]
320pub struct Page {
321    /// Maximum items per page (the API caps this server-side).
322    pub limit: Option<u32>,
323    /// Cursor from a previous response's `next_page_token`.
324    pub page_token: Option<String>,
325}
326
327impl Page {
328    fn query(&self) -> Vec<(&'static str, String)> {
329        let mut q = Vec::new();
330        if let Some(limit) = self.limit {
331            q.push(("limit", limit.to_string()));
332        }
333        if let Some(token) = &self.page_token {
334            q.push(("page_token", token.clone()));
335        }
336        q
337    }
338}
339
340/// Request body for [`Client::create_inbox`]. All fields optional; the API
341/// generates a username when none is given.
342#[derive(Clone, Debug, Default, Serialize)]
343pub struct CreateInbox {
344    /// Local part of the address; random when omitted.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub username: Option<String>,
347    /// A verified domain (or subdomain of one); defaults to `agentmail.to`.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub domain: Option<String>,
350    /// Human-readable sender name shown in email clients.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub display_name: Option<String>,
353    /// Your own idempotency/reference id for this inbox.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub client_id: Option<String>,
356    /// Arbitrary JSON stored alongside the inbox.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub metadata: Option<serde_json::Value>,
359}
360
361/// An agent-owned inbox, as the API returns it.
362#[derive(Clone, Debug, Deserialize)]
363pub struct Inbox {
364    /// Unique id, used in every message call.
365    pub inbox_id: String,
366    /// The address itself, e.g. `my-agent@agentmail.to`.
367    pub email: String,
368    /// Human-readable sender name, when set.
369    #[serde(default)]
370    pub display_name: Option<String>,
371    /// Owning pod, when the account uses pods.
372    #[serde(default)]
373    pub pod_id: Option<String>,
374    /// Your reference id from creation, when set.
375    #[serde(default)]
376    pub client_id: Option<String>,
377    /// RFC 3339 creation timestamp.
378    #[serde(default)]
379    pub created_at: Option<String>,
380}
381
382/// One page of inboxes from [`Client::list_inboxes_page`].
383#[derive(Clone, Debug, Deserialize)]
384pub struct InboxList {
385    /// Total inboxes in the account (not just this page).
386    pub count: u64,
387    /// This page of inboxes.
388    #[serde(default)]
389    pub inboxes: Vec<Inbox>,
390    /// Cursor for the next page; `None` on the last page.
391    #[serde(default)]
392    pub next_page_token: Option<String>,
393}
394
395/// Request body for [`Client::send_message`]. At least one recipient in `to`
396/// and one of `text`/`html` are required by the API.
397#[derive(Clone, Debug, Default, Serialize)]
398pub struct SendMessage {
399    /// Primary recipients.
400    #[serde(skip_serializing_if = "Vec::is_empty")]
401    pub to: Vec<String>,
402    /// Carbon-copy recipients.
403    #[serde(skip_serializing_if = "Vec::is_empty")]
404    pub cc: Vec<String>,
405    /// Blind-carbon-copy recipients.
406    #[serde(skip_serializing_if = "Vec::is_empty")]
407    pub bcc: Vec<String>,
408    /// Subject line.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub subject: Option<String>,
411    /// Plain-text body.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub text: Option<String>,
414    /// HTML body (send both for multipart).
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub html: Option<String>,
417    /// Labels to attach to the sent message.
418    #[serde(skip_serializing_if = "Vec::is_empty")]
419    pub labels: Vec<String>,
420}
421
422/// The API's acknowledgement of a send.
423#[derive(Clone, Debug, Deserialize)]
424pub struct SentMessage {
425    /// Id of the message just sent.
426    pub message_id: String,
427    /// Thread the message was filed under.
428    pub thread_id: String,
429}
430
431/// A message as the API returns it. List items are a subset of the full
432/// get-message shape; every non-id field defaults so both parse.
433#[derive(Clone, Debug, Deserialize)]
434pub struct Message {
435    /// Unique id within the inbox.
436    pub message_id: String,
437    /// Inbox the message belongs to.
438    #[serde(default)]
439    pub inbox_id: Option<String>,
440    /// Conversation thread id.
441    #[serde(default)]
442    pub thread_id: Option<String>,
443    /// Sender address.
444    #[serde(default)]
445    pub from: Option<String>,
446    /// Recipient addresses.
447    #[serde(default)]
448    pub to: Vec<String>,
449    /// Subject line.
450    #[serde(default)]
451    pub subject: Option<String>,
452    /// Short plain-text excerpt (list responses).
453    #[serde(default)]
454    pub preview: Option<String>,
455    /// Full plain-text body (get responses).
456    #[serde(default)]
457    pub text: Option<String>,
458    /// Full HTML body (get responses).
459    #[serde(default)]
460    pub html: Option<String>,
461    /// Labels on the message (e.g. `received`, `unread`).
462    #[serde(default)]
463    pub labels: Vec<String>,
464    /// RFC 3339 send/receive timestamp.
465    #[serde(default)]
466    pub timestamp: Option<String>,
467}
468
469/// One page of messages from [`Client::list_messages_page`].
470#[derive(Clone, Debug, Deserialize)]
471pub struct MessageList {
472    /// Total messages in the inbox (not just this page).
473    pub count: u64,
474    /// This page of messages.
475    #[serde(default)]
476    pub messages: Vec<Message>,
477    /// Cursor for the next page; `None` on the last page.
478    #[serde(default)]
479    pub next_page_token: Option<String>,
480}
481
482/// Request body for [`Client::create_webhook`].
483#[derive(Clone, Debug, Default, Serialize)]
484pub struct CreateWebhook {
485    /// HTTPS endpoint to deliver events to.
486    pub url: String,
487    /// e.g. `["message.received"]`.
488    pub event_types: Vec<String>,
489    /// Limit delivery to these inboxes; empty means all.
490    #[serde(skip_serializing_if = "Vec::is_empty")]
491    pub inbox_ids: Vec<String>,
492    /// Your own idempotency/reference id for this webhook.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub client_id: Option<String>,
495}
496
497/// A webhook subscription, as the API returns it.
498#[derive(Clone, Debug, Deserialize)]
499pub struct Webhook {
500    /// Unique id, used to delete the subscription.
501    pub webhook_id: String,
502    /// The subscribed HTTPS endpoint.
503    pub url: String,
504    /// Signing secret, returned once, on creation.
505    #[serde(default)]
506    pub secret: Option<String>,
507    /// Subscribed event types.
508    #[serde(default)]
509    pub event_types: Vec<String>,
510    /// Inboxes the subscription is limited to; empty means all.
511    #[serde(default)]
512    pub inbox_ids: Vec<String>,
513    /// Whether the subscription currently delivers.
514    pub enabled: bool,
515}
516
517/// One page of webhooks from [`Client::list_webhooks_page`].
518#[derive(Clone, Debug, Deserialize)]
519pub struct WebhookList {
520    /// Total webhooks in the account (not just this page).
521    pub count: u64,
522    /// This page of webhooks.
523    #[serde(default)]
524    pub webhooks: Vec<Webhook>,
525    /// Cursor for the next page; `None` on the last page.
526    #[serde(default)]
527    pub next_page_token: Option<String>,
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn wire_shapes_round_trip() {
536        // Response example from the OpenAPI spec.
537        let inbox: Inbox = serde_json::from_str(
538            r#"{"pod_id":"pod_1","inbox_id":"ib_1","email":"x@agentmail.to",
539                "display_name":"X","updated_at":"2024-01-15T09:30:00Z",
540                "created_at":"2024-01-15T09:30:00Z","surprise_field":1}"#,
541        )
542        .unwrap();
543        assert_eq!(inbox.email, "x@agentmail.to");
544
545        // List items are a subset of the get shape, both must parse.
546        let list: MessageList = serde_json::from_str(
547            r#"{"count":1,"messages":[{"message_id":"m1","thread_id":"t1",
548                "from":"a@b.c","subject":"hi","preview":"…","timestamp":"2026-01-01T00:00:00Z"}]}"#,
549        )
550        .unwrap();
551        assert_eq!(list.messages[0].message_id, "m1");
552        assert!(list.messages[0].text.is_none());
553
554        // Requests omit empties so the API's validators stay quiet.
555        let body = serde_json::to_value(SendMessage {
556            to: vec!["a@b.c".into()],
557            subject: Some("s".into()),
558            text: Some("t".into()),
559            ..Default::default()
560        })
561        .unwrap();
562        assert_eq!(
563            body,
564            serde_json::json!({"to":["a@b.c"],"subject":"s","text":"t"}),
565        );
566    }
567
568    #[test]
569    fn path_segments_stay_paths() {
570        assert_eq!(urlish("ib_abc-123.x@y+z"), "ib_abc-123.x@y+z");
571        assert_eq!(urlish("a/b c"), "a%2Fb%20c");
572        // Non-ASCII encodes per UTF-8 byte, not per code point.
573        assert_eq!(urlish("café"), "caf%C3%A9");
574        assert_eq!(urlish("😀"), "%F0%9F%98%80");
575    }
576
577    #[test]
578    fn page_query_pairs() {
579        assert!(Page::default().query().is_empty());
580        let q = Page {
581            limit: Some(10),
582            page_token: Some("tok".into()),
583        }
584        .query();
585        assert_eq!(
586            q,
587            vec![
588                ("limit", "10".to_string()),
589                ("page_token", "tok".to_string())
590            ],
591        );
592    }
593}