1use crate::types::{CalendarAttendee, CalendarEvent, Domain, Message, Recurrence, Thread};
25use crate::{Client, Error};
26use futures_util::{SinkExt, StreamExt};
27use serde::{Deserialize, Serialize};
28use tokio_tungstenite::tungstenite::Message as WsFrame;
29use tokio_tungstenite::tungstenite::client::IntoClientRequest;
30
31pub const DEFAULT_WEBSOCKET_URL: &str = "wss://ws.agentmail.to";
34
35#[derive(Clone, Debug, Default, Serialize)]
38pub struct Subscribe {
39 #[serde(skip_serializing_if = "Vec::is_empty")]
41 pub event_types: Vec<String>,
42 #[serde(skip_serializing_if = "Vec::is_empty")]
44 pub inbox_ids: Vec<String>,
45 #[serde(skip_serializing_if = "Vec::is_empty")]
47 pub pod_ids: Vec<String>,
48}
49
50#[derive(Serialize)]
51struct SubscribeFrame<'a> {
52 #[serde(rename = "type")]
53 kind: &'static str,
54 #[serde(flatten)]
55 sub: &'a Subscribe,
56}
57
58#[derive(Clone, Debug, Deserialize)]
60pub struct Subscribed {
61 #[serde(default)]
63 pub event_types: Vec<String>,
64 #[serde(default)]
66 pub inbox_ids: Vec<String>,
67 #[serde(default)]
69 pub pod_ids: Vec<String>,
70}
71
72#[derive(Clone, Debug, Deserialize)]
74pub struct EventRecipient {
75 #[serde(default)]
77 pub address: Option<String>,
78 #[serde(default)]
80 pub status: Option<String>,
81}
82
83#[derive(Clone, Debug, Deserialize)]
85pub struct CalendarEventPrevious {
86 #[serde(default)]
88 pub title: Option<String>,
89 #[serde(default)]
91 pub description: Option<String>,
92 #[serde(default)]
94 pub location: Option<String>,
95 #[serde(default)]
97 pub metadata: Option<serde_json::Value>,
98 #[serde(default)]
100 pub status: Option<String>,
101 #[serde(default)]
103 pub all_day: Option<bool>,
104 #[serde(default)]
106 pub start: Option<String>,
107 #[serde(default)]
109 pub end: Option<String>,
110 #[serde(default)]
112 pub timezone: Option<String>,
113 #[serde(default)]
115 pub duration_mode: Option<String>,
116 #[serde(default)]
118 pub recurrence: Option<Recurrence>,
119 #[serde(default)]
121 pub attendees: Vec<CalendarAttendee>,
122}
123
124#[derive(Clone, Debug, Deserialize)]
126pub struct MessageReceivedEvent {
127 #[serde(default)]
130 pub event_type: Option<String>,
131 #[serde(default)]
133 pub event_id: Option<String>,
134 pub message: Message,
136 pub thread: Thread,
138}
139
140macro_rules! envelope_event {
141 ($name:ident { $payload:ident : $payload_ty:ty $(, $extra:ident : $extra_ty:ty)* }) => {
142#[derive(Clone, Debug, Deserialize)]
144pub struct $name {
145 #[serde(default)]
147 pub event_type: Option<String>,
148 #[serde(default)]
150 pub event_id: Option<String>,
151 #[serde(default)]
153 pub inbox_id: Option<String>,
154 pub $payload: $payload_ty,
156 $(#[serde(default)]
158 pub $extra: $extra_ty,)*
159 }
160 };
161}
162
163#[derive(Clone, Debug, Deserialize)]
165pub struct DispatchPayload {
166 #[serde(default)]
168 pub inbox_id: Option<String>,
169 #[serde(default)]
171 pub thread_id: Option<String>,
172 #[serde(default)]
174 pub message_id: Option<String>,
175 #[serde(default)]
177 pub timestamp: Option<String>,
178 #[serde(default)]
180 pub recipients: Vec<String>,
181}
182
183envelope_event!(MessageSentEvent {
184 send: DispatchPayload
185});
186envelope_event!(MessageDeliveredEvent {
187 delivery: DispatchPayload
188});
189envelope_event!(MessageOpenedEvent { open: OpenPayload });
190envelope_event!(MessageBouncedEvent {
191 bounce: BouncePayload
192});
193envelope_event!(MessageComplainedEvent {
194 complaint: ComplaintPayload
195});
196envelope_event!(MessageRejectedEvent {
197 reject: RejectPayload
198});
199envelope_event!(DomainVerifiedEvent { domain: Domain });
200envelope_event!(CalendarEventCreatedEvent {
201 calendar_event: CalendarEvent
202});
203envelope_event!(CalendarEventDeletedEvent {
204 calendar_event: CalendarEvent
205});
206envelope_event!(CalendarEventRespondedEvent {
207 calendar_event: CalendarEvent
208});
209envelope_event!(CalendarEventUpdatedEvent { calendar_event: CalendarEvent, previous: Option<CalendarEventPrevious> });
210envelope_event!(CalendarEventStartingEvent { calendar_event: CalendarEvent, scheduled_at: Option<String> });
211envelope_event!(CalendarEventEndingEvent { calendar_event: CalendarEvent, scheduled_at: Option<String> });
212
213#[derive(Clone, Debug, Deserialize)]
215pub struct OpenPayload {
216 #[serde(default)]
218 pub inbox_id: Option<String>,
219 #[serde(default)]
221 pub thread_id: Option<String>,
222 #[serde(default)]
224 pub message_id: Option<String>,
225 #[serde(default)]
227 pub timestamp: Option<String>,
228}
229
230#[derive(Clone, Debug, Deserialize)]
232pub struct BouncePayload {
233 #[serde(default)]
235 pub inbox_id: Option<String>,
236 #[serde(default)]
238 pub thread_id: Option<String>,
239 #[serde(default)]
241 pub message_id: Option<String>,
242 #[serde(default)]
244 pub timestamp: Option<String>,
245 #[serde(default)]
247 pub sub_type: Option<String>,
248 #[serde(default)]
250 pub recipients: Vec<EventRecipient>,
251}
252
253#[derive(Clone, Debug, Deserialize)]
255pub struct ComplaintPayload {
256 #[serde(default)]
258 pub inbox_id: Option<String>,
259 #[serde(default)]
261 pub thread_id: Option<String>,
262 #[serde(default)]
264 pub message_id: Option<String>,
265 #[serde(default)]
267 pub timestamp: Option<String>,
268 #[serde(default)]
270 pub sub_type: Option<String>,
271 #[serde(default)]
273 pub recipients: Vec<String>,
274}
275
276#[derive(Clone, Debug, Deserialize)]
278pub struct RejectPayload {
279 #[serde(default)]
281 pub inbox_id: Option<String>,
282 #[serde(default)]
284 pub thread_id: Option<String>,
285 #[serde(default)]
287 pub message_id: Option<String>,
288 #[serde(default)]
290 pub timestamp: Option<String>,
291 #[serde(default)]
293 pub reason: Option<String>,
294}
295
296#[derive(Clone, Debug)]
298pub enum RealtimeEvent {
299 Subscribed(Subscribed),
301 MessageReceived(MessageReceivedEvent),
305 MessageSent(MessageSentEvent),
307 MessageDelivered(MessageDeliveredEvent),
309 MessageOpened(MessageOpenedEvent),
311 MessageBounced(MessageBouncedEvent),
313 MessageComplained(MessageComplainedEvent),
315 MessageRejected(MessageRejectedEvent),
317 DomainVerified(DomainVerifiedEvent),
319 CalendarEventCreated(CalendarEventCreatedEvent),
321 CalendarEventUpdated(CalendarEventUpdatedEvent),
323 CalendarEventDeleted(CalendarEventDeletedEvent),
325 CalendarEventStarting(CalendarEventStartingEvent),
327 CalendarEventEnding(CalendarEventEndingEvent),
329 CalendarEventResponded(CalendarEventRespondedEvent),
331 Other(serde_json::Value),
333 Binary(Vec<u8>),
335 Closed,
338}
339
340pub struct RealtimeStream {
343 ws: tokio_tungstenite::WebSocketStream<
344 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
345 >,
346}
347
348fn realtime_error(e: tokio_tungstenite::tungstenite::Error) -> Error {
349 Error::Realtime(e.to_string())
350}
351
352impl RealtimeStream {
353 pub async fn subscribe(&mut self, subscribe: &Subscribe) -> Result<(), Error> {
355 let frame = SubscribeFrame {
356 kind: "subscribe",
357 sub: subscribe,
358 };
359 let text = serde_json::to_string(&frame).map_err(|e| Error::Decode {
360 reason: e.to_string(),
361 body: String::new(),
362 })?;
363 self.ws
364 .send(WsFrame::Text(text.into()))
365 .await
366 .map_err(realtime_error)
367 }
368
369 pub async fn next_event(&mut self) -> Result<RealtimeEvent, Error> {
373 loop {
374 match self.ws.next().await {
375 Some(Ok(WsFrame::Text(text))) => {
376 let value: serde_json::Value =
377 serde_json::from_str(&text).map_err(|e| Error::Decode {
378 reason: e.to_string(),
379 body: text.to_string(),
380 })?;
381 return Ok(decode_event(value));
382 }
383 Some(Ok(WsFrame::Binary(bytes))) => {
384 return Ok(RealtimeEvent::Binary(bytes.to_vec()));
385 }
386 Some(Ok(WsFrame::Ping(_) | WsFrame::Pong(_))) => continue,
388 Some(Ok(WsFrame::Close(_))) | None => return Ok(RealtimeEvent::Closed),
389 Some(Ok(WsFrame::Frame(_))) => continue,
390 Some(Err(e)) => return Err(realtime_error(e)),
391 }
392 }
393 }
394}
395
396fn decode_event(value: serde_json::Value) -> RealtimeEvent {
397 match value.get("type").and_then(|t| t.as_str()) {
398 Some("subscribed") => serde_json::from_value(value.clone())
399 .map(|s: Subscribed| RealtimeEvent::Subscribed(s))
400 .unwrap_or_else(|_| RealtimeEvent::Other(value)),
401 Some("event") => decode_domain_event(value),
402 _ => RealtimeEvent::Other(value),
403 }
404}
405
406fn decode_domain_event(value: serde_json::Value) -> RealtimeEvent {
407 use RealtimeEvent as E;
408 let event_type = value
409 .get("event_type")
410 .and_then(|t| t.as_str())
411 .unwrap_or("");
412 let decoded = match event_type {
413 "message.received"
414 | "message.received.spam"
415 | "message.received.blocked"
416 | "message.received.unauthenticated" => {
417 serde_json::from_value(value.clone()).map(E::MessageReceived)
418 }
419 "message.sent" => serde_json::from_value(value.clone()).map(E::MessageSent),
420 "message.delivered" => serde_json::from_value(value.clone()).map(E::MessageDelivered),
421 "message.opened" => serde_json::from_value(value.clone()).map(E::MessageOpened),
422 "message.bounced" => serde_json::from_value(value.clone()).map(E::MessageBounced),
423 "message.complained" => serde_json::from_value(value.clone()).map(E::MessageComplained),
424 "message.rejected" => serde_json::from_value(value.clone()).map(E::MessageRejected),
425 "domain.verified" => serde_json::from_value(value.clone()).map(E::DomainVerified),
426 "calendar.event.created" => {
427 serde_json::from_value(value.clone()).map(E::CalendarEventCreated)
428 }
429 "calendar.event.updated" => {
430 serde_json::from_value(value.clone()).map(E::CalendarEventUpdated)
431 }
432 "calendar.event.deleted" => {
433 serde_json::from_value(value.clone()).map(E::CalendarEventDeleted)
434 }
435 "calendar.event.starting" => {
436 serde_json::from_value(value.clone()).map(E::CalendarEventStarting)
437 }
438 "calendar.event.ending" => {
439 serde_json::from_value(value.clone()).map(E::CalendarEventEnding)
440 }
441 "calendar.event.responded" => {
442 serde_json::from_value(value.clone()).map(E::CalendarEventResponded)
443 }
444 _ => return RealtimeEvent::Other(value),
445 };
446 decoded.unwrap_or(RealtimeEvent::Other(value))
447}
448
449fn derive_websocket_url(base_url: &str) -> String {
450 let (scheme, rest) = if let Some(rest) = base_url.strip_prefix("https://") {
451 ("wss://", rest)
452 } else if let Some(rest) = base_url.strip_prefix("http://") {
453 ("ws://", rest)
454 } else {
455 return DEFAULT_WEBSOCKET_URL.to_string();
456 };
457 let host = rest.split('/').next().unwrap_or(rest);
458 let ws_host = host
462 .split('.')
463 .map(|label| if label == "api" { "ws" } else { label })
464 .collect::<Vec<_>>()
465 .join(".");
466 format!("{scheme}{ws_host}")
467}
468
469impl Client {
470 #[cfg(feature = "websockets")]
473 pub fn websocket_url(&self) -> String {
474 self.ws_url_override
475 .clone()
476 .unwrap_or_else(|| derive_websocket_url(&self.base_url))
477 }
478
479 #[cfg(feature = "websockets")]
484 #[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
485 pub fn with_websocket_url(mut self, url: impl Into<String>) -> Self {
486 self.ws_url_override = Some(url.into().trim_end_matches('/').to_string());
487 self
488 }
489
490 #[cfg(feature = "websockets")]
499 #[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
500 pub async fn connect_realtime(&self, subscribe: &Subscribe) -> Result<RealtimeStream, Error> {
501 let url = format!(
502 "{}/v0?api_key={}",
503 self.websocket_url(),
504 crate::util::urlish(&self.api_key)
505 );
506 let mut request = url.as_str().into_client_request().map_err(realtime_error)?;
507 request.headers_mut().insert(
508 "Authorization",
509 format!("Bearer {}", self.api_key)
510 .parse()
511 .map_err(|e| Error::Realtime(format!("invalid authorization header: {e}")))?,
512 );
513 let (ws, _response) = tokio_tungstenite::connect_async(request)
514 .await
515 .map_err(realtime_error)?;
516 let mut stream = RealtimeStream { ws };
517 stream.subscribe(subscribe).await?;
518 Ok(stream)
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn url_derivation_matches_upstream_environments() {
528 assert_eq!(
530 derive_websocket_url("https://api.agentmail.to"),
531 "wss://ws.agentmail.to"
532 );
533 assert_eq!(
534 derive_websocket_url("https://api.agentmail.eu"),
535 "wss://ws.agentmail.eu"
536 );
537 assert_eq!(
538 derive_websocket_url("https://x402.api.agentmail.to"),
539 "wss://x402.ws.agentmail.to"
540 );
541 assert_eq!(
543 derive_websocket_url("http://127.0.0.1:8080"),
544 "ws://127.0.0.1:8080"
545 );
546 }
547
548 #[test]
549 fn subscribe_frame_has_const_type_and_skips_empty() {
550 let frame = serde_json::to_value(SubscribeFrame {
551 kind: "subscribe",
552 sub: &Subscribe {
553 inbox_ids: vec!["ib_1".into()],
554 ..Default::default()
555 },
556 })
557 .unwrap();
558 assert_eq!(
559 frame,
560 serde_json::json!({"type": "subscribe", "inbox_ids": ["ib_1"]})
561 );
562 }
563
564 #[test]
565 fn unknown_event_types_decode_as_other() {
566 let event = decode_event(serde_json::json!({
567 "type": "event", "event_type": "carrier.pigeon", "data": 1
568 }));
569 assert!(matches!(event, RealtimeEvent::Other(_)));
570 }
571}