1#![warn(missing_docs)]
36
37use serde::{Deserialize, Serialize};
38
39pub const DEFAULT_BASE_URL: &str = "https://api.agentmail.to";
42
43pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
45
46#[derive(Debug, thiserror::Error)]
48pub enum Error {
49 #[error("AGENTMAIL_API_KEY is not set")]
51 MissingApiKey,
52 #[error("transport error: {0}")]
55 Transport(#[from] reqwest::Error),
56 #[error("AgentMail answered {status}: {body}")]
58 Api {
59 status: reqwest::StatusCode,
61 body: String,
63 },
64 #[error("undecodable AgentMail response ({reason}): {body}")]
67 Decode {
68 reason: String,
70 body: String,
72 },
73}
74
75pub struct Client {
78 http: reqwest::Client,
79 base_url: String,
80 api_key: String,
81}
82
83impl 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 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
97 let _ = rustls::crypto::ring::default_provider().install_default();
102 Client {
103 http: reqwest::Client::builder()
104 .timeout(DEFAULT_TIMEOUT)
105 .build()
106 .expect("reqwest client"),
109 base_url: base_url.into().trim_end_matches('/').to_string(),
110 api_key: api_key.into(),
111 }
112 }
113
114 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 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 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 pub async fn list_inboxes(&self) -> Result<InboxList, Error> {
174 self.list_inboxes_page(Page::default()).await
175 }
176
177 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 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 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 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 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 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 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 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 pub async fn list_webhooks(&self) -> Result<WebhookList, Error> {
278 self.list_webhooks_page(Page::default()).await
279 }
280
281 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 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
300fn 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#[derive(Clone, Debug, Default)]
320pub struct Page {
321 pub limit: Option<u32>,
323 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#[derive(Clone, Debug, Default, Serialize)]
343pub struct CreateInbox {
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub username: Option<String>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub domain: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub display_name: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub client_id: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub metadata: Option<serde_json::Value>,
359}
360
361#[derive(Clone, Debug, Deserialize)]
363pub struct Inbox {
364 pub inbox_id: String,
366 pub email: String,
368 #[serde(default)]
370 pub display_name: Option<String>,
371 #[serde(default)]
373 pub pod_id: Option<String>,
374 #[serde(default)]
376 pub client_id: Option<String>,
377 #[serde(default)]
379 pub created_at: Option<String>,
380}
381
382#[derive(Clone, Debug, Deserialize)]
384pub struct InboxList {
385 pub count: u64,
387 #[serde(default)]
389 pub inboxes: Vec<Inbox>,
390 #[serde(default)]
392 pub next_page_token: Option<String>,
393}
394
395#[derive(Clone, Debug, Default, Serialize)]
398pub struct SendMessage {
399 #[serde(skip_serializing_if = "Vec::is_empty")]
401 pub to: Vec<String>,
402 #[serde(skip_serializing_if = "Vec::is_empty")]
404 pub cc: Vec<String>,
405 #[serde(skip_serializing_if = "Vec::is_empty")]
407 pub bcc: Vec<String>,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub subject: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub text: Option<String>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub html: Option<String>,
417 #[serde(skip_serializing_if = "Vec::is_empty")]
419 pub labels: Vec<String>,
420}
421
422#[derive(Clone, Debug, Deserialize)]
424pub struct SentMessage {
425 pub message_id: String,
427 pub thread_id: String,
429}
430
431#[derive(Clone, Debug, Deserialize)]
434pub struct Message {
435 pub message_id: String,
437 #[serde(default)]
439 pub inbox_id: Option<String>,
440 #[serde(default)]
442 pub thread_id: Option<String>,
443 #[serde(default)]
445 pub from: Option<String>,
446 #[serde(default)]
448 pub to: Vec<String>,
449 #[serde(default)]
451 pub subject: Option<String>,
452 #[serde(default)]
454 pub preview: Option<String>,
455 #[serde(default)]
457 pub text: Option<String>,
458 #[serde(default)]
460 pub html: Option<String>,
461 #[serde(default)]
463 pub labels: Vec<String>,
464 #[serde(default)]
466 pub timestamp: Option<String>,
467}
468
469#[derive(Clone, Debug, Deserialize)]
471pub struct MessageList {
472 pub count: u64,
474 #[serde(default)]
476 pub messages: Vec<Message>,
477 #[serde(default)]
479 pub next_page_token: Option<String>,
480}
481
482#[derive(Clone, Debug, Default, Serialize)]
484pub struct CreateWebhook {
485 pub url: String,
487 pub event_types: Vec<String>,
489 #[serde(skip_serializing_if = "Vec::is_empty")]
491 pub inbox_ids: Vec<String>,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub client_id: Option<String>,
495}
496
497#[derive(Clone, Debug, Deserialize)]
499pub struct Webhook {
500 pub webhook_id: String,
502 pub url: String,
504 #[serde(default)]
506 pub secret: Option<String>,
507 #[serde(default)]
509 pub event_types: Vec<String>,
510 #[serde(default)]
512 pub inbox_ids: Vec<String>,
513 pub enabled: bool,
515}
516
517#[derive(Clone, Debug, Deserialize)]
519pub struct WebhookList {
520 pub count: u64,
522 #[serde(default)]
524 pub webhooks: Vec<Webhook>,
525 #[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 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 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 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 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}