#![warn(missing_docs)]
use serde::{Deserialize, Serialize};
pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("AGENTMAIL_API_KEY is not set")]
MissingApiKey,
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("AgentMail answered {status}: {body}")]
Api {
status: reqwest::StatusCode,
body: String,
},
#[error("undecodable AgentMail response ({reason}): {body}")]
Decode {
reason: String,
body: String,
},
}
pub struct Client {
http: reqwest::Client,
base_url: String,
api_key: String,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.base_url)
.field("api_key", &"[redacted]")
.finish_non_exhaustive()
}
}
impl Client {
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let _ = rustls::crypto::ring::default_provider().install_default();
Client {
http: reqwest::Client::builder()
.timeout(DEFAULT_TIMEOUT)
.build()
.expect("reqwest client"),
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key: api_key.into(),
}
}
pub fn from_env() -> Result<Self, Error> {
let key = std::env::var("AGENTMAIL_API_KEY").map_err(|_| Error::MissingApiKey)?;
let base =
std::env::var("AGENTMAIL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
Ok(Self::new(key, base))
}
async fn request<T: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
query: &[(&str, String)],
body: Option<serde_json::Value>,
) -> Result<T, Error> {
let mut req = self
.http
.request(method, format!("{}{path}", self.base_url))
.bearer_auth(&self.api_key);
if !query.is_empty() {
req = req.query(query);
}
if let Some(body) = body {
req = req.json(&body);
}
let resp = req.send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(Error::Api { status, body: text });
}
let text = if text.trim().is_empty() {
"null"
} else {
&text
};
serde_json::from_str(text).map_err(|e| Error::Decode {
reason: e.to_string(),
body: text.to_string(),
})
}
pub async fn create_inbox(&self, inbox: CreateInbox) -> Result<Inbox, Error> {
self.request(
reqwest::Method::POST,
"/v0/inboxes",
&[],
Some(serde_json::to_value(inbox).expect("serializable")),
)
.await
}
pub async fn list_inboxes(&self) -> Result<InboxList, Error> {
self.list_inboxes_page(Page::default()).await
}
pub async fn list_inboxes_page(&self, page: Page) -> Result<InboxList, Error> {
self.request(reqwest::Method::GET, "/v0/inboxes", &page.query(), None)
.await
}
pub async fn get_inbox(&self, inbox_id: &str) -> Result<Inbox, Error> {
self.request(
reqwest::Method::GET,
&format!("/v0/inboxes/{}", urlish(inbox_id)),
&[],
None,
)
.await
}
pub async fn delete_inbox(&self, inbox_id: &str) -> Result<(), Error> {
self.request(
reqwest::Method::DELETE,
&format!("/v0/inboxes/{}", urlish(inbox_id)),
&[],
None,
)
.await
}
pub async fn send_message(
&self,
inbox_id: &str,
message: SendMessage,
) -> Result<SentMessage, Error> {
self.request(
reqwest::Method::POST,
&format!("/v0/inboxes/{}/messages/send", urlish(inbox_id)),
&[],
Some(serde_json::to_value(message).expect("serializable")),
)
.await
}
pub async fn list_messages(&self, inbox_id: &str) -> Result<MessageList, Error> {
self.list_messages_page(inbox_id, Page::default()).await
}
pub async fn list_messages_page(
&self,
inbox_id: &str,
page: Page,
) -> Result<MessageList, Error> {
self.request(
reqwest::Method::GET,
&format!("/v0/inboxes/{}/messages", urlish(inbox_id)),
&page.query(),
None,
)
.await
}
pub async fn get_message(&self, inbox_id: &str, message_id: &str) -> Result<Message, Error> {
self.request(
reqwest::Method::GET,
&format!(
"/v0/inboxes/{}/messages/{}",
urlish(inbox_id),
urlish(message_id),
),
&[],
None,
)
.await
}
pub async fn create_webhook(&self, webhook: CreateWebhook) -> Result<Webhook, Error> {
self.request(
reqwest::Method::POST,
"/v0/webhooks",
&[],
Some(serde_json::to_value(webhook).expect("serializable")),
)
.await
}
pub async fn list_webhooks(&self) -> Result<WebhookList, Error> {
self.list_webhooks_page(Page::default()).await
}
pub async fn list_webhooks_page(&self, page: Page) -> Result<WebhookList, Error> {
self.request(reqwest::Method::GET, "/v0/webhooks", &page.query(), None)
.await
}
pub async fn delete_webhook(&self, webhook_id: &str) -> Result<(), Error> {
self.request(
reqwest::Method::DELETE,
&format!("/v0/webhooks/{}", urlish(webhook_id)),
&[],
None,
)
.await
}
}
fn urlish(segment: &str) -> String {
segment
.bytes()
.map(|b| match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'@' | b'+' => {
(b as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
#[derive(Clone, Debug, Default)]
pub struct Page {
pub limit: Option<u32>,
pub page_token: Option<String>,
}
impl Page {
fn query(&self) -> Vec<(&'static str, String)> {
let mut q = Vec::new();
if let Some(limit) = self.limit {
q.push(("limit", limit.to_string()));
}
if let Some(token) = &self.page_token {
q.push(("page_token", token.clone()));
}
q
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct CreateInbox {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Inbox {
pub inbox_id: String,
pub email: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub pod_id: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct InboxList {
pub count: u64,
#[serde(default)]
pub inboxes: Vec<Inbox>,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct SendMessage {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub bcc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SentMessage {
pub message_id: String,
pub thread_id: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Message {
pub message_id: String,
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub to: Vec<String>,
#[serde(default)]
pub subject: Option<String>,
#[serde(default)]
pub preview: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub html: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub timestamp: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct MessageList {
pub count: u64,
#[serde(default)]
pub messages: Vec<Message>,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct CreateWebhook {
pub url: String,
pub event_types: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub inbox_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Webhook {
pub webhook_id: String,
pub url: String,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub event_types: Vec<String>,
#[serde(default)]
pub inbox_ids: Vec<String>,
pub enabled: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct WebhookList {
pub count: u64,
#[serde(default)]
pub webhooks: Vec<Webhook>,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_shapes_round_trip() {
let inbox: Inbox = serde_json::from_str(
r#"{"pod_id":"pod_1","inbox_id":"ib_1","email":"x@agentmail.to",
"display_name":"X","updated_at":"2024-01-15T09:30:00Z",
"created_at":"2024-01-15T09:30:00Z","surprise_field":1}"#,
)
.unwrap();
assert_eq!(inbox.email, "x@agentmail.to");
let list: MessageList = serde_json::from_str(
r#"{"count":1,"messages":[{"message_id":"m1","thread_id":"t1",
"from":"a@b.c","subject":"hi","preview":"…","timestamp":"2026-01-01T00:00:00Z"}]}"#,
)
.unwrap();
assert_eq!(list.messages[0].message_id, "m1");
assert!(list.messages[0].text.is_none());
let body = serde_json::to_value(SendMessage {
to: vec!["a@b.c".into()],
subject: Some("s".into()),
text: Some("t".into()),
..Default::default()
})
.unwrap();
assert_eq!(
body,
serde_json::json!({"to":["a@b.c"],"subject":"s","text":"t"}),
);
}
#[test]
fn path_segments_stay_paths() {
assert_eq!(urlish("ib_abc-123.x@y+z"), "ib_abc-123.x@y+z");
assert_eq!(urlish("a/b c"), "a%2Fb%20c");
assert_eq!(urlish("café"), "caf%C3%A9");
assert_eq!(urlish("😀"), "%F0%9F%98%80");
}
#[test]
fn page_query_pairs() {
assert!(Page::default().query().is_empty());
let q = Page {
limit: Some(10),
page_token: Some("tok".into()),
}
.query();
assert_eq!(
q,
vec![
("limit", "10".to_string()),
("page_token", "tok".to_string())
],
);
}
}