use crate::types::{CalendarAttendee, CalendarEvent, Domain, Message, Recurrence, Thread};
use crate::{Client, Error};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio_tungstenite::tungstenite::Message as WsFrame;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
pub const DEFAULT_WEBSOCKET_URL: &str = "wss://ws.agentmail.to";
#[derive(Clone, Debug, Default, Serialize)]
pub struct Subscribe {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub event_types: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub inbox_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pod_ids: Vec<String>,
}
#[derive(Serialize)]
struct SubscribeFrame<'a> {
#[serde(rename = "type")]
kind: &'static str,
#[serde(flatten)]
sub: &'a Subscribe,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Subscribed {
#[serde(default)]
pub event_types: Vec<String>,
#[serde(default)]
pub inbox_ids: Vec<String>,
#[serde(default)]
pub pod_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct EventRecipient {
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct CalendarEventPrevious {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub all_day: Option<bool>,
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub end: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub duration_mode: Option<String>,
#[serde(default)]
pub recurrence: Option<Recurrence>,
#[serde(default)]
pub attendees: Vec<CalendarAttendee>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct MessageReceivedEvent {
#[serde(default)]
pub event_type: Option<String>,
#[serde(default)]
pub event_id: Option<String>,
pub message: Message,
pub thread: Thread,
}
macro_rules! envelope_event {
($name:ident { $payload:ident : $payload_ty:ty $(, $extra:ident : $extra_ty:ty)* }) => {
#[derive(Clone, Debug, Deserialize)]
pub struct $name {
/// The event type on the wire (e.g. `message.sent`).
#[serde(default)]
pub event_type: Option<String>,
#[serde(default)]
pub event_id: Option<String>,
#[serde(default)]
pub inbox_id: Option<String>,
pub $payload: $payload_ty,
$( #[serde(default)]
pub $extra: $extra_ty,)*
}
};
}
#[derive(Clone, Debug, Deserialize)]
pub struct DispatchPayload {
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub message_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub recipients: Vec<String>,
}
envelope_event!(MessageSentEvent {
send: DispatchPayload
});
envelope_event!(MessageDeliveredEvent {
delivery: DispatchPayload
});
envelope_event!(MessageOpenedEvent { open: OpenPayload });
envelope_event!(MessageBouncedEvent {
bounce: BouncePayload
});
envelope_event!(MessageComplainedEvent {
complaint: ComplaintPayload
});
envelope_event!(MessageRejectedEvent {
reject: RejectPayload
});
envelope_event!(DomainVerifiedEvent { domain: Domain });
envelope_event!(CalendarEventCreatedEvent {
calendar_event: CalendarEvent
});
envelope_event!(CalendarEventDeletedEvent {
calendar_event: CalendarEvent
});
envelope_event!(CalendarEventRespondedEvent {
calendar_event: CalendarEvent
});
envelope_event!(CalendarEventUpdatedEvent { calendar_event: CalendarEvent, previous: Option<CalendarEventPrevious> });
envelope_event!(CalendarEventStartingEvent { calendar_event: CalendarEvent, scheduled_at: Option<String> });
envelope_event!(CalendarEventEndingEvent { calendar_event: CalendarEvent, scheduled_at: Option<String> });
#[derive(Clone, Debug, Deserialize)]
pub struct OpenPayload {
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub message_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct BouncePayload {
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub message_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub sub_type: Option<String>,
#[serde(default)]
pub recipients: Vec<EventRecipient>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ComplaintPayload {
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub message_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub sub_type: Option<String>,
#[serde(default)]
pub recipients: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RejectPayload {
#[serde(default)]
pub inbox_id: Option<String>,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default)]
pub message_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Clone, Debug)]
pub enum RealtimeEvent {
Subscribed(Subscribed),
MessageReceived(MessageReceivedEvent),
MessageSent(MessageSentEvent),
MessageDelivered(MessageDeliveredEvent),
MessageOpened(MessageOpenedEvent),
MessageBounced(MessageBouncedEvent),
MessageComplained(MessageComplainedEvent),
MessageRejected(MessageRejectedEvent),
DomainVerified(DomainVerifiedEvent),
CalendarEventCreated(CalendarEventCreatedEvent),
CalendarEventUpdated(CalendarEventUpdatedEvent),
CalendarEventDeleted(CalendarEventDeletedEvent),
CalendarEventStarting(CalendarEventStartingEvent),
CalendarEventEnding(CalendarEventEndingEvent),
CalendarEventResponded(CalendarEventRespondedEvent),
Other(serde_json::Value),
Binary(Vec<u8>),
Closed,
}
pub struct RealtimeStream {
ws: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
}
fn realtime_error(e: tokio_tungstenite::tungstenite::Error) -> Error {
Error::Realtime(e.to_string())
}
impl RealtimeStream {
pub async fn subscribe(&mut self, subscribe: &Subscribe) -> Result<(), Error> {
let frame = SubscribeFrame {
kind: "subscribe",
sub: subscribe,
};
let text = serde_json::to_string(&frame).map_err(|e| Error::Decode {
reason: e.to_string(),
body: String::new(),
})?;
self.ws
.send(WsFrame::Text(text.into()))
.await
.map_err(realtime_error)
}
pub async fn next_event(&mut self) -> Result<RealtimeEvent, Error> {
loop {
match self.ws.next().await {
Some(Ok(WsFrame::Text(text))) => {
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|e| Error::Decode {
reason: e.to_string(),
body: text.to_string(),
})?;
return Ok(decode_event(value));
}
Some(Ok(WsFrame::Binary(bytes))) => {
return Ok(RealtimeEvent::Binary(bytes.to_vec()));
}
Some(Ok(WsFrame::Ping(_) | WsFrame::Pong(_))) => continue,
Some(Ok(WsFrame::Close(_))) | None => return Ok(RealtimeEvent::Closed),
Some(Ok(WsFrame::Frame(_))) => continue,
Some(Err(e)) => return Err(realtime_error(e)),
}
}
}
}
fn decode_event(value: serde_json::Value) -> RealtimeEvent {
match value.get("type").and_then(|t| t.as_str()) {
Some("subscribed") => serde_json::from_value(value.clone())
.map(|s: Subscribed| RealtimeEvent::Subscribed(s))
.unwrap_or_else(|_| RealtimeEvent::Other(value)),
Some("event") => decode_domain_event(value),
_ => RealtimeEvent::Other(value),
}
}
fn decode_domain_event(value: serde_json::Value) -> RealtimeEvent {
use RealtimeEvent as E;
let event_type = value
.get("event_type")
.and_then(|t| t.as_str())
.unwrap_or("");
let decoded = match event_type {
"message.received"
| "message.received.spam"
| "message.received.blocked"
| "message.received.unauthenticated" => {
serde_json::from_value(value.clone()).map(E::MessageReceived)
}
"message.sent" => serde_json::from_value(value.clone()).map(E::MessageSent),
"message.delivered" => serde_json::from_value(value.clone()).map(E::MessageDelivered),
"message.opened" => serde_json::from_value(value.clone()).map(E::MessageOpened),
"message.bounced" => serde_json::from_value(value.clone()).map(E::MessageBounced),
"message.complained" => serde_json::from_value(value.clone()).map(E::MessageComplained),
"message.rejected" => serde_json::from_value(value.clone()).map(E::MessageRejected),
"domain.verified" => serde_json::from_value(value.clone()).map(E::DomainVerified),
"calendar.event.created" => {
serde_json::from_value(value.clone()).map(E::CalendarEventCreated)
}
"calendar.event.updated" => {
serde_json::from_value(value.clone()).map(E::CalendarEventUpdated)
}
"calendar.event.deleted" => {
serde_json::from_value(value.clone()).map(E::CalendarEventDeleted)
}
"calendar.event.starting" => {
serde_json::from_value(value.clone()).map(E::CalendarEventStarting)
}
"calendar.event.ending" => {
serde_json::from_value(value.clone()).map(E::CalendarEventEnding)
}
"calendar.event.responded" => {
serde_json::from_value(value.clone()).map(E::CalendarEventResponded)
}
_ => return RealtimeEvent::Other(value),
};
decoded.unwrap_or(RealtimeEvent::Other(value))
}
fn derive_websocket_url(base_url: &str) -> String {
let (scheme, rest) = if let Some(rest) = base_url.strip_prefix("https://") {
("wss://", rest)
} else if let Some(rest) = base_url.strip_prefix("http://") {
("ws://", rest)
} else {
return DEFAULT_WEBSOCKET_URL.to_string();
};
let host = rest.split('/').next().unwrap_or(rest);
let ws_host = host
.split('.')
.map(|label| if label == "api" { "ws" } else { label })
.collect::<Vec<_>>()
.join(".");
format!("{scheme}{ws_host}")
}
impl Client {
#[cfg(feature = "websockets")]
pub fn websocket_url(&self) -> String {
self.ws_url_override
.clone()
.unwrap_or_else(|| derive_websocket_url(&self.base_url))
}
#[cfg(feature = "websockets")]
#[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
pub fn with_websocket_url(mut self, url: impl Into<String>) -> Self {
self.ws_url_override = Some(url.into().trim_end_matches('/').to_string());
self
}
#[cfg(feature = "websockets")]
#[cfg_attr(docsrs, doc(cfg(feature = "websockets")))]
pub async fn connect_realtime(&self, subscribe: &Subscribe) -> Result<RealtimeStream, Error> {
let url = format!(
"{}/v0?api_key={}",
self.websocket_url(),
crate::util::urlish(&self.api_key)
);
let mut request = url.as_str().into_client_request().map_err(realtime_error)?;
request.headers_mut().insert(
"Authorization",
format!("Bearer {}", self.api_key)
.parse()
.map_err(|e| Error::Realtime(format!("invalid authorization header: {e}")))?,
);
let (ws, _response) = tokio_tungstenite::connect_async(request)
.await
.map_err(realtime_error)?;
let mut stream = RealtimeStream { ws };
stream.subscribe(subscribe).await?;
Ok(stream)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_derivation_matches_upstream_environments() {
assert_eq!(
derive_websocket_url("https://api.agentmail.to"),
"wss://ws.agentmail.to"
);
assert_eq!(
derive_websocket_url("https://api.agentmail.eu"),
"wss://ws.agentmail.eu"
);
assert_eq!(
derive_websocket_url("https://x402.api.agentmail.to"),
"wss://x402.ws.agentmail.to"
);
assert_eq!(
derive_websocket_url("http://127.0.0.1:8080"),
"ws://127.0.0.1:8080"
);
}
#[test]
fn subscribe_frame_has_const_type_and_skips_empty() {
let frame = serde_json::to_value(SubscribeFrame {
kind: "subscribe",
sub: &Subscribe {
inbox_ids: vec!["ib_1".into()],
..Default::default()
},
})
.unwrap();
assert_eq!(
frame,
serde_json::json!({"type": "subscribe", "inbox_ids": ["ib_1"]})
);
}
#[test]
fn unknown_event_types_decode_as_other() {
let event = decode_event(serde_json::json!({
"type": "event", "event_type": "carrier.pigeon", "data": 1
}));
assert!(matches!(event, RealtimeEvent::Other(_)));
}
}